From 2aa2101a1dca3c3008b24251719a3631c28cff59 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:24:22 -0300 Subject: [PATCH 1/3] feat(tools): share_file shows the owner files the bot made The bot passes 1 to 10 paths and each becomes a card in its chat. Only files the files panel could read are accepted (the crew's work folder, the bot's folder, or files it wrote), using the same check as files.read. The bot rules and the tool description ask for it when a file is done and when the owner asks for one again. --- crates/botloft-core/src/chat.rs | 15 ++++ crates/botloftd/src/service/files/mod.rs | 72 ++++++++++++++---- crates/botloftd/src/tools/calls.rs | 2 + crates/botloftd/src/tools/catalog.rs | 3 +- crates/botloftd/src/tools/mod.rs | 4 +- crates/botloftd/src/tools/share.rs | 67 +++++++++++++++++ crates/botloftd/src/workspace/files.rs | 4 + crates/botloftd/tests/mcp.rs | 3 +- crates/botloftd/tests/share_file.rs | 96 ++++++++++++++++++++++++ 9 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 crates/botloftd/src/tools/share.rs create mode 100644 crates/botloftd/tests/share_file.rs diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index 26ab3567..6c37c7ba 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -109,6 +109,14 @@ pub fn tool_summary(name: &str, input: &Value) -> String { field(input, "task").map(str::to_owned) } "mcp__botloft__browser_open" => field(input, "url").map(str::to_owned), + "mcp__botloft__share_file" => input.get("files").and_then(Value::as_array).map(|files| { + let names: Vec = files + .iter() + .filter_map(Value::as_str) + .map(file_name) + .collect(); + names.join(", ") + }), // Element refs ("e12") mean nothing to the owner, and a direction // needs words: the app says those (spec 21.8). "mcp__botloft__browser_press" => field(input, "key").map(str::to_owned), @@ -239,6 +247,13 @@ mod tests { "notebook jupyter" ); assert_eq!(tool_summary("SomethingNew", &json!({ "a": 1 })), ""); + assert_eq!( + tool_summary( + "mcp__botloft__share_file", + &json!({ "files": [r"C:\work\report.pdf", "out/chart.png"] }) + ), + "report.pdf, chart.png" + ); assert_eq!( tool_summary( "mcp__botloft__browser_open", diff --git a/crates/botloftd/src/service/files/mod.rs b/crates/botloftd/src/service/files/mod.rs index 684d5fd6..6aa85140 100644 --- a/crates/botloftd/src/service/files/mod.rs +++ b/crates/botloftd/src/service/files/mod.rs @@ -121,14 +121,18 @@ fn file_name(path: &Path) -> String { .map_or_else(String::new, |name| name.to_string_lossy().into_owned()) } -/// `files.read`: the bytes of a file `files.list` can show. A path outside -/// the bot's folders that the bot did not write is refused, so the panel -/// cannot be used to read anything else on the computer. -pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { - let (_, places) = places(daemon, ¶ms.bot_id)?; - let asked = PathBuf::from(¶ms.path); - let gone = || ApiError::NotFound(format!("{} is no longer there", params.path)); - let path = asked.canonicalize().map_err(|_| gone())?; +/// Why a path is not one of the bot's files. +enum Refused { + /// Nothing is there, or it is not a file. + Gone, + /// Outside the bot's folders, and the bot did not write it. + Outside, +} + +/// The real path of one of the bot's files: inside one of its two folders, +/// or written by its own calls. `asked` must be absolute. +fn reach(places: &Places, asked: &Path) -> Result<(PathBuf, std::fs::Metadata), Refused> { + let path = asked.canonicalize().map_err(|_| Refused::Gone)?; let inside = [&places.work, &places.workspace] .into_iter() .filter_map(|root| root.canonicalize().ok()) @@ -139,15 +143,28 @@ pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { .filter_map(|file| Path::new(file).canonicalize().ok()) .any(|file| file == path); if !asked.is_absolute() || !(inside || written) { - return Err(ApiError::NotFound(format!( - "{} is not one of this bot's files", - params.path - ))); + return Err(Refused::Outside); } - let meta = std::fs::metadata(&path).map_err(|_| gone())?; + let meta = std::fs::metadata(&path).map_err(|_| Refused::Gone)?; if !meta.is_file() { - return Err(gone()); + return Err(Refused::Gone); } + Ok((path, meta)) +} + +/// `files.read`: the bytes of a file `files.list` can show. A path outside +/// the bot's folders that the bot did not write is refused, so the panel +/// cannot be used to read anything else on the computer. +pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { + let (_, places) = places(daemon, ¶ms.bot_id)?; + let gone = || ApiError::NotFound(format!("{} is no longer there", params.path)); + let (path, meta) = + reach(&places, Path::new(¶ms.path)).map_err(|refused| match refused { + Refused::Gone => gone(), + Refused::Outside => { + ApiError::NotFound(format!("{} is not one of this bot's files", params.path)) + } + })?; if meta.len() > PREVIEW_MAX_BYTES { return Err(ApiError::validation(format!( "the file is larger than {} MB", @@ -160,3 +177,30 @@ pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { data: BASE64.encode(bytes), }) } + +/// A file the bot shows the owner in the chat (`share_file`, spec 10): one +/// `files.read` can read, so the owner can preview and save it. A relative +/// path is in the bot's own folder, where it runs. +pub fn shared(daemon: &Daemon, bot_id: &BotId, path: &str) -> ApiResult { + let (_, places) = places(daemon, bot_id)?; + let asked = std::path::absolute(places.workspace.join(path.trim())) + .map_err(|_| ApiError::NotFound(format!("{path} is not a file path")))?; + let (_, meta) = reach(&places, &asked).map_err(|refused| { + ApiError::NotFound(match refused { + Refused::Gone => format!("There is no file at {}", asked.display()), + Refused::Outside => format!( + "{} is outside the work folder and your own folder: copy it into the work \ + folder first, then share the copy", + asked.display() + ), + }) + })?; + let written = keys(&places.written).contains(&key(&asked)); + let found = Found { + size: meta.len(), + modified_at: millis(&meta), + path: asked, + }; + let folders = [places.work.as_path(), places.workspace.as_path()]; + Ok(describe(found, written, &folders)) +} diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs index 6afe4f5d..481e0d30 100644 --- a/crates/botloftd/src/tools/calls.rs +++ b/crates/botloftd/src/tools/calls.rs @@ -16,6 +16,7 @@ use tracing::warn; use super::Failure; use super::catalog::{COMPLETE_TASK, CREW_ROSTER, MY_TASKS, SEND_MESSAGE}; +use super::share::{self, SHARE_FILE}; use crate::service::tasks::{self, BotMessage}; use crate::service::{ApiError, bots, lead}; use crate::state::Daemon; @@ -36,6 +37,7 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result parse(arguments).and_then(|args| send(daemon, bot, args)), COMPLETE_TASK => parse(arguments).and_then(|args| complete(daemon, bot, args)), MY_TASKS => parse(arguments).and_then(|args| my_tasks(daemon, bot, args)), + SHARE_FILE => parse(arguments).and_then(|args| share::share(daemon, bot, args)), other => return Err(invalid(&format!("Unknown tool: {other}"))), }; Ok(tool_result(outcome)) diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 60c1079f..b96e8cc6 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -24,7 +24,8 @@ pub fn tools() -> Value { if let Value::Array(list) = &mut tools { // Before permission_prompt, which stays last. let at = list.len() - 1; - list.splice(at..at, super::browser_catalog::tools()); + let more = std::iter::once(super::share::tool()).chain(super::browser_catalog::tools()); + list.splice(at..at, more); } tools } diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index 4ab23ce2..5c4984ef 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -16,6 +16,7 @@ mod calls; mod catalog; mod era; mod routine; +mod share; mod suggest; use std::sync::Arc; @@ -40,7 +41,8 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000; const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \ or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \ - suggest new bots. schedule_routine asks the owner for work at set times. The browser_ tools drive your own web browser, which the owner can watch \ + suggest new bots. schedule_routine asks the owner for work at set times. share_file shows the \ + owner files you made, as cards in the chat they can open and save. The browser_ tools drive your own web browser, which the owner can watch \ live and take over when you ask with browser_ask_owner. The owner writes to you directly; messages from other bots and from Botloft start with \ [botloft]."; diff --git a/crates/botloftd/src/tools/share.rs b/crates/botloftd/src/tools/share.rs new file mode 100644 index 00000000..96e1b05e --- /dev/null +++ b/crates/botloftd/src/tools/share.rs @@ -0,0 +1,67 @@ +//! `share_file` (spec 10): the bot shows the owner files it made, as cards +//! in the chat they can open and save. The card is the tool's own chat +//! item; this only checks the files and describes them. + +use botloft_core::ids::BotId; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::calls::explain; +use crate::service::files; +use crate::state::Daemon; + +pub const SHARE_FILE: &str = "share_file"; +/// Files in one card group, as many as the owner can attach to a message. +pub const SHARE_MAX: usize = 10; + +pub(super) fn tool() -> Value { + json!({ + "name": SHARE_FILE, + "title": "Share a file", + "description": "Shows the owner files you made, or that they ask you for, as cards in \ + your chat: they can preview each one, open it and save a copy anywhere. Use it \ + whenever you finish a file for the owner, and when they ask for one again, instead of \ + writing its path in your reply. Only files in the crew's work folder or your own \ + folder, or files you wrote, can be shared.", + "inputSchema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": SHARE_MAX, + "description": "Paths of the files: absolute, or relative to your own \ + folder. Write them with forward slashes (C:/folder/file.md).", + }, + }, + "required": ["files"], + "additionalProperties": false, + }, + "annotations": { "readOnlyHint": true }, + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ShareArgs { + files: Vec, +} + +/// Every file must be one the owner can read; otherwise none is shown and +/// the bot reads which one and why. +pub(super) fn share(daemon: &Daemon, bot: &BotId, args: ShareArgs) -> Result { + if args.files.is_empty() || args.files.len() > SHARE_MAX { + return Err(format!("files takes 1 to {SHARE_MAX} paths")); + } + let shown = args + .files + .iter() + .map(|path| files::shared(daemon, bot, path).map_err(explain)) + .collect::, _>>()?; + Ok(json!({ + "shown": shown, + "note": "The owner sees each file as a card in your chat, with buttons to open and save \ + it. You don't need to repeat the paths in your reply.", + })) +} diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index d7319e41..442fa86c 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -121,6 +121,10 @@ app; the other bots of your crew send you messages too. their chat as markdown: keep them short and clear, and say what you did. - Files the owner sends are saved in `attachments/` in this folder; the message lists them. Images also come inline. +- When you finish a file for the owner (a report, a spreadsheet, an image), + call `share_file` with its path: they get a card in the chat to preview, + open and save it. Do the same when they ask again for a file you made. + Don't write the path in your reply instead. - When you need a permission, the owner gets a request in the chat with Allow and Deny. If they deny it, find another way or ask them. - The owner may not be technical. Every time you run a command, fill in its diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index 26c7608d..86f026a6 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -73,6 +73,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "my_tasks", "suggest_bot", "schedule_routine", + "share_file", "browser_open", "browser_look", "browser_click", @@ -128,7 +129,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(18) + Some(19) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/crates/botloftd/tests/share_file.rs b/crates/botloftd/tests/share_file.rs new file mode 100644 index 00000000..5222d99d --- /dev/null +++ b/crates/botloftd/tests/share_file.rs @@ -0,0 +1,96 @@ +//! `share_file` (spec 10): the bot shows the owner its files as cards in +//! the chat, and only files the app may read. + +mod common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use common::TestDaemon; +use common::mcp::Mcp; +use serde_json::{Value, json}; + +fn put(path: &Path, text: &str) { + fs::create_dir_all(path.parent().expect("parent")).expect("dirs"); + fs::write(path, text).expect("write"); +} + +/// A running bot, its MCP client and its two folders: its own and the crew's. +async fn one_bot() -> (TestDaemon, Mcp, PathBuf, PathBuf) { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let bot = app.call("bots.create", bot).await.expect("bot"); + let process = t.process_of(&bot).await; + let client = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + let workspace = PathBuf::from(bot["workspace"].as_str().expect("workspace")); + let work = workspace.parent().expect("crew folder").join("shared"); + (t, client, workspace, work) +} + +#[tokio::test] +async fn a_bot_shares_files_from_its_folders() { + let (_t, mut client, workspace, work) = one_bot().await; + put(&work.join("report.pdf"), "pdf!"); + put(&workspace.join("out/chart.png"), "png"); + + let shared = client + .tool( + "share_file", + json!({ "files": [work.join("report.pdf"), "out/chart.png"] }), + ) + .await + .expect("shared"); + let shown = shared["shown"].as_array().expect("shown"); + assert_eq!(shown.len(), 2); + assert_eq!(shown[0]["name"], "report.pdf"); + assert_eq!(shown[0]["mediaType"], "application/pdf"); + assert_eq!(shown[0]["size"], 4); + assert_eq!(shown[0]["folder"], ""); + // A relative path is in the bot's own folder, where it runs. + assert_eq!(shown[1]["name"], "chart.png"); + assert_eq!(shown[1]["folder"], "out"); + let chart = PathBuf::from(shown[1]["path"].as_str().expect("path")); + assert!(chart.is_absolute(), "{}", chart.display()); + assert!(chart.ends_with(Path::new("out").join("chart.png"))); +} + +#[tokio::test] +async fn nothing_outside_its_folders_is_shared() { + let (_t, mut client, _workspace, work) = one_bot().await; + put(&work.join("ok.txt"), "ok"); + let elsewhere = tempfile::tempdir().expect("tempdir"); + let private = elsewhere.path().join("private.txt"); + put(&private, "private"); + + let outside = client + .tool( + "share_file", + json!({ "files": [work.join("ok.txt"), private] }), + ) + .await + .expect_err("outside"); + assert!( + outside.contains("copy it into the work folder"), + "{outside}" + ); + + for (files, says) in [ + (json!([work.join("missing.txt")]), "There is no file"), + (json!([work]), "There is no file"), + (json!([]), "1 to 10"), + ] { + let err = client + .tool("share_file", json!({ "files": files })) + .await + .expect_err("refused"); + assert!(err.contains(says), "{err}"); + } + let unknown: Value = json!({ "files": ["ok.txt"], "caption": "x" }); + let err = client.tool("share_file", unknown).await.expect_err("args"); + assert!(err.contains("invalid arguments"), "{err}"); +} From 4d549959a873602a29caeef2855c0d968cd07ab5 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:24:23 -0300 Subject: [PATCH 2/3] feat(app): save_file_as copies a bot file where the owner picks The command opens the system Save As dialog itself, so the page can never write anywhere the owner did not choose. --- app/src-tauri/src/lib.rs | 9 ++++++++ app/src-tauri/src/save.rs | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 app/src-tauri/src/save.rs diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 57e82be4..6b4a5578 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod claude; mod daemon; mod open; +mod save; mod sign_in; #[cfg(unix)] mod terminal; @@ -118,6 +119,13 @@ async fn reveal_file(path: String) -> Result<(), String> { .await } +/// Saves a copy of a bot's file where the owner picks with the system's +/// Save dialog. Returns false when they cancel. +#[tauri::command] +async fn save_file_as(app: tauri::AppHandle, path: String) -> Result { + blocking(move || save::copy_as(&app, Path::new(&path))).await +} + /// Opens Botloft when the owner signs in to Windows, or stops doing so. #[tauri::command] async fn open_at_sign_in(on: bool) -> Result<(), String> { @@ -184,6 +192,7 @@ pub fn run() { open_path, open_file, reveal_file, + save_file_as, open_url, open_at_sign_in, launched_at_sign_in diff --git a/app/src-tauri/src/save.rs b/app/src-tauri/src/save.rs new file mode 100644 index 00000000..b6e0666f --- /dev/null +++ b/app/src-tauri/src/save.rs @@ -0,0 +1,47 @@ +//! Saving a copy of a bot's file where the owner picks (spec 15.2). The +//! system's Save dialog chooses the place, so a page can never write a file +//! anywhere the owner did not pick. + +use std::path::Path; + +use tauri::{AppHandle, Manager as _}; +use tauri_plugin_dialog::DialogExt as _; + +/// Asks where to save a copy of `path` and copies it there. `Ok(false)` +/// when the owner cancels. +pub fn copy_as(app: &AppHandle, path: &Path) -> Result { + if !path.is_absolute() || !path.is_file() { + return Err(format!("{} is not a file", path.display())); + } + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut dialog = app.dialog().file().set_file_name(&name); + if let Some(extension) = path.extension().and_then(|extension| extension.to_str()) { + dialog = dialog.add_filter(extension.to_uppercase(), &[extension]); + } + if let Some(window) = app.get_webview_window("main") { + dialog = dialog.set_parent(&window); + } + let Some(chosen) = dialog.blocking_save_file() else { + return Ok(false); + }; + let target = chosen.into_path().map_err(|err| err.to_string())?; + // The system dialog already asked before replacing a file; copying a + // file onto itself would empty it. + if same_file(path, &target) { + return Ok(true); + } + std::fs::copy(path, &target) + .map(drop) + .map_err(|err| format!("could not save {}: {err}", target.display()))?; + Ok(true) +} + +fn same_file(a: &Path, b: &Path) -> bool { + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} From 6d2c982e1c8ff80b47201f8a9481d31b1bb3182f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:24:24 -0300 Subject: [PATCH 3/3] feat(chat): cards for files the bot shares A successful share_file becomes one card per file, with the type icon or an image thumbnail, name, size, Open and Save as. Clicking the file opens its preview in the files panel. A failed call keeps the tool line. --- app/src/dev/seedChats.ts | 12 ++- app/src/features/bots/BotView.tsx | 8 +- app/src/features/chat/BotRun.tsx | 8 +- app/src/features/chat/SharedFiles.tsx | 118 +++++++++++++++++++++ app/src/features/chat/images.ts | 51 +++++++-- app/src/features/chat/rows.ts | 13 ++- app/src/features/chat/shared.ts | 39 +++++++ app/src/features/chat/sharedFiles.test.tsx | 99 +++++++++++++++++ app/src/features/files/FilesPanel.tsx | 7 +- app/src/features/files/showFile.ts | 8 +- app/src/i18n/en/files.ts | 5 + app/src/i18n/en/tools.ts | 1 + app/src/i18n/es/files.ts | 5 + app/src/i18n/es/tools.ts | 1 + app/src/i18n/pt-BR/files.ts | 5 + app/src/i18n/pt-BR/tools.ts | 1 + app/src/lib/fakeFiles.ts | 20 +++- app/src/lib/fakeHost.ts | 8 ++ app/src/lib/host.ts | 5 + app/src/lib/tauriHost.ts | 1 + docs/spec.md | 10 +- 21 files changed, 402 insertions(+), 23 deletions(-) create mode 100644 app/src/features/chat/SharedFiles.tsx create mode 100644 app/src/features/chat/shared.ts create mode 100644 app/src/features/chat/sharedFiles.test.tsx diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts index f5af5f08..0c6cac80 100644 --- a/app/src/dev/seedChats.ts +++ b/app/src/dev/seedChats.ts @@ -97,11 +97,19 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void { writtenByBot: true, at: fake.now, }); - made.add(crew.scout, "papers.csv", { + const papers = made.add(crew.scout, "papers.csv", { text: "paper,pages,deep_read\nRetrieval at scale,14,yes\nAgents that plan,22,yes\nSmall evals,9,maybe\n", at: fake.now + MINUTE, }); - made.add(crew.scout, "week-39-report.docx", { text: "docx", at: fake.now + 2 * MINUTE }); + const report = made.add(crew.scout, "week-39-report.docx", { + text: "docx", + at: fake.now + 2 * MINUTE, + }); + const shared = chat.tool(crew.scout, "mcp__botloft__share_file", { + summary: "week-39-report.docx, papers.csv", + input: JSON.stringify({ files: [report.path, papers.path] }), + }); + chat.finish(shared, JSON.stringify({ shown: [report, papers] })); at(92); chat.reply(crew.scout, SUMMARY); const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", { diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index f7aa4754..feb305a5 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -2,7 +2,7 @@ import { X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; -import type { Bot, Crew } from "../../lib/protocol.gen"; +import type { Bot, BotFile, Crew } from "../../lib/protocol.gen"; import { useWindowVisible } from "../../shell/visibility"; import { type BotPanel, routinesOf } from "../../store/app"; import { useApp } from "../../store/context"; @@ -74,6 +74,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { setSince(now); }, [bot.id]); const [shown, setShown] = useState(null); + // A file shared in the chat, for when the panel's list leaves it out. + const [described, setDescribed] = useState(null); // biome-ignore lint/correctness/useExhaustiveDependencies: another bot's file is not this bot's useEffect(() => setShown(null), [bot.id]); const filesOpen = side === "files"; @@ -90,12 +92,13 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { // A tool line in the chat points to its file: read the list first, so the // file is in it when the panel goes to it. // Stable, so the chat does not re-render when this view does. - const showFile = useStable(async (path: string) => { + const showFile = useStable(async (path: string, file?: BotFile) => { if (!filesOpen) { seen(); } setSide("files"); await files.refresh(); + setDescribed(file ?? null); setShown(path); }); const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length; @@ -213,6 +216,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { data={files} since={since} path={shown} + described={described} onPath={setShown} onClose={toggleFiles} /> diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index 2916d9c2..453e2310 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -12,6 +12,8 @@ import { BotAvatar, moodOf } from "../bots/BotAvatar"; import { ApprovalCard } from "./ApprovalCard"; import { Markdown } from "./Markdown"; import { runParts, splitDraft } from "./rows"; +import { SharedFiles } from "./SharedFiles"; +import { isSharedFiles } from "./shared"; import { ToolLines } from "./ToolLines"; export interface Live { @@ -124,7 +126,11 @@ function BotRunView({ const head = part[0] as ChatItem; switch (head.body.kind) { case "tool": - return ; + return isSharedFiles(head) ? ( + + ) : ( + + ); case "reply": // It was already on screen as it was written. return ; diff --git a/app/src/features/chat/SharedFiles.tsx b/app/src/features/chat/SharedFiles.tsx new file mode 100644 index 00000000..f8afc26a --- /dev/null +++ b/app/src/features/chat/SharedFiles.tsx @@ -0,0 +1,118 @@ +// Files the bot shared with `share_file` (spec 10, 15.3): one card each, +// with the file's tile or picture, its name, kind and size, and buttons to +// preview it, open it and save a copy anywhere. + +import { Download, ExternalLink } from "lucide-react"; +import { useContext } from "react"; +import { useT } from "../../i18n"; +import { fileSize } from "../../lib/format"; +import type { BotFile, ChatItem } from "../../lib/protocol.gen"; +import { useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { useArrival } from "../../ui/motion"; +import { attempt } from "../../ui/toast"; +import { fileKind, kindIcon } from "../files/kinds"; +import { ShowFile } from "../files/showFile"; +import { useFileImage } from "./images"; +import { sharedFiles } from "./shared"; + +/** "PDF" from report.pdf; nothing for a name without one. */ +function extension(name: string): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot + 1).toUpperCase() : ""; +} + +function Picture({ botId, file }: { botId: string; file: BotFile }) { + const source = useFileImage(botId, file); + if (source.kind !== "ready") { + return null; + } + return ( + {file.name} + ); +} + +function FileCard({ botId, file }: { botId: string; file: BotFile }) { + const t = useT().files; + const host = useHost(); + const showFile = useContext(ShowFile); + const kind = fileKind(file.mediaType, file.name); + const Icon = kindIcon(kind); + const what = [extension(file.name), fileSize(file.size)].filter(Boolean).join(" · "); + const about = ( + <> + + + + + + {file.name} + + {what} + + + ); + return ( +
+ +
+ {/* Where there is a files panel, the file itself opens its preview. */} + {showFile ? ( + + ) : ( +
{about}
+ )} + +
+
+ ); +} + +export function SharedFiles({ item, botId, bot }: { item: ChatItem; botId: string; bot: string }) { + const t = useT().files; + const arrival = useArrival(item.createdAt); + if (item.body.kind !== "tool") { + return null; + } + const files = sharedFiles(item.body); + if (files.length === 0) { + return null; + } + return ( +
    + {files.map((file) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/app/src/features/chat/images.ts b/app/src/features/chat/images.ts index 70db5f70..674b297d 100644 --- a/app/src/features/chat/images.ts +++ b/app/src/features/chat/images.ts @@ -1,10 +1,11 @@ // Images the owner attached, as data URLs for thumbnails. What the app sent // in this session is known already; the rest is read back from the bot's -// folder with `attachments.read` (spec 9.5). The CSP allows `data:` images -// only, so no blob URLs. +// folder with `attachments.read` (spec 9.5). Images a bot shares are read +// with `files.read` (spec 8.4). The CSP allows `data:` images only, so no +// blob URLs. import { useEffect, useState } from "react"; -import type { Attachment } from "../../lib/protocol.gen"; +import type { Attachment, BotFile } from "../../lib/protocol.gen"; import { useApi } from "../../store/context"; /** Types the webview shows safely as ``. */ @@ -23,10 +24,10 @@ export function dataUrl(mediaType: string, base64: string): string { return `data:${mediaType};base64,${base64}`; } -/** Remembers an image the app has the bytes of. */ -export function rememberImage(attachmentId: string, url: string): void { - cache.delete(attachmentId); - cache.set(attachmentId, url); +/** Remembers an image the app has the bytes of, by attachment id or file. */ +export function rememberImage(key: string, url: string): void { + cache.delete(key); + cache.set(key, url); while (cache.size > CACHE_MAX) { const oldest = cache.keys().next().value; if (oldest === undefined) { @@ -76,3 +77,39 @@ export function useImage(attachment: Attachment): ImageSource { return source; } + +/** The thumbnail of a file a bot shared, read again when the file changes. */ +export function useFileImage(botId: string, file: BotFile): ImageSource { + const api = useApi(); + const shown = isImage(file.mediaType) && file.size <= THUMBNAIL_MAX_BYTES; + const key = `file:${file.path}:${file.modifiedAt}`; + const [source, setSource] = useState(() => { + const known = cache.get(key); + if (known) { + return { kind: "ready", url: known }; + } + return shown ? { kind: "loading" } : { kind: "none" }; + }); + + useEffect(() => { + if (!shown || cache.has(key)) { + return; + } + let alive = true; + api.call("files.read", { botId, path: file.path }).then( + (read) => { + const url = dataUrl(read.mediaType, read.data); + rememberImage(key, url); + if (alive) { + setSource({ kind: "ready", url }); + } + }, + () => alive && setSource({ kind: "missing" }), + ); + return () => { + alive = false; + }; + }, [api, botId, file.path, key, shown]); + + return source; +} diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index 91b2bbc8..5d3e9efa 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -3,6 +3,7 @@ // line starts every day. import type { ChatItem, Message, NoticeItem } from "../../lib/protocol.gen"; +import { isSharedFiles } from "./shared"; export type Row = | { kind: "day"; key: string; at: number } @@ -48,12 +49,20 @@ export function chatRows(items: ChatItem[]): Row[] { return rows; } -/** Splits a run into replies, approvals and turns alone, and tool calls together. */ +/** Tool calls that run together as lines; shared files are cards of their own. */ +function isLine(item: ChatItem | undefined): boolean { + return item?.body.kind === "tool" && !isSharedFiles(item); +} + +/** + * Splits a run into replies, approvals, shared files and turns alone, and + * tool calls together. + */ export function runParts(items: ChatItem[]): ChatItem[][] { const parts: ChatItem[][] = []; for (const item of items) { const last = parts.at(-1); - if (item.body.kind === "tool" && last?.[0]?.body.kind === "tool") { + if (last && isLine(item) && isLine(last[0])) { last.push(item); } else { parts.push([item]); diff --git a/app/src/features/chat/shared.ts b/app/src/features/chat/shared.ts new file mode 100644 index 00000000..e41ac874 --- /dev/null +++ b/app/src/features/chat/shared.ts @@ -0,0 +1,39 @@ +// What a `share_file` call shared (spec 10): the daemon describes each file +// in the tool's answer, as the files panel lists it. + +import type { BotFile, ChatItem, ToolItem } from "../../lib/protocol.gen"; +import { toolKey } from "./toolNames"; + +function isBotFile(value: unknown): value is BotFile { + const file = value as Partial | null; + return ( + typeof file?.path === "string" && + typeof file.name === "string" && + typeof file.mediaType === "string" && + typeof file.size === "number" + ); +} + +/** The files the daemon described in the tool's answer. */ +export function sharedFiles(tool: ToolItem): BotFile[] { + try { + const answer: unknown = JSON.parse(tool.output ?? ""); + const shown = (answer as { shown?: unknown } | null)?.shown; + return Array.isArray(shown) ? shown.filter(isBotFile) : []; + } catch { + return []; + } +} + +/** + * A `share_file` call that worked shows as file cards, not as a tool line. + * One whose answer cannot be read stays a tool line, where it can be opened. + */ +export function isSharedFiles(item: ChatItem): boolean { + return ( + item.body.kind === "tool" && + toolKey(item.body.name) === "share_file" && + item.body.status === "done" && + sharedFiles(item.body).length > 0 + ); +} diff --git a/app/src/features/chat/sharedFiles.test.tsx b/app/src/features/chat/sharedFiles.test.tsx new file mode 100644 index 00000000..e77e1610 --- /dev/null +++ b/app/src/features/chat/sharedFiles.test.tsx @@ -0,0 +1,99 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import type { BotFile } from "../../lib/protocol.gen"; +import { crewOpened, openBot, renderApp } from "../../test/app"; +import { runParts } from "./rows"; + +afterEach(cleanup); + +/** A crew "Ops" with @scout, idle. */ +function crew() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + return { fake, scout }; +} + +/** Scout shares `files` with `share_file`, as the daemon answers it. */ +function share(fake: FakeBotloft, botId: string, files: BotFile[], failed = false) { + const call = fake.chat.tool(botId, "mcp__botloft__share_file", { + summary: files.map((file) => file.name).join(", "), + input: JSON.stringify({ files: files.map((file) => file.path) }), + }); + const answer = failed + ? "There is no file at C:\\Work\\gone.pdf" + : JSON.stringify({ shown: files }); + return fake.chat.finish(call, answer, failed); +} + +async function openScout(fake: FakeBotloft) { + const rendered = renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + return rendered; +} + +const cards = () => screen.findByRole("list", { name: "Files shared by Scout" }); + +describe("shared files", () => { + test("show as cards that open, save a copy and preview", async () => { + const { fake, scout } = crew(); + const report = fake.files.add(scout.id, "report.pdf", { text: "%PDF-1.7", folder: "final" }); + const notes = fake.files.add(scout.id, "notes.md", { text: "# Notes" }); + share(fake, scout.id, [report, notes]); + const { host } = await openScout(fake); + + const list = await cards(); + const items = within(list).getAllByRole("listitem"); + expect(items).toHaveLength(2); + expect(items[0]?.textContent).toContain("report.pdf"); + expect(items[0]?.textContent).toContain("PDF · 8 B"); + // A card, not a tool line. + expect(screen.queryByRole("button", { name: /Share a file/ })).toBeNull(); + + const first = within(items[0] as HTMLElement); + fireEvent.click(first.getByRole("button", { name: "Save as…" })); + fireEvent.click(first.getByRole("button", { name: "Open" })); + await waitFor(() => expect(host.savedFiles).toEqual([report.path])); + expect(host.openedFiles).toEqual([report.path]); + + fireEvent.click(within(items[1] as HTMLElement).getByRole("button", { name: /^Preview/ })); + const panel = await screen.findByRole("complementary", { name: "Files from Scout" }); + expect(await within(panel).findByRole("heading", { name: "Notes" })).toBeDefined(); + }); + + test("a file the panel's list leaves out still previews from the card", async () => { + const { fake, scout } = crew(); + // The list skips files older than the bot; the card still knows it. + const old = fake.files.add(scout.id, "old.md", { text: "# Older than the bot", listed: false }); + share(fake, scout.id, [old]); + await openScout(fake); + + const list = await cards(); + fireEvent.click(within(list).getByRole("button", { name: /^Preview/ })); + const panel = await screen.findByRole("complementary", { name: "Files from Scout" }); + expect(await within(panel).findByRole("heading", { name: "Older than the bot" })).toBeDefined(); + }); + + test("a share that failed stays a tool line with its error", async () => { + const { fake, scout } = crew(); + const gone = fake.files.add(scout.id, "gone.pdf"); + share(fake, scout.id, [gone], true); + await openScout(fake); + expect(await screen.findByRole("button", { name: /Share a file/ })).toBeDefined(); + expect(screen.queryByRole("list", { name: "Files shared by Scout" })).toBeNull(); + }); + + test("shared files stand apart from the tool lines around them", () => { + const { fake, scout } = crew(); + const file = fake.files.add(scout.id, "a.csv", { text: "a" }); + const read = fake.chat.finish(fake.chat.tool(scout.id, "Read"), "ok"); + const shared = share(fake, scout.id, [file]); + const grep = fake.chat.finish(fake.chat.tool(scout.id, "Grep"), "ok"); + const write = fake.chat.finish(fake.chat.tool(scout.id, "Write"), "ok"); + expect(runParts([read, shared, grep, write]).map((part) => part.length)).toEqual([1, 1, 2]); + }); +}); diff --git a/app/src/features/files/FilesPanel.tsx b/app/src/features/files/FilesPanel.tsx index 32226198..e4aee84c 100644 --- a/app/src/features/files/FilesPanel.tsx +++ b/app/src/features/files/FilesPanel.tsx @@ -19,6 +19,7 @@ export function FilesPanel({ data, since, path, + described, onPath, onClose, }: { @@ -28,11 +29,15 @@ export function FilesPanel({ since: number; /** The file shown, or null for the list. */ path: string | null; + /** The file at `path` as the chat described it, if the list leaves it out. */ + described?: BotFile | null; onPath(path: string | null): void; onClose(): void; }) { const t = useT().files; - const chosen = data.files.find((file) => file.path === path); + const chosen = + data.files.find((file) => file.path === path) ?? + (described?.path === path ? described : undefined); return ( diff --git a/app/src/features/files/showFile.ts b/app/src/features/files/showFile.ts index 25c0dae1..66a04537 100644 --- a/app/src/features/files/showFile.ts +++ b/app/src/features/files/showFile.ts @@ -1,6 +1,8 @@ -// How a tool line in the chat asks for its file to be shown in the files -// panel (spec 15.1). Absent where there is no panel, so no button shows. +// How the chat asks for a file to be shown in the files panel (spec 15.1). +// Absent where there is no panel, so no button shows. A file the chat +// already describes comes along, for one the panel's list leaves out. import { createContext } from "react"; +import type { BotFile } from "../../lib/protocol.gen"; -export const ShowFile = createContext<((path: string) => void) | null>(null); +export const ShowFile = createContext<((path: string, file?: BotFile) => void) | null>(null); diff --git a/app/src/i18n/en/files.ts b/app/src/i18n/en/files.ts index 12c6a0e4..9a26ca6a 100644 --- a/app/src/i18n/en/files.ts +++ b/app/src/i18n/en/files.ts @@ -18,9 +18,14 @@ export const files = { back: "All files", open: "Open", reveal: "Show in folder", + shared: (bot: string) => `Files shared by ${bot}`, + save: "Save as…", + saveOne: (name: string) => `Save a copy of ${name}`, + previewOne: "Preview", failed: { open: "Could not open the file", reveal: "Could not show the file", + save: "Could not save the file", }, preview: { loading: "Loading…", diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts index 71d9f3d6..26e03557 100644 --- a/app/src/i18n/en/tools.ts +++ b/app/src/i18n/en/tools.ts @@ -33,6 +33,7 @@ export const tools = { my_tasks: "See its tasks", suggest_bot: "Suggest a bot", schedule_routine: "Set up a routine", + share_file: "Share a file", browser: "Use a site", browser_help: "Ask for your hand", browser_open: "Open a page", diff --git a/app/src/i18n/es/files.ts b/app/src/i18n/es/files.ts index df10fb94..0aa5ec32 100644 --- a/app/src/i18n/es/files.ts +++ b/app/src/i18n/es/files.ts @@ -20,9 +20,14 @@ export const files: Messages["files"] = { back: "Todos los archivos", open: "Abrir", reveal: "Mostrar en la carpeta", + shared: (bot) => `Archivos compartidos por ${bot}`, + save: "Guardar como…", + saveOne: (name) => `Guardar una copia de ${name}`, + previewOne: "Ver", failed: { open: "No se pudo abrir el archivo", reveal: "No se pudo mostrar el archivo", + save: "No se pudo guardar el archivo", }, preview: { loading: "Cargando…", diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts index 24bef187..04388b93 100644 --- a/app/src/i18n/es/tools.ts +++ b/app/src/i18n/es/tools.ts @@ -34,6 +34,7 @@ export const tools: Messages["tools"] = { my_tasks: "Ver sus tareas", suggest_bot: "Sugerir un bot", schedule_routine: "Crear una rutina", + share_file: "Compartir un archivo", browser: "Usar un sitio", browser_help: "Pedir tu ayuda", browser_open: "Abrir una página", diff --git a/app/src/i18n/pt-BR/files.ts b/app/src/i18n/pt-BR/files.ts index e719d9cf..06ebe4e6 100644 --- a/app/src/i18n/pt-BR/files.ts +++ b/app/src/i18n/pt-BR/files.ts @@ -20,9 +20,14 @@ export const files: Messages["files"] = { back: "Todos os arquivos", open: "Abrir", reveal: "Mostrar na pasta", + shared: (bot) => `Arquivos compartilhados por ${bot}`, + save: "Salvar como…", + saveOne: (name) => `Salvar uma cópia de ${name}`, + previewOne: "Ver", failed: { open: "Não foi possível abrir o arquivo", reveal: "Não foi possível mostrar o arquivo", + save: "Não foi possível salvar o arquivo", }, preview: { loading: "Carregando…", diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts index a52333ab..9ce0f8c7 100644 --- a/app/src/i18n/pt-BR/tools.ts +++ b/app/src/i18n/pt-BR/tools.ts @@ -33,6 +33,7 @@ export const tools: Messages["tools"] = { my_tasks: "Ver as próprias tarefas", suggest_bot: "Sugerir um bot", schedule_routine: "Criar uma rotina", + share_file: "Compartilhar um arquivo", browser: "Usar um site", browser_help: "Pedir sua ajuda", browser_open: "Abrir uma página", diff --git a/app/src/lib/fakeFiles.ts b/app/src/lib/fakeFiles.ts index b491fb75..f8b1edd4 100644 --- a/app/src/lib/fakeFiles.ts +++ b/app/src/lib/fakeFiles.ts @@ -17,17 +17,26 @@ const TYPES: Record = { export class FakeFiles { private readonly files = new Map(); private readonly contents = new Map(); + /** Readable but not listed, like a file older than the bot (spec 8.4). */ + private readonly unlisted = new Set(); constructor(private readonly fake: FakeBotloft) {} /** * A file the bot made. `text` is what its preview shows; `folder` is - * relative to the work folder. + * relative to the work folder. `listed: false` leaves it out of the + * list, as the daemon does with a file older than the bot. */ add( botId: BotId, name: string, - options: { text?: string; folder?: string; writtenByBot?: boolean; at?: number } = {}, + options: { + text?: string; + folder?: string; + writtenByBot?: boolean; + at?: number; + listed?: boolean; + } = {}, ): BotFile { this.fake.bot(botId); const folder = options.folder ?? ""; @@ -43,6 +52,9 @@ export class FakeFiles { }; const list = (this.files.get(botId) ?? []).filter((other) => other.path !== file.path); this.files.set(botId, [file, ...list]); + if (options.listed === false) { + this.unlisted.add(file.path); + } this.contents.set(file.path, { mediaType: file.mediaType, data: encodeBytes(new TextEncoder().encode(text)), @@ -54,7 +66,9 @@ export class FakeFiles { return { "files.list": ({ botId }) => { this.fake.bot(botId, false); - return [...(this.files.get(botId) ?? [])].sort((a, b) => b.modifiedAt - a.modifiedAt); + return (this.files.get(botId) ?? []) + .filter((file) => !this.unlisted.has(file.path)) + .sort((a, b) => b.modifiedAt - a.modifiedAt); }, "files.read": ({ botId, path }) => { const known = (this.files.get(botId) ?? []).some((file) => file.path === path); diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index ee283fb6..70dc0ab5 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -26,6 +26,9 @@ export class FakeHost implements Host { /** Files opened with their program, and files shown in their folder. */ readonly openedFiles: string[] = []; readonly revealed: string[] = []; + /** Files saved as a copy, and whether the Save dialog picks a place. */ + readonly savedFiles: string[] = []; + saveChosen = true; /** What the folder picker gives back; null is a cancel. */ nextFolder: string | null = null; /** Where each folder picker started. */ @@ -169,6 +172,11 @@ export class FakeHost implements Host { return Promise.resolve(); } + saveFileAs(path: string): Promise { + this.savedFiles.push(path); + return Promise.resolve(this.saveChosen); + } + pickFolder(_title: string, start?: string): Promise { this.pickerStarts.push(start ?? null); return Promise.resolve(this.nextFolder); diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index c4375c0e..ec0c6e41 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -119,6 +119,11 @@ export interface Host { openFile(path: string): Promise; /** Shows a file in its folder, selected. */ revealFile(path: string): Promise; + /** + * Saves a copy of a file where the owner picks with the system's Save + * dialog. Resolves false if they cancel. + */ + saveFileAs(path: string): Promise; /** * Asks the owner for a folder with Windows' folder picker, starting at * `start` if given. Resolves null if they cancel. diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index dfaedb2d..7ebe42af 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -100,6 +100,7 @@ export function tauriHost(): Host { openPath: (path) => invoke("open_path", { path }), openFile: (path) => invoke("open_file", { path }), revealFile: (path) => invoke("reveal_file", { path }), + saveFileAs: (path) => invoke("save_file_as", { path }), pickFolder: async (title, start) => { const picked = await open({ directory: true, diff --git a/docs/spec.md b/docs/spec.md index 61633da0..0204bd9a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -131,7 +131,7 @@ O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha attachments\\ arquivos que o dono mandou no chat (9.5) .claude\ settings.json regra de negação para os segredos e o que o Claude Code não carrega no bot, 7.5 (gerado pelo daemon, sobrescrito a cada start) - rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools, como explicar cada comando ao dono (10.1), que agendar é rotina (20) e, no chefe, como liderar (gerado pelo daemon) + rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools, como explicar cada comando ao dono (10.1), que agendar é rotina (20), que arquivo pronto para o dono vai no chat com `share_file` (10) e, no chefe, como liderar (gerado pelo daemon) .botloft\ mcp.json config MCP do bot (gerado pelo daemon) ``` @@ -369,6 +369,8 @@ O app mostra os arquivos que o bot fez num painel ao lado do chat (15.1). O daem `files.list {botId}` devolve até 200 `BotFile` (`path`, `name`, `folder` relativa à pasta de trabalho ou à do bot, `mediaType` pela extensão, `size`, `modifiedAt`, `writtenByBot`), do mais novo para o mais antigo. `files.read {botId, path}` devolve `{mediaType, data}` (base64) de um arquivo que a lista poderia mostrar: dentro de uma das duas pastas ou escrito pelo bot. Qualquer outro caminho, relativo ou que suba de pasta dá `not_found`, para o painel não ler o resto do computador. Acima de 20 MiB dá `validation`. Nenhum caminho nem conteúdo vai para o log em `info` ou acima (8.5). +**No chat:** o bot mostra ao dono um arquivo que fez, ou que o dono pediu de novo, com a tool `share_file` (10). Só vale arquivo que `files.read` leria (dentro de uma das duas pastas ou escrito pelo bot), para o cartão poder mostrar e salvar o que aponta; um caminho relativo é da pasta do bot, onde ele roda. Não há item novo: o item `tool` da chamada guarda na `output` a resposta da tool, com um `BotFile` por arquivo em `shown`, e o app desenha cartões a partir dela (15.1). Um arquivo de antes do bot, que a lista deixa de fora, continua compartilhável: o cartão leva o `BotFile` ao painel. + ### 8.5 Privacidade Itens do chat são dado pessoal como o corpo das messages: nunca vão para o log em nível `info` ou acima. O log de `debug` registra só tipos de evento e ids. @@ -527,6 +529,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | `my_tasks` | `role?` (`assigned`, `requested`) | tasks `open` e `expired`: id, de, para, status, prazo relativo, hops e o pedido original | | `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `reason` (até 1 000) | `created` e, criado, `handle`, `name`, `role`, `model` e um lembrete para mandar a primeira task; recusado ou sem resposta, o porquê (10.2). Só o chefe | | `schedule_routine` | `name`, `prompt` (até 8 000 caracteres), `schedule` (como em 20.2), `bot?` (handle de outro bot da crew) | `created` e, criada, `routine_id`, nome, pedido, horário, fuso e a próxima vez; recusada ou sem resposta, o aviso de que nada foi agendado (20.12) | +| `share_file` | `files` (1 a 10 caminhos, absolutos ou relativos à pasta do bot) | `shown` (um `BotFile` por arquivo, 8.4) e um lembrete de que o dono vê cada um como cartão no chat; um arquivo fora das pastas do bot e não escrito por ele, ou que não existe, recusa a chamada inteira e diz qual e por quê | | `permission_prompt` | `tool_name`, `input`, `tool_use_id` | decisão do dono (10.1). Chamada pelo Claude Code, não pelo modelo | | `browser_*` | seção 21.4 | o navegador do bot: abrir, ler, clicar, digitar, rolar, ver a tela, pedir a mão do dono | @@ -790,7 +793,7 @@ Layout, como um app de mensagens: - **Sugestão de bot** (10.2): um cartão no chat do chefe, " sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido. - **Pedido de permissão** (10.1): um cartão no chat, " quer " ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, " escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz " não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Quando o pedido tem `always` (10.1), entre Permitir e Negar fica um terceiro botão que diz o que cobre: "Permitir sempre este comando", "Permitir sempre em example.com", "Permitir sempre neste arquivo" ou só "Permitir sempre"; a dica dele avisa que o bot não vai pedir aquilo de novo e que dá para desfazer nos detalhes. Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. No fim dele, "Permitido sem perguntar" lista as regras do bot (10.1): a ação no idioma do dono ("Rodar um comando") e, embaixo, o comando, o site ou o arquivo, cada uma com um X que faz o bot voltar a perguntar. A lista é lida ao abrir o painel e segue `bot.rules`; vazia, diz que "Permitir sempre" coloca as coisas ali. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel. -- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou. +- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Um `share_file` que deu certo (8.4) não vira linha: cada arquivo vira um cartão no chat, com o quadrado do tipo (ou a miniatura de uma imagem de até 8 MB, lida com `files.read`), nome, extensão e tamanho, "Abrir" e "Salvar como…" (`save_file_as`, 15.2). Um clique no arquivo abre a prévia no painel. Num chat estreito, o botão de salvar fica só com o ícone. Se a resposta da tool não puder ser lida, ou se a chamada falhou, fica a linha da ferramenta, com o erro. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou. ### 15.2 Comandos Tauri @@ -813,6 +816,7 @@ Layout, como um app de mensagens: | seletor de pastas | não é comando próprio: o app usa o `tauri-plugin-dialog` (`open({directory: true})`, permissão `dialog:allow-open`) para a pasta de trabalho da crew (5) | | `open_file` | abre um arquivo do bot com o programa que o Windows usa para ele (`explorer.exe `). Só extensões de documento, imagem, som e vídeo (`pdf`, `png`, `md`, `docx`, `xlsx`, `mp4`...); qualquer outra, como `.exe`, `.bat`, `.ps1` ou `.lnk`, é recusada, porque o Explorer a executaria | | `reveal_file` | abre o Explorer na pasta do arquivo, com ele marcado (`/select,`). Nada é executado. No macOS, `open -R`; no Linux, que não tem um jeito comum de marcar o arquivo, abre a pasta dele | +| `save_file_as` | salva uma cópia de um arquivo do bot onde o dono escolher: o próprio comando abre o diálogo Salvar como do sistema (`tauri-plugin-dialog`, pelo Rust, com o nome do arquivo e um filtro pela extensão) e copia; responde `false` se o dono cancelar. Quem escolhe o destino é sempre o diálogo, então a página não grava arquivo em lugar nenhum que o dono não tenha escolhido. Copiar um arquivo sobre ele mesmo não faz nada | | `open_url` | abre no navegador padrão um link de uma resposta do bot; só `http` e `https`, porque qualquer outro esquema pode iniciar um programa. Seguir o link dentro do app trocaria a janela pela página | | overlay na taskbar | não é comando próprio: o app usa `setOverlayIcon` da janela (permissão `core:window:allow-set-overlay-icon`) e marca o ícone com um ponto enquanto algo espera o dono: aprovação pendente, bot em `auth_error`, ou mensagem não entregue a um bot ativo. Entregas mortas para bot arquivado não contam: foram abandonadas de propósito. Com "Marcar o ícone quando um bot responder" (15.1), o mesmo ponto aparece enquanto algum bot está não lido | @@ -856,6 +860,7 @@ No chat: - Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar. - Pedido para seguir com um plano (10.1) é um cartão com o plano em markdown, um campo para o que deve mudar e os botões Aprovar plano e Pedir mudanças. Respondido, vira uma linha que abre o plano de novo. - Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho. +- Arquivos que o bot compartilha (`share_file`) aparecem como cartões na fala dele, com Abrir e Salvar como… (15.1). Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/app-icon.svg`, de onde `pnpm tauri icon app-icon.svg` gera os PNG, o ICO e o ICNS; as pastas `android` e `ios` que ele cria são apagadas) é o mascote laranja, na cor que mostra o desenho como foi feito, sobre um quadrado preto, montado com os mesmos contornos e camadas de `mascotArt.ts`. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação, sem fundo e com um contorno fino e discreto (escuro no tema claro, claro no escuro) para as cores claras não sumirem. O avatar é a primeira pose da folha de poses da chama, vetorizada (`mascotArt.ts`): o corpo com as labaredas, uma gotinha de fogo solta acima da ponta, camadas de luz e sombra recortadas pelo contorno e olhos pretos com brilhos e reflexo avermelhado embaixo, tudo retingido para a cor do bot (matiz, saturação e luminosidade), então todo bot tem a mesma luz e sombra. A referência da retintura é a primeira cor da paleta (`#FF7A59`): um bot laranja fica igual ao desenho, e as cores claras da paleta mantêm a profundidade em vez de desbotar. A gotinha passa um pouco acima do quadrado do avatar, como as faíscas. O mascote do próprio Botloft (barra de título, mensagens do daemon) é o do ícone: laranja sobre o quadrado preto. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. O mascote se mexe conforme o que o bot faz (ver Movimento), sem substituir isso. @@ -1032,6 +1037,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Dono no controle com o Claude Code real | 21.10 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedido para abrir uma página de login local e chamar o dono, o bot carregou `browser_ask_owner` pelo `ToolSearch` sozinho, com a tarefa na língua do dono. Pelo cartão, o painel abriu já nas mãos do dono; clique, digitação, troca de campo e Enter chegaram ao Edge, e a página de login entrou na conta. Devolvido, a tool respondeu com a página nova e o bot listou os pedidos. A senha digitada não apareceu no log, no banco nem na conversa do Claude Code (o bot leu só o que a página mostrava). Numa página comprida, a roda do mouse sobre a tela ao vivo rolou a página do bot e não o painel | feito | | Abas nas mãos do dono com o Claude Code real | 21.10 | **A verificar**: com um bot no meio de uma tarefa num site, o dono assume, abre uma aba nova em outro endereço e devolve. O bot deve ler o aviso de que a aba mudou (no erro da tool que ia agir, ou no `browser_look`), ler a aba nova e seguir a tarefa nela ou abrir de novo a página de que precisa, sem agir às cegas. Recarregar sem assumir: o bot lê "The owner reloaded the page." e continua | manual (PR) | | Cursor do bot nas telas | 22.3 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual), com o app num Edge sem janela: pedidas quatro páginas (floricultura, cafeteria, livraria, academia), o cursor seguiu o título, cada cartão, cada item de plano e cada linha de tabela enquanto o bot escrevia, e a prancheta rolou junto. Visto aqui: num `iframe` de outra origem, o script às vezes lê a página com `innerHeight` 0 e só depois recebe o tamanho (por isso a espera do `resize`); a versão escondida fala antes de ir para a frente (por isso vale a marca do quadro à mostra). O conteúdo das páginas não foi para o log | feito | +| Bot compartilha arquivos com `share_file` | 10, 8.4 | Ferramenta nossa, sem comportamento novo do Claude Code: a tool vem liberada por `--allowedTools mcp__botloft` e é carregada pelo `ToolSearch` como as outras. **Testado com 2.1.284** (daemon de dev, Haiku, modo Aceitar edições), só com a regra de 5.1 e a descrição da tool: pedido um relatório salvo na pasta de trabalho, o bot o escreveu e chamou `share_file` sozinho; pedido "me manda o relatório de novo", chamou de novo sem escrever o caminho na resposta. A primeira chamada veio com o caminho do Windows com `\` sem escape, que o próprio Claude Code recusa como JSON inválido; o bot repetiu certo. Por isso a descrição do campo pede barras normais. O conteúdo não foi para o log | feito | | Esforço em `-p` | 7.4 | Confirmado (`cli-reference`, `model-config`, `headless`): `--effort` aceita `low`, `medium`, `high`, `xhigh`, `max` (e `ultracode`, não usado), vale só para a sessão e não é guardado; os níveis dependem do modelo (Fable, Opus e Sonnet atuais têm os cinco; Opus 4.6 e Sonnet 4.6 não têm `xhigh`); sem escolha, o padrão é `high`, menos em Opus 5.5 e Sonnet 5.5 (`medium`) e Opus 4.7 (`xhigh`); `/effort ` funciona em `-p` e vale só para a sessão; as chaves `effortLevel` e `modelSettings` das settings não aceitam `max`; trocar o esforço no meio da sessão mantém o cache em Opus 5.5, Sonnet 5.5 e Fable 5.1 e o invalida nos outros (`prompt-caching`). **Testado com 2.1.284** (`--setting-sources project,local`, plano Max): `--effort max` e `xhigh` com `--model sonnet` são aplicados; um valor desconhecido só avisa no stderr e usa o padrão; `--resume` sem a flag volta ao padrão do modelo; sem a flag, Sonnet 5.5 e Opus 5.5 aplicam `medium` e Fable 5.1 `high`; Haiku 4.5 não tem níveis (o `initialize` do SDK lista `supportedEffortLevels` para os outros e nada para ele) e ignora a flag sem erro. `/effort low` mandado como mensagem troca na hora, com uma resposta sintética e sem custo, e o pedido de controle `apply_flag_settings {effortLevel}` também; o daemon reinicia o processo em vez disso. O que acontece com um nível que o modelo não tem (`xhigh` num modelo antigo) não foi visto: os apelidos do Botloft apontam para modelos com os cinco | feito | | Pedidos de controle no stdin | 9.2 | `getContextUsage()` é documentado no Agent SDK (`agent-sdk/typescript`), com `totalTokens`, `maxTokens`, `rawMaxTokens`, `percentage`, `autoCompactThreshold` e `isAutoCompactEnabled`; o formato no fio e `get_settings` **não são documentados**. **Testado com 2.1.284**: `{"type":"control_request","request_id","request":{"subtype":"get_context_usage"}}` é respondido com `control_response` (`subtype: "success"`, o mesmo `request_id`, `response`) em até 2,5 s, antes do primeiro turno, entre turnos e durante um turno, sem `system/init` nem `result`; `get_settings` responde `{effective, sources, applied: {model, effort, ...}}`, com `effort: null` em Haiku; um `subtype` desconhecido responde `subtype: "error"`. Numa sessão retomada, a primeira resposta levou uma vez mais de 15 s. **Pelo daemon de dev** (Haiku e Sonnet): o modelo e o esforço do modelo chegaram ao app antes do primeiro turno, e `--model sonnet --effort low` apareceu na linha de comando do bot depois de `bots.setEffort` | feito | | Uso de contexto no `stream-json` | 8.6 | Parcialmente documentado: `usage` e `modelUsage` no `result` (`agent-sdk/typescript`), janela de 1 milhão em Fable, Sonnet 5+ e Opus 4.7+ e compactação automática em cerca de 967 mil (`model-config`). **Visto com 2.1.284**: cada `assistant` traz `message.usage` (`input_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`), e a soma dos três primeiros bate com o `totalTokens` do `get_context_usage` a menos de 1%; `result.modelUsage[modelo]` traz `contextWindow` (200000 em Haiku 4.5, 1000000 em Sonnet 5.5) e `maxOutputTokens`; `result.usage.iterations[]` traz cada pedido do turno; `autoCompactThreshold` é a janela menos 33 mil (167000, 967000; 67000 com `--autocompact 100k`, que também encolhe `maxTokens`); `/context` como mensagem devolve um `assistant` sintético com `context_usage` e abre um turno, por isso o daemon usa o pedido de controle. `total_cost_usd` é o total do processo, não do turno. **Pelo daemon de dev**: o app recebeu o tamanho ao subir o bot, a cada pedido ao modelo e no fim do turno | feito |