From 1d477505f308b7c304841bf0ea5b19afaa2515c5 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:55:50 -0300 Subject: [PATCH] feat(island): the cards that wait and today's activity, for the island Spec section 31 designs the island, the Botloft at the top of the screen. Its first slice (I1) is in the daemon: `cards.list` returns the requests and questions that wait, as the phone's cards with its rules (cut, at the computer) but with no account needed, and `activity.today {since}` counts what the crews did since the app's local midnight without a bot's turn: tasks, messages between bots, requests answered, files and lines edited, and who spent the most time in turns. --- app/src/lib/fake.ts | 2 + app/src/lib/fakeIsland.test.ts | 46 +++++ app/src/lib/fakeIsland.ts | 134 +++++++++++++ app/src/lib/protocol.gen.ts | 57 ++++++ crates/botloft-core/src/protocol/export.rs | 4 + .../src/protocol/export_island.rs | 21 ++ crates/botloft-core/src/protocol/island.rs | 60 ++++++ crates/botloft-core/src/protocol/mod.rs | 4 + crates/botloft-store/src/lib.rs | 1 + crates/botloft-store/src/today.rs | 125 ++++++++++++ crates/botloftd/src/mobile/mod.rs | 2 +- crates/botloftd/src/rpc/dispatch.rs | 7 +- crates/botloftd/src/service/island.rs | 25 +++ crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/tests/island.rs | 181 ++++++++++++++++++ docs/spec.md | 106 ++++++++++ 16 files changed, 773 insertions(+), 3 deletions(-) create mode 100644 app/src/lib/fakeIsland.test.ts create mode 100644 app/src/lib/fakeIsland.ts create mode 100644 crates/botloft-core/src/protocol/export_island.rs create mode 100644 crates/botloft-core/src/protocol/island.rs create mode 100644 crates/botloft-store/src/today.rs create mode 100644 crates/botloftd/src/service/island.rs create mode 100644 crates/botloftd/tests/island.rs diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index f2567c97..b2ec4dc0 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -16,6 +16,7 @@ import { FakeCrewAccess } from "./fakeCrewAccess"; import { crewHandlers } from "./fakeCrews"; import { FakeDesktop } from "./fakeDesktop"; import { FakeFiles } from "./fakeFiles"; +import { islandHandlers } from "./fakeIsland"; import { FakeLesson } from "./fakeLesson"; import { FakeMcp } from "./fakeMcp"; import { mobileHandlers } from "./fakeMobile"; @@ -325,6 +326,7 @@ export class FakeBotloft implements BotloftApi { ...this.reactions.handlers(), ...this.lesson.handlers(), ...usageHandlers(this), + ...islandHandlers(this), ...backupHandlers(this), ...cloudHandlers(this), ...autoBackupHandlers(this), diff --git a/app/src/lib/fakeIsland.test.ts b/app/src/lib/fakeIsland.test.ts new file mode 100644 index 00000000..8063874e --- /dev/null +++ b/app/src/lib/fakeIsland.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { FakeBotloft } from "./fake"; + +describe("the fake's island methods", () => { + it("lists what waits with the phone's rules, oldest first", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.chat.ask(scout.id, "Bash", "git status", JSON.stringify({ command: "git status" })); + fake.chat.ask(scout.id, "mcp__github__create_issue", "Fix it", "{}"); + fake.chat.ask(scout.id, "Bash", "echo", `{"command": "echo ${"x".repeat(20)}…`); + fake.questions.ask(scout.id, "Coffee?", ["Yes", "No"]); + + const cards = await fake.call("cards.list"); + expect( + cards.approvals.map((card) => [card.text.slice(0, 10), card.cut, card.atComputer]), + ).toEqual([ + ["git status", false, false], + ["{}", false, true], + ['{"command"', true, false], + ]); + expect(cards.approvals[0]?.bot.name).toBe("Scout"); + expect(cards.approvals[0]?.crew).toBe("Ops"); + expect(cards.questions.map((card) => card.text)).toEqual(["Coffee?"]); + }); + + it("counts the edits and the time in turns since the start of the day", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.chat.tool(scout.id, "Edit", { + status: "done", + file: "C:\\work\\a.md", + lines: { added: 3, removed: 1 }, + }); + fake.chat.turn(scout.id); + + const today = await fake.call("activity.today", { since: 0 }); + expect(today.filesEdited).toBe(1); + expect(today.lines).toEqual({ added: 3, removed: 1 }); + expect(today.busiest?.name).toBe("Scout"); + const later = await fake.call("activity.today", { since: fake.now + 1 }); + expect(later.filesEdited).toBe(0); + expect(later.busiest).toBeNull(); + }); +}); diff --git a/app/src/lib/fakeIsland.ts b/app/src/lib/fakeIsland.ts new file mode 100644 index 00000000..070e7b6d --- /dev/null +++ b/app/src/lib/fakeIsland.ts @@ -0,0 +1,134 @@ +// The fake daemon's island methods (spec 31): the cards that wait, built +// from the fake chat and questions with the phone's rules (28.5), and what +// the crews did since a moment, counted like the daemon's `today.rs`. + +import type { FakeBotloft, Handlers } from "./fake"; +import type { ApprovalCard, BusyBot, QuestionCard } from "./protocol.gen"; + +/** The longest text a card shows whole (the daemon's `CARD_TEXT_MAX`). */ +const CARD_TEXT_MAX = 8 * 1024; + +/** The tools a card may allow; the rest is answered in the Botloft. */ +const MAY_ALLOW = new Set([ + "Bash", + "PowerShell", + "WebFetch", + "WebSearch", + "Read", + "Write", + "Edit", + "MultiEdit", + "NotebookEdit", + "ExitPlanMode", + "mcp__botloft__show_plan", +]); + +/** What to read before allowing, and whether it is not all of the request. */ +function textOf(input: string): { text: string; cut: boolean } { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return { text: input.slice(0, CARD_TEXT_MAX), cut: true }; + } + const fields = parsed as { command?: unknown; plan?: unknown }; + const text = + typeof fields.command === "string" + ? fields.command + : typeof fields.plan === "string" + ? fields.plan + : input; + return text.length > CARD_TEXT_MAX + ? { text: text.slice(0, CARD_TEXT_MAX), cut: true } + : { text, cut: false }; +} + +export function islandHandlers(fake: FakeBotloft): Pick { + const who = (botId: string) => { + const bot = fake.bot(botId, false); + return { bot: { name: bot.name, color: bot.color }, crew: fake.crew(bot.crewId, false).name }; + }; + return { + "cards.list": () => { + const approvals: ApprovalCard[] = []; + for (const item of fake.chat.items) { + if (item.body.kind !== "approval" || item.body.status !== "pending") { + continue; + } + approvals.push({ + approvalId: item.body.approvalId, + ...who(item.botId), + createdAt: item.createdAt, + toolName: item.body.toolName, + summary: item.body.summary, + ...(item.body.explanation !== null && { explanation: item.body.explanation }), + ...textOf(item.body.input), + atComputer: !MAY_ALLOW.has(item.body.toolName), + }); + } + const questions: QuestionCard[] = fake.questions.asked + .map((entry) => entry.question) + .filter((question) => question.status === "open") + .map((question) => ({ + questionId: question.id, + ...who(question.botId), + createdAt: question.createdAt, + text: question.text, + options: question.options, + })); + const oldest = (a: { createdAt: number }, b: { createdAt: number }) => + a.createdAt - b.createdAt; + return { approvals: approvals.sort(oldest), questions: questions.sort(oldest) }; + }, + "activity.today": ({ since }) => { + const tasks = [...fake.conversation.tasks.values()].filter((task) => task.updatedAt >= since); + const files = new Set(); + const lines = { added: 0, removed: 0 }; + let requestsAnswered = 0; + const work = new Map(); + for (const item of fake.chat.items) { + const body = item.body; + if ( + body.kind === "tool" && + item.createdAt >= since && + body.status === "done" && + body.lines + ) { + if (body.file) files.add(body.file); + lines.added += body.lines.added; + lines.removed += body.lines.removed; + } + if (body.kind === "approval" && item.updatedAt >= since) { + if (body.status === "allowed" || body.status === "denied") requestsAnswered += 1; + } + if (body.kind === "turn" && item.createdAt >= since) { + work.set(item.botId, (work.get(item.botId) ?? 0) + body.durationMs); + } + } + const [top] = [...work.entries()].filter(([, ms]) => ms > 0).sort((a, b) => b[1] - a[1]); + let busiest: BusyBot | null = null; + if (top) { + const bot = fake.bot(top[0], false); + busiest = { + botId: bot.id, + name: bot.name, + color: bot.color, + crew: fake.crew(bot.crewId, false).name, + workMs: top[1], + }; + } + return { + tasksDone: tasks.filter((task) => task.status === "done").length, + tasksFailed: tasks.filter((task) => task.status === "failed" || task.status === "expired") + .length, + botMessages: fake.conversation.messages.filter( + (message) => message.fromKind === "bot" && message.createdAt >= since, + ).length, + requestsAnswered, + filesEdited: files.size, + lines, + busiest, + }; + }, + }; +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 022c8c09..4d2b875c 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1630,6 +1630,61 @@ export type ToPhone = { "t": "snapshot", approvals: Array, questio */ export type FromPhone = { "t": "sync" } | { "t": "approval.answer", approvalId: ApprovalId, allow: boolean, note?: string, } | { "t": "question.answer", questionId: QuestionId, answer: string, } | { "t": "question.dismiss", questionId: QuestionId, } | { "t": "chats" } | { "t": "history", req: number, botId: BotId, before?: ChatItemId, } | { "t": "send", clientId: string, botId: BotId, text: string, } | { "t": "watch", botId?: BotId, }; +/** + * Every request and question waiting for the owner, oldest first, as the + * phone sees them (spec 31.5, 28.5). + */ +export type Cards = { approvals: Array, questions: Array, }; + +/** + * Params of `activity.today`. + */ +export type ActivityTodayParams = { +/** + * Where the day starts, in Unix ms: the app sends its local midnight. + */ +since: number, }; + +export type BusyBot = { botId: BotId, name: string, color: string, crew: string, +/** + * Time in turns since `since`, in ms. + */ +workMs: number, }; + +/** + * What every crew did since `since`, counted from the database without a + * bot's turn (spec 31.4). + */ +export type DayActivity = { +/** + * Tasks between bots that ended done. + */ +tasksDone: number, +/** + * Tasks that ended failed or expired. + */ +tasksFailed: number, +/** + * Messages one bot sent another. + */ +botMessages: number, +/** + * Requests the owner allowed or denied. + */ +requestsAnswered: number, +/** + * Distinct files that edits counted in `lines` changed. + */ +filesEdited: number, +/** + * Lines added and removed by the edits that finished well (spec 8.2). + */ +lines: EditLines, +/** + * The bot that spent the most time in turns; `None` on a quiet day. + */ +busiest: BusyBot | null, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -2374,6 +2429,8 @@ export interface RpcMethods { "mobile.pair_cancel": { params: MobilePairIdParams; result: null }; "mobile.pair_confirm": { params: MobilePairConfirmParams; result: null }; "mobile.revoke": { params: MobileRevokeParams; result: MobileStatus }; + "cards.list": { params: undefined; result: Cards }; + "activity.today": { params: ActivityTodayParams; result: DayActivity }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index d2c2b13f..0469553d 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -26,6 +26,8 @@ mod cloud; mod crew_access; #[path = "export_desktop.rs"] mod desktop; +#[path = "export_island.rs"] +mod island; #[path = "export_mcp.rs"] mod mcp; #[path = "export_mobile.rs"] @@ -210,6 +212,7 @@ fn export_bindings() { backup::decls(&mut out); cloud::decls(&mut out); mobile::decls(&mut out); + island::decls(&mut out); out.decl::(); usage::decls(&mut out); questions::decls(&mut out); @@ -411,6 +414,7 @@ fn export_bindings() { backup::methods(&mut out); cloud::methods(&mut out); mobile::methods(&mut out); + island::methods(&mut out); routines::methods(&mut out); questions::methods(&mut out); reactions::methods(&mut out); diff --git a/crates/botloft-core/src/protocol/export_island.rs b/crates/botloft-core/src/protocol/export_island.rs new file mode 100644 index 00000000..3e7ff832 --- /dev/null +++ b/crates/botloft-core/src/protocol/export_island.rs @@ -0,0 +1,21 @@ +//! The island part of the TypeScript bindings (spec 31). + +use super::super::*; +use super::Out; + +/// After the mobile cards, which `Cards` holds. +pub(super) fn decls(out: &mut Out) { + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method(method::CARDS_LIST, "undefined", &out.name::()); + out.method( + method::ACTIVITY_TODAY, + &out.name::(), + &out.name::(), + ); +} diff --git a/crates/botloft-core/src/protocol/island.rs b/crates/botloft-core/src/protocol/island.rs new file mode 100644 index 00000000..51d05aa8 --- /dev/null +++ b/crates/botloft-core/src/protocol/island.rs @@ -0,0 +1,60 @@ +//! What the island asks the daemon (spec 31): the cards that wait for the +//! owner and what the crews did today. + +use serde::{Deserialize, Serialize}; + +use super::{ApprovalCard, EditLines, QuestionCard}; +use crate::ids::BotId; + +/// Every request and question waiting for the owner, oldest first, as the +/// phone sees them (spec 31.5, 28.5). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Cards { + pub approvals: Vec, + pub questions: Vec, +} + +/// Params of `activity.today`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ActivityTodayParams { + /// Where the day starts, in Unix ms: the app sends its local midnight. + pub since: i64, +} + +/// What every crew did since `since`, counted from the database without a +/// bot's turn (spec 31.4). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct DayActivity { + /// Tasks between bots that ended done. + pub tasks_done: u32, + /// Tasks that ended failed or expired. + pub tasks_failed: u32, + /// Messages one bot sent another. + pub bot_messages: u32, + /// Requests the owner allowed or denied. + pub requests_answered: u32, + /// Distinct files that edits counted in `lines` changed. + pub files_edited: u32, + /// Lines added and removed by the edits that finished well (spec 8.2). + pub lines: EditLines, + /// The bot that spent the most time in turns; `None` on a quiet day. + pub busiest: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BusyBot { + pub bot_id: BotId, + pub name: String, + pub color: String, + pub crew: String, + /// Time in turns since `since`, in ms. + pub work_ms: i64, +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 478ec584..8d82c955 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -60,6 +60,7 @@ mod desktop; #[cfg(test)] mod export; mod files; +mod island; mod lesson; mod mcp; mod messaging; @@ -85,6 +86,7 @@ pub use cloud::*; pub use crew_access::*; pub use desktop::*; pub use files::*; +pub use island::*; pub use lesson::*; pub use mcp::*; pub use messaging::*; @@ -214,6 +216,8 @@ pub mod method { /// The owner opens the bot's closed browser for themselves. pub const BROWSER_START: &str = "browser.start"; pub const SCREENS_LIST: &str = "screens.list"; + pub const CARDS_LIST: &str = "cards.list"; + pub const ACTIVITY_TODAY: &str = "activity.today"; pub const QUESTIONS_LIST: &str = "questions.list"; pub const QUESTIONS_ANSWER: &str = "questions.answer"; pub const QUESTIONS_DISMISS: &str = "questions.dismiss"; diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index 9f61516a..878948a8 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -29,6 +29,7 @@ mod routines; mod search; mod signals; mod tasks; +mod today; mod usage; use std::path::Path; diff --git a/crates/botloft-store/src/today.rs b/crates/botloft-store/src/today.rs new file mode 100644 index 00000000..4df33440 --- /dev/null +++ b/crates/botloft-store/src/today.rs @@ -0,0 +1,125 @@ +//! What every crew did since a moment (spec 31.4), counted from the tables +//! without a bot's turn: the island's "Today" card. + +use botloft_core::protocol::{BusyBot, DayActivity, EditLines}; +use rusqlite::{OptionalExtension, params}; + +use crate::{Result, Store, parse_column}; + +/// A count from SQLite, which keeps integers as `i64`. +fn count(value: i64) -> u32 { + u32::try_from(value.max(0)).unwrap_or(u32::MAX) +} + +impl Store { + pub fn day_activity(&self, since: i64) -> Result { + let (done, failed): (i64, i64) = self.conn.query_row( + "SELECT COALESCE(SUM(status = 'done'), 0), \ + COALESCE(SUM(status IN ('failed', 'expired')), 0) \ + FROM tasks WHERE updated_at >= ?1", + params![since], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let bot_messages: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM messages WHERE from_kind = 'bot' AND created_at >= ?1", + params![since], + |row| row.get(0), + )?; + let answered: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM approvals \ + WHERE status IN ('allowed', 'denied') AND answered_at >= ?1", + params![since], + |row| row.get(0), + )?; + let (files, added, removed): (i64, i64, i64) = self.conn.query_row( + "SELECT COUNT(DISTINCT json_extract(data, '$.file')), \ + COALESCE(SUM(json_extract(data, '$.lines.added')), 0), \ + COALESCE(SUM(json_extract(data, '$.lines.removed')), 0) \ + FROM chat_items \ + WHERE kind = 'tool' AND created_at >= ?1 \ + AND json_extract(data, '$.status') = 'done' \ + AND json_type(data, '$.lines') = 'object'", + params![since], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + let busiest = self + .conn + .query_row( + "SELECT b.id, b.name, b.color, w.name, \ + SUM(json_extract(c.data, '$.durationMs')) AS work \ + FROM chat_items c JOIN bots b ON b.id = c.bot_id \ + JOIN crews w ON w.id = b.crew_id \ + WHERE c.kind = 'turn' AND c.created_at >= ?1 \ + GROUP BY b.id HAVING work > 0 \ + ORDER BY work DESC, b.name LIMIT 1", + params![since], + |row| { + Ok(BusyBot { + bot_id: parse_column(row, 0)?, + name: row.get(1)?, + color: row.get(2)?, + crew: row.get(3)?, + work_ms: row.get(4)?, + }) + }, + ) + .optional()?; + Ok(DayActivity { + tasks_done: count(done), + tasks_failed: count(failed), + bot_messages: count(bot_messages), + requests_answered: count(answered), + files_edited: count(files), + lines: EditLines { + added: count(added), + removed: count(removed), + }, + busiest, + }) + } +} + +#[cfg(test)] +mod tests { + use rusqlite::params; + + use crate::tests::Fixture; + + #[test] + fn the_day_counts_only_what_happened_since_its_start() { + let fx = Fixture::new(); + let conn = &fx.store.conn; + let (crew, scout, writer) = (&fx.crew.id, &fx.bots[0].id, &fx.bots[1].id); + for (id, status, at) in [ + ("tsk_1", "done", 50), + ("tsk_2", "failed", 60), + ("tsk_3", "expired", 70), + ("tsk_4", "open", 80), + ("tsk_5", "done", 5), + ] { + conn.execute( + "INSERT INTO tasks (id, crew_id, requester_bot_id, assignee_bot_id, status, \ + deadline_at, hops, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, 0, ?6)", + params![id, crew.to_string(), scout.to_string(), writer.to_string(), status, at], + ) + .expect("task"); + } + for (id, kind, at) in [ + ("msg_1", "bot", 20), + ("msg_2", "owner", 30), + ("msg_3", "bot", 1), + ] { + conn.execute( + "INSERT INTO messages (id, crew_id, from_kind, to_bot_id, kind, body, created_at) \ + VALUES (?1, ?2, ?3, ?4, 'note', 'x', ?5)", + params![id, crew.to_string(), kind, writer.to_string(), at], + ) + .expect("message"); + } + let day = fx.store.day_activity(10).expect("day"); + assert_eq!((day.tasks_done, day.tasks_failed), (1, 2)); + assert_eq!(day.bot_messages, 1); + assert_eq!((day.requests_answered, day.files_edited), (0, 0)); + assert_eq!(day.busiest, None); + } +} diff --git a/crates/botloftd/src/mobile/mod.rs b/crates/botloftd/src/mobile/mod.rs index 0b802fb0..96ab1a43 100644 --- a/crates/botloftd/src/mobile/mod.rs +++ b/crates/botloftd/src/mobile/mod.rs @@ -3,7 +3,7 @@ //! or being connected, and nothing of what passes goes in the log. mod bridge; -mod cards; +pub(crate) mod cards; mod chats; mod kdf; mod pair; diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 7f74784e..ddf789cd 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -9,8 +9,9 @@ use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ self, ApiError, ApiResult, agents, archive, attachments, autobackup, backup, bots, catalog, - chat, cloud, crew_access, crews, delete, deliveries, desktop, files, lead, mcp, messages, - mobile, models, modes, questions, reactions, routines, rules, screens, settings, tasks, usage, + chat, cloud, crew_access, crews, delete, deliveries, desktop, files, island, lead, mcp, + messages, mobile, models, modes, questions, reactions, routines, rules, screens, settings, + tasks, usage, }; use crate::state::Daemon; @@ -128,6 +129,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(routines::runs(daemon, parse(params)?)), method::TASKS_LIST => reply(tasks::list(daemon, parse(params)?)), method::BROWSER_LIST => reply(Ok(daemon.browsers.list())), + method::CARDS_LIST => reply(Ok::<_, ApiError>(island::cards(daemon))), + method::ACTIVITY_TODAY => reply(island::today(daemon, parse(params)?)), method::QUESTIONS_LIST => reply(questions::list(daemon, parse(params)?)), method::QUESTIONS_ANSWER => reply(questions::answer(daemon, parse(params)?)), method::QUESTIONS_DISMISS => reply(questions::dismiss(daemon, parse(params)?)), diff --git a/crates/botloftd/src/service/island.rs b/crates/botloftd/src/service/island.rs new file mode 100644 index 00000000..664025bc --- /dev/null +++ b/crates/botloftd/src/service/island.rs @@ -0,0 +1,25 @@ +//! `cards.list` and `activity.today` (spec 31): what the island shows. + +use botloft_core::protocol::{ActivityTodayParams, Cards, DayActivity}; + +use super::{ApiError, ApiResult}; +use crate::mobile::cards; +use crate::state::Daemon; + +/// The requests and questions waiting, as the phone's cards, with no +/// account or phone needed. +pub fn cards(daemon: &Daemon) -> Cards { + let (approvals, questions) = cards::waiting(daemon); + Cards { + approvals, + questions, + } +} + +/// What every crew did since the app's local midnight. +pub fn today(daemon: &Daemon, params: ActivityTodayParams) -> ApiResult { + if params.since < 0 { + return Err(ApiError::validation("since must not be negative")); + } + Ok(daemon.store().day_activity(params.since)?) +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 66d27192..8b13f1e6 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -16,6 +16,7 @@ pub mod delete; pub mod deliveries; pub mod desktop; pub mod files; +pub mod island; pub mod lead; pub mod mcp; pub mod mcp_state; diff --git a/crates/botloftd/tests/island.rs b/crates/botloftd/tests/island.rs new file mode 100644 index 00000000..c594b2c9 --- /dev/null +++ b/crates/botloftd/tests/island.rs @@ -0,0 +1,181 @@ +//! What the island reads (spec 31): the cards that wait for the owner, as +//! the phone sees them but with no account, and what the crews did today. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::BotState; +use common::bots::ready_bot; +use common::mcp::Mcp; +use common::{Client, TestDaemon, stream}; +use serde_json::{Value, json}; + +/// A lead in a turn, ready to ask; also its MCP client for `ask_owner`. +async fn lead( + t: &TestDaemon, + app: &mut Client, +) -> (Value, botloftd::runtime::fake::FakeProcess, Mcp) { + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (bot, process, mcp) = ready_bot(t, app, &crew, "Lead").await; + app.call("messages.send", json!({ "botId": bot["id"], "body": "Go" })) + .await + .expect("send"); + let lines = process.wait_lines(1).await; + process + .emit(stream::replay(lines.last().expect("line"))) + .await; + (bot, process, mcp) +} + +/// Claude Code asks for permission; the decision comes out of the handle. +async fn ask( + process: &botloftd::runtime::fake::FakeProcess, + mcp: &Mcp, + id: &str, + tool: &str, + input: &Value, +) -> tokio::task::JoinHandle { + process + .emit(stream::tool_use(id, tool, input.clone())) + .await; + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + let args = json!({ "tool_name": tool, "input": input, "tool_use_id": id }); + tokio::spawn(async move { mcp.tool("permission_prompt", args).await.expect("decision") }) +} + +/// `cards.list` once it has `approvals` requests and `questions` questions. +async fn cards_when(app: &mut Client, approvals: usize, questions: usize) -> Value { + let mut cards = Value::Null; + for _ in 0..200 { + cards = app.call("cards.list", json!({})).await.expect("cards"); + let count = |name: &str| cards[name].as_array().map_or(0, Vec::len); + if count("approvals") == approvals && count("questions") == questions { + return cards; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("expected {approvals} requests and {questions} questions, got {cards}"); +} + +#[tokio::test] +async fn the_island_sees_what_waits_as_the_phone_does_without_an_account() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let (_bot, process, mut mcp) = lead(&t, &mut app).await; + + let plain = json!({ "command": "git status", "description": "Shows what changed" }); + let first = ask(&process, &mcp, "toolu_1", "Bash", &plain).await; + let durable = json!({ "title": "Fix it" }); + let second = ask( + &process, + &mcp, + "toolu_2", + "mcp__github__create_issue", + &durable, + ) + .await; + let long = json!({ "command": format!("echo {}", "x".repeat(9000)) }); + let _third = ask(&process, &mcp, "toolu_3", "Bash", &long).await; + mcp.tool( + "ask_owner", + json!({ "question": "Coffee?", "options": ["Yes", "No"] }), + ) + .await + .expect("asked"); + + let cards = cards_when(&mut app, 3, 1).await; + let approvals = cards["approvals"].as_array().expect("approvals"); + // Oldest first, each with what the owner needs to decide. + assert_eq!(approvals[0]["text"], "git status"); + assert_eq!(approvals[0]["explanation"], "Shows what changed"); + assert_eq!(approvals[0]["bot"]["name"], "Lead"); + assert_eq!(approvals[0]["crew"], "Ops"); + assert_eq!( + ( + approvals[0]["cut"].clone(), + approvals[0]["atComputer"].clone() + ), + (json!(false), json!(false)) + ); + assert_eq!(approvals[1]["atComputer"], true); + assert_eq!(approvals[2]["cut"], true); + assert_eq!(cards["questions"][0]["text"], "Coffee?"); + assert_eq!(cards["questions"][0]["options"], json!(["Yes", "No"])); + + // Answered at the computer, a card leaves the list. + app.call( + "approvals.answer", + json!({ "approvalId": approvals[0]["approvalId"], "allow": true }), + ) + .await + .expect("answer"); + assert_eq!(first.await.expect("task")["behavior"], "allow"); + let cards = cards_when(&mut app, 2, 1).await; + assert_eq!( + cards["approvals"][0]["approvalId"], + approvals[1]["approvalId"] + ); + app.call( + "approvals.answer", + json!({ "approvalId": approvals[1]["approvalId"], "allow": false }), + ) + .await + .expect("deny"); + assert_eq!(second.await.expect("task")["behavior"], "deny"); +} + +#[tokio::test] +async fn today_counts_the_edits_answers_and_who_worked_most() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let (bot, process, mcp) = lead(&t, &mut app).await; + + let edit = json!({ + "file_path": "C:\\work\\notes.md", + "old_string": "TODO", + "new_string": "First\nSecond", + }); + let decision = ask(&process, &mcp, "toolu_1", "Edit", &edit).await; + let cards = cards_when(&mut app, 1, 0).await; + app.call( + "approvals.answer", + json!({ "approvalId": cards["approvals"][0]["approvalId"], "allow": true }), + ) + .await + .expect("answer"); + decision.await.expect("task"); + process + .emit(stream::tool_result("toolu_1", "Updated", false)) + .await; + process.emit(stream::result(false)).await; + t.until_state(&bot, BotState::Idle).await; + + let today = app + .call("activity.today", json!({ "since": 0 })) + .await + .expect("today"); + assert_eq!(today["filesEdited"], 1); + assert_eq!(today["lines"], json!({ "added": 2, "removed": 1 })); + assert_eq!(today["requestsAnswered"], 1); + assert_eq!(today["busiest"]["name"], "Lead"); + assert_eq!(today["busiest"]["crew"], "Ops"); + assert_eq!(today["busiest"]["workMs"], 1234); + + // A day that starts later saw nothing. + let later = app + .call("activity.today", json!({ "since": i64::MAX / 2 })) + .await + .expect("later"); + assert_eq!(later["filesEdited"], 0); + assert_eq!(later["requestsAnswered"], 0); + assert_eq!(later["busiest"], Value::Null); + let err = app + .call("activity.today", json!({ "since": -1 })) + .await + .expect_err("refused"); + assert!(err.message.contains("since"), "{}", err.message); +} diff --git a/docs/spec.md b/docs/spec.md index 56791387..82198230 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -743,6 +743,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `tasks.list` | `crewId?, status?` | `Task[]` | | `browser.list`, `browser.watch`, `browser.unwatch`, `browser.take`, `browser.release`, `browser.input` | seção 21.7 | o navegador dos bots, a tela ao vivo e o dono no controle | | `screens.list` | seção 22.4 | as telas HTML do bot | +| `cards.list` | — | `{approvals: ApprovalCard[], questions: QuestionCard[]}`: os pedidos e as perguntas que esperam o dono, do mais velho ao mais novo, como o celular os vê (31.5, 28.5), sem exigir conta | +| `activity.today` | `since` (ms Unix, a meia-noite local do app) | `DayActivity`: o que as equipes fizeram desde `since`, sem turno de agente (31.4) | | `questions.list` | `status?` (`open` se ausente) | `Question[]` de bots e crews ativos, da mais nova à mais velha (23.5) | | `questions.answer` | `questionId, answer` | `Question`; a resposta vai ao bot como message do dono (23.3) | | `questions.dismiss` | `questionId` | `Question`; fecha sem avisar o bot (23.3) | @@ -1141,6 +1143,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 11. Ferramentas conectadas: o dono liga servidores MCP próprios (LinkedIn, sistemas internos) a bots escolhidos, sem tirar o `--strict-mcp-config`. Desenho na seção 25. 12. Catálogo de bots: modelos prontos de função (Designer, Pesquisador...) que o dono escolhe na Agência de bots e adiciona à equipe, e que o chefe usa para sugerir bots com instruções melhores. Desenho na seção 26. 13. Modelos de equipe (começar uma equipe já com os bots certos) e resumo diário (o chefe conta, no fim do dia, o que cada bot fez). Desenho na seção 29. +14. Ilha: o Botloft no topo da tela, uma pílula que vira painel, com os pedidos e as perguntas de todas as equipes, os agentes ao vivo, uma conversa rápida e o uso do plano, sem abrir o Botloft. Desenho na seção 31. ## 19. Pontos a verificar na versão alvo do Claude Code @@ -2750,3 +2753,106 @@ Um agente só roda bots quando o Botloft impõe o que a seção 7.5 impõe no Cl | `codex`: leitura de arquivos | Segue **aberta**: o sandbox `read-only` deixa ler qualquer arquivo sem perguntar (`C:\Windows\win.ini` foi lido por comando). Um perfil de permissão (`default_permissions` e `permissions..filesystem` com `deny`) só vale com `windows.sandbox = "elevated"`, que precisa da configuração de administrador do Codex; mesmo onde ela existe, pastas novas dentro do perfil do usuário ficam ilegíveis para o usuário do sandbox (a pasta de trabalho falhou com `CreateProcessWithLogonW failed: 267`). Por isso o aviso do diálogo continua ("ainda pode ler qualquer arquivo") | não resolvido | | `codex`: uso do plano | `account/rateLimits/read` (sem custo de cota) devolveu, com o `codex-cli` 0.148.0-alpha.9 e o plano gratuito, `rateLimits.primary { usedPercent: 100, windowDurationMins: 43200, resetsAt: 1794165867 }` (segundos), `planType: "free"` e `rateLimitReachedType: "rate_limit_reached"`. O daemon lê isso depois do `initialize` e de novo a cada `account/rateLimits/updated` (que é esparso), e a janela de uso do app mostra o Codex com o nome e o plano, ao lado do Claude Code (`SystemStatus.agentUsage`). Janelas de 300 e 10080 minutos têm os nomes das do Claude Code; 43200 é `thirty_day` | feito | | `codex`: cerca por nome parecido | A cerca de pastas casava por texto: uma crew `Suite` fechava a pasta `Suite2\Ana` e a edição era negada sem cartão. Agora o nome da pasta tem que terminar ali (`Suite2` e `Suite.old` são outras pastas) | feito | + +## 31. Ilha + +Status: **I1 feito** (os dois métodos do daemon); I2 a I5 em desenho. A ideia e a aparência vêm do Coucou (`docs/coucou-analysis.md`, fora do git): uma ilha preta presa na borda de cima da tela, que cresce da pílula até um painel. Daqui só sai a ideia; o desenho, o mascote, os sons, os textos e o código são nossos (regra de autoria). O Mochi e os sons do Coucou têm direitos reservados e nunca entram. + +### 31.1 O que é + +Os agentes trabalham enquanto o dono faz outra coisa. A **ilha** é o Botloft no topo da tela: uma forma preta que sai da borda de cima, no meio, sempre por cima das outras janelas. Encolhida, é uma **pílula** com o mascote de quem trabalha ou espera. Aberta, é um **painel** com abas: os agentes ao vivo, os pedidos e as perguntas, uma conversa rápida e o uso do plano. Quando um agente precisa do dono, ela **desce sozinha** com o pedido; quando um termina, o mascote **comemora**. + +O dono acompanha e responde sem trocar de janela. O que não cabe na ilha (pedido longo, decisão durável, a conversa inteira) tem "Abrir no Botloft", que leva ao lugar certo. + +No texto que o dono lê, a ilha não tem nome próprio: a opção é "Mostrar o Botloft no topo da tela" (Configurações → Avisos). + +### 31.2 Aparência + +A ilha parece uma extensão da borda da tela, não uma janela. + +- **Forma.** Preta de verdade (`#000`) nos dois temas, como a câmera de um notebook. Os cantos de cima são retos e encostam na borda da tela; os de baixo são redondos (raio de 22 px na pílula, 28 px no painel). Sem borda, sem sombra pesada: uma sombra leve embaixo só para separar de um fundo escuro. +- **Pílula** (encolhida): 200 × 36 px. No meio, o mascote do agente mais importante (quem espera > quem trabalha), em 24 px. Dos lados, sinais pequenos: à esquerda, o ponto de aviso com o número de quem espera; à direita, quantos trabalham. Sem nada acontecendo, a pílula some. +- **Painel** (aberta): 720 px de largura, altura pelo conteúdo, até 60% da tela. Em cima, uma **barra de abas** de ícones (Início, Conversa, Uso) à esquerda e, à direita, Pausar todas as equipes, som ligado/desligado e Abrir o Botloft. Embaixo, os **cartões**: cinza muito escuro sobre o preto, raio de 16 px, com espaço de 8 px entre eles. +- **Mascote grande.** No cartão de um agente, o mascote em 72 px à esquerda, com um brilho suave da cor dele por trás, o nome e o motor embaixo ("Scout · Claude Code"), e a lista de passos. +- **Tipografia e cor.** A do app (15.3), em texto claro sobre o escuro. Os tons de estado (`ok`, `work`, `warn`, `danger`) são os do tema escuro; o "+N −M" e o diff usam as cores de 15.3 (8.2). Código em letra mono, com números de linha. +- **Tokens próprios**, `--island-*` em `island.css`, escuros nos dois temas como os do terminal (`--terminal*`, 15.1): fundo, cartão, linha, texto, texto apagado. +- **Crescer e encolher.** A pílula vira painel crescendo a partir do topo (largura e altura com mola curta, 320 ms; o conteúdo aparece 80 ms depois). Fechar faz o caminho de volta. Com "Menos animações" ou `prefers-reduced-motion`, troca sem crescer. + +### 31.3 Onde ela mora + +- **No app, não no daemon.** É uma segunda janela do Tauri (rótulo `island`), criada pelo processo do app ao subir. Sem o app aberto (nem janela, nem ícone perto do relógio), não há ilha; o aviso do Windows segue como hoje. +- **Página própria**, `island.html` com `src/island/`, no mesmo build do Vite que o `index.html`. Abre a própria conexão com o daemon (o token por `read_owner_token`, 15.2) e usa só `BotloftApi` e `Host` (15.1). Os testes usam `FakeBotloft`. +- **Janela:** `decorations: false`, `transparent: true`, `alwaysOnTop: true`, `skipTaskbar: true`, `resizable: false`, `shadow: false` e **sem foco ao aparecer** (`focus: false`). A janela tem sempre o tamanho do que mostra (a pílula ou o painel), porque a parte transparente não deixa o clique passar no Windows; a página mede e pede o tamanho (`setSize`, `setPosition`) antes de crescer, e a forma cresce dentro dela. +- **Capacidade própria** (`capabilities/island.json`, `windows: ["island"]`): mostrar, esconder, tamanho e posição dela mesma, `read_owner_token`, o evento que abre a janela principal num chat e o arrastar de arquivos para dentro. Nada de abrir arquivo, link ou pasta. + +### 31.4 Abas e utilidades + +**Início.** O que pede o dono vem primeiro, depois quem trabalha. + +- **Pedidos e perguntas.** Um por vez, o mais antigo primeiro, com "2 de 3" e setas. Cada um com o mascote e o nome do agente, a equipe, a ação no idioma do dono ("Rodar um comando"), a explicação, o comando, o arquivo com "+N −M" (8.2) ou o plano, e os botões. Uma pergunta mostra o texto, as opções como botões e um campo para responder com as próprias palavras. Regras em 31.5. +- **Agentes ao vivo.** Um cartão por agente trabalhando (`busy`), de todas as equipes, os que começaram antes primeiro, até três (o resto vira "+2 trabalhando", que abre a lista). No cartão: o mascote grande, o nome, o motor e a equipe; à esquerda, os **passos** do turno, as últimas quatro ferramentas como linhas curtas ("Ler um arquivo", "Editar um arquivo", "Rodar um comando") com o estado (feito, rodando, falhou); à direita, o que ele está fazendo agora: numa edição, o **diff** com o nome do arquivo numa aba e os números de linha quando a entrada veio inteira (8.2, 15.3); num comando, o comando e a explicação; numa resposta, o texto ao vivo (`chat.delta`), cortado nas últimas linhas. Um clique no cartão abre o chat dele no Botloft. +- **Comemoração.** Quando um agente passa de trabalhando a disponível, o mascote dele pula na pílula (o pulo de 15.3, `cheer`), e a ilha mostra por 4 s "Scout terminou" com a primeira linha da resposta. Com o som ligado, um som curto nosso, feito na hora como os de 15.1. Se o dono desligou "Avisar quando um agente terminar" (15.1), não comemora. +- **Resumo do dia.** No fim da aba, sem agentes trabalhando: um cartão "Hoje" com o que as equipes fizeram desde a meia-noite, contado pelo daemon, sem turno de agente e sem gastar plano: tarefas concluídas e com falha, mensagens entre agentes, pedidos respondidos, arquivos editados com o total de "+N −M", e o agente que mais trabalhou. Um método novo, `activity.today {since}`, soma isso do banco (`today.rs` do store): `since` é a meia-noite local que o app manda, porque o fuso é do computador do dono. Tarefas contam pelo `updated_at` (concluídas; com falha ou vencidas); mensagens, as de `from_kind = bot`; pedidos, os permitidos ou negados com `answered_at` no dia; edições, os itens `tool` terminados bem com `lines` (8.2), e os arquivos distintos pelo `file`; quem mais trabalhou, a soma do `durationMs` dos turnos. + +**Conversa.** Uma conversa rápida com qualquer agente. + +- Em cima, o agente escolhido (mascote, nome e equipe), que troca num menu com todos os agentes, os que esperam ou trabalham primeiro. Embaixo, as últimas falas dos dois, a resposta chegando ao vivo, e o campo em forma de pílula com o botão de enviar. Enviar é `messages.send`, como o compositor (15.1). As ferramentas aparecem como uma linha só ("Rodou 2 comandos"), e "Abrir no Botloft" leva ao chat inteiro. +- **Arquivo.** Arrastar um arquivo para a pílula ou para o painel abre a Conversa com ele anexado ao agente escolhido (o mascote abre a boca enquanto o arquivo está em cima). Valem os limites dos anexos (9.5): até 10 arquivos, `attachment_max_mb` cada. + +**Uso.** + +- **Plano.** As duas janelas do plano do Claude (5 horas e semana, `system.status.usage`, 8.1) como barras, com quanto foi usado e quando renovam; os outros motores (30) embaixo, quando houver. As barras ficam na cor de aviso a partir de 80%. Na pílula, a de 5 horas aparece como um anel fino em volta do mascote quando passa de 80%. +- **Rotinas.** As próximas três rotinas de todas as equipes (`nextRunAt`, 20.3), com o agente, o nome e quanto falta ("em 25 min"); "Rodar agora" usa o mesmo método das Rotinas (20.8). + +### 31.5 Os pedidos na ilha + +- **Do daemon, como o celular.** Um método novo, `cards.list`, devolve `{approvals: ApprovalCard[], questions: QuestionCard[]}`, os mesmos cartões que o celular recebe (28.5), montados pela mesma função (`mobile::cards::waiting`), com `cut` e `atComputer`. Não exige conta nem celular conectado. A página relê a lista quando chega `chat.item` de pedido ou pergunta, `question.changed` ou `bot.state`, juntando os avisos de 200 ms. +- **As mesmas regras do celular** (28.5), aplicadas pelo daemon: + 1. Só permite o que mostra inteiro: cartão `cut` tem só "Negar" e "Abrir no Botloft". + 2. Só "uma vez": a ilha não grava "Permitir sempre" nem edita a entrada; "Abrir no Botloft" leva ao cartão do chat, que tem tudo. + 3. O que muda o computador de forma durável (`atComputer`: sugestão de agente, rotina, acesso a outra equipe, desktop, ferramentas conectadas, navegador em mãos) aparece como "Precisa de você no Botloft", com "Negar" quando couber e "Abrir no Botloft". + 4. Responder chama `approvals.answer` e `questions.answer` como o chat: `conflict` se já foi respondido em outro lugar, e o cartão some. + +### 31.6 Quando ela aparece + +- **Desce** aberta na aba Início quando chega um pedido ou pergunta, se a janela principal do Botloft não está em primeiro plano. Com ela na frente, o pedido já aparece no chat e a ilha fica na pílula. +- **Fica na pílula** enquanto agentes trabalham e nada espera o dono, e **some** quando não há nada. "Esconder" (ou Esc, depois de clicar nela) volta à pílula. +- **Abre** com um clique na pílula, e **espia** com o mouse parado na borda de cima, sobre a pílula, por 300 ms; fecha 1 s depois de o mouse sair, se o dono não clicou dentro. O app lê a posição do mouse a cada 120 ms (`cursor_position` do Tauri, sem Win32 no código), só com a ilha ligada. +- **Não desce sobre tela cheia nem apresentação.** Quando o Windows não mostraria um aviso (`SHQueryUserNotificationState` diferente de `QUNS_ACCEPTS_NOTIFICATIONS`: jogo ou vídeo em tela cheia, apresentação, "Não incomodar"), a ilha só mostra a pílula, sem texto de pedido nem comemoração. A chamada fica num módulo do app só para Windows (`src-tauri/src/quiet.rs`). +- **Monitor:** o principal, ou "onde está o mouse" (Configurações), no meio da borda de cima da área de trabalho. +- **No lugar do aviso do Windows.** Com a ilha ligada e podendo descer, os avisos de "precisa de você" e de "terminou" (15.1) não saem, para nada aparecer duas vezes; o som toca do mesmo jeito. Quando a ilha só pode mostrar a pílula, os avisos seguem a regra do Windows, que é não incomodar. + +### 31.7 Segurança + +- **Nunca rouba o foco.** A ilha aparece sem foco, então um Enter digitado em outro programa não aprova nada nem envia mensagem. O teclado só age na ilha depois de o dono clicar nela. +- **Clique por engano.** Um cartão de pedido que acabou de aparecer ignora cliques nos botões por 600 ms. +- **Nada novo no log** (13): a página não loga cartão, resposta, conversa nem arquivo; `cards.list` e `activity.today` não logam conteúdo. +- **O que aparece na tela** é o que o chat mostra. Em tela compartilhada sem modo apresentação, o texto aparece: a opção pode ser desligada. + +### 31.8 Movimento e CPU + +A chama trabalhando (15.3) é a animação mais cara do app (#162), e a ilha fica sempre à vista: ela **não usa a chama animada**. Na pílula e nos cartões, o mascote é o desenho parado do estado, e quem trabalha "respira" (escala e opacidade, 2 s, só `transform` e `opacity`); os passos usam o círculo girando do chat. Comemoração, crescer e encolher tocam uma vez. Com "Menos animações", nada se mexe sozinho. Meta, medida no I5: a ilha na pílula com dois agentes trabalhando gasta menos de 1% de CPU, e escondida, nada além dos avisos do daemon. + +### 31.9 Configurações e dados + +Configurações → Avisos: **"Mostrar o Botloft no topo da tela"** (`botloft.island`, ligada por padrão), **"Em qual tela"** (principal ou onde está o mouse, `botloft.islandScreen`) e **"Comemorar quando um agente terminar"** (`botloft.islandCheer`, ligada). Ficam no `localStorage` como as outras escolhas do app (15.1). Nada muda no banco; o protocolo ganha `cards.list` e `activity.today`. + +Textos nos três idiomas (15.6), sem jargão: "Precisa de você no Botloft", "Abrir no Botloft", "Esconder", "3 esperando", "3 trabalhando", "Scout terminou", "Hoje". + +### 31.10 Fora desta etapa + +- Linux e macOS: a opção fica escondida até a janela ser vista nos dois (Wayland sem posição livre, macOS com o notch de verdade). +- Atalho global para abrir a ilha e responder pelo teclado. +- "Permitir sempre" na ilha. +- O mascote solto na área de trabalho. +- Serviços de fora (GitHub, Vercel) e música. + +### 31.11 Marcos + +| Marco | O que entra | Teste | +|---|---|---| +| **I1** Daemon | `cards.list` com os cartões do celular, sem exigir conta; `activity.today` | `tests/`: um pedido e uma pergunta abertos aparecem; cortado com `cut`; sugestão de agente com `atComputer`; respondido some; `activity.today` conta tarefas, mensagens, pedidos e "+N −M" de hoje e ignora ontem (`tests/island.rs`, `today.rs`). **Feito** | +| **I2** Janela, forma e pedidos | a janela `island`, a capacidade própria, `island.html`, os tokens `--island-*`, pílula e painel, a aba Início com os pedidos, "Abrir no Botloft", três idiomas | `FakeBotloft`: pedido aparece e responde; `cut` e `atComputer` só com Negar e Abrir; `conflict` some sem erro; cliques nos primeiros 600 ms ignorados; pílula com o número certo; `pnpm check`; a forma vista na prévia (`pnpm dev`, `?island`) nos dois temas | +| **I3** Agentes ao vivo, comemoração e resumo | cartões dos agentes com passos e diff, texto ao vivo, "+2 trabalhando", comemoração, cartão Hoje | `FakeBotloft`: um agente edita e o cartão mostra o passo e o diff; outro termina e comemora; o resumo com os números do fake | +| **I4** Conversa, arquivo e uso | aba Conversa com escolha do agente, texto ao vivo e envio; arrastar arquivo; aba Uso com plano e rotinas, "Rodar agora"; anel na pílula | `FakeBotloft`: mandar e ver a resposta; arquivo anexado e grande demais recusado; barras na cor de aviso a partir de 80%; rotinas na ordem com "em 25 min" | +| **I5** Quando aparece e teste real | descer, pílula, espiar, janela principal na frente, tela cheia e apresentação, no lugar do aviso do Windows; Configurações; CPU medida | lógica de quando aparecer com relógio falso (`FakeHost`); `quiet.rs` com os estados do Windows; **a verificar** (19): sem foco ao aparecer, transparência no WebView2, sobre tela cheia; CPU pelo CDP (`webview-frame-measuring`); manual com o Claude Code real: com o Botloft atrás de outra janela, um agente pede um comando, a ilha desce sem foco, Permitir pela ilha, o chat mostra "Permitido"; outro agente termina e comemora |