diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index f2567c9..b2ec4dc 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 0000000..8063874 --- /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 0000000..070e7b6 --- /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 022c8c0..4d2b875 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 d2c2b13..0469553 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 0000000..3e7ff83 --- /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 0000000..51d05aa --- /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 478ec58..8d82c95 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 9f61516..878948a 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 0000000..4df3344 --- /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 0b802fb..96ab1a4 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 7f74784..ddf789c 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 0000000..664025b --- /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 66d2719..8b13f1e 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 0000000..c594b2c --- /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 5679138..8219823 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 |