diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d908ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Build output +build/output/ +*.exe + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# Node +node_modules/ +npm-debug.log + +# Plugin data (runtime, not shipped) +bates-core/plugins/*/data/ +bates-core/plugins/*/node_modules/ +bates-enhance/integrations/*/node_modules/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Secrets (should never be committed) +*.env +.env.* +credentials.json +auth-profiles.json +*.key +*.pem + +# Temporary +*.tmp +*.bak +*.orig + +# Search index data +bates-enhance/integrations/search/search-index/db/ +bates-enhance/integrations/search/search-index/venv/ diff --git a/DISCLAIMER.txt b/DISCLAIMER.txt new file mode 100644 index 0000000..fd9c6c8 --- /dev/null +++ b/DISCLAIMER.txt @@ -0,0 +1,49 @@ +BATES AI ASSISTANT -- IMPORTANT DISCLAIMER + +PLEASE READ CAREFULLY BEFORE PROCEEDING WITH INSTALLATION. + +This software is provided "AS IS", without warranty of any kind, express +or implied. This is an EXPERIMENTAL, PRE-RELEASE PROJECT under active +development. + +By installing and using this software, you acknowledge and accept the +following: + +1. USE AT YOUR OWN RISK. The authors, contributors, and maintainers of + this project accept no responsibility or liability for any damage, + data loss, system instability, security incidents, unexpected costs, + or any other harm resulting from the use or misuse of this software. + +2. SYSTEM MODIFICATIONS. This installer modifies your system + configuration, including enabling WSL2, installing packages, creating + systemd services, setting up cron jobs, and configuring network + services. These changes may affect your system's stability, security, + and performance. + +3. THIRD-PARTY SERVICES. This software interacts with third-party APIs + and services (Anthropic, OpenAI, Google, Telegram, Twilio, Microsoft + 365, ElevenLabs, and others). You are solely responsible for any + costs, terms of service violations, or consequences arising from the + use of these services. + +4. NO WARRANTY. No guarantee of correctness, security, or fitness for + any particular purpose. The installer scripts have been tested on + specific hardware and software configurations. Your results may vary. + +5. AUTONOMOUS AI AGENTS. This software manages AI agents that can take + autonomous actions including sending messages, making API calls, + reading and writing files, and executing commands. You are responsible + for supervising and configuring these agents appropriately. + +6. BACK UP YOUR DATA before running the installer. We strongly recommend + testing on a dedicated or non-critical machine first. + +7. NO AFFILIATION. This project is not affiliated with, endorsed by, or + supported by OpenClaw, Anthropic, OpenAI, Google, Microsoft, Telegram, + Twilio, ElevenLabs, or any other third-party service mentioned herein. + +This software is licensed under the Apache License, Version 2.0. See the +LICENSE file for the full license text. + +BY PROCEEDING WITH THE INSTALLATION, YOU ACCEPT FULL RESPONSIBILITY FOR +ANY AND ALL CONSEQUENCES. diff --git a/README.md b/README.md index 5def77f..d056a89 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,22 @@ Built on [OpenClaw](https://openclaw.ai) · Open Source · Apache 2.0 --- +> **DISCLAIMER -- PLEASE READ BEFORE PROCEEDING** +> +> This software is provided **"AS IS"**, without warranty of any kind, express or implied. This is an **experimental, pre-release project** under active development. By using this software, you acknowledge and accept the following: +> +> - **USE AT YOUR OWN RISK.** The authors, contributors, and maintainers of this project accept **no responsibility or liability** for any damage, data loss, system instability, security incidents, unexpected costs, or any other harm resulting from the use or misuse of this software. +> - This installer **modifies your system configuration**, including enabling WSL2, installing packages, creating systemd services, setting up cron jobs, and configuring network services. These changes may affect your system's stability, security, and performance. +> - This software interacts with **third-party APIs and services** (Anthropic, OpenAI, Google, Telegram, Twilio, Microsoft 365, etc.). You are solely responsible for any costs, terms of service violations, or consequences arising from the use of these services. +> - **No guarantee of correctness, security, or fitness for any particular purpose.** The installer scripts have been tested on specific hardware and software configurations. Your results may vary. +> - This software manages **AI agents that can take autonomous actions** including sending messages, making API calls, reading and writing files, and executing commands. You are responsible for supervising and configuring these agents appropriately. +> - **Back up your data before running the installer.** We strongly recommend testing on a dedicated or non-critical machine first. +> - This project is **not affiliated with, endorsed by, or supported by** OpenClaw, Anthropic, OpenAI, Google, Microsoft, Telegram, Twilio, ElevenLabs, or any other third-party service mentioned herein. +> +> **BY PROCEEDING WITH THE INSTALLATION, YOU ACCEPT FULL RESPONSIBILITY FOR ANY AND ALL CONSEQUENCES.** + +--- + ## What Bates Does Bates runs 24/7 on your Windows PC and handles your operational workflow autonomously: diff --git a/bates-core/BatesCore.iss b/bates-core/BatesCore.iss new file mode 100644 index 0000000..9dbf859 --- /dev/null +++ b/bates-core/BatesCore.iss @@ -0,0 +1,378 @@ +; BatesCore.iss -- Inno Setup script for Bates AI Assistant +; Compiles to BatesCore-2.0.0.exe +; +; Prerequisites handled by this installer: +; - Windows 10/11 Pro (build 19041+) +; - 8GB RAM minimum +; - 20GB free disk space +; - Internet connectivity +; - Admin rights (for WSL2 enablement) + +#define MyAppName "Bates AI Assistant" +#define MyAppVersion "2.0.0" +#define MyAppPublisher "getBates" +#define MyAppURL "https://github.com/getBates/Bates" + +[Setup] +AppId={{A7E3B4C1-8F9D-4E6A-B2C5-1D0F3E7A9B8C} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL}/issues +DefaultDirName={localappdata}\BatesInstaller +DefaultGroupName={#MyAppName} +OutputDir=..\build\output +OutputBaseFilename=BatesCore-{#MyAppVersion} +Compression=lzma2/ultra64 +SolidCompression=yes +PrivilegesRequired=admin +AllowNoIcons=yes +DisableProgramGroupPage=yes +LicenseFile=..\DISCLAIMER.txt +InfoBeforeFile=..\LICENSE +SetupIconFile=assets\bates-icon.ico +WizardSmallImageFile=assets\installer-logo.bmp +WizardImageFile=assets\installer-banner.bmp +WizardStyle=modern +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0.19041 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +; Disclaimer (also shown by install.ps1 and core-setup.sh) +Source: "..\DISCLAIMER.txt"; DestDir: "{app}"; Flags: ignoreversion + +; Core setup scripts +Source: "core-setup.sh"; DestDir: "{app}"; Flags: ignoreversion +Source: "core-configure.sh"; DestDir: "{app}"; Flags: ignoreversion +Source: "core-verify.sh"; DestDir: "{app}"; Flags: ignoreversion +Source: "core-remote-access.sh"; DestDir: "{app}"; Flags: ignoreversion +Source: "core-client-setup.ps1"; DestDir: "{app}"; Flags: ignoreversion +Source: "install.ps1"; DestDir: "{app}"; Flags: ignoreversion + +; Desktop app (for client mode) +Source: "desktop\bates-command-center.exe"; DestDir: "{app}\desktop"; Flags: ignoreversion skipifsourcedoesntexist +Source: "desktop\src-tauri\icons\icon.ico"; DestDir: "{app}\desktop"; DestName: "icon.ico"; Flags: ignoreversion skipifsourcedoesntexist + +; Libraries +Source: "lib\*"; DestDir: "{app}\lib"; Flags: ignoreversion recursesubdirs + +; Workspace templates +Source: "workspace-core\*"; DestDir: "{app}\workspace-core"; Flags: ignoreversion recursesubdirs + +; Scripts +Source: "scripts-core\*"; DestDir: "{app}\scripts-core"; Flags: ignoreversion recursesubdirs + +; Plugins +Source: "plugins\*"; DestDir: "{app}\plugins"; Flags: ignoreversion recursesubdirs + +; Systemd templates +Source: "systemd\*"; DestDir: "{app}\systemd"; Flags: ignoreversion + +; Config templates +Source: "templates\*"; DestDir: "{app}\templates"; Flags: ignoreversion + +; Crontab template +Source: "crontab\*"; DestDir: "{app}\crontab"; Flags: ignoreversion + +; Brand assets +Source: "assets\*"; DestDir: "{app}\assets"; Flags: ignoreversion + +[Run] +; Launch the PowerShell bootstrap after installation +Filename: "powershell.exe"; \ + Parameters: "-ExecutionPolicy Bypass -File ""{app}\install.ps1"" -InstallDir ""{app}"""; \ + StatusMsg: "Setting up Bates AI Assistant..."; \ + Flags: runascurrentuser waituntilterminated + +[UninstallRun] +; Run uninstall script if it exists +Filename: "wsl.exe"; \ + Parameters: "-d Ubuntu-24.04 -- bash -c ""~/.openclaw/scripts/uninstall.sh --auto 2>/dev/null || true"""; \ + Flags: runhidden waituntilterminated + +[UninstallDelete] +Type: filesandordirs; Name: "{app}" + +[Code] +// Pascal Script for prerequisite validation + +function IsWindows10ProOrLater(): Boolean; +var + Version: TWindowsVersion; +begin + GetWindowsVersionEx(Version); + // Windows 10 = 10.0, build 19041+ (version 2004) + Result := (Version.Major >= 10) and (Version.Build >= 19041); +end; + +function GetTotalRAM(): Integer; +var + MemStatus: MEMORYSTATUS; +begin + // Note: GlobalMemoryStatus is 32-bit limited, but good enough for our check + GlobalMemoryStatus(MemStatus); + Result := MemStatus.dwTotalPhys div (1024 * 1024 * 1024); +end; + +function GetFreeDiskSpace(): Integer; +var + FreeBytesAvailable: Int64; + TotalBytes: Int64; + FreeBytes: Int64; +begin + if GetDiskFreeSpaceEx(ExpandConstant('{sd}'), FreeBytesAvailable, TotalBytes, FreeBytes) then + Result := FreeBytesAvailable div (1024 * 1024 * 1024) + else + Result := 0; +end; + +function CheckInternetConnection(): Boolean; +var + WinHttpReq: Variant; +begin + Result := False; + try + WinHttpReq := CreateOleObject('WinHttp.WinHttpRequest.5.1'); + WinHttpReq.Open('GET', 'https://github.com', False); + WinHttpReq.SetTimeouts(5000, 5000, 5000, 5000); + WinHttpReq.Send(''); + Result := (WinHttpReq.Status = 200); + except + Result := False; + end; +end; + +function InitializeSetup(): Boolean; +var + RAM: Integer; + Disk: Integer; + ErrorMsg: String; +begin + Result := True; + ErrorMsg := ''; + + // Check Windows version + if not IsWindows10ProOrLater() then + begin + ErrorMsg := ErrorMsg + '- WSL2 requires Windows 10 Pro version 2004 (build 19041) or later.' + #13#10; + end; + + // Check RAM + RAM := GetTotalRAM(); + if RAM < 7 then // Use 7 as threshold (8GB reports as ~7.x) + begin + ErrorMsg := ErrorMsg + '- Bates needs at least 8GB RAM. Detected: ' + IntToStr(RAM) + 'GB.' + #13#10; + end; + + // Check disk space + Disk := GetFreeDiskSpace(); + if Disk < 20 then + begin + ErrorMsg := ErrorMsg + '- At least 20GB free disk space required. Available: ' + IntToStr(Disk) + 'GB.' + #13#10; + end; + + // Check internet + if not CheckInternetConnection() then + begin + ErrorMsg := ErrorMsg + '- Internet connection required for installation.' + #13#10; + end; + + if ErrorMsg <> '' then + begin + MsgBox('Prerequisites not met:' + #13#10 + #13#10 + ErrorMsg + #13#10 + + 'Please fix these issues and try again.', mbError, MB_OK); + Result := False; + end; +end; + +// ============================================================ +// Custom Finish Page — Referral, Mailing List, GitHub Stars +// ============================================================ + +var + FinishPage: TWizardPage; + EmailEdit: TNewEdit; + ReferralLabel: TNewStaticText; + ReferralUrlLabel: TNewStaticText; + +procedure OpenBrowser(Url: String); +var + ErrorCode: Integer; +begin + ShellExec('open', Url, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode); +end; + +procedure GitHubStarClick(Sender: TObject); +begin + OpenBrowser('https://github.com/getBates/Bates'); +end; + +procedure MailingListSubscribe(Sender: TObject); +var + ErrorCode: Integer; +begin + // Open the mailing list page in the browser — no hardcoded API endpoint + OpenBrowser('https://getBates.ai/newsletter'); +end; + +procedure ReferralCopyClick(Sender: TObject); +var + ReferralUrl: String; + MachineId: String; +begin + MachineId := GetEnv('COMPUTERNAME'); + ReferralUrl := 'https://getBates.ai/r/' + MachineId; + ReferralUrlLabel.Caption := ReferralUrl; + // Copy to clipboard is not directly available in Inno Setup, + // but we can show it for manual copy + MsgBox('Your referral link:' + #13#10 + #13#10 + ReferralUrl + #13#10 + #13#10 + + 'Share this link with friends!', mbInformation, MB_OK); +end; + +procedure InitializeWizard(); +var + TitleLabel: TNewStaticText; + SubtitleLabel: TNewStaticText; + GitHubBtn: TNewButton; + SubscribeBtn: TNewButton; + ReferralBtn: TNewButton; + EmailLabel: TNewStaticText; + SeparatorLabel: TNewStaticText; + YOffset: Integer; +begin + FinishPage := CreateCustomPage(wpInfoAfter, 'Setup Complete!', + 'Bates is ready. A few optional things before you go:'); + + YOffset := 8; + + // --- GitHub Stars Section --- + TitleLabel := TNewStaticText.Create(FinishPage); + TitleLabel.Parent := FinishPage.Surface; + TitleLabel.Caption := 'Star us on GitHub'; + TitleLabel.Font.Style := [fsBold]; + TitleLabel.Font.Size := 10; + TitleLabel.Top := YOffset; + TitleLabel.Left := 0; + + YOffset := YOffset + 22; + + SubtitleLabel := TNewStaticText.Create(FinishPage); + SubtitleLabel.Parent := FinishPage.Surface; + SubtitleLabel.Caption := 'Help others discover Bates — it takes 2 seconds.'; + SubtitleLabel.Top := YOffset; + SubtitleLabel.Left := 0; + + YOffset := YOffset + 22; + + GitHubBtn := TNewButton.Create(FinishPage); + GitHubBtn.Parent := FinishPage.Surface; + GitHubBtn.Caption := 'Star on GitHub'; + GitHubBtn.Top := YOffset; + GitHubBtn.Left := 0; + GitHubBtn.Width := 150; + GitHubBtn.Height := 28; + GitHubBtn.OnClick := @GitHubStarClick; + + YOffset := YOffset + 42; + + // --- Separator --- + SeparatorLabel := TNewStaticText.Create(FinishPage); + SeparatorLabel.Parent := FinishPage.Surface; + SeparatorLabel.Caption := '_______________________________________________'; + SeparatorLabel.Top := YOffset; + SeparatorLabel.Left := 0; + SeparatorLabel.Font.Color := clGray; + + YOffset := YOffset + 24; + + // --- Mailing List Section --- + TitleLabel := TNewStaticText.Create(FinishPage); + TitleLabel.Parent := FinishPage.Surface; + TitleLabel.Caption := 'Stay updated'; + TitleLabel.Font.Style := [fsBold]; + TitleLabel.Font.Size := 10; + TitleLabel.Top := YOffset; + TitleLabel.Left := 0; + + YOffset := YOffset + 22; + + SubtitleLabel := TNewStaticText.Create(FinishPage); + SubtitleLabel.Parent := FinishPage.Surface; + SubtitleLabel.Caption := 'Get notified about new features and updates. No spam, ever.'; + SubtitleLabel.Top := YOffset; + SubtitleLabel.Left := 0; + + YOffset := YOffset + 22; + + EmailLabel := TNewStaticText.Create(FinishPage); + EmailLabel.Parent := FinishPage.Surface; + EmailLabel.Caption := 'Email:'; + EmailLabel.Top := YOffset + 4; + EmailLabel.Left := 0; + + EmailEdit := TNewEdit.Create(FinishPage); + EmailEdit.Parent := FinishPage.Surface; + EmailEdit.Top := YOffset; + EmailEdit.Left := 42; + EmailEdit.Width := 220; + + SubscribeBtn := TNewButton.Create(FinishPage); + SubscribeBtn.Parent := FinishPage.Surface; + SubscribeBtn.Caption := 'Subscribe'; + SubscribeBtn.Top := YOffset; + SubscribeBtn.Left := 270; + SubscribeBtn.Width := 90; + SubscribeBtn.Height := 24; + SubscribeBtn.OnClick := @MailingListSubscribe; + + YOffset := YOffset + 42; + + // --- Separator --- + SeparatorLabel := TNewStaticText.Create(FinishPage); + SeparatorLabel.Parent := FinishPage.Surface; + SeparatorLabel.Caption := '_______________________________________________'; + SeparatorLabel.Top := YOffset; + SeparatorLabel.Left := 0; + SeparatorLabel.Font.Color := clGray; + + YOffset := YOffset + 24; + + // --- Referral Section --- + TitleLabel := TNewStaticText.Create(FinishPage); + TitleLabel.Parent := FinishPage.Surface; + TitleLabel.Caption := 'Share Bates'; + TitleLabel.Font.Style := [fsBold]; + TitleLabel.Font.Size := 10; + TitleLabel.Top := YOffset; + TitleLabel.Left := 0; + + YOffset := YOffset + 22; + + SubtitleLabel := TNewStaticText.Create(FinishPage); + SubtitleLabel.Parent := FinishPage.Surface; + SubtitleLabel.Caption := 'Know someone who''d love their own AI assistant?'; + SubtitleLabel.Top := YOffset; + SubtitleLabel.Left := 0; + + YOffset := YOffset + 22; + + ReferralUrlLabel := TNewStaticText.Create(FinishPage); + ReferralUrlLabel.Parent := FinishPage.Surface; + ReferralUrlLabel.Caption := ''; + ReferralUrlLabel.Top := YOffset + 4; + ReferralUrlLabel.Left := 0; + ReferralUrlLabel.Font.Color := clBlue; + + ReferralBtn := TNewButton.Create(FinishPage); + ReferralBtn.Parent := FinishPage.Surface; + ReferralBtn.Caption := 'Get Referral Link'; + ReferralBtn.Top := YOffset; + ReferralBtn.Left := 0; + ReferralBtn.Width := 150; + ReferralBtn.Height := 28; + ReferralBtn.OnClick := @ReferralCopyClick; +end; diff --git a/bates-core/agents/amara/HEARTBEAT.md b/bates-core/agents/amara/HEARTBEAT.md new file mode 100644 index 0000000..ad599b0 --- /dev/null +++ b/bates-core/agents/amara/HEARTBEAT.md @@ -0,0 +1,46 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. Escola Email Scan (REQUIRED) +Check rk@vernot.com inbox via ms365-reader for escola-related emails. +Use: list-mail-messages with search='"escola" OR "school" OR "Caravela" OR "DGEstE" OR "trilingual"' top=10 + +Also check for emails from known escola contacts (parents, teachers, municipality). + +For each new/unread escola-related email: +- Extract contact info (name, email, role) and append to /home/openclaw/.openclaw/shared/memory/escola/contacts.md +- Extract deadlines or event dates and append to /home/openclaw/.openclaw/shared/memory/escola/upcoming.md +- If urgent (deadline <48h, parent complaint, regulatory): write to outbox/escalate-TIMESTAMP.md + **ESCALATION POLICY**: Do NOT deliver to Robert's DM directly. Write the escalate file and the daily coordination meeting (08:45) will triage it. + +## 2. Planner Check +Check Escola Caravela Planner tasks (planId: HXpYhx5p5EWodt0e_KE0OZcAC8ze) via ms365-assistant. +Note overdue tasks and upcoming deadlines. Append to /home/openclaw/.openclaw/shared/memory/escola/upcoming.md. + +## 3. Knowledge Persistence +Append new contacts, deadlines, facts to observations/findings.md with tags: +- [contact] Name - role, email (source: escola email) +- [deadline] Date - what (source: escola email/planner) + +## 4. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:4406a4934a234cd4bc80fad5e31d4669@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Amara] [DATE]
[SUMMARY]"}}' +``` +Channel: escola-ops (19:4406a4934a234cd4bc80fad5e31d4669@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 5. Output Format +This response is delivered to the escola-ops Teams channel. +- If urgent items: "[URGENT] [sender]: [subject] -- [action needed]" +- If new contacts or deadlines: "Escola update: [N] new items tracked" +- If nothing new: NO_REPLY + +## 6. Daily Standup (MANDATORY — first run after 06:00) + +Write outbox/standup.md (file only — do NOT post to Teams; the coordination meeting at 08:45 handles posting): +- New escola communications tracked +- Upcoming events/deadlines +- Blockers diff --git a/bates-core/agents/archer/HEARTBEAT.md b/bates-core/agents/archer/HEARTBEAT.md new file mode 100644 index 0000000..42ccdf1 --- /dev/null +++ b/bates-core/agents/archer/HEARTBEAT.md @@ -0,0 +1,78 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. Cross-Agent Knowledge Synthesis (REQUIRED) +Review recent activity across all agents to identify improvement opportunities: +- Read /home/openclaw/.openclaw/shared/memory/global/coding-activity-*.md (most recent 2 files) +- Read /home/openclaw/.openclaw/shared/memory/global/market-intel-*.md (most recent) +- Read /home/openclaw/.openclaw/shared/memory/global/documentation-gaps.md (if exists) +- Check recent proposals/ from Mira + +Look for: patterns, repeated issues, cross-project opportunities, tool gaps. + +## 2. Proactive Improvement Proposals (REQUIRED) +Based on findings from step 1, identify the top 1-3 improvement opportunities: +- Architecture improvements Robert hasn't noticed +- Tool or library recommendations that would speed up work +- Process inefficiencies that could be automated +- Integration opportunities between fDesk, SynapseLayer, and Escola projects +- Technical debt that's accumulating in the codebase + +For each opportunity: +- Write a concise proposal to `proposals/improvement-YYYY-MM-DD.md` +- Include: problem observed, proposed solution, expected benefit, effort estimate +- Rate priority: HIGH/MEDIUM/LOW + +## 3. Documentation Gap Analysis (REQUIRED) +Review recent code review proposals in proposals/ (last 5 files by date). +Review recent cursor transcripts in observations/cursor/ (last 3 files by date). + +For each, identify: +- Recurring code patterns that need SOPs or templates +- Errors or debugging sessions that could be documented to prevent recurrence +- Architecture decisions made in code that aren't documented anywhere +- Missing API documentation for new endpoints + +Write findings to /home/openclaw/.openclaw/shared/memory/global/documentation-gaps.md. + +## 4. Process Improvement +Check /home/openclaw/.openclaw/shared/memory/global/coding-activity-*.md (Mira's observations) for recurring issues. +If same error type appears 3+ times: propose a prevention automation or checklist. + +## 5. Knowledge Persistence +Append documentation-related findings to observations/findings.md: +- [pattern] Documentation: [what needs documenting] (source: code review/cursor) +- [decision] Technical: [lesson learned] (source: sub-agent task) + +## 6. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Archer] [DATE]
[SUMMARY]"}}' +``` +Channel: cross-business (19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 7. Output Format +This response is delivered to the cross-business Teams channel. +- If improvement proposals: "Proposals: [N] improvements. Top: [most valuable in 1 line]" +- If docs gaps: "Docs: [N] gaps identified. Top: [most critical]" +- If automation opportunity: "[SOP] Recurring issue: [pattern] -- proposed fix: [solution]" +- If nothing new: NO_REPLY + +## 7. Daily Standup (MANDATORY — first run after 06:00) +Write `outbox/standup.md` (file only — do NOT post to Teams; the standup compiler at 08:30 handles that). +Format: +``` +**Yesterday:** [SOP findings, documentation gaps identified] +**Today:** [automation proposals, improvement analysis planned] +**Blockers:** [any blockers or "None"] +``` + +## 8. Weekly Update (write by Friday 16:00) +Write outbox/weekly-update.md: +- Improvement proposals made this week +- Documentation gaps found +- Cross-agent insights +- Knowledge base health diff --git a/bates-core/agents/conrad/HEARTBEAT.md b/bates-core/agents/conrad/HEARTBEAT.md new file mode 100644 index 0000000..c26c6f7 --- /dev/null +++ b/bates-core/agents/conrad/HEARTBEAT.md @@ -0,0 +1,97 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. +If feedback says to change approach, adjust accordingly. + + +## 0.5. Dedup Check (MANDATORY before posting) + +Before posting ANYTHING to Teams channels or writing escalations: + +1. Read your last 3 channel posts: `~/.openclaw/scripts/graph-api.sh GET "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:35613cb0484c4387bd7f7d3e6059bf33@thread.tacv2/messages?$top=3"` +2. If your finding is materially the same as a recent post (same email, same draft, same deadline), DO NOT post again. +3. Only post if there is NEW information: a new email arrived, a status changed, or a deadline moved. +4. "Drafts awaiting review" is NOT new information if you posted it in the last 12 hours. +5. Escalations repeat only if the deadline is inside 24 hours AND you haven't escalated in the last 4 hours. + +**Repeating the same alert every hour is a critical failure. Robert sees every post.** + +## 1. Email Scan (REQUIRED) +Check BOTH mailboxes using their dedicated MCP servers (do NOT use graph-api.sh for fdesk mail — it has no access): + +**rk@fdesk.tech** — use `ms365-fdesk-reader`: +```bash +mcporter call ms365-fdesk-reader list-mail-messages top=10 'select=["subject","from","receivedDateTime","hasAttachments","bodyPreview","isRead"]' +``` + +**cp-desk@fdesk.tech** — use `ms365-support-reader`: +```bash +mcporter call ms365-support-reader list-mail-messages top=10 'select=["subject","from","receivedDateTime","hasAttachments","bodyPreview","isRead"]' +``` + +### Filtering Rules (CRITICAL — do not report everything) + +**SKIP silently** (do not mention in output or channel post): +- Newsletters, marketing, automated notifications +- Read emails (isRead=true) +- Auto-replies, out-of-office, delivery receipts +- Planner/Teams/SharePoint system notifications +- Emails older than 6 hours (already covered by previous runs) + +**Log to shared memory only** (no channel post, no escalation): +- Florian/Till/Kristina pipeline updates → append to /home/openclaw/.openclaw/shared/memory/fdesk/deal-pipeline.md +- PwC/BNY/CSSF compliance updates (no imminent deadline) → append to /home/openclaw/.openclaw/shared/memory/fdesk/regulatory-updates.md +- Routine business correspondence that is informational only + +**Post to fdesk-ops channel** (via step 4): +- New emails from external parties requiring a response or decision +- Deal status changes (new term sheet, signed document, counterparty reply) +- Meeting requests (also trigger auto-calendar in step 1a) + +**Escalate** (write outbox/escalate-TIMESTAMP.md): +- ONLY for items that are genuinely urgent and time-sensitive: + - Legal/regulatory deadlines within 7 days + - Investor or counterparty waiting for a reply >24h + - Compliance action required (CSSF filing, AML review) + - Contract execution pending Robert's signature +- Do NOT escalate routine emails, informational updates, or items that can wait for the next morning briefing +- **ESCALATION POLICY**: Do NOT deliver to Robert's DM directly. Write the escalate file and the daily coordination meeting (08:45) will triage it. + +### 1a. Auto-Calendar for Meeting Emails (MANDATORY) +If ANY email contains a meeting invitation sent as plain text (Zoom link, Teams link, Google Meet, or any scheduling with date/time but NO ICS/calendar attachment): +1. Read the full email body to extract: date, time (with timezone), meeting link, meeting ID/passcode, attendees +2. Create a calendar event on rk@fdesk.tech: `~/.openclaw/scripts/graph-api.sh POST "/users/rk@fdesk.tech/events"` with JSON body containing subject, start/end times, location (set to meeting link), body (full meeting details including link + passcode), and attendees if known +3. Post to fdesk-ops confirming: "[AUTO-CAL] Created: [subject] on [date/time] with [organizer]. Meeting link in event." +4. This is automatic — do NOT wait for Robert to ask. See appointment → create event → notify. + +## 2. Deal Pipeline Context +After scanning emails, write one sentence answering: "What is Robert likely dealing with in fDesk today?" Overwrite /home/openclaw/.openclaw/shared/memory/fdesk/context-today.md with this context line (dated). + +## 3. Knowledge Persistence +If you found new contacts, deadlines, facts, or deal updates: +- Append tagged entries to observations/findings.md +- Format: `- [tag] detail (source: email/calendar)` + +## 4. Teams Channel Post (only if actionable findings) +Post to fdesk-ops ONLY if there are actionable items (emails needing response, deal changes, meetings created). Do NOT post routine scans or "nothing new" summaries. +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:35613cb0484c4387bd7f7d3e6059bf33@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Conrad] [DATE]
[SUMMARY]"}}' +``` +Channel: fdesk-ops (19:35613cb0484c4387bd7f7d3e6059bf33@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. + +**If nothing actionable was found: reply NO_REPLY. Do NOT post to channel.** + +## 5. Output Format +This response is delivered to the fdesk-ops Teams channel (NOT Robert's DM). +- If actionable items found: one line per item with action needed +- If deal pipeline updated with material changes: "Pipeline: [what changed]" +- If nothing actionable: NO_REPLY (preferred — silence is better than noise) + +## 6. Daily Standup (MANDATORY — first run after 06:00) + +Write outbox/standup.md (file only — do NOT post to Teams; the coordination meeting at 08:45 handles posting): +- Completed: what was processed/escalated +- Planned: active deals and threads to monitor +- Blockers: anything needing Bates or Robert diff --git a/bates-core/agents/dash/HEARTBEAT.md b/bates-core/agents/dash/HEARTBEAT.md new file mode 100644 index 0000000..c23680d --- /dev/null +++ b/bates-core/agents/dash/HEARTBEAT.md @@ -0,0 +1,65 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. OpenClaw Version Check (REQUIRED - EVERY HEARTBEAT) +Check for new OpenClaw versions: +- Run: `openclaw --version` to get current installed version +- Search GitHub: `gh api repos/openclaw/openclaw/releases/latest` or brave-search "site:github.com openclaw openclaw releases" +- Compare installed vs latest +- If new version available: + - Summarize changelog/release notes + - Write to outbox/escalate-new-version.md for immediate notification + - Post to bates-rollout Teams channel + - Track in observations/version-history.md + +## 2. GitHub Repo Health (REQUIRED) +Check Robert's GitHub repos for health: +- List repos with uncommitted changes or unpushed branches +- Check for stale PRs (open > 7 days without activity) +- Check for failing CI/CD runs +- If repos need updates: spawn Claude Code to fix (commit, push, resolve PR comments) + +## 3. Windows Installer Check +- Track any issues/bugs related to the OpenClaw Windows installer +- Search for related GitHub issues +- Note improvement opportunities in observations/windows-installer.md + +## 4. Community Monitoring +Use brave-search to check: +- OpenClaw GitHub: search "site:github.com openclaw" for recent discussions, issues, or PRs +- OpenClaw community: search "openclaw" for recent mentions, blog posts, or forum discussions + +For each notable finding: +- Summarize in 1-2 sentences +- Rate importance: HIGH (security issue, breaking change), MEDIUM (feature request, community growth), LOW (general mention) +- Write to /home/openclaw/.openclaw/shared/memory/global/openclaw-community.md (append dated entry) + +## 5. Meetup/Event Check +Search for upcoming OpenClaw or AI agent meetups in European cities (Berlin, Lisbon, Zurich, Brussels, Vienna, London). +If new events found, append to /home/openclaw/.openclaw/shared/memory/global/openclaw-community.md. + +## 6. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:447ce1f9a8f1420a9d60f82449d84d24@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Dash] [DATE]
[SUMMARY]"}}' +``` +Channel: bates-rollout (19:447ce1f9a8f1420a9d60f82449d84d24@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 7. Output Format +This response is delivered to the bates-rollout Teams channel. +- If new version: "[UPDATE] OpenClaw [version] available! [key changes]" +- If repo issues: "[REPO] [N] repos need attention: [summary]" +- If HIGH community items: "[ALERT] OpenClaw: [issue summary]" +- If community activity: "Community: [N] new mentions. Notable: [summary]" +- If nothing new: NO_REPLY + +## 8. Daily Standup (MANDATORY — first run after 06:00) +Write outbox/standup.md (file only, do NOT post to Teams; the coordination meeting at 08:45 handles posting): +- Version status (current vs latest) +- Repo health summary +- Community activity summary +- Upcoming events +- Blockers diff --git a/bates-core/agents/kira/HEARTBEAT.md b/bates-core/agents/kira/HEARTBEAT.md new file mode 100644 index 0000000..e2a4863 --- /dev/null +++ b/bates-core/agents/kira/HEARTBEAT.md @@ -0,0 +1,67 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. Project Folder Review (REQUIRED) +Review OneDrive project folders for new/updated social media posts and branding materials: +- fDesk: `Documents\fDesk\` (rk@fdesk.tech) +- SynapseLayer: `Documents\SynapseLayer\` (rk@fdesk.tech) +- Bates Distro: `Documents\Bates Distro\` (rk@fdesk.tech) +- School: `V-Private\Caravela Nova\` (rk@vernot.com) + +Study existing posts for tone, style, branding consistency. Note any new materials. + +## 2. Content Opportunity Scan (REQUIRED) +Read Robert's recent sent emails for content that could be repurposed: +- Check ms365-reader sent items (top 10, last 48h) +- Check ms365-fdesk-reader sent items (top 10, last 48h) + +Look for: +- Emails where Robert explained something well (could become a LinkedIn post) +- Deal announcements or milestones (could become a case study) +- Technical explanations (could become a blog post for SynapseLayer) +- School updates (could become a marketing piece for Escola Caravela) + +## 3. Content Ideas Persistence & Posting +For each content opportunity found: +- Write a 2-sentence pitch (topic + angle + format) +- Append to /home/openclaw/.openclaw/shared/memory/global/content-ideas.md with date and source +- Tag with venture: [fDesk] [SynapseLayer] [Escola] [Personal Brand] + +Post IDEAS to the dedicated Teams channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:gyjb1z51442BmTREuUKLkdsl5bi-WOzJQc_gJtsHn8E1@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Kira] Content Idea [DATE]
[IDEA PITCH]"}}' +``` + +## 4. Brand Consistency Check +If Robert posted on LinkedIn recently (search brave-search: "Robert Koller linkedin"), note the topic and tone. Check for consistency with existing content strategy. + +## 5. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Kira] [DATE]
[SUMMARY]"}}' +``` +Channel: cross-business (19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 6. Output Format +This response is delivered to the cross-business Teams channel. +- If content ideas found: "Content: [N] ideas. Top: [best pitch in 1 line]" +- If brand opportunity: "[OPPORTUNITY] [specific content suggestion]" +- If nothing new: NO_REPLY + +## 6. Daily Standup (MANDATORY — first run after 06:00) +Write `outbox/standup.md` (file only — do NOT post to Teams; the standup compiler at 08:30 handles that). +Format: +``` +**Yesterday:** [content ideas generated, brand opportunities found] +**Today:** [posts to draft, content scans planned] +**Blockers:** [any blockers or "None"] +``` + +## 7. Weekly Update (write by Friday 16:00) +Write outbox/weekly-update.md: +- Content ideas generated this week +- Posts published (if tracked) +- Upcoming content calendar diff --git a/bates-core/agents/mercer/HEARTBEAT.md b/bates-core/agents/mercer/HEARTBEAT.md new file mode 100644 index 0000000..b885e78 --- /dev/null +++ b/bates-core/agents/mercer/HEARTBEAT.md @@ -0,0 +1,61 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. Regulatory Scan (REQUIRED) +Use brave-search to check for regulatory developments: +- "CSSF Luxembourg" + recent news (fDesk regulatory) +- "EU securitization regulation" OR "STS framework" + recent news +- "GDPR enforcement" + recent news (SynapseLayer data protection) +- "Portuguese education ministry" OR "DGEstE" + recent news (Escola Caravela) + +For each relevant finding: +- Summarize in 2-3 sentences +- Note which venture it affects +- Rate impact: HIGH (requires action), MEDIUM (monitor), LOW (background) +- Append to /home/openclaw/.openclaw/shared/memory/fdesk/regulatory-updates.md (dated entry) + +## 2. Compliance Calendar Check +Read /home/openclaw/.openclaw/shared/memory/global/calendar-overview.md (Jules maintains this). +Cross-reference with known regulatory deadlines: +- UCI reporting deadlines +- CSSF filing deadlines +- Corporate tax deadlines +- GDPR-related obligations + +Write upcoming compliance deadlines to /home/openclaw/.openclaw/shared/memory/fdesk/compliance-calendar.md. + +## 3. Knowledge Persistence +Append new regulatory facts to observations/findings.md: +- [fact] Regulatory: [finding] (source: web search/CSSF) +- [deadline] [date] - [compliance obligation] (source: regulatory scan) + +## 4. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Mercer] [DATE]
[SUMMARY]"}}' +``` +Channel: cross-business (19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 5. Output Format +This response is delivered to the cross-business Teams channel. +- If HIGH-impact finding: "[REGULATORY] [jurisdiction]: [development] -- action needed by [date]" +- If compliance deadline approaching: "[DEADLINE] [date]: [obligation]" +- If nothing significant: NO_REPLY + +## 5. Daily Standup (MANDATORY — first run after 06:00) +Write `outbox/standup.md` (file only — do NOT post to Teams; the standup compiler at 08:30 handles that). +Format: +``` +**Yesterday:** [regulatory updates found, compliance deadlines tracked] +**Today:** [regulatory scans planned, risk items to review] +**Blockers:** [any blockers or "None"] +``` + +## 6. Weekly Update (write by Friday 16:00) +Write outbox/weekly-update.md: +- Key regulatory developments +- Upcoming compliance dates +- Cross-business legal risks diff --git a/bates-core/agents/mira/HEARTBEAT.md b/bates-core/agents/mira/HEARTBEAT.md new file mode 100644 index 0000000..eb1f676 --- /dev/null +++ b/bates-core/agents/mira/HEARTBEAT.md @@ -0,0 +1,59 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. System Health (REQUIRED) +- Check disk space: df -h / | tail -1 +- Check gateway status: systemctl --user is-active openclaw-gateway +- Check for failed cron jobs: read ~/.openclaw/cron/jobs.json, find jobs with consecutiveErrors > 0 + +## 2. Coding Activity Observation (best-effort, skip if no data) +Check if /home/openclaw/.openclaw/observations/cursor/ exists. If the directory does not exist or is empty, skip this section entirely (this is normal, not an alert). Do NOT report missing cursor transcripts as an alert. + +If cursor transcripts DO exist: +- Count files modified in the last 24 hours +- Read the 2-3 most recent JSON files +- Extract: files/repos worked on, session names, lines added +- Write findings to /home/openclaw/.openclaw/shared/memory/global/coding-activity-YYYY-MM-DD.md + +## 3. Proactive Supervision +- Check recent git logs across known repos for Robert's commits +- Look for patterns: repeated error types, missing tests, code style issues +- Note any areas where automation could help + +## 4. Automation Proposals +If you spot a repetitive pattern or inefficiency: +- Draft a proposal in proposals/automation-YYYY-MM-DD.md +- Include: problem, proposed solution, estimated effort, expected benefit +- Notify via outbox if the proposal is HIGH priority + +## 5. Cron Effectiveness (Monday only) +On Mondays, review cron job states: +- Which jobs have consecutiveErrors > 0? +- Which jobs have lastDurationMs > 120000? +- Which jobs produced NO_REPLY more than 5 times in a row? +Report anomalies. + +## 6. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:447ce1f9a8f1420a9d60f82449d84d24@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Mira] [DATE]
[SUMMARY]"}}' +``` +Channel: bates-rollout (19:447ce1f9a8f1420a9d60f82449d84d24@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 7. Output Format +This response is delivered to the bates-rollout Teams channel. +- If system issues: "[ALERT] [component]: [issue]" +- If coding activity observed: "Coding: [N] sessions, working on [repos]. Help opportunity: [X]" +- If automation proposal: "[PROPOSAL] [brief description]" +- If cron issues (Monday): "Cron audit: [N] issues found" +- If all healthy and no new activity: NO_REPLY + +## 8. Daily Standup (MANDATORY — first run after 06:00) + +Write outbox/standup.md (file only, do NOT post to Teams; the coordination meeting at 08:45 handles posting): +- System status summary +- What was completed/monitored +- Blockers diff --git a/bates-core/agents/nova/HEARTBEAT.md b/bates-core/agents/nova/HEARTBEAT.md new file mode 100644 index 0000000..961eafc --- /dev/null +++ b/bates-core/agents/nova/HEARTBEAT.md @@ -0,0 +1,73 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. Context Gathering (REQUIRED) +Before searching the web, understand what Robert is working on right now: +- Read /home/openclaw/.openclaw/shared/memory/fdesk/deal-pipeline.md (active deals) +- Read /home/openclaw/.openclaw/shared/memory/fdesk/context-today.md (today's focus) +- List the 5 most recently modified files in drafts/ +- Read /home/openclaw/.openclaw/shared/memory/global/coding-activity-*.md (most recent, if exists) + +## 2. RSS/News Insights (REQUIRED) +Since Feedly API is currently broken, use alternative sources: +- Run brave-search queries for Robert's key domains: fintech securitization, AI agent infrastructure, Portuguese education, treasury management +- Use perplexity/sonar-pro for deeper research on the most relevant topic from step 1 +- Check Hacker News (brave-search "site:news.ycombinator.com [topic]") for tech trends +- Extract top 3-5 most relevant articles based on current project context +- Summarize key takeaways + +## 3. Perplexity Deep Research (REQUIRED) +Based on context from step 1, run 1-2 Perplexity (sonar-pro) searches: +- Query should be informed by active projects, deals, or coding activity +- Focus on emerging trends, competitor moves, regulatory changes +- Use perplexity/sonar-pro model for these queries + +## 4. Targeted Brave Search (REQUIRED) +Run up to 2 brave-search queries for real-time context: +- If Robert drafted an email about a company: search "[company name] news [this week]" +- If a deal is in pipeline: search for the counterparty, industry segment, or regulatory developments +- If Escola Caravela activity found: search Portuguese education news, European school regulations +- If coding on NowTreasury/SynapseLayer: search for relevant fintech/AI infrastructure developments + +Do NOT run generic searches like "fintech news." Every query must be contextually informed by step 1. + +## 5. Knowledge Persistence (REQUIRED) +For each meaningful finding: +- Write a 2-3 sentence summary +- Note relevance to which venture (fDesk/Synapse/Escola) +- Rate: HIGH (Robert should know today), MEDIUM (useful context), LOW (background) +- Append to /home/openclaw/.openclaw/shared/memory/global/market-intel-YYYY-MM-DD.md (use today's date) + +If HIGH-actionability item found, also write to outbox/escalate-market-TIMESTAMP.md. +**ESCALATION POLICY**: Do NOT deliver to Robert's DM directly. Write the escalate file and the daily coordination meeting (08:45) will triage it. + +## 6. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Nova] [DATE]
[SUMMARY]"}}' +``` +Channel: cross-business (19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 7. Output Format +This response is delivered to the cross-business Teams channel. +- "[N] items found. [HIGH count] requiring attention." +- One-line summary of each HIGH item +- If nothing meaningful: NO_REPLY + +## 7. Daily Standup (MANDATORY — first run after 06:00) +Write `outbox/standup.md` (file only — do NOT post to Teams; the standup compiler at 08:30 handles that). +Format: +``` +**Yesterday:** [market intel items found, research highlights] +**Today:** [trends to investigate, searches planned] +**Blockers:** [any blockers or "None"] +``` + +## 8. Weekly Update (write by Friday 16:00) +Write outbox/weekly-update.md: +- Key findings this week (Perplexity + Brave + HN) +- Upcoming items on the radar +- Cross-business insights diff --git a/bates-core/agents/paige/HEARTBEAT.md b/bates-core/agents/paige/HEARTBEAT.md new file mode 100644 index 0000000..332edc3 --- /dev/null +++ b/bates-core/agents/paige/HEARTBEAT.md @@ -0,0 +1,56 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. Financial Email Scan (REQUIRED) +Check rk@vernot.com inbox via ms365-reader for financial items: +- Search for: invoices, payment confirmations, bank notifications, subscription renewals, expense receipts +- Use: list-mail-messages with search='"invoice" OR "payment" OR "receipt" OR "subscription" OR "renewal" OR "Rechnung"' top=10 + +For each financial email found: +- Extract: amount, vendor/sender, due date (if any), category +- Append to /home/openclaw/.openclaw/shared/memory/private/financial-items.md: + ``` + - [YYYY-MM-DD] [vendor] [amount] [category] [status: paid/pending/overdue] + ``` + +## 2. Subscription Monitoring +Note any subscription renewal or expiry warnings. These are time-sensitive: +- If expiry <7 days: write escalation to outbox/escalate-TIMESTAMP.md + **ESCALATION POLICY**: Do NOT deliver to Robert's DM directly. Write the escalate file and the daily coordination meeting (08:45) will triage it. +- If auto-renewal success: just log in financial-items.md + +## 3. Knowledge Persistence +Append new financial facts to observations/findings.md: +- [fact] Financial: [vendor] [amount] [status] (source: email) +- [deadline] [date] - payment due for [vendor] (source: email) + +## 4. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:719e9c4defd9450486716839ee8ff382@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Paige] [DATE]
[SUMMARY]"}}' +``` +Channel: private (19:719e9c4defd9450486716839ee8ff382@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 5. Output Format +This response is delivered to the private Teams channel. +- If overdue/expiring items: "[PAYMENT] [vendor]: [amount] due [date]" +- If new items tracked: "Finance: [N] items logged" +- If nothing new: NO_REPLY + +## 5. Daily Standup (MANDATORY — first run after 06:00) +Write `outbox/standup.md` (file only — do NOT post to Teams; the standup compiler at 08:30 handles that). +Format: +``` +**Yesterday:** [financial items tracked, payments processed] +**Today:** [payments due, renewals to flag] +**Blockers:** [any blockers or "None"] +``` + +## 6. Weekly Update (write by Friday 16:00) +Write outbox/weekly-update.md: +- Financial items tracked this week +- Upcoming payments/renewals +- Total spend by category (if enough data) diff --git a/bates-core/agents/quinn/HEARTBEAT.md b/bates-core/agents/quinn/HEARTBEAT.md new file mode 100644 index 0000000..15e275b --- /dev/null +++ b/bates-core/agents/quinn/HEARTBEAT.md @@ -0,0 +1,68 @@ +# Heartbeat Checklist + +## 0. Check Inbox (ALWAYS FIRST) +Read all files in inbox/. Process any feedback or task assignments. Delete processed files. + +## 1. Scan All Agent Activity (REQUIRED) +Check the central cron jobs file for all agent activity: +- Read `/home/openclaw/.openclaw/cron/jobs.json` +- For each job: note agentId, name, lastRunStatus, consecutiveErrors, lastDurationMs +- Check `/home/openclaw/.openclaw/agents/main/sessions/` for active subagent sessions +- Check each agent's outbox: `/home/openclaw/.openclaw/agents/{agent}/outbox/` for pending messages + +Collect: agent name, cron job statuses (ok/error), active session count, pending outbox items. + +## 2. Microsoft To Do Sync (REQUIRED) +**Agent Tasks list ID:** `AAMkADI5MDU0OThkLTM2ZjItNDA1YS05MDY1LTZlOTA2ZmNjMjEzNwAuAAAAAAAEGmEdFnYlRpGm5ddo9XdyAQBNUrDGIj66TIi6yxmhxkIcAAAVLHLVAAA=` + +Using ms365-assistant MCP tools: +- Fetch tasks: `list-todo-tasks todoTaskListId="AAMkADI5MDU0OThkLTM2ZjItNDA1YS05MDY1LTZlOTA2ZmNjMjEzNwAuAAAAAAAEGmEdFnYlRpGm5ddo9XdyAQBNUrDGIj66TIi6yxmhxkIcAAAVLHLVAAA="` +- Compare tracked tasks against actual agent activity from step 1 +- Create new tasks for any untracked subagent spins or failing cron jobs: + `create-todo-task todoTaskListId="AAMkADI5MDU0OThkLTM2ZjItNDA1YS05MDY1LTZlOTA2ZmNjMjEzNwAuAAAAAAAEGmEdFnYlRpGm5ddo9XdyAQBNUrDGIj66TIi6yxmhxkIcAAAVLHLVAAA=" body='{"title":"[AGENT] description","importance":"normal"}'` +- Task title format: `[AGENT] Task description (source: cron/subagent/manual)` +- ALWAYS create tasks for: cron errors (consecutiveErrors > 0), stuck sessions, failed deliveries + +## 3. Task Completion Check (REQUIRED) +For each open task in "Agent Tasks": +- Match it to the corresponding cron job or session +- If the job is now running OK (consecutiveErrors == 0, lastRunStatus == "ok"): mark task as done + `update-todo-task todoTaskListId="..." todoTaskId="..." body='{"status":"completed"}'` +- If failed/stuck: note the failure reason in the task body +- If stuck > 2 hours: escalate to Bates via outbox/escalate-stuck-task.md + +## 4. Stuck Task Resolution +For tasks that are stuck or failed: +1. Read the cron job's lastError field from `/home/openclaw/.openclaw/cron/jobs.json` +2. Check if gateway is running: `systemctl --user is-active openclaw-gateway` +3. If restartable (transient error): write a message to the agent's inbox/ requesting retry +4. If not restartable: write to outbox/escalate-stuck-task.md for Bates with error details + +## 5. Teams Channel Post (if you have new findings) +If you produced new findings (not NO_REPLY), post a summary to your designated channel: +```bash +~/.openclaw/scripts/graph-api.sh POST "/teams/640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c/channels/19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2/messages" '{"body":{"contentType":"html","content":"[Quinn] Task Tracker [DATE]
[SUMMARY]"}}' +``` +Channel: cross-business (19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2) +Keep it under 500 chars. Use HTML bold for headers. Include date. Skip this step if NO_REPLY. + +## 6. Output Format +- If task updates: "Tasks: [N] active, [M] completed, [K] stuck/escalated" +- If stuck tasks: "[STUCK] [agent]: [task] - [action taken]" +- If all clear: NO_REPLY + +## 6. Daily Standup (MANDATORY — first run after 06:00) +Write `outbox/standup.md` (file only — do NOT post to Teams; the standup compiler at 08:30 handles that). +Format: +``` +**Yesterday:** [task tracking stats, stuck items resolved] +**Today:** [completion rates to check, tasks to triage] +**Blockers:** [any blockers or "None"] +``` + +## 7. Weekly Update (write by Friday 16:00) +Write outbox/weekly-update.md: +- Total tasks tracked this week +- Completion rate by agent +- Stuck/escalated tasks +- Recommendations for process improvements diff --git a/bates-core/assets/bates-icon.ico b/bates-core/assets/bates-icon.ico new file mode 100644 index 0000000..fa93e8d Binary files /dev/null and b/bates-core/assets/bates-icon.ico differ diff --git a/bates-core/assets/installer-banner.bmp b/bates-core/assets/installer-banner.bmp new file mode 100644 index 0000000..2fb15a9 Binary files /dev/null and b/bates-core/assets/installer-banner.bmp differ diff --git a/bates-core/assets/installer-logo.bmp b/bates-core/assets/installer-logo.bmp new file mode 100644 index 0000000..d5f39f3 Binary files /dev/null and b/bates-core/assets/installer-logo.bmp differ diff --git a/bates-core/assets/installer-splash.png b/bates-core/assets/installer-splash.png new file mode 100644 index 0000000..a702bbf Binary files /dev/null and b/bates-core/assets/installer-splash.png differ diff --git a/bates-core/core-client-setup.ps1 b/bates-core/core-client-setup.ps1 new file mode 100644 index 0000000..0eaceb0 --- /dev/null +++ b/bates-core/core-client-setup.ps1 @@ -0,0 +1,280 @@ +# core-client-setup.ps1 -- Client Mode: Dashboard App + SSH/RDP Shortcuts +# Installs Bates Command Center desktop app and creates connection shortcuts +# to a remote Bates server. No WSL2, no gateway -- just the viewer + remote tools. + +param( + [string]$InstallDir = "$env:LOCALAPPDATA\BatesInstaller" +) + +$ErrorActionPreference = "Stop" + +function Write-Step($msg) { + Write-Host "" + Write-Host "==> $msg" -ForegroundColor Cyan +} + +function Write-Success($msg) { + Write-Host "[OK] $msg" -ForegroundColor Green +} + +function Write-Warn($msg) { + Write-Host "[WARN] $msg" -ForegroundColor Yellow +} + +function Write-Fail($msg) { + Write-Host "[ERROR] $msg" -ForegroundColor Red +} + +Write-Host "" +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host " Bates AI Assistant -- Client Setup" -ForegroundColor Cyan +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "This machine will be set up as a client to connect to a remote" -ForegroundColor White +Write-Host "Bates server. No AI services will run locally." -ForegroundColor White +Write-Host "" + +# ============================================================ +# Step 1: Collect server connection info +# ============================================================ +Write-Step "Server connection details" + +Write-Host "Enter the address of the machine where Bates is installed." -ForegroundColor White +Write-Host "This can be a Tailscale hostname, IP address, or DNS name." -ForegroundColor Gray +Write-Host "" + +$serverHost = Read-Host "Bates server address (e.g., bates-server.tail0e82c9.ts.net)" +if ([string]::IsNullOrEmpty($serverHost)) { + Write-Fail "Server address is required." + exit 1 +} + +$serverUser = Read-Host "Linux (WSL) username on the server [openclaw]" +if ([string]::IsNullOrEmpty($serverUser)) { $serverUser = "openclaw" } + +$winUser = Read-Host "Windows username on the server [$env:USERNAME]" +if ([string]::IsNullOrEmpty($winUser)) { $winUser = $env:USERNAME } + +$gatewayPort = Read-Host "Gateway port [18789]" +if ([string]::IsNullOrEmpty($gatewayPort)) { $gatewayPort = "18789" } + +# Windows host may have a different Tailscale hostname than WSL +$winHost = Read-Host "Windows host address (if different from server, or Enter for same) [$serverHost]" +if ([string]::IsNullOrEmpty($winHost)) { $winHost = $serverHost } + +Write-Success "Server: $serverHost (gateway port $gatewayPort)" + +# ============================================================ +# Step 2: Install Bates Command Center desktop app +# ============================================================ +Write-Step "Installing Bates Command Center..." + +$desktopExe = Join-Path $InstallDir "desktop\bates-command-center.exe" +$appDir = "$env:LOCALAPPDATA\BatesCommandCenter" + +if (Test-Path $desktopExe) { + # Create app directory + New-Item -ItemType Directory -Path $appDir -Force | Out-Null + + # Copy the executable + Copy-Item $desktopExe "$appDir\Bates Command Center.exe" -Force + Write-Success "Desktop app installed to: $appDir" + + # Create Desktop shortcut + $desktopPath = [Environment]::GetFolderPath("Desktop") + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut("$desktopPath\Bates Command Center.lnk") + $shortcut.TargetPath = "$appDir\Bates Command Center.exe" + $shortcut.Description = "Bates AI Assistant Dashboard" + $shortcut.WorkingDirectory = $appDir + + # Use custom icon if available + $iconPath = Join-Path $InstallDir "desktop\icon.ico" + if (Test-Path $iconPath) { + Copy-Item $iconPath "$appDir\icon.ico" -Force + $shortcut.IconLocation = "$appDir\icon.ico" + } + + $shortcut.Save() + Write-Success "Desktop shortcut created" + + # Create Start Menu shortcut + $startMenu = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs" + $startShortcut = $shell.CreateShortcut("$startMenu\Bates Command Center.lnk") + $startShortcut.TargetPath = "$appDir\Bates Command Center.exe" + $startShortcut.Description = "Bates AI Assistant Dashboard" + $startShortcut.WorkingDirectory = $appDir + if (Test-Path "$appDir\icon.ico") { + $startShortcut.IconLocation = "$appDir\icon.ico" + } + $startShortcut.Save() + Write-Success "Start Menu shortcut created" +} else { + Write-Warn "Desktop app not found in installer package." + Write-Host "You can access the dashboard via browser instead:" -ForegroundColor Gray + Write-Host " http://${serverHost}:${gatewayPort}/dashboard" -ForegroundColor White +} + +# ============================================================ +# Step 3: Generate SSH key pair +# ============================================================ +Write-Step "Setting up SSH key for passwordless access..." + +$remoteDir = [Environment]::GetFolderPath("Desktop") + "\Bates Remote" +New-Item -ItemType Directory -Path $remoteDir -Force | Out-Null + +$keyPath = "$remoteDir\bates-remote" + +if (Test-Path $keyPath) { + Write-Host "SSH key already exists at: $keyPath" -ForegroundColor Gray +} else { + # Generate ed25519 key pair + ssh-keygen -t ed25519 -f $keyPath -N '""' -C "bates-remote-access" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Success "SSH key pair generated" + } else { + # Try without empty passphrase quoting (varies by ssh-keygen version) + ssh-keygen -t ed25519 -f $keyPath -N "" -C "bates-remote-access" 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Success "SSH key pair generated" + } else { + Write-Warn "Could not generate SSH key. You may need to create one manually." + Write-Host " ssh-keygen -t ed25519 -f `"$keyPath`" -N `"`" -C `"bates-remote-access`"" + } + } +} + +# ============================================================ +# Step 4: Create connection shortcuts +# ============================================================ +Write-Step "Creating connection shortcuts..." + +# SSH to WSL (Linux) +$sshWslContent = @" +@echo off +title SSH - Bates Server (Linux) +echo Connecting to Bates server (WSL)... +echo Host: $serverHost +echo. +ssh -i "%~dp0bates-remote" -o StrictHostKeyChecking=no $serverUser@$serverHost +if errorlevel 1 ( + echo. + echo Connection failed. Ensure: + echo 1. The Bates server is running + echo 2. SSH server is running on the server + echo 3. Tailscale is connected on both machines + echo 4. Your public key is installed on the server +) +echo. +pause +"@ +Set-Content "$remoteDir\SSH - Bates Server (Linux).bat" $sshWslContent +Write-Success "Created: SSH - Bates Server (Linux).bat" + +# SSH to Windows host +$sshWinContent = @" +@echo off +title SSH - Bates Server (Windows) +echo Connecting to Bates server (Windows host)... +echo Host: $winHost +echo. +ssh -i "%~dp0bates-remote" -o StrictHostKeyChecking=no $winUser@$winHost +if errorlevel 1 ( + echo. + echo Connection failed. Ensure: + echo 1. Windows OpenSSH Server is running on the server + echo 2. Tailscale is connected on both machines + echo 3. Your public key is installed on the server +) +echo. +pause +"@ +Set-Content "$remoteDir\SSH - Bates Server (Windows).bat" $sshWinContent +Write-Success "Created: SSH - Bates Server (Windows).bat" + +# RDP to Windows host +$rdpContent = @" +full address:s:$winHost +username:s:$winUser +prompt for credentials:i:1 +screen mode id:i:2 +desktopwidth:i:1920 +desktopheight:i:1080 +session bpp:i:32 +compression:i:1 +displayconnectionbar:i:1 +autoreconnection enabled:i:1 +authentication level:i:2 +negotiate security layer:i:1 +"@ +Set-Content "$remoteDir\RDP - Bates Server.rdp" $rdpContent +Write-Success "Created: RDP - Bates Server.rdp" + +# Dashboard browser shortcut +$dashUrl = "http://${serverHost}:${gatewayPort}/dashboard" +$shell = New-Object -ComObject WScript.Shell +$dashShortcut = $shell.CreateShortcut("$remoteDir\Dashboard (Browser).url") +$dashShortcut.TargetPath = $dashUrl +$dashShortcut.Save() +Write-Success "Created: Dashboard (Browser).url" + +# ============================================================ +# Step 5: Key installation instructions +# ============================================================ +Write-Step "SSH key installation" + +$pubKeyPath = "$keyPath.pub" +if (Test-Path $pubKeyPath) { + $pubKey = Get-Content $pubKeyPath -Raw + Write-Host "" + Write-Host "Your public key needs to be added to the Bates server." -ForegroundColor Yellow + Write-Host "" + Write-Host "Option A: Copy this key and add it on the server:" -ForegroundColor White + Write-Host "" + Write-Host " $($pubKey.Trim())" -ForegroundColor Gray + Write-Host "" + Write-Host " On the server, run:" -ForegroundColor White + Write-Host " echo '$($pubKey.Trim())' >> ~/.ssh/authorized_keys" -ForegroundColor Gray + Write-Host "" + Write-Host "Option B: Use ssh-copy-id (if you know the password):" -ForegroundColor White + Write-Host " ssh-copy-id -i `"$pubKeyPath`" $serverUser@$serverHost" -ForegroundColor Gray + Write-Host "" + + # Copy to clipboard + try { + $pubKey.Trim() | Set-Clipboard + Write-Success "Public key copied to clipboard" + } catch { + # Clipboard not available (e.g., headless) + } +} else { + Write-Warn "No public key found. Generate one manually and install on server." +} + +# ============================================================ +# Summary +# ============================================================ +Write-Host "" +Write-Host "==========================================" -ForegroundColor Green +Write-Host " Client Setup Complete!" -ForegroundColor Green +Write-Host "==========================================" -ForegroundColor Green +Write-Host "" +Write-Host "What was installed:" -ForegroundColor White +Write-Host "" + +if (Test-Path "$appDir\Bates Command Center.exe") { + Write-Host " [x] Bates Command Center desktop app" -ForegroundColor Green + Write-Host " Location: $appDir" -ForegroundColor Gray +} +Write-Host " [x] SSH/RDP shortcuts" -ForegroundColor Green +Write-Host " Location: $remoteDir" -ForegroundColor Gray +Write-Host "" +Write-Host "Dashboard URL: $dashUrl" -ForegroundColor Cyan +Write-Host "" +Write-Host "Next steps:" -ForegroundColor Yellow +Write-Host " 1. Install your SSH public key on the Bates server (see above)" +Write-Host " 2. Ensure Tailscale is connected on both machines" +Write-Host " 3. Open Bates Command Center or the browser dashboard" +Write-Host "" +Write-Host "Press any key to exit..." +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") diff --git a/bates-core/core-configure.sh b/bates-core/core-configure.sh new file mode 100755 index 0000000..5c0cce1 --- /dev/null +++ b/bates-core/core-configure.sh @@ -0,0 +1,426 @@ +#!/usr/bin/env bash +# core-configure.sh -- Phase 3: AI auth + personalization + Telegram +# Called after core-setup.sh has installed all dependencies. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/template-engine.sh" + +export PATH="$HOME/.npm-global/bin:$PATH" + +echo "" +echo "===========================================" +echo " Bates Core -- Configuration" +echo "===========================================" + +# ============================================================ +# AI Provider Selection +# ============================================================ +echo "" +echo "Choose your AI subscription:" +echo " 1) Anthropic (Claude Max) -- Best quality, Opus 4.6" +echo " 2) OpenAI (ChatGPT Pro) -- GPT-5.2" +echo " 3) Google (Gemini Advanced) -- Gemini 3 Pro" +echo " 4) OpenAI Codex (ChatGPT Plus/Pro) -- GPT-5.4" +echo "" +read -rp "Selection [1]: " PROVIDER_CHOICE +PROVIDER_CHOICE="${PROVIDER_CHOICE:-1}" + +case "$PROVIDER_CHOICE" in + 1) + export PROVIDER="anthropic" + export PRIMARY_MODEL="anthropic/claude-opus-4-6" + export PRIMARY_MODEL_SHORT="Opus 4.6" + echo "" + echo "Anthropic subscription auth requires a token from Claude Code." + echo "" + echo "In another terminal, run:" + echo " claude setup-token" + echo "" + echo "Then paste the token here." + echo "" + read -rp "Subscription token: " SUB_TOKEN + if [[ -z "$SUB_TOKEN" ]]; then + fatal "Subscription token is required." + fi + # Try the interactive openclaw CLI first; fall back to manual credential + # storage if no TTY is available (e.g. piped input, automation). + if openclaw models auth setup-token --provider anthropic <<< "$SUB_TOKEN" 2>/dev/null; then + success "Anthropic subscription configured." + else + warn "openclaw CLI auth requires an interactive terminal. Storing token manually..." + mkdir -p "$HOME/.openclaw/credentials" + chmod 700 "$HOME/.openclaw/credentials" + echo -n "$SUB_TOKEN" > "$HOME/.openclaw/credentials/anthropic-token" + chmod 600 "$HOME/.openclaw/credentials/anthropic-token" + success "Anthropic token stored manually. Run 'openclaw models auth setup-token --provider anthropic' later to complete interactive setup." + fi + + echo "" + read -rp "Optional: API key as fallback (or Enter to skip): " API_KEY + if [[ -n "$API_KEY" ]]; then + # Store API key in systemd drop-in for gateway + cat > "$HOME/.config/systemd/user/openclaw-gateway.service.d/api-key.conf" << EOF +[Service] +Environment="ANTHROPIC_API_KEY=$API_KEY" +EOF + chmod 600 "$HOME/.config/systemd/user/openclaw-gateway.service.d/api-key.conf" + success "API key fallback configured." + fi + ;; + 2) + export PROVIDER="openai" + export PRIMARY_MODEL="openai/gpt-5.2" + export PRIMARY_MODEL_SHORT="GPT-5.2" + echo "" + echo "Starting OpenAI auth flow..." + openclaw models auth --provider openai + ;; + 3) + export PROVIDER="google" + export PRIMARY_MODEL="google/gemini-3-pro-preview" + export PRIMARY_MODEL_SHORT="Gemini 3 Pro" + echo "" + echo "Starting Google auth flow..." + openclaw models auth --provider google + ;; + 4) + export PROVIDER="openai-codex" + export PRIMARY_MODEL="openai-codex/gpt-5.4" + export PRIMARY_MODEL_SHORT="GPT-5.4" + echo "" + echo "Starting OpenAI Codex auth flow (uses ChatGPT Plus OAuth)..." + openclaw models auth --provider openai-codex + ;; + *) + fatal "Invalid selection: $PROVIDER_CHOICE" + ;; +esac + +# ============================================================ +# Personalization +# ============================================================ +echo "" +echo "--- Personalization ---" +read -rp "Assistant name [Bates]: " ASSISTANT_NAME +export ASSISTANT_NAME="${ASSISTANT_NAME:-Bates}" + +read -rp "Your name: " USER_NAME +if [[ -z "$USER_NAME" ]]; then + fatal "Your name is required." +fi +export USER_NAME + +read -rp "Your timezone [Europe/Lisbon]: " USER_TZ +export USER_TZ="${USER_TZ:-Europe/Lisbon}" + +# ============================================================ +# Email Account Suggestion +# ============================================================ +echo "" +echo "===========================================" +echo " Email Account for $ASSISTANT_NAME" +echo "===========================================" +echo "" +echo "The next step will connect a Microsoft account for email, calendar, and OneDrive." +echo "" +echo "Don't want to use your main account? Create a free Outlook.com account to try" +echo "$ASSISTANT_NAME first. You can switch to your primary account later in Settings." +echo "" +echo " -> https://outlook.live.com/owa/?nlp=1&signup=1" +echo "" +read -rp "Press Enter when ready to continue..." + +# ============================================================ +# Microsoft 365 Sign-In (optional, recommended) +# ============================================================ +echo "" +echo "===========================================" +echo " Microsoft 365 — Sign in with Microsoft" +echo "===========================================" +echo "" +echo "Connect your Microsoft account to enable email, calendar, and OneDrive." +echo "Works with Microsoft 365 Business, Enterprise, Education, Family, Personal," +echo "and free Outlook.com accounts." +echo "" +echo "Some features (Teams) require a work or school account." +echo "" +read -rp "Set up Microsoft 365 integration now? (y/n) [y]: " M365_CHOICE +M365_CHOICE="${M365_CHOICE:-y}" + +M365_CONFIGURED=false +if [[ "$M365_CHOICE" =~ ^[Yy] ]]; then + # Ensure Python deps are available + pip3 install -q requests pyyaml 2>/dev/null || true + + M365_SIGNIN_SCRIPT="$SCRIPT_DIR/scripts-core/m365-gateway/oauth-signin.py" + if [[ -f "$M365_SIGNIN_SCRIPT" ]]; then + if python3 "$M365_SIGNIN_SCRIPT"; then + M365_CONFIGURED=true + + # Read account info for later use + M365_ACCOUNT_FILE="$HOME/.openclaw/m365-safety/account-info.json" + if [[ -f "$M365_ACCOUNT_FILE" ]]; then + M365_EMAIL=$(python3 -c "import json; print(json.load(open('$M365_ACCOUNT_FILE')).get('email',''))" 2>/dev/null || echo "") + M365_NAME=$(python3 -c "import json; print(json.load(open('$M365_ACCOUNT_FILE')).get('display_name',''))" 2>/dev/null || echo "") + M365_TYPE=$(python3 -c "import json; print(json.load(open('$M365_ACCOUNT_FILE')).get('account_type',''))" 2>/dev/null || echo "") + if [[ -n "$M365_EMAIL" ]]; then + export ASSISTANT_EMAIL="$M365_EMAIL" + success "Microsoft 365 connected: $M365_EMAIL ($M365_TYPE)" + fi + fi + + # Install and enable M365 safety gateway service + if [[ -f "$SCRIPT_DIR/scripts-core/m365-gateway/m365-safety-gateway.service" ]]; then + cp "$SCRIPT_DIR/scripts-core/m365-gateway/m365-safety-gateway.service" \ + "$HOME/.config/systemd/user/" + systemctl --user daemon-reload + systemctl --user enable m365-safety-gateway 2>/dev/null || true + info "M365 safety gateway service installed" + fi + else + warn "Microsoft sign-in failed or was cancelled. You can set it up later with: bates-enhance.sh m365" + fi + else + warn "Sign-in script not found. You can set up M365 later with: bates-enhance.sh m365" + fi +else + info "Skipped. You can set up Microsoft 365 later with: bates-enhance.sh m365" +fi +echo "" + +# ============================================================ +# Anonymous Analytics (opt-in) +# ============================================================ +echo "" +echo "===========================================" +echo " Anonymous Usage Analytics (Optional)" +echo "===========================================" +echo "" +echo "Help improve Bates by sharing anonymous usage analytics." +echo "Only event counts (emails sent, calendar events created, etc.)." +echo "No content, recipients, subjects, or personal data is ever shared." +echo "You can change this anytime in Settings." +echo "" +read -rp "Enable anonymous analytics? (y/n) [n]: " ANALYTICS_CHOICE +ANALYTICS_CHOICE="${ANALYTICS_CHOICE:-n}" + +M365_ACCT_TYPE="${M365_TYPE:-unknown}" +if [[ "$ANALYTICS_CHOICE" =~ ^[Yy] ]]; then + python3 -c " +import sys; sys.path.insert(0, '$SCRIPT_DIR/scripts-core/m365-gateway') +from analytics import Analytics +Analytics.setup(enabled=True, bates_version='2.0.0', account_type='$M365_ACCT_TYPE') +print(' Analytics enabled.') +" 2>/dev/null || warn "Could not configure analytics" +else + python3 -c " +import sys; sys.path.insert(0, '$SCRIPT_DIR/scripts-core/m365-gateway') +from analytics import Analytics +Analytics.setup(enabled=False, bates_version='2.0.0', account_type='$M365_ACCT_TYPE') +" 2>/dev/null || true + info "Analytics disabled." +fi +echo "" + +# ============================================================ +# Telegram Setup +# ============================================================ +echo "" +echo "===========================================" +echo " Telegram Setup (your first messaging channel)" +echo "===========================================" +echo "" +echo "Telegram lets you talk to $ASSISTANT_NAME from your phone, anywhere." +echo "" +echo "Step 1: Create a bot" +echo " Open Telegram and message @BotFather:" +echo " /newbot -> follow the prompts -> copy the bot token" +echo "" +read -rp "Bot token (e.g., 7123456789:AAF...): " TELEGRAM_BOT_TOKEN +if [[ -z "$TELEGRAM_BOT_TOKEN" ]]; then + fatal "Telegram bot token is required." +fi +export TELEGRAM_BOT_TOKEN + +echo "" +echo "Step 2: Your Telegram user ID" +echo " Message @userinfobot in Telegram to get your numeric ID." +echo "" +read -rp "Your Telegram user ID (numeric): " TELEGRAM_USER_ID +if [[ -z "$TELEGRAM_USER_ID" ]]; then + fatal "Telegram user ID is required." +fi +export TELEGRAM_USER_ID + +# ============================================================ +# Write Bates Version +# ============================================================ +BATES_VERSION="2.0.0" +echo "$BATES_VERSION" > "$HOME/.openclaw/bates-version" + +# ============================================================ +# Generate Configuration +# ============================================================ +step "Generating configuration..." + +# Render openclaw.json +template_render "$SCRIPT_DIR/templates/openclaw.json.template" \ + "$HOME/.openclaw/openclaw.json" +chmod 600 "$HOME/.openclaw/openclaw.json" +success "openclaw.json generated" + +# Render auth profiles +mkdir -p "$HOME/.openclaw/agents/main/agent" +template_render "$SCRIPT_DIR/templates/auth-profiles.json.template" \ + "$HOME/.openclaw/agents/main/agent/auth-profiles.json" +chmod 600 "$HOME/.openclaw/agents/main/agent/auth-profiles.json" +success "Auth profiles generated" + +# ============================================================ +# Deploy Workspace +# ============================================================ +step "Deploying workspace..." + +# Render template files +for f in "$SCRIPT_DIR"/workspace-core/*.template; do + [[ -f "$f" ]] || continue + basename_full="$(basename "$f")" + basename_no_ext="${basename_full%.template}" + target="$HOME/.openclaw/workspace/$basename_no_ext" + template_render "$f" "$target" + echo " Rendered: $basename_no_ext" +done + +# Copy non-template files +for f in "$SCRIPT_DIR"/workspace-core/*.md; do + [[ -f "$f" ]] || continue + basename_full="$(basename "$f")" + # Skip if a .template version exists (already rendered above) + if [[ -f "$SCRIPT_DIR/workspace-core/${basename_full}.template" ]]; then + continue + fi + cp "$f" "$HOME/.openclaw/workspace/$basename_full" + echo " Copied: $basename_full" +done + +# Copy rules +if [[ -d "$SCRIPT_DIR/workspace-core/rules" ]]; then + cp "$SCRIPT_DIR"/workspace-core/rules/*.md "$HOME/.openclaw/workspace/rules/" 2>/dev/null || true + echo " Copied: rules/" +fi + +# Copy skills +if [[ -d "$SCRIPT_DIR/workspace-core/skills" ]]; then + cp -r "$SCRIPT_DIR"/workspace-core/skills/* "$HOME/.openclaw/workspace/skills/" 2>/dev/null || true + echo " Copied: skills/" +fi + +# Copy observations +if [[ -d "$SCRIPT_DIR/workspace-core/observations" ]]; then + cp "$SCRIPT_DIR"/workspace-core/observations/*.md "$HOME/.openclaw/workspace/observations/" 2>/dev/null || true + echo " Copied: observations/" +fi + +success "Workspace deployed" + +# ============================================================ +# Deploy Scripts +# ============================================================ +step "Installing scripts..." +cp "$SCRIPT_DIR"/scripts-core/*.sh "$HOME/.openclaw/scripts/" 2>/dev/null || true +chmod +x "$HOME/.openclaw/scripts/"*.sh 2>/dev/null || true +success "Scripts installed" + +# ============================================================ +# Deploy Plugins +# ============================================================ +step "Installing plugins..." + +# Cost tracker +if [[ -d "$SCRIPT_DIR/plugins/cost-tracker" ]]; then + mkdir -p "$HOME/.openclaw/extensions/cost-tracker" + cp -r "$SCRIPT_DIR/plugins/cost-tracker/"* "$HOME/.openclaw/extensions/cost-tracker/" + success "Cost tracker plugin installed" +fi + +# Dashboard +if [[ -d "$SCRIPT_DIR/plugins/dashboard" ]]; then + mkdir -p "$HOME/.openclaw/extensions/dashboard" + cp -r "$SCRIPT_DIR/plugins/dashboard/"* "$HOME/.openclaw/extensions/dashboard/" + # Install dashboard dependencies if package.json exists + if [[ -f "$HOME/.openclaw/extensions/dashboard/package.json" ]]; then + (cd "$HOME/.openclaw/extensions/dashboard" && npm install --production 2>/dev/null) || true + fi + success "Dashboard plugin installed" +fi + +# ============================================================ +# Core Cron Jobs (via OpenClaw) +# ============================================================ +step "Setting up cron jobs..." + +openclaw cron add --name "health-log" \ + --schedule "0 */6 * * *" --tz "$USER_TZ" \ + --message "Run health check: check gateway status, disk space, memory. Save to observations/health.json." \ + 2>/dev/null || warn "health-log cron already exists" + +openclaw cron add --name "context-watchdog" \ + --schedule "0 * * * *" --tz "$USER_TZ" \ + --message "Check context token usage. If approaching limit, trigger compaction." \ + 2>/dev/null || warn "context-watchdog cron already exists" + +openclaw cron add --name "proactive-checkin" \ + --schedule "0 2,9,12,16,20 * * *" --tz "$USER_TZ" \ + --message "Proactive check-in. Review available local data. Score changes. Only message if actionable." \ + 2>/dev/null || warn "proactive-checkin cron already exists" + +openclaw cron add --name "daily-usage-summary" \ + --schedule "0 22 * * *" --tz "$USER_TZ" \ + --message "Generate daily usage summary. Run: python3 ~/.openclaw/scripts/m365-gateway/daily-usage-summary.py --save --mixpost. Report the summary to the user." \ + 2>/dev/null || warn "daily-usage-summary cron already exists" + +# --- Automated updates (daily at 4 AM) --- +step "Setting up automated updates..." +chmod +x "$HOME/.openclaw/scripts/bates-update.sh" 2>/dev/null || true + +success "5 core cron jobs configured" + +# ============================================================ +# System Crontab +# ============================================================ +step "Installing system crontab..." + +CRONTAB_CONTENT="$(cat <> /tmp/watchdog-bates.log 2>&1 +*/30 * * * * $HOME/.openclaw/scripts/archive-sessions.sh >> /tmp/archive-sessions.log 2>&1 +0 2 * * * rm -f $HOME/.openclaw/sessions.json && systemctl --user restart openclaw-gateway >> /tmp/session-cleanup.log 2>&1 +0 4 * * * $HOME/.openclaw/scripts/bates-update.sh --quiet >> /tmp/bates-update.log 2>&1 +EOF +)" + +# Merge with existing crontab (don't overwrite user entries) +(crontab -l 2>/dev/null | grep -v 'watchdog-bates\|archive-sessions\|session-cleanup\|bates-update'; echo "$CRONTAB_CONTENT") | crontab - +success "System crontab installed" + +# ============================================================ +# File Permissions +# ============================================================ +step "Securing configuration..." +chmod 600 "$HOME/.openclaw/openclaw.json" +chmod 600 "$HOME/.openclaw/agents/main/agent/auth-profiles.json" 2>/dev/null || true +chmod -R 700 "$HOME/.config/systemd/user/openclaw-gateway.service.d/" 2>/dev/null || true +success "Permissions set" + +# ============================================================ +# OpenClaw Onboard +# ============================================================ +step "Running OpenClaw onboard..." +openclaw onboard --install-daemon 2>/dev/null || warn "Onboard may have already run" + +echo "" +success "Configuration complete!" +echo "" +echo "Next: Run core-verify.sh to start the gateway and verify everything works." diff --git a/bates-core/core-remote-access.sh b/bates-core/core-remote-access.sh new file mode 100644 index 0000000..94161e4 --- /dev/null +++ b/bates-core/core-remote-access.sh @@ -0,0 +1,415 @@ +#!/usr/bin/env bash +# core-remote-access.sh -- Phase 5: SSH server + optional desktop shortcuts +# Called by install.ps1 after core-verify.sh completes successfully. +# +# Flags: +# --server-only Configure SSH server only (no desktop shortcuts) +# Used in server mode -- shortcuts are created on the client instead. +# +# Without flags, creates a "Bates Remote" folder on the Windows desktop containing: +# 1. SSH shortcut to Windows host (passwordless) +# 2. SSH shortcut to WSL (passwordless) +# 3. RDP shortcut to Windows host +# 4. Dedicated SSH key pair (bundled for portability) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +# ------------------------------------------------------------------- +# Parse flags +# ------------------------------------------------------------------- +SERVER_ONLY=false +for arg in "$@"; do + case "$arg" in + --server-only) SERVER_ONLY=true ;; + esac +done + +if $SERVER_ONLY; then + echo "" + echo "===========================================" + echo " Bates Core -- SSH Server Setup" + echo "===========================================" + echo "" +else + echo "" + echo "===========================================" + echo " Bates Core -- Remote Access Setup" + echo "===========================================" + echo "" +fi + +# ------------------------------------------------------------------- +# Step 1 -- Verify environment +# ------------------------------------------------------------------- +step "Verify environment" + +if [[ ! -d /mnt/c/Windows ]]; then + fatal "This script must run inside WSL2 on a Windows host." +fi + +# Detect Windows username +WIN_USER=$(cmd.exe /C "echo %USERNAME%" 2>/dev/null | tr -d '\r\n' || true) +if [[ -z "$WIN_USER" ]]; then + WIN_USER=$(basename "$(ls -d /mnt/c/Users/*/ 2>/dev/null | grep -iv -E 'Public|Default|All' | head -1)" 2>/dev/null || true) +fi +if [[ -z "$WIN_USER" ]]; then + prompt_default "Windows username" "" WIN_USER +fi +info "Windows username: $WIN_USER" + +WIN_DESKTOP="/mnt/c/Users/$WIN_USER/Desktop" +if [[ ! -d "$WIN_DESKTOP" ]]; then + fatal "Windows Desktop not found at: $WIN_DESKTOP" +fi + +LINUX_USER="$(whoami)" +info "WSL username: $LINUX_USER" + +success "Environment verified." + +# ------------------------------------------------------------------- +# Step 2 -- Configure SSH server in WSL +# ------------------------------------------------------------------- +step "Configure SSH server in WSL" + +if command -v sshd &>/dev/null; then + info "OpenSSH server is already installed." +else + info "Installing OpenSSH server..." + sudo apt-get update -qq + sudo apt-get install -y -qq openssh-server +fi + +# Generate host keys if missing +if [[ ! -f /etc/ssh/ssh_host_ed25519_key ]]; then + info "Generating SSH host keys..." + sudo ssh-keygen -A +fi + +# Ensure pubkey auth is enabled +SSHD_CONFIG="/etc/ssh/sshd_config" +if ! grep -q "^PubkeyAuthentication yes" "$SSHD_CONFIG" 2>/dev/null; then + info "Enabling pubkey authentication in sshd_config..." + sudo sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' "$SSHD_CONFIG" +fi + +# Ensure ListenAddress covers all interfaces (for Tailscale) +if ! grep -q "^ListenAddress 0.0.0.0" "$SSHD_CONFIG" 2>/dev/null; then + if grep -q "^ListenAddress" "$SSHD_CONFIG"; then + sudo sed -i 's/^ListenAddress.*/ListenAddress 0.0.0.0/' "$SSHD_CONFIG" + fi +fi + +# Enable and start sshd +if systemctl list-unit-files ssh.service &>/dev/null; then + sudo systemctl enable ssh.service + sudo systemctl restart ssh.service + success "SSH server running (systemd: ssh.service)." +elif systemctl list-unit-files sshd.service &>/dev/null; then + sudo systemctl enable sshd.service + sudo systemctl restart sshd.service + success "SSH server running (systemd: sshd.service)." +else + info "Starting sshd manually..." + sudo /usr/sbin/sshd + success "SSH server started." + warn "sshd is not managed by systemd. It may not survive a WSL restart." +fi + +# ------------------------------------------------------------------- +# Server-only mode: SSH server is configured, done. +# ------------------------------------------------------------------- +if $SERVER_ONLY; then + echo "" + success "SSH server configured and ready for client connections." + echo "" + info "To connect from your working machine:" + info " Run the Bates installer there and choose 'Client'." + echo "" + exit 0 +fi + +# ------------------------------------------------------------------- +# Step 3 -- Detect Tailscale hostnames +# ------------------------------------------------------------------- +step "Detect Tailscale endpoints" + +WSL_TS_HOSTNAME="" +WSL_TS_IP="" +if command -v tailscale &>/dev/null; then + WSL_TS_HOSTNAME=$(tailscale status --json 2>/dev/null \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('Self',{}).get('DNSName','').rstrip('.'))" 2>/dev/null || true) + WSL_TS_IP=$(tailscale ip -4 2>/dev/null || true) +fi + +if [[ -n "$WSL_TS_HOSTNAME" ]]; then + info "WSL Tailscale hostname: $WSL_TS_HOSTNAME" + info "WSL Tailscale IP: $WSL_TS_IP" +else + warn "Tailscale not detected in WSL." + prompt_default "WSL SSH host (IP or hostname)" "localhost" WSL_TS_HOSTNAME + WSL_TS_IP="$WSL_TS_HOSTNAME" +fi + +WIN_TS_HOSTNAME="" +WIN_TS_IP="" + +WIN_TS_STATUS=$(powershell.exe -NoProfile -Command \ + "try { \$s = tailscale status --json 2>\$null | ConvertFrom-Json; Write-Host \$s.Self.DNSName.TrimEnd('.') } catch { }" 2>/dev/null | tr -d '\r\n' || true) + +if [[ -n "$WIN_TS_STATUS" ]]; then + WIN_TS_HOSTNAME="$WIN_TS_STATUS" + WIN_TS_IP=$(powershell.exe -NoProfile -Command \ + "try { tailscale ip -4 2>\$null } catch { }" 2>/dev/null | tr -d '\r\n' || true) + info "Windows Tailscale hostname: $WIN_TS_HOSTNAME" + info "Windows Tailscale IP: $WIN_TS_IP" +else + warn "Tailscale not detected on Windows host." + info "For remote access, install Tailscale on Windows too." + echo "" + prompt_default "Windows host (IP, hostname, or Tailscale name)" "" WIN_TS_HOSTNAME + WIN_TS_IP="$WIN_TS_HOSTNAME" +fi + +if [[ -z "$WIN_TS_HOSTNAME" ]]; then + fatal "A Windows host address is required for SSH and RDP shortcuts." +fi + +# ------------------------------------------------------------------- +# Step 4 -- Check Windows OpenSSH Server +# ------------------------------------------------------------------- +step "Check Windows OpenSSH Server" + +WIN_SSH_OK=$(powershell.exe -NoProfile -Command \ + "try { \$s = Get-Service sshd -ErrorAction SilentlyContinue; if (\$s) { Write-Host \$s.Status } } catch { }" 2>/dev/null | tr -d '\r\n' || true) + +if [[ "$WIN_SSH_OK" == "Running" ]]; then + success "Windows OpenSSH Server is running." +elif [[ -n "$WIN_SSH_OK" ]]; then + info "Windows OpenSSH Server status: $WIN_SSH_OK. Starting it..." + powershell.exe -NoProfile -Command \ + "Start-Service sshd; Set-Service -Name sshd -StartupType Automatic" 2>/dev/null || true + success "Windows OpenSSH Server started." +else + warn "Windows OpenSSH Server is not installed." + echo "" + info "To install (run in an elevated PowerShell on Windows):" + echo " Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0" + echo " Start-Service sshd" + echo " Set-Service -Name sshd -StartupType Automatic" + echo "" + if confirm "Try to install Windows OpenSSH Server now? (requires admin)"; then + powershell.exe -NoProfile -Command \ + "Start-Process powershell -Verb RunAs -Wait -ArgumentList '-Command Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0; Start-Service sshd; Set-Service -Name sshd -StartupType Automatic'" 2>/dev/null || true + WIN_SSH_RECHECK=$(powershell.exe -NoProfile -Command \ + "try { (Get-Service sshd).Status } catch { }" 2>/dev/null | tr -d '\r\n' || true) + if [[ "$WIN_SSH_RECHECK" == "Running" ]]; then + success "Windows OpenSSH Server installed and running." + else + warn "Could not verify. The SSH-to-Windows shortcut may not work." + fi + else + warn "Skipped. SSH-to-Windows shortcut may not work until OpenSSH Server is installed." + fi +fi + +# ------------------------------------------------------------------- +# Step 5 -- Check Windows RDP +# ------------------------------------------------------------------- +step "Check Windows Remote Desktop" + +RDP_ENABLED=$(powershell.exe -NoProfile -Command \ + "try { (Get-ItemProperty 'HKLM:\System\CurrentControlSet\Control\Terminal Server').fDenyTSConnections } catch { Write-Host 'unknown' }" 2>/dev/null | tr -d '\r\n' || true) + +if [[ "$RDP_ENABLED" == "0" ]]; then + success "Windows Remote Desktop is enabled." +elif [[ "$RDP_ENABLED" == "1" ]]; then + warn "Windows Remote Desktop is disabled." + echo "" + if confirm "Try to enable RDP now? (requires admin)"; then + powershell.exe -NoProfile -Command \ + "Start-Process powershell -Verb RunAs -Wait -ArgumentList '-Command Set-ItemProperty \"HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\" -Name fDenyTSConnections -Value 0; Enable-NetFirewallRule -DisplayGroup \"Remote Desktop\"'" 2>/dev/null || true + RDP_RECHECK=$(powershell.exe -NoProfile -Command \ + "try { (Get-ItemProperty 'HKLM:\System\CurrentControlSet\Control\Terminal Server').fDenyTSConnections } catch { }" 2>/dev/null | tr -d '\r\n' || true) + if [[ "$RDP_RECHECK" == "0" ]]; then + success "Remote Desktop enabled." + else + warn "Could not verify RDP status." + fi + else + warn "Skipped. Enable RDP manually if you want the RDP shortcut to work." + fi +else + warn "Could not detect RDP status." +fi + +# ------------------------------------------------------------------- +# Step 6 -- Generate SSH key pair +# ------------------------------------------------------------------- +step "Generate SSH key pair for passwordless access" + +SHORTCUT_DIR="$WIN_DESKTOP/Bates Remote" +mkdir -p "$SHORTCUT_DIR" + +KEY_NAME="bates-remote" +KEY_PATH="$SHORTCUT_DIR/$KEY_NAME" + +if [[ -f "$KEY_PATH" ]]; then + info "SSH key already exists at: $KEY_PATH" + PUBKEY=$(cat "$KEY_PATH.pub") +else + info "Generating dedicated ed25519 key pair..." + ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -C "bates-remote-access" + success "Key pair generated." + PUBKEY=$(cat "$KEY_PATH.pub") +fi + +# ------------------------------------------------------------------- +# Step 7 -- Install public key on WSL +# ------------------------------------------------------------------- +step "Install SSH key for WSL access" + +WSL_SSH_DIR="/home/$LINUX_USER/.ssh" +WSL_AUTH_KEYS="$WSL_SSH_DIR/authorized_keys" + +mkdir -p "$WSL_SSH_DIR" +chmod 700 "$WSL_SSH_DIR" + +if [[ -f "$WSL_AUTH_KEYS" ]] && grep -qF "$PUBKEY" "$WSL_AUTH_KEYS" 2>/dev/null; then + info "Public key already in WSL authorized_keys." +else + echo "$PUBKEY" >> "$WSL_AUTH_KEYS" + chmod 600 "$WSL_AUTH_KEYS" + success "Public key added to WSL authorized_keys." +fi + +# ------------------------------------------------------------------- +# Step 8 -- Install public key on Windows +# ------------------------------------------------------------------- +step "Install SSH key for Windows access" + +WIN_PUBKEY_ESCAPED=$(echo "$PUBKEY" | sed 's/"/\\"/g') + +WIN_KEY_INSTALLED=$(powershell.exe -NoProfile -Command " + \$pubkey = '$WIN_PUBKEY_ESCAPED' + \$userAuthKeys = \"\$env:USERPROFILE\\.ssh\\authorized_keys\" + \$adminAuthKeys = 'C:\\ProgramData\\ssh\\administrators_authorized_keys' + + \$sshDir = \"\$env:USERPROFILE\\.ssh\" + if (-not (Test-Path \$sshDir)) { New-Item -ItemType Directory -Path \$sshDir -Force | Out-Null } + + if (Test-Path \$userAuthKeys) { + if ((Get-Content \$userAuthKeys -Raw) -match [regex]::Escape(\$pubkey)) { + Write-Host 'already'; exit + } + } + + Add-Content -Path \$userAuthKeys -Value \$pubkey -Encoding UTF8 + Write-Host 'added' + + try { + if (-not (Test-Path \$adminAuthKeys) -or -not ((Get-Content \$adminAuthKeys -Raw) -match [regex]::Escape(\$pubkey))) { + Add-Content -Path \$adminAuthKeys -Value \$pubkey -Encoding UTF8 + } + } catch { } +" 2>/dev/null | tr -d '\r\n' || true) + +if [[ "$WIN_KEY_INSTALLED" == "already" ]]; then + info "Public key already in Windows authorized_keys." +elif [[ "$WIN_KEY_INSTALLED" == "added" ]]; then + success "Public key added to Windows authorized_keys." +else + warn "Could not install key on Windows. You may need to add it manually." +fi + +# ------------------------------------------------------------------- +# Step 9 -- Create desktop shortcuts +# ------------------------------------------------------------------- +step "Create connection shortcuts" + +cat > "$SHORTCUT_DIR/SSH - Windows Host.bat" << EOFBAT +@echo off +title SSH - Windows Host ($WIN_TS_HOSTNAME) +echo Connecting to Windows host via SSH... +echo Host: $WIN_TS_HOSTNAME +echo. +ssh -i "%~dp0$KEY_NAME" -o StrictHostKeyChecking=no $WIN_USER@$WIN_TS_HOSTNAME +if errorlevel 1 ( + echo. + echo Connection failed. Ensure: + echo 1. Windows OpenSSH Server is running + echo 2. Tailscale is connected on both machines + echo 3. Host is reachable: ping $WIN_TS_HOSTNAME +) +echo. +pause +EOFBAT + +info "Created: SSH - Windows Host.bat" + +cat > "$SHORTCUT_DIR/SSH - WSL (Linux).bat" << EOFBAT +@echo off +title SSH - WSL Linux ($WSL_TS_HOSTNAME) +echo Connecting to WSL (Linux) via SSH... +echo Host: $WSL_TS_HOSTNAME +echo. +ssh -i "%~dp0$KEY_NAME" -o StrictHostKeyChecking=no $LINUX_USER@$WSL_TS_HOSTNAME +if errorlevel 1 ( + echo. + echo Connection failed. Ensure: + echo 1. WSL is running on the remote machine + echo 2. SSH server is running inside WSL + echo 3. Tailscale is connected on both machines + echo 4. Host is reachable: ping $WSL_TS_HOSTNAME +) +echo. +pause +EOFBAT + +info "Created: SSH - WSL (Linux).bat" + +cat > "$SHORTCUT_DIR/RDP - Windows Host.rdp" << EOFRDP +full address:s:$WIN_TS_HOSTNAME +username:s:$WIN_USER +prompt for credentials:i:1 +screen mode id:i:2 +desktopwidth:i:1920 +desktopheight:i:1080 +session bpp:i:32 +compression:i:1 +displayconnectionbar:i:1 +autoreconnection enabled:i:1 +authentication level:i:2 +negotiate security layer:i:1 +EOFRDP + +info "Created: RDP - Windows Host.rdp" + +success "All shortcuts created in: $SHORTCUT_DIR" + +# ------------------------------------------------------------------- +# Summary +# ------------------------------------------------------------------- +step "Summary" + +echo "" +info "Remote access shortcuts created in:" +info " $SHORTCUT_DIR" +echo "" +info "Shortcuts (passwordless via bundled SSH key):" +info " SSH - Windows Host.bat -> ssh $WIN_USER@$WIN_TS_HOSTNAME" +info " SSH - WSL (Linux).bat -> ssh $LINUX_USER@$WSL_TS_HOSTNAME" +info " RDP - Windows Host.rdp -> mstsc to $WIN_TS_HOSTNAME" +echo "" +info "SSH key pair:" +info " Private: $KEY_PATH (bundled with shortcuts)" +info " Public: $KEY_PATH.pub" +echo "" +info "Copy the 'Bates Remote' folder to your primary machine's Desktop." +info "SSH connections will work without a password." +echo "" + +success "Remote access setup complete." diff --git a/bates-core/core-setup.sh b/bates-core/core-setup.sh new file mode 100755 index 0000000..df6b60c --- /dev/null +++ b/bates-core/core-setup.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# core-setup.sh -- Phase 2: Linux environment setup +# Called by install.ps1 after WSL2 + Ubuntu are ready. +# Installs all system dependencies and prepares the environment. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" +source "$SCRIPT_DIR/lib/prerequisites.sh" + +echo "" +echo "===========================================" +echo " Bates Core -- Linux Environment Setup" +echo "===========================================" +echo "" + +# --- Disclaimer --- +DISCLAIMER_FILE="$SCRIPT_DIR/../DISCLAIMER.txt" +if [[ -f "$DISCLAIMER_FILE" ]]; then + echo -e "${YELLOW}${BOLD}" + echo "============================================" + echo " IMPORTANT -- PLEASE READ BEFORE CONTINUING" + echo "============================================" + echo -e "${NC}" + cat "$DISCLAIMER_FILE" + echo "" + echo -e "${YELLOW}${BOLD}============================================${NC}" + echo "" + if [[ "${BATES_ACCEPT_DISCLAIMER:-}" == "yes" ]]; then + info "Disclaimer accepted via BATES_ACCEPT_DISCLAIMER=yes" + else + echo -e "${BOLD}You must accept this disclaimer to continue.${NC}" + echo "" + read -rp "Type 'I ACCEPT' to proceed (or anything else to abort): " DISCLAIMER_REPLY + if [[ "$DISCLAIMER_REPLY" != "I ACCEPT" ]]; then + echo "" + error "Installation aborted. You must accept the disclaimer to proceed." + exit 1 + fi + echo "" + success "Disclaimer accepted." + fi + echo "" +fi + +# --- Prerequisite Checks --- +run_all_checks + +# --- System Packages --- +step "Updating system packages..." +sudo apt-get update -qq +sudo apt-get install -y -qq \ + build-essential curl git jq ntpdate poppler-utils tmux \ + python3 python3-pip python3-venv + +# --- Node.js 22 --- +step "Installing Node.js 22..." +if command -v node &>/dev/null && [[ "$(node -v)" == v22.* ]]; then + success "Node.js $(node -v) already installed" +else + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y -qq nodejs + success "Node.js $(node -v) installed" +fi + +# --- npm global prefix --- +step "Configuring npm global prefix..." +mkdir -p "$HOME/.npm-global" +npm config set prefix "$HOME/.npm-global" +if ! grep -q '.npm-global/bin' "$HOME/.bashrc" 2>/dev/null; then + echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$HOME/.bashrc" +fi +export PATH="$HOME/.npm-global/bin:$PATH" + +# --- OpenClaw --- +step "Installing OpenClaw..." +if command -v openclaw &>/dev/null; then + success "OpenClaw already installed ($(openclaw --version 2>/dev/null || echo 'unknown version'))" +else + npm install -g openclaw + success "OpenClaw installed" +fi + +# --- mcporter --- +step "Installing mcporter..." +if command -v mcporter &>/dev/null; then + success "mcporter already installed" +else + npm install -g mcporter + success "mcporter installed" +fi + +# --- Claude Code --- +step "Installing Claude Code..." +if command -v claude &>/dev/null; then + success "Claude Code already installed ($(claude --version 2>/dev/null || echo 'unknown version'))" +else + npm install -g @anthropic-ai/claude-code + success "Claude Code installed" +fi + +# --- OpenAI Codex CLI --- +step "Installing OpenAI Codex CLI..." +if command -v codex &>/dev/null; then + success "OpenAI Codex CLI already installed ($(codex --version 2>/dev/null || echo 'unknown version'))" +else + npm install -g @openai/codex + success "OpenAI Codex CLI installed" +fi + +# --- systemd linger --- +step "Enabling systemd linger..." +if loginctl show-user "$(whoami)" 2>/dev/null | grep -q "Linger=yes"; then + success "Linger already enabled" +else + sudo loginctl enable-linger "$(whoami)" + success "Linger enabled" +fi + +# --- /etc/wsl.conf (ensures linger survives WSL reboots) --- +step "Configuring /etc/wsl.conf..." +WSL_CONF="/etc/wsl.conf" +NEEDS_UPDATE=false +if [[ ! -f "$WSL_CONF" ]]; then + NEEDS_UPDATE=true +elif ! grep -q "enable-linger" "$WSL_CONF" 2>/dev/null; then + NEEDS_UPDATE=true +fi + +if [[ "$NEEDS_UPDATE" == "true" ]]; then + # Build wsl.conf with boot command that re-enables linger on every WSL start + TEMP_WSL_CONF="$(mktemp)" + sed "s|{{WSL_USERNAME}}|$(whoami)|g" "$SCRIPT_DIR/templates/wsl-conf.template" > "$TEMP_WSL_CONF" + sudo cp "$TEMP_WSL_CONF" "$WSL_CONF" + rm -f "$TEMP_WSL_CONF" + success "/etc/wsl.conf configured (linger boot command added)" +else + success "/etc/wsl.conf already configured" +fi + +# --- Directory structure --- +step "Creating directory structure..." +mkdir -p "$HOME/.openclaw"/{workspace/{rules,refs,skills,observations},scripts,extensions,cron,agents/main/{sessions,archive},enhance,m365-safety} +mkdir -p "$HOME/.config/systemd/user" + +# --- Python virtual environment for M365 gateway --- +step "Setting up Python virtual environment..." +if [[ ! -d "$HOME/.openclaw/venv" ]]; then + python3 -m venv "$HOME/.openclaw/venv" +fi +"$HOME/.openclaw/venv/bin/pip" install -q requests aiohttp pyyaml 2>/dev/null || true +success "Python venv ready" + +# --- Copy M365 gateway scripts --- +if [[ -d "$SCRIPT_DIR/scripts-core/m365-gateway" ]]; then + mkdir -p "$HOME/.openclaw/scripts/m365-gateway" + cp "$SCRIPT_DIR/scripts-core/m365-gateway/"*.py "$HOME/.openclaw/scripts/m365-gateway/" 2>/dev/null || true + cp "$SCRIPT_DIR/scripts-core/m365-gateway/"*.yaml "$HOME/.openclaw/scripts/m365-gateway/" 2>/dev/null || true + cp "$SCRIPT_DIR/scripts-core/m365-gateway/"*.service "$HOME/.openclaw/scripts/m365-gateway/" 2>/dev/null || true + chmod +x "$HOME/.openclaw/scripts/m365-gateway/"*.py 2>/dev/null || true +fi + +# --- Clock sync timer --- +step "Installing clock-sync timer..." +cp "$SCRIPT_DIR/systemd/clock-sync.service" "$HOME/.config/systemd/user/" +cp "$SCRIPT_DIR/systemd/clock-sync.timer" "$HOME/.config/systemd/user/" +systemctl --user daemon-reload +systemctl --user enable clock-sync.timer 2>/dev/null || true + +# --- Gateway service --- +step "Installing gateway service..." +cp "$SCRIPT_DIR/systemd/openclaw-gateway.service.template" \ + "$HOME/.config/systemd/user/openclaw-gateway.service" +# Replace %h with actual home dir (systemd user units support %h, but template needs it) +sed -i "s|%h|$HOME|g" "$HOME/.config/systemd/user/openclaw-gateway.service" + +# NODE_PATH drop-in for npm-global plugin resolution +mkdir -p "$HOME/.config/systemd/user/openclaw-gateway.service.d" +cat > "$HOME/.config/systemd/user/openclaw-gateway.service.d/node-path.conf" << EOF +[Service] +Environment="NODE_PATH=$HOME/.npm-global/lib/node_modules" +EOF + +systemctl --user daemon-reload + +echo "" +success "Linux environment setup complete." +echo "" +echo "Next: Run core-configure.sh to set up AI auth and personalization." diff --git a/bates-core/core-verify.sh b/bates-core/core-verify.sh new file mode 100755 index 0000000..64e7a60 --- /dev/null +++ b/bates-core/core-verify.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# core-verify.sh -- Phase 4: Health check + open dashboard +# Called after core-configure.sh to verify everything works. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +export PATH="$HOME/.npm-global/bin:$PATH" + +echo "" +echo "===========================================" +echo " Bates Core -- Verification" +echo "===========================================" +echo "" + +PASS=0 +FAIL=0 + +check() { + local name="$1" + shift + if "$@" &>/dev/null 2>&1; then + echo -e " ${GREEN}[PASS]${NC} $name" + ((PASS++)) + else + echo -e " ${RED}[FAIL]${NC} $name" + ((FAIL++)) + fi +} + +# --- Start Gateway --- +step "Starting gateway service..." +systemctl --user daemon-reload +systemctl --user enable --now openclaw-gateway 2>/dev/null || true + +echo "Waiting for gateway to start..." +sleep 8 + +# --- Run Checks --- +step "Running verification checks..." +echo "" + +check "Gateway service running" systemctl --user is-active openclaw-gateway +check "Dashboard accessible" curl -sf --max-time 5 http://localhost:18789/dashboard +check "Cost tracker API" curl -sf --max-time 5 http://localhost:18789/cost-tracker/api/today +check "Cron jobs configured" bash -c "openclaw cron list 2>/dev/null | grep -q health-log" +check "Claude Code installed" command -v claude +check "OpenAI Codex CLI installed" command -v codex +check "Scripts installed" test -x "$HOME/.openclaw/scripts/watchdog-bates.sh" +check "Workspace deployed" test -f "$HOME/.openclaw/workspace/SOUL.md" +check "Bates version file" test -f "$HOME/.openclaw/bates-version" +check "Auto-update script installed" test -x "$HOME/.openclaw/scripts/bates-update.sh" +check "Auto-update cron configured" bash -c "crontab -l 2>/dev/null | grep -q bates-update" + +# Check Telegram channel +check "Telegram channel configured" bash -c "python3 -c \"import json; c=json.load(open('$HOME/.openclaw/openclaw.json')); assert c['channels']['telegram']['enabled']\"" + +# Check M365 (optional — only if tokens exist) +if [[ -f "$HOME/.openclaw/m365-safety/tokens.json" ]]; then + check "M365 tokens configured" test -s "$HOME/.openclaw/m365-safety/tokens.json" + check "M365 safety gateway service" systemctl --user is-enabled m365-safety-gateway 2>/dev/null + check "M365 account info" test -f "$HOME/.openclaw/m365-safety/account-info.json" +fi + +echo "" +echo "===========================================" +echo " Results: $PASS passed, $FAIL failed" +echo "===========================================" +echo "" + +if [[ $FAIL -eq 0 ]]; then + echo "All checks passed! Your assistant is ready." + echo "" + echo "Dashboard: http://localhost:18789/dashboard" + echo "" + + # Read assistant name from config + ASSISTANT_NAME=$(python3 -c " +import json +c = json.load(open('$HOME/.openclaw/openclaw.json')) +name = c.get('agents', {}).get('definitions', {}).get('main', {}).get('name', 'Bates') +print(name.split(' (')[0]) +" 2>/dev/null || echo "Bates") + + echo "$ASSISTANT_NAME is now running and ready to chat!" + echo "" + echo "Talk to $ASSISTANT_NAME:" + echo " - Web dashboard: http://localhost:18789/dashboard" + echo " - Telegram: open the bot you created and send a message" + echo "" + echo "To add more integrations later:" + echo " bates-enhance.sh" + echo "" + + # Activate Telegram pairing + echo "===========================================" + echo " Telegram Activation" + echo "===========================================" + echo "" + echo "Open your Telegram bot and send any message to start the pairing." + echo "The gateway will prompt you to approve the pairing." + echo "" + echo "Check gateway logs for pairing status:" + echo " journalctl --user -u openclaw-gateway -n 20 --no-pager" + echo "" + + # Try to open browser on Windows + cmd.exe /c start http://localhost:18789/dashboard 2>/dev/null || true +else + echo "Some checks failed. Review the errors above." + echo "" + echo "Troubleshooting:" + echo " Gateway logs: journalctl --user -u openclaw-gateway -n 30 --no-pager" + echo " Service status: systemctl --user status openclaw-gateway" + echo " Config file: cat ~/.openclaw/openclaw.json" + echo "" + echo "Common issues:" + echo " - Gateway not starting: check Node.js version (need v22+)" + echo " - Dashboard not accessible: check port 18789 is not in use" + echo " - Auth failure: re-run 'claude setup-token' and update via openclaw models auth" +fi diff --git a/bates-core/crontab/core-crontab.template b/bates-core/crontab/core-crontab.template new file mode 100644 index 0000000..1d4cdae --- /dev/null +++ b/bates-core/crontab/core-crontab.template @@ -0,0 +1,11 @@ +# Bates Core system cron +# Installed by core-configure.sh + +# Process watchdog: restart gateway if it dies +*/2 * * * * {{HOME}}/.openclaw/scripts/watchdog-bates.sh >> /tmp/watchdog-bates.log 2>&1 + +# Session archival: move old .jsonl files to archive/ +*/30 * * * * {{HOME}}/.openclaw/scripts/archive-sessions.sh >> /tmp/archive-sessions.log 2>&1 + +# Daily session cleanup: clear stale session state at 2 AM +0 2 * * * rm -f {{HOME}}/.openclaw/sessions.json && systemctl --user restart openclaw-gateway >> /tmp/session-cleanup.log 2>&1 diff --git a/bates-core/desktop/.gitignore b/bates-core/desktop/.gitignore new file mode 100644 index 0000000..1ad4efb --- /dev/null +++ b/bates-core/desktop/.gitignore @@ -0,0 +1 @@ +dist-builds/ diff --git a/bates-core/desktop/build.sh b/bates-core/desktop/build.sh new file mode 100755 index 0000000..6f98545 --- /dev/null +++ b/bates-core/desktop/build.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Build Bates Command Center desktop app +# Usage: ./build.sh [linux|windows] + +set -e +cd "$(dirname "$0")/src-tauri" + +# Ensure Rust is available +if ! command -v cargo &>/dev/null; then + echo "Rust not found. Install with: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + exit 1 +fi + +TARGET="${1:-linux}" + +case "$TARGET" in + linux) + echo "Building for Linux..." + cargo tauri build 2>&1 + echo "" + echo "Build complete! Artifacts:" + ls -lh target/release/bundle/deb/*.deb 2>/dev/null + ls -lh target/release/bundle/appimage/*.AppImage 2>/dev/null + ls -lh target/release/bates-command-center 2>/dev/null + ;; + windows) + echo "Building for Windows (cross-compilation)..." + if ! command -v cargo-xwin &>/dev/null; then + echo "Installing cargo-xwin..." + cargo install cargo-xwin + fi + cargo xwin build --release --target x86_64-pc-windows-msvc 2>&1 + echo "" + echo "Build complete! Binary:" + ls -lh target/x86_64-pc-windows-msvc/release/bates-command-center.exe 2>/dev/null + echo "" + echo "Note: For MSI/NSIS installer, build natively on Windows:" + echo " cd src-tauri && cargo tauri build" + ;; + *) + echo "Usage: $0 [linux|windows]" + exit 1 + ;; +esac diff --git a/bates-core/desktop/dist/index.html b/bates-core/desktop/dist/index.html new file mode 100644 index 0000000..c0a96f5 --- /dev/null +++ b/bates-core/desktop/dist/index.html @@ -0,0 +1,105 @@ + + + + + +Bates Command Center + + + +
+

Bates Command Center

+

Connect to your OpenClaw gateway

+
+ + +
+
+ + +
+ +
+
+ + + diff --git a/bates-core/desktop/src-tauri/.gitignore b/bates-core/desktop/src-tauri/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/bates-core/desktop/src-tauri/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/bates-core/desktop/src-tauri/Cargo.lock b/bates-core/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..d2df31a --- /dev/null +++ b/bates-core/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5578 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bates-command-center" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-notification", + "tauri-plugin-shell", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.36.1", + "precomputed-hash", + "selectors 0.35.0", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" +dependencies = [ + "log", + "markup5ever 0.36.1", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.13.0", + "selectors 0.24.0", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mac-notification-sys" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril", +] + +[[package]] +name = "markup5ever" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "quick-xml 0.38.4", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.4+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" +dependencies = [ + "bitflags 2.11.0", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.0.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 1.0.0+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.15", +] + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wry" +version = "0.54.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/bates-core/desktop/src-tauri/Cargo.toml b/bates-core/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..3bb5f29 --- /dev/null +++ b/bates-core/desktop/src-tauri/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bates-command-center" +version = "0.1.0" +description = "Bates Command Center — AI Operations Dashboard" +authors = ["getBates"] +license = "MIT" +repository = "https://github.com/getBates/Bates" +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-shell = "2" +tauri-plugin-notification = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/bates-core/desktop/src-tauri/build.rs b/bates-core/desktop/src-tauri/build.rs new file mode 100644 index 0000000..2ba80a8 --- /dev/null +++ b/bates-core/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/bates-core/desktop/src-tauri/capabilities/default.json b/bates-core/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..84306c8 --- /dev/null +++ b/bates-core/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,10 @@ +{ + "identifier": "default", + "description": "Default capabilities for Bates Command Center", + "windows": ["main"], + "permissions": [ + "core:default", + "shell:allow-open", + "notification:default" + ] +} diff --git a/bates-core/desktop/src-tauri/icons/128x128.png b/bates-core/desktop/src-tauri/icons/128x128.png new file mode 100644 index 0000000..330fb1a Binary files /dev/null and b/bates-core/desktop/src-tauri/icons/128x128.png differ diff --git a/bates-core/desktop/src-tauri/icons/128x128@2x.png b/bates-core/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..b61c3d4 Binary files /dev/null and b/bates-core/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/bates-core/desktop/src-tauri/icons/32x32.png b/bates-core/desktop/src-tauri/icons/32x32.png new file mode 100644 index 0000000..53914f8 Binary files /dev/null and b/bates-core/desktop/src-tauri/icons/32x32.png differ diff --git a/bates-core/desktop/src-tauri/icons/icon.ico b/bates-core/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..6034443 Binary files /dev/null and b/bates-core/desktop/src-tauri/icons/icon.ico differ diff --git a/bates-core/desktop/src-tauri/icons/icon.png b/bates-core/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..a2e665e Binary files /dev/null and b/bates-core/desktop/src-tauri/icons/icon.png differ diff --git a/bates-core/desktop/src-tauri/src/main.rs b/bates-core/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..506c3be --- /dev/null +++ b/bates-core/desktop/src-tauri/src/main.rs @@ -0,0 +1,66 @@ +// Prevents additional console window on Windows in release +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use tauri::Manager; + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_notification::init()) + .setup(|app| { + // Build tray menu + let show_item = tauri::menu::MenuItemBuilder::with_id("show", "Show Dashboard") + .build(app)?; + let quit_item = tauri::menu::MenuItemBuilder::with_id("quit", "Quit") + .build(app)?; + let menu = tauri::menu::MenuBuilder::new(app) + .item(&show_item) + .separator() + .item(&quit_item) + .build()?; + + // Build tray icon + let _tray = tauri::tray::TrayIconBuilder::new() + .menu(&menu) + .on_menu_event(|app_handle, event| { + match event.id().as_ref() { + "show" => { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "quit" => { + std::process::exit(0); + } + _ => {} + } + }) + .on_tray_icon_event(|tray_icon, event| { + if let tauri::tray::TrayIconEvent::Click { + button: tauri::tray::MouseButton::Left, + button_state: tauri::tray::MouseButtonState::Up, + .. + } = event + { + let app_handle = tray_icon.app_handle(); + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }) + .build(app)?; + + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + // Minimize to tray instead of closing + let _ = window.hide(); + api.prevent_close(); + } + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/bates-core/desktop/src-tauri/tauri.conf.json b/bates-core/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9ae11f7 --- /dev/null +++ b/bates-core/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://raw.githubusercontent.com/nicehash/tauri/dev/crates/tauri-cli/schema.json", + "productName": "Bates Command Center", + "version": "0.1.0", + "identifier": "com.getbates.commandcenter", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:18789/dashboard/" + }, + "app": { + "windows": [ + { + "title": "Bates Command Center", + "width": 1400, + "height": 900, + "minWidth": 800, + "minHeight": 600, + "resizable": true, + "fullscreen": false, + "decorations": true, + "transparent": false + } + ], + "security": { + "csp": null + }, + "trayIcon": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true + } + }, + "bundle": { + "active": true, + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.ico" + ], + "targets": ["msi", "nsis"], + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "" + } + } +} diff --git a/bates-core/install.ps1 b/bates-core/install.ps1 new file mode 100644 index 0000000..387091c --- /dev/null +++ b/bates-core/install.ps1 @@ -0,0 +1,362 @@ +# install.ps1 -- Phase 1: Windows Bootstrap for Bates AI Assistant +# Run by Inno Setup after prerequisite checks pass, or standalone. +# +# This script: +# 1. Enables WSL2 if not already enabled +# 2. Installs Ubuntu 24.04 +# 3. Configures .wslconfig +# 4. Creates a Windows Scheduled Task for WSL2 auto-start +# 5. Handles reboot if needed (auto-resume via Scheduled Task) +# 6. Launches core-setup.sh inside WSL2 + +param( + [string]$InstallDir = "$env:LOCALAPPDATA\BatesInstaller" +) + +$ErrorActionPreference = "Stop" + +function Write-Step($msg) { + Write-Host "" + Write-Host "==> $msg" -ForegroundColor Cyan +} + +function Write-Success($msg) { + Write-Host "[OK] $msg" -ForegroundColor Green +} + +function Write-Warn($msg) { + Write-Host "[WARN] $msg" -ForegroundColor Yellow +} + +function Write-Fail($msg) { + Write-Host "[ERROR] $msg" -ForegroundColor Red +} + +# ============================================================ +# Banner +# ============================================================ +Write-Host "" +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host " Bates AI Assistant -- Windows Setup" -ForegroundColor Cyan +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host "" + +# ============================================================ +# Role Selection (Server vs Client) +# ============================================================ +Write-Host "How will this machine be used?" -ForegroundColor White +Write-Host "" +Write-Host " 1) Server -- Run the AI assistant 24/7 on this machine" -ForegroundColor White +Write-Host " (installs WSL2, gateway, agents, plugins)" -ForegroundColor Gray +Write-Host "" +Write-Host " 2) Client -- Connect to a Bates server from this machine" -ForegroundColor White +Write-Host " (installs dashboard app + SSH/RDP shortcuts)" -ForegroundColor Gray +Write-Host "" + +$roleChoice = Read-Host "Selection [1]" +if ([string]::IsNullOrEmpty($roleChoice)) { $roleChoice = "1" } + +if ($roleChoice -eq "2") { + Write-Step "Client mode selected" + Write-Host "" + + # Run client setup script + $clientScript = Join-Path $InstallDir "core-client-setup.ps1" + if (Test-Path $clientScript) { + & $clientScript -InstallDir $InstallDir + } else { + Write-Fail "Client setup script not found: $clientScript" + Write-Host "Expected at: bates-core\core-client-setup.ps1" + } + exit $LASTEXITCODE +} + +Write-Step "Server mode selected" +Write-Host "" + +# ============================================================ +# Disclaimer acceptance +# ============================================================ +$disclaimerPath = Join-Path $InstallDir "DISCLAIMER.txt" +if (-not (Test-Path (Join-Path $InstallDir ".disclaimer-accepted"))) { + if (Test-Path $disclaimerPath) { + Write-Host "==========================================" -ForegroundColor Yellow + Write-Host " IMPORTANT -- PLEASE READ CAREFULLY" -ForegroundColor Yellow + Write-Host "==========================================" -ForegroundColor Yellow + Write-Host "" + Get-Content $disclaimerPath | Write-Host + Write-Host "" + Write-Host "==========================================" -ForegroundColor Yellow + Write-Host "" + + if ($env:BATES_ACCEPT_DISCLAIMER -eq "yes") { + Write-Success "Disclaimer accepted via BATES_ACCEPT_DISCLAIMER=yes" + } else { + Write-Host "You must accept this disclaimer to continue." -ForegroundColor White + Write-Host "" + $reply = Read-Host "Type 'I ACCEPT' to proceed (or anything else to abort)" + if ($reply -ne "I ACCEPT") { + Write-Host "" + Write-Fail "Installation aborted. You must accept the disclaimer to proceed." + exit 1 + } + Write-Host "" + Write-Success "Disclaimer accepted." + } + + # Mark as accepted so we don't re-prompt after reboot + "accepted" | Out-File (Join-Path $InstallDir ".disclaimer-accepted") -Force + Write-Host "" + } +} + +# ============================================================ +# Check if resuming after reboot +# ============================================================ +$resumeMarker = Join-Path $InstallDir ".resume-after-reboot" +if (Test-Path $resumeMarker) { + Write-Step "Resuming after reboot..." + Remove-Item $resumeMarker -Force + + # Remove the resume scheduled task + Unregister-ScheduledTask -TaskName "BatesInstallResume" -Confirm:$false -ErrorAction SilentlyContinue + + # Jump straight to WSL2 setup + goto_wsl_setup + exit 0 +} + +# ============================================================ +# Step 1: Check and Enable WSL2 +# ============================================================ +Write-Step "Checking WSL2..." + +$needsReboot = $false + +# Check WSL feature +$wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -ErrorAction SilentlyContinue +if ($null -eq $wslFeature -or $wslFeature.State -ne "Enabled") { + Write-Step "Enabling Windows Subsystem for Linux..." + dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart | Out-Null + $needsReboot = $true +} + +# Check Virtual Machine Platform +$vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -ErrorAction SilentlyContinue +if ($null -eq $vmFeature -or $vmFeature.State -ne "Enabled") { + Write-Step "Enabling Virtual Machine Platform..." + dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart | Out-Null + $needsReboot = $true +} + +# Set WSL2 as default version +try { + wsl --set-default-version 2 2>$null | Out-Null +} catch { + # May fail if WSL not fully installed yet (needs reboot) +} + +Write-Success "WSL2 features enabled" + +# ============================================================ +# Step 2: Handle Reboot if Needed +# ============================================================ +if ($needsReboot) { + Write-Step "WSL2 requires a system reboot to complete installation." + Write-Host "" + Write-Host "After reboot, the installer will resume automatically." -ForegroundColor Yellow + Write-Host "" + + # Create resume marker + New-Item -Path $resumeMarker -ItemType File -Force | Out-Null + + # Create scheduled task to resume after reboot + $action = New-ScheduledTaskAction -Execute "powershell.exe" ` + -Argument "-ExecutionPolicy Bypass -File `"$InstallDir\install.ps1`" -InstallDir `"$InstallDir`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + $principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Highest + Register-ScheduledTask -TaskName "BatesInstallResume" ` + -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null + + Write-Success "Resume task created" + + $answer = Read-Host "Reboot now? (y/n)" + if ($answer -match "^[Yy]") { + Restart-Computer -Force + } else { + Write-Host "" + Write-Host "Please reboot manually, then the installer will resume." -ForegroundColor Yellow + exit 0 + } +} + +# ============================================================ +# Step 3: Install Ubuntu 24.04 +# ============================================================ +function goto_wsl_setup { + Write-Step "Checking Ubuntu 24.04..." + + # Check if Ubuntu-24.04 is already installed + $distros = wsl --list --quiet 2>$null + if ($distros -match "Ubuntu-24.04") { + Write-Success "Ubuntu 24.04 already installed" + } else { + Write-Step "Installing Ubuntu 24.04 (this may take a few minutes)..." + wsl --install -d Ubuntu-24.04 --no-launch 2>$null + + if ($LASTEXITCODE -ne 0) { + # Try alternative method + wsl --install Ubuntu-24.04 2>$null + } + + Write-Success "Ubuntu 24.04 installed" + } + + # Set as default distribution + wsl --set-default Ubuntu-24.04 2>$null + + # ============================================================ + # Step 4: Configure .wslconfig + # ============================================================ + Write-Step "Configuring WSL2..." + + $wslConfigPath = Join-Path $env:USERPROFILE ".wslconfig" + $wslConfigSource = Join-Path $InstallDir "templates\wslconfig.template" + + if (Test-Path $wslConfigSource) { + Copy-Item $wslConfigSource $wslConfigPath -Force + } else { + # Fallback: write config directly + @" +[wsl2] +memory=12GB +vmIdleTimeout=-1 + +[boot] +systemd=true +"@ | Set-Content $wslConfigPath + } + Write-Success ".wslconfig configured" + + # Restart WSL to apply config + wsl --shutdown 2>$null + Start-Sleep -Seconds 3 + + # ============================================================ + # Step 5: Create WSL2 Auto-Start Scheduled Task + # ============================================================ + Write-Step "Setting up WSL2 auto-start..." + + $wslAction = New-ScheduledTaskAction -Execute "wsl.exe" ` + -Argument "-d Ubuntu-24.04 -- bash -c 'sleep 5'" + $wslTrigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME + $wslSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries + Register-ScheduledTask -TaskName "BatesWSLAutoStart" ` + -Action $wslAction -Trigger $wslTrigger -Settings $wslSettings -Force | Out-Null + + Write-Success "WSL2 auto-start configured" + + # ============================================================ + # Step 6: Copy installer files into WSL2 + # ============================================================ + Write-Step "Copying installer files to WSL2..." + + # Convert Windows path to WSL path + $wslInstallDir = "/mnt/" + $InstallDir.Replace("\", "/").Replace(":", "").ToLower() + # Alternative: copy to a known location in WSL + $wslTargetDir = "/tmp/bates-installer" + + # Create target directory and copy files + wsl -d Ubuntu-24.04 -- bash -c "rm -rf $wslTargetDir && mkdir -p $wslTargetDir" + wsl -d Ubuntu-24.04 -- bash -c "cp -r '$wslInstallDir/'* '$wslTargetDir/' 2>/dev/null || true" + + # Make scripts executable + wsl -d Ubuntu-24.04 -- bash -c "chmod +x '$wslTargetDir/'*.sh '$wslTargetDir/scripts-core/'*.sh 2>/dev/null || true" + + Write-Success "Files copied to WSL2" + + # ============================================================ + # Step 7: Run Linux Setup + # ============================================================ + Write-Step "Starting Linux environment setup..." + Write-Host "" + Write-Host "This will install Node.js, OpenClaw, and system packages inside WSL2." -ForegroundColor Yellow + Write-Host "You may be prompted for your WSL2 user password (sudo)." -ForegroundColor Yellow + Write-Host "" + + # Run core-setup.sh + wsl -d Ubuntu-24.04 -- bash "$wslTargetDir/core-setup.sh" + + if ($LASTEXITCODE -eq 0) { + Write-Success "Linux setup complete" + + # ============================================================ + # Step 8: Run Configuration (interactive) + # ============================================================ + Write-Step "Starting configuration..." + Write-Host "" + Write-Host "You will be asked to:" -ForegroundColor Yellow + Write-Host " 1. Choose your AI provider (Anthropic, OpenAI, etc.)" -ForegroundColor White + Write-Host " 2. Sign in with Microsoft (for email, calendar, Teams)" -ForegroundColor White + Write-Host " 3. Set up Telegram (messaging channel)" -ForegroundColor White + Write-Host "" + + wsl -d Ubuntu-24.04 -- bash "$wslTargetDir/core-configure.sh" + + if ($LASTEXITCODE -eq 0) { + Write-Success "Configuration complete" + + # ============================================================ + # Step 9: Verify Installation + # ============================================================ + wsl -d Ubuntu-24.04 -- bash "$wslTargetDir/core-verify.sh" + + if ($LASTEXITCODE -eq 0) { + # ============================================================ + # Step 10: Set up SSH server (so clients can connect) + # ============================================================ + Write-Step "Configuring SSH server for remote access..." + Write-Host "" + Write-Host "This configures SSH so client machines can connect to this server." -ForegroundColor Yellow + Write-Host "Desktop shortcuts are NOT created here (install on client machine instead)." -ForegroundColor Yellow + Write-Host "" + + wsl -d Ubuntu-24.04 -- bash "$wslTargetDir/core-remote-access.sh" --server-only + + if ($LASTEXITCODE -eq 0) { + Write-Success "SSH server configured" + } else { + Write-Warn "SSH server setup had issues. You can retry later with: bates-enhance.sh remote-access" + } + } + } else { + Write-Fail "Configuration failed. Check the output above." + Write-Host "You can retry: wsl -d Ubuntu-24.04 -- bash $wslTargetDir/core-configure.sh" + } + } else { + Write-Fail "Linux setup failed. Check the output above." + Write-Host "You can retry: wsl -d Ubuntu-24.04 -- bash $wslTargetDir/core-setup.sh" + } +} + +# Call the setup function (when not resuming) +goto_wsl_setup + +# ============================================================ +# Final Message +# ============================================================ +Write-Host "" +Write-Host "==========================================" -ForegroundColor Green +Write-Host " Installation Complete!" -ForegroundColor Green +Write-Host "==========================================" -ForegroundColor Green +Write-Host "" +Write-Host "Your AI assistant is running at: http://localhost:18789/dashboard" +Write-Host "" +Write-Host "To connect from another machine, run the installer there and choose 'Client'." +Write-Host "" +Write-Host "To add more integrations later, run in WSL2:" +Write-Host " bates-enhance.sh" +Write-Host "" +Write-Host "Press any key to exit..." +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") diff --git a/bates-core/lib/common.sh b/bates-core/lib/common.sh new file mode 100755 index 0000000..41d73ca --- /dev/null +++ b/bates-core/lib/common.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# common.sh -- Shared functions for Bates installer scripts +# Provides logging, colors, prompts, and step tracking + +set -euo pipefail + +# Colors (only if terminal supports them) +if [[ -t 1 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + CYAN='\033[0;36m' + BOLD='\033[1m' + NC='\033[0m' +else + RED='' GREEN='' YELLOW='' CYAN='' BOLD='' NC='' +fi + +# Step counter +_STEP_NUM=0 + +step() { + ((_STEP_NUM++)) || true + echo -e "\n${CYAN}==> Step ${_STEP_NUM}: $1${NC}" +} + +info() { + echo -e "${CYAN}[INFO]${NC} $1" +} + +success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +fatal() { + error "$1" + exit 1 +} + +# Prompt with default value +prompt_default() { + local prompt="$1" + local default="$2" + local varname="$3" + local input + + if [[ -n "$default" ]]; then + read -rp "$prompt [$default]: " input + eval "$varname=\"${input:-$default}\"" + else + read -rp "$prompt: " input + eval "$varname=\"$input\"" + fi +} + +# Yes/No prompt (returns 0 for yes, 1 for no) +confirm() { + local prompt="${1:-Continue?}" + local reply + read -rp "$prompt (y/n): " reply + [[ "$reply" =~ ^[Yy] ]] +} + +# Check if a command exists +require_cmd() { + local cmd="$1" + local msg="${2:-$cmd is required but not installed}" + if ! command -v "$cmd" &>/dev/null; then + fatal "$msg" + fi +} + +# Spinner for long-running commands +spinner() { + local pid=$1 + local msg="${2:-Working...}" + local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' + local i=0 + while kill -0 "$pid" 2>/dev/null; do + printf "\r${CYAN}%s${NC} %s" "${spin:i++%${#spin}:1}" "$msg" + sleep 0.1 + done + printf "\r" +} + +# Run a command with spinner +run_with_spinner() { + local msg="$1" + shift + "$@" &>/dev/null & + local pid=$! + spinner "$pid" "$msg" + wait "$pid" + local rc=$? + if [[ $rc -eq 0 ]]; then + success "$msg" + else + error "$msg (exit code $rc)" + return $rc + fi +} + +# Get the install directory (where bates-core/ scripts live) +get_install_dir() { + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}")" && pwd)" + # If called from lib/, go up one level + if [[ "$(basename "$script_dir")" == "lib" ]]; then + echo "$(dirname "$script_dir")" + else + echo "$script_dir" + fi +} + +INSTALL_DIR="$(get_install_dir)" diff --git a/bates-core/lib/prerequisites.sh b/bates-core/lib/prerequisites.sh new file mode 100755 index 0000000..d7e4299 --- /dev/null +++ b/bates-core/lib/prerequisites.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# prerequisites.sh -- System prerequisite checks for Bates Core +# Called from core-setup.sh to verify the environment is suitable + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +check_wsl2() { + if [[ -f /proc/version ]] && grep -qi microsoft /proc/version; then + success "Running inside WSL2" + return 0 + else + error "Not running inside WSL2" + return 1 + fi +} + +check_ubuntu() { + if [[ -f /etc/os-release ]]; then + local version + version=$(grep VERSION_ID /etc/os-release | cut -d'"' -f2) + if [[ "$version" == "24.04" ]]; then + success "Ubuntu 24.04 detected" + return 0 + else + warn "Ubuntu $version detected (24.04 recommended)" + return 0 + fi + else + error "Cannot determine Linux distribution" + return 1 + fi +} + +check_ram() { + local min_gb="${1:-8}" + local total_kb + total_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}') + local total_gb=$(( total_kb / 1048576 )) + + if [[ $total_gb -ge $min_gb ]]; then + success "RAM: ${total_gb}GB (minimum ${min_gb}GB)" + return 0 + else + error "Insufficient RAM: ${total_gb}GB (minimum ${min_gb}GB)" + return 1 + fi +} + +check_disk() { + local min_gb="${1:-20}" + local avail_kb + avail_kb=$(df -k "$HOME" | tail -1 | awk '{print $4}') + local avail_gb=$(( avail_kb / 1048576 )) + + if [[ $avail_gb -ge $min_gb ]]; then + success "Disk space: ${avail_gb}GB free (minimum ${min_gb}GB)" + return 0 + else + error "Insufficient disk space: ${avail_gb}GB (minimum ${min_gb}GB)" + return 1 + fi +} + +check_internet() { + if curl -sf --max-time 10 https://github.com &>/dev/null; then + success "Internet connection OK" + return 0 + else + error "No internet connection (cannot reach github.com)" + return 1 + fi +} + +check_systemd() { + if systemctl --user status &>/dev/null 2>&1; then + success "systemd user session available" + return 0 + else + error "systemd user session not available" + echo " This may require WSL2 with systemd enabled." + echo " Add [boot] systemd=true to /etc/wsl.conf and restart WSL2." + return 1 + fi +} + +# Run all prerequisite checks, fail if any critical check fails +run_all_checks() { + local failures=0 + + info "Checking prerequisites..." + echo "" + + check_wsl2 || ((failures++)) + check_ubuntu || true # non-critical + check_ram 8 || ((failures++)) + check_disk 20 || ((failures++)) + check_internet || ((failures++)) + check_systemd || ((failures++)) + + echo "" + if [[ $failures -gt 0 ]]; then + fatal "$failures prerequisite check(s) failed. Fix the issues above and try again." + fi + success "All prerequisite checks passed." +} diff --git a/bates-core/lib/template-engine.sh b/bates-core/lib/template-engine.sh new file mode 100755 index 0000000..9f74275 --- /dev/null +++ b/bates-core/lib/template-engine.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# template-engine.sh -- Replace {{PLACEHOLDER}} variables in template files +# +# Usage: +# source lib/template-engine.sh +# export ASSISTANT_NAME="Bates" USER_NAME="Robert" +# template_render "input.template" "output.conf" +# +# Placeholders use the format {{VAR_NAME}} where VAR_NAME matches +# an exported environment variable. Unset variables are left as-is. + +template_render() { + local template="$1" + local output="$2" + + if [[ ! -f "$template" ]]; then + echo "ERROR: Template not found: $template" >&2 + return 1 + fi + + cp "$template" "$output" + + # Find all {{VAR}} placeholders in the output file + local vars + vars=$(grep -oP '\{\{[A-Z_][A-Z0-9_]*\}\}' "$output" 2>/dev/null | sort -u) || true + + for var_with_braces in $vars; do + # Strip {{ and }} + local var_name="${var_with_braces#\{\{}" + var_name="${var_name%\}\}}" + + # Get the value from the environment + local var_value="${!var_name:-}" + + if [[ -n "$var_value" ]]; then + # Escape special sed characters in the value + local escaped_value + escaped_value=$(printf '%s' "$var_value" | sed 's/[&/\]/\\&/g') + sed -i "s|{{${var_name}}}|${escaped_value}|g" "$output" + fi + done +} + +# Render a template string (stdin) to stdout +template_render_string() { + local content + content=$(cat) + + local vars + vars=$(echo "$content" | grep -oP '\{\{[A-Z_][A-Z0-9_]*\}\}' 2>/dev/null | sort -u) || true + + for var_with_braces in $vars; do + local var_name="${var_with_braces#\{\{}" + var_name="${var_name%\}\}}" + local var_value="${!var_name:-}" + if [[ -n "$var_value" ]]; then + local escaped_value + escaped_value=$(printf '%s' "$var_value" | sed 's/[&/\]/\\&/g') + content=$(echo "$content" | sed "s|{{${var_name}}}|${escaped_value}|g") + fi + done + + echo "$content" +} diff --git a/bates-core/plugins/channel-bridge/index.ts b/bates-core/plugins/channel-bridge/index.ts new file mode 100644 index 0000000..3712979 --- /dev/null +++ b/bates-core/plugins/channel-bridge/index.ts @@ -0,0 +1,915 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { execSync } from "child_process"; +import { emptyPluginConfigSchema, buildMediaPayload } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = join(PLUGIN_DIR, "data"); +const SUBSCRIPTIONS_FILE = join(DATA_DIR, "subscriptions.json"); + +const GRAPH_BASE = "https://graph.microsoft.com/v1.0"; +const TOKEN_CACHE_PATH = + "/home/openclaw/.openclaw/assistant/node_modules/@softeria/ms-365-mcp-server/.token-cache.json"; +const ASSISTANT_CLIENT_ID = "3b2534d6-597a-4d5a-918d-2ea9e4ea8425"; +const TENANT_ID = "a523f509-d02e-4799-a80f-b0661d9e01af"; +const TEAM_ID = "640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c"; + +const BOT_APP_ID = "08c6086e-a3e9-4952-8b45-bc3a986c81c7"; +const BOT_SERVICE_URL = `https://smba.trafficmanager.net/uk/${TENANT_ID}/`; + +const SUBSCRIPTION_RENEWAL_MS = 50 * 60 * 1000; // 50 minutes +const SUBSCRIPTION_LIFETIME_MS = 55 * 60 * 1000; // 55 minutes (max is 60) +const CLIENT_STATE_SECRET = "channel-bridge-v1"; +const WEBHOOK_URL = + "https://openclawgateway-1.tail0e82c9.ts.net/channel-bridge/webhook"; +const CONVERSATIONS_FILE = + "/home/openclaw/.openclaw/msteams-conversations.json"; +const DEDUP_TTL_MS = 5 * 60 * 1000; +const DEDUP_MAX = 500; +const VOICE_CHANNELS_FILE = join(DATA_DIR, "voice-channels.json"); + +function loadVoiceChannels(): Set { + try { + if (existsSync(VOICE_CHANNELS_FILE)) { + const data = JSON.parse(readFileSync(VOICE_CHANNELS_FILE, "utf-8")); + return new Set(Array.isArray(data.voiceChannels) ? data.voiceChannels : []); + } + } catch {} + return new Set(); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +type SubscriptionRecord = { + id: string; + channelId: string; + expirationDateTime: string; +}; + +// --------------------------------------------------------------------------- +// Token management (Graph API - assistant account) +// --------------------------------------------------------------------------- +let graphToken: string | null = null; +let graphTokenExpiresAt = 0; + +async function getGraphToken(): Promise { + if (graphToken && Date.now() < graphTokenExpiresAt - 300_000) { + return graphToken; + } + + // Trigger mcporter to refresh the cache + try { + execSync('mcporter call ms365-assistant.get-current-user select=\'["id"]\' 2>/dev/null', { + timeout: 30_000, + }); + } catch { + // May fail but cache file might still have valid refresh token + } + + const cache = JSON.parse(readFileSync(TOKEN_CACHE_PATH, "utf-8")); + const entry = Object.values(cache.RefreshToken || {})[0] as any; + const refreshToken = entry?.secret; + if (!refreshToken) throw new Error("No refresh token in assistant token cache"); + + const params = new URLSearchParams({ + client_id: ASSISTANT_CLIENT_ID, + refresh_token: refreshToken, + grant_type: "refresh_token", + scope: "https://graph.microsoft.com/.default", + }); + + const res = await fetch( + `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`, + { method: "POST", body: params }, + ); + const data = (await res.json()) as any; + if (!data.access_token) throw new Error(`Graph token refresh failed: ${JSON.stringify(data)}`); + + graphToken = data.access_token; + graphTokenExpiresAt = Date.now() + (data.expires_in || 3600) * 1000; + return graphToken!; +} + +// --------------------------------------------------------------------------- +// Token management (Bot Framework - for sending replies) +// --------------------------------------------------------------------------- +let botToken: string | null = null; +let botTokenExpiresAt = 0; + +async function getBotToken(appPassword: string): Promise { + if (botToken && Date.now() < botTokenExpiresAt - 60_000) { + return botToken; + } + + const params = new URLSearchParams({ + grant_type: "client_credentials", + client_id: BOT_APP_ID, + client_secret: appPassword, + scope: "https://api.botframework.com/.default", + }); + + const res = await fetch( + `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`, + { method: "POST", body: params }, + ); + const data = (await res.json()) as any; + if (!data.access_token) throw new Error(`Bot token fetch failed: ${JSON.stringify(data)}`); + + botToken = data.access_token; + botTokenExpiresAt = Date.now() + (data.expires_in || 3600) * 1000; + return botToken!; +} + +// --------------------------------------------------------------------------- +// Graph API helper +// --------------------------------------------------------------------------- +async function graphApi(method: string, endpoint: string, body?: any): Promise { + const token = await getGraphToken(); + const opts: RequestInit = { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }; + if (body) opts.body = JSON.stringify(body); + + const res = await fetch(`${GRAPH_BASE}${endpoint}`, opts); + if (method === "DELETE" && (res.status === 204 || res.status === 404)) return null; + if (!res.ok) { + const text = await res.text(); + throw new Error(`Graph ${method} ${endpoint} (${res.status}): ${text.slice(0, 200)}`); + } + const ct = res.headers.get("content-type") || ""; + if (ct.includes("application/json")) return res.json(); + return null; +} + +async function graphApiBinary(endpoint: string): Promise<{ buffer: Buffer; contentType?: string }> { + const token = await getGraphToken(); + const res = await fetch(`${GRAPH_BASE}${endpoint}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Graph binary GET ${endpoint} (${res.status}): ${text.slice(0, 200)}`); + } + const buffer = Buffer.from(await res.arrayBuffer()); + return { buffer, contentType: res.headers.get("content-type") || undefined }; +} + +// --------------------------------------------------------------------------- +// Subscription persistence +// --------------------------------------------------------------------------- +function loadSubscriptions(): SubscriptionRecord[] { + try { + if (existsSync(SUBSCRIPTIONS_FILE)) { + return JSON.parse(readFileSync(SUBSCRIPTIONS_FILE, "utf-8")).subscriptions || []; + } + } catch {} + return []; +} + +function saveSubscriptions(subs: SubscriptionRecord[]): void { + if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); + writeFileSync(SUBSCRIPTIONS_FILE, JSON.stringify({ subscriptions: subs }, null, 2)); +} + +// --------------------------------------------------------------------------- +// Subscription CRUD +// --------------------------------------------------------------------------- +async function createSubscription(channelId: string, log: any): Promise { + const resource = `/teams/${TEAM_ID}/channels/${channelId}/messages`; + const expirationDateTime = new Date(Date.now() + SUBSCRIPTION_LIFETIME_MS).toISOString(); + + try { + const result = await graphApi("POST", "/subscriptions", { + changeType: "created", + notificationUrl: WEBHOOK_URL, + resource, + expirationDateTime, + clientState: CLIENT_STATE_SECRET, + }); + log.info(`subscription created for ${channelId.slice(0, 30)}: ${result.id}`); + return { id: result.id, channelId, expirationDateTime: result.expirationDateTime }; + } catch (err: any) { + log.error(`subscription create failed for ${channelId.slice(0, 30)}: ${err.message}`); + return null; + } +} + +async function renewSubscription(sub: SubscriptionRecord, log: any): Promise { + const newExpiry = new Date(Date.now() + SUBSCRIPTION_LIFETIME_MS).toISOString(); + try { + await graphApi("PATCH", `/subscriptions/${sub.id}`, { expirationDateTime: newExpiry }); + sub.expirationDateTime = newExpiry; + log.info(`subscription renewed: ${sub.id.slice(0, 12)}`); + return true; + } catch (err: any) { + log.warn(`subscription renew failed ${sub.id}: ${err.message}`); + return false; + } +} + +async function deleteSubscription(subId: string, log: any): Promise { + try { + await graphApi("DELETE", `/subscriptions/${subId}`); + } catch (err: any) { + log.debug(`subscription delete failed ${subId}: ${err.message}`); + } +} + +// --------------------------------------------------------------------------- +// Message dedup +// --------------------------------------------------------------------------- +const processedMessages = new Map(); + +function isDuplicate(id: string): boolean { + const ts = processedMessages.get(id); + if (!ts) return false; + if (Date.now() - ts > DEDUP_TTL_MS) { + processedMessages.delete(id); + return false; + } + return true; +} + +function markProcessed(id: string): void { + processedMessages.set(id, Date.now()); + if (processedMessages.size > DEDUP_MAX) { + const cutoff = Date.now() - DEDUP_TTL_MS; + for (const [k, v] of processedMessages) { + if (v < cutoff) processedMessages.delete(k); + } + } +} + +// --------------------------------------------------------------------------- +// HTML stripping +// --------------------------------------------------------------------------- +function stripHtml(html: string): string { + return html + .replace(/]*>.*?<\/at>/gi, "") // @mention tags + .replace(//gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " ") + .trim(); +} + +// --------------------------------------------------------------------------- +// Conversation reference storage (shared with msteams extension) +// --------------------------------------------------------------------------- +function storeConversationReference(conversationId: string, ref: any): void { + try { + let store: any = { version: 1, conversations: {} }; + if (existsSync(CONVERSATIONS_FILE)) { + try { + store = JSON.parse(readFileSync(CONVERSATIONS_FILE, "utf-8")); + } catch {} + } + store.conversations[conversationId] = { ...ref, lastSeenAt: new Date().toISOString() }; + writeFileSync(CONVERSATIONS_FILE, JSON.stringify(store, null, 2)); + } catch { + // Best effort + } +} + +// --------------------------------------------------------------------------- +// Send reply to channel via Bot Framework REST API +// --------------------------------------------------------------------------- +async function sendToChannel( + conversationId: string, + text: string, + appPassword: string, + replyToId?: string, +): Promise { + const token = await getBotToken(appPassword); + const activity: Record = { + type: "message", + text, + from: { id: `28:${BOT_APP_ID}`, name: "Bates" }, + conversation: { id: conversationId }, + }; + + // For thread replies, append ;messageid= to the conversation ID in the URL + // This is the Bot Framework pattern for posting into an existing Teams channel thread + const convPath = replyToId + ? `${conversationId};messageid=${replyToId}` + : conversationId; + const url = `${BOT_SERVICE_URL}v3/conversations/${encodeURIComponent(convPath)}/activities`; + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(activity), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Bot send failed (${res.status}): ${errText.slice(0, 200)}`); + } + const result = (await res.json()) as any; + return result.id || "unknown"; +} + +// --------------------------------------------------------------------------- +// Media download from Graph API messages +// --------------------------------------------------------------------------- +const MEDIA_MAX_BYTES = 20 * 1024 * 1024; // 20MB, matches tools.media.audio.maxBytes + +type DownloadedMedia = { path: string; contentType?: string }; + +async function downloadMessageMedia( + channelId: string, + messageId: string, + isReply: boolean, + parentMessageId: string | undefined, + message: any, + core: any, + log: any, +): Promise { + const media: DownloadedMedia[] = []; + + // Build the Graph API message endpoint for hostedContents + let msgEndpoint: string; + if (isReply && parentMessageId) { + msgEndpoint = `/teams/${TEAM_ID}/channels/${channelId}/messages/${parentMessageId}/replies/${messageId}`; + } else { + msgEndpoint = `/teams/${TEAM_ID}/channels/${channelId}/messages/${messageId}`; + } + + // Strategy 1: Download file attachments with contentUrl + const attachments = Array.isArray(message.attachments) ? message.attachments : []; + for (const att of attachments) { + const contentUrl = att.contentUrl; + if (!contentUrl) continue; + // Skip non-downloadable attachment types (cards, adaptive cards, etc.) + if (att.contentType?.startsWith("application/vnd.microsoft.card")) continue; + + try { + const token = await getGraphToken(); + const fetched = await core.channel.media.fetchRemoteMedia({ + url: contentUrl, + maxBytes: MEDIA_MAX_BYTES, + requestInit: { headers: { Authorization: `Bearer ${token}` } }, + }); + const mime = core.media.detectMime({ + buffer: fetched.buffer, + headerMime: fetched.contentType || att.contentType, + filePath: att.name, + }); + const saved = await core.channel.media.saveMediaBuffer( + fetched.buffer, + mime || fetched.contentType || att.contentType, + "inbound", + MEDIA_MAX_BYTES, + att.name, + ); + media.push({ path: saved.path, contentType: saved.contentType }); + log.info("channel-bridge: attachment downloaded", { + name: att.name, + contentType: saved.contentType, + size: saved.size, + }); + } catch (err: any) { + log.warn(`channel-bridge: attachment download failed (${att.name}): ${err.message}`); + } + } + + // Strategy 2: Download hostedContents (voice messages appear here) + try { + const hostedResult = await graphApi("GET", `${msgEndpoint}/hostedContents`); + const hostedItems = hostedResult?.value || []; + for (const item of hostedItems) { + const hostedId = item.id; + if (!hostedId) continue; + + try { + // Try inline base64 content first + if (item.contentBytes) { + const buffer = Buffer.from(item.contentBytes, "base64"); + if (buffer.byteLength > MEDIA_MAX_BYTES) continue; + const mime = core.media.detectMime({ + buffer, + headerMime: item.contentType || undefined, + }); + const saved = await core.channel.media.saveMediaBuffer( + buffer, + mime || item.contentType, + "inbound", + MEDIA_MAX_BYTES, + ); + media.push({ path: saved.path, contentType: saved.contentType }); + log.info("channel-bridge: hostedContent (base64) saved", { + id: hostedId, + contentType: saved.contentType, + size: saved.size, + }); + continue; + } + + // Download binary via $value endpoint + const binary = await graphApiBinary(`${msgEndpoint}/hostedContents/${hostedId}/$value`); + if (binary.buffer.byteLength > MEDIA_MAX_BYTES) continue; + const mime = core.media.detectMime({ + buffer: binary.buffer, + headerMime: binary.contentType, + }); + const saved = await core.channel.media.saveMediaBuffer( + binary.buffer, + mime || binary.contentType, + "inbound", + MEDIA_MAX_BYTES, + ); + media.push({ path: saved.path, contentType: saved.contentType }); + log.info("channel-bridge: hostedContent ($value) saved", { + id: hostedId, + contentType: saved.contentType, + size: saved.size, + }); + } catch (err: any) { + log.warn(`channel-bridge: hostedContent download failed (${hostedId}): ${err.message}`); + } + } + } catch (err: any) { + log.debug(`channel-bridge: hostedContents fetch skipped: ${err.message}`); + } + + return media; +} + +// --------------------------------------------------------------------------- +// Core: process a channel message notification +// --------------------------------------------------------------------------- +async function processMessage( + channelId: string, + messageId: string, + isReply: boolean, + parentMessageId: string | undefined, + api: OpenClawPluginApi, +): Promise { + const { config: cfg, runtime: core, logger: log } = api; + + // Fetch full message + let endpoint = `/teams/${TEAM_ID}/channels/${channelId}/messages/${messageId}`; + if (isReply && parentMessageId) { + endpoint = `/teams/${TEAM_ID}/channels/${channelId}/messages/${parentMessageId}/replies/${messageId}`; + } + + const message = await graphApi("GET", endpoint); + + // Skip bot's own messages (both bot application and the bates@vernot.com user account) + const BATES_USER_ID = "c7d938aa-d820-4dd9-8f80-e43791c1bd51"; + if (message.from?.application?.id === BOT_APP_ID) { + log.info("channel-bridge: skipping bot application message"); + return; + } + if (message.from?.user?.id === BATES_USER_ID || message.from?.user?.displayName === "Bates") { + log.info("channel-bridge: skipping bates user message"); + return; + } + + // Skip system messages + if (message.messageType !== "message") { + log.info(`channel-bridge: skipping ${message.messageType} message`); + return; + } + + const senderName = message.from?.user?.displayName || "Unknown"; + const senderId = message.from?.user?.id || "unknown"; + const bodyContent = message.body?.content || ""; + const bodyType = message.body?.contentType || "text"; + + let text = bodyType === "html" ? stripHtml(bodyContent) : bodyContent.trim(); + + // Download media attachments (voice messages, images, files) + const mediaList = await downloadMessageMedia( + channelId, messageId, isReply, parentMessageId, message, core, log, + ); + const mediaPayload = mediaList.length > 0 + ? buildMediaPayload(mediaList.map(m => ({ path: m.path, contentType: m.contentType }))) + : {}; + + // Allow media-only messages (voice messages have no text body) + if (!text && mediaList.length === 0) return; + + // For voice-only messages, set a placeholder + if (!text && mediaList.length > 0) { + text = ""; + } + + log.info("processing bridged channel message", { + channel: channelId.slice(0, 30), + sender: senderName, + preview: text.slice(0, 80), + isReply, + mediaCount: mediaList.length, + }); + + // Resolve route + const conversationId = channelId; + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "msteams", + peer: { kind: "channel", id: conversationId }, + }); + + const teamsFrom = `msteams:channel:${conversationId}`; + const teamsTo = `conversation:${conversationId}`; + + // Check if this is a voice channel + const isVoiceChannel = loadVoiceChannels().has(channelId); + + // Build envelope + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + + let envelopeBody = `${senderName}: ${text}`; + if (isVoiceChannel) { + const voiceHint = "[VOICE CHANNEL - The user is listening via text-to-speech (Android Auto). " + + "Write in flowing prose that sounds natural when read aloud. " + + "No Adaptive Cards, no markdown tables, no bullet lists, no code blocks. " + + "Normal response length is fine - the user can listen for several minutes.]"; + envelopeBody = `${voiceHint}\n\n${senderName}: ${text}`; + } + + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Teams", + from: "channel", + timestamp: new Date(message.createdDateTime), + previousTimestamp, + envelope: envelopeOptions, + body: envelopeBody, + }); + + // Also notify main session via system event (like the real msteams handler does) + const preview = text.replace(/\s+/g, " ").slice(0, 160); + core.system.enqueueSystemEvent(`Teams channel message from ${senderName}: ${preview}`, { + sessionKey: route.sessionKey, + contextKey: `channel-bridge:${conversationId}:${messageId}`, + }); + + // Build inbound context + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: text, + CommandBody: text, + From: teamsFrom, + To: teamsTo, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: "channel" as const, + ConversationLabel: "channel", + GroupSubject: "channel", + SenderName: senderName, + SenderId: senderId, + Provider: "msteams" as const, + Surface: "msteams" as const, + MessageSid: messageId, + Timestamp: new Date(message.createdDateTime).getTime(), + WasMentioned: true, + CommandAuthorized: true, + OriginatingChannel: "msteams" as const, + OriginatingTo: teamsTo, + // Thread root ID: for replies it's the parent, for top-level it's the message itself + MessageThreadId: isReply && parentMessageId ? parentMessageId : messageId, + ...mediaPayload, + }); + + // Thread root for reply routing + const threadRootId = isReply && parentMessageId ? parentMessageId : messageId; + + // Record inbound session + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + log.warn(`channel-bridge: session record error: ${String(err)}`); + }, + }); + + // Store conversation reference for proactive messaging + storeConversationReference(conversationId, { + activityId: messageId, + user: { id: senderId, name: senderName, aadObjectId: senderId }, + agent: { id: `28:${BOT_APP_ID}`, name: "bates-msteams" }, + bot: { id: `28:${BOT_APP_ID}`, name: "bates-msteams" }, + conversation: { + id: conversationId, + conversationType: "channel", + tenantId: TENANT_ID, + }, + channelId: "msteams", + serviceUrl: BOT_SERVICE_URL, + }); + + // Create reply dispatcher that sends via Bot Framework REST API + const appPassword = cfg.channels?.msteams?.appPassword; + if (!appPassword) { + log.error("channel-bridge: no msteams appPassword in config"); + return; + } + + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload: any) => { + const replyText = payload.text?.trim(); + if (!replyText) return; + + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "msteams", + }); + const converted = core.channel.text.convertMarkdownTables(replyText, tableMode); + const chunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams"); + const chunkMode = core.channel.text.resolveChunkMode(cfg, "msteams"); + const chunks = core.channel.text.chunkMarkdownTextWithMode(converted, chunkLimit, chunkMode); + + for (const chunk of chunks) { + const trimmed = chunk.trim(); + if (!trimmed) continue; + try { + const msgId = await sendToChannel(conversationId, trimmed, appPassword, threadRootId); + log.info("reply sent to channel", { conversationId: conversationId.slice(0, 30), msgId, threadRootId }); + } catch (err) { + log.error(`channel-bridge reply failed: ${String(err)}`); + } + } + }, + onError: (err: any, info: any) => { + log.error(`channel-bridge dispatch error (${info?.kind}): ${String(err)}`); + }, + onReplyStart: () => {}, + }); + + // Dispatch to agent + try { + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); + markDispatchIdle(); + log.info("channel-bridge dispatch complete", { queuedFinal, counts }); + } catch (err) { + log.error(`channel-bridge dispatch failed: ${String(err)}`); + } +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- +const plugin = { + id: "channel-bridge", + name: "Channel Bridge (Graph API)", + description: "Bridges Teams channel messages via Graph API change notifications", + configSchema: emptyPluginConfigSchema(), + + register(api: OpenClawPluginApi) { + const cfg = api.config; + const log = api.logger; + let renewalTimer: ReturnType | null = null; + let subscriptions: SubscriptionRecord[] = []; + let initDone = false; + + // ----- HTTP webhook handler ----- + // auth: "plugin" — Graph API sends unauthenticated webhook POSTs + // match: "prefix" — handles /channel-bridge/webhook and query params + api.registerHttpRoute({ + path: "/channel-bridge/webhook", + auth: "plugin", + handler: async (req: any, res: any): Promise => { + const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); + + // Graph subscription validation + const validationToken = url.searchParams.get("validationToken"); + if (validationToken) { + log.info("channel-bridge: validation request"); + res.setHeader("Content-Type", "text/plain"); + res.writeHead(200); + res.end(validationToken); + return; + } + + if (req.method !== "POST") { + res.writeHead(405); + res.end("Method not allowed"); + return; + } + + // Read body + let body = ""; + await new Promise((resolve) => { + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", resolve); + }); + + // Respond 202 immediately (Graph requires fast response) + res.writeHead(202); + res.end(); + + // Process notifications asynchronously + try { + const payload = JSON.parse(body); + const notifications = payload.value || []; + + for (const notification of notifications) { + if (notification.clientState !== CLIENT_STATE_SECRET) { + log.warn("channel-bridge: invalid clientState, skipping"); + continue; + } + + const resource = (notification.resource || "") as string; + + // Parse channel ID and message ID from resource path + // Format: teams('...')/channels('...')/messages('...') + // or: teams('...')/channels('...')/messages('...')/replies('...') + const channelMatch = resource.match(/channels\('([^']+)'\)/); + const channelId = channelMatch?.[1]; + const isReply = resource.includes("/replies("); + const parentMatch = resource.match(/messages\('([^']+)'\)/); + const parentMessageId = isReply ? parentMatch?.[1] : undefined; + + // Extract message ID: from resourceData (app permissions) or resource path (delegated) + const resourceData = notification.resourceData || {}; + let messageId = resourceData.id as string | undefined; + if (!messageId) { + // For delegated permissions, extract from resource path + if (isReply) { + const replyMatch = resource.match(/replies\('([^']+)'\)/); + messageId = replyMatch?.[1]; + } else { + messageId = parentMatch?.[1]; // messages('...') + } + } + + if (!channelId || !messageId) { + log.warn("channel-bridge: missing channelId or messageId", { + resource, + resourceData: JSON.stringify(resourceData), + changeType: notification.changeType, + }); + continue; + } + + log.info(`channel-bridge: notification received`, { + changeType: notification.changeType, + channelId: channelId.slice(0, 30), + messageId, + isReply, + }); + + if (isDuplicate(messageId)) { + log.info("channel-bridge: skipping duplicate", { messageId }); + continue; + } + markProcessed(messageId); + + processMessage(channelId, messageId, isReply, parentMessageId, api).catch((err) => { + log.error(`channel-bridge: process failed for ${messageId}: ${String(err)}`); + }); + } + } catch (err) { + log.error(`channel-bridge: notification parse failed: ${String(err)}`); + } + }, + }); + + // ----- Lifecycle: create subscriptions on start ----- + const initSubscriptions = async () => { + if (initDone) return; + initDone = true; + log.info("channel-bridge: creating Graph subscriptions"); + + const teamsCfg = cfg.channels?.msteams; + const teamConfig = (teamsCfg?.teams as any)?.[TEAM_ID]; + if (!teamConfig?.channels) { + log.warn("channel-bridge: no channels in msteams.teams config"); + return; + } + + const channelIds = Object.keys(teamConfig.channels) as string[]; + log.info(`channel-bridge: subscribing to ${channelIds.length} channels`); + + // Delete stale subscriptions from previous runs + const oldSubs = loadSubscriptions(); + for (const old of oldSubs) { + await deleteSubscription(old.id, log); + } + + // Create fresh subscriptions + subscriptions = []; + for (const channelId of channelIds) { + const sub = await createSubscription(channelId, log); + if (sub) subscriptions.push(sub); + } + saveSubscriptions(subscriptions); + log.info(`channel-bridge: ${subscriptions.length}/${channelIds.length} subscriptions active`); + + // Set up renewal timer + renewalTimer = setInterval(() => { + log.info(`channel-bridge: renewal timer fired, ${subscriptions.length} subscriptions to renew`); + (async () => { + const renewed: SubscriptionRecord[] = []; + for (const sub of subscriptions) { + const ok = await renewSubscription(sub, log); + if (ok) { + renewed.push(sub); + } else { + log.info(`channel-bridge: renewal failed for ${sub.channelId.slice(0, 30)}, recreating`); + const fresh = await createSubscription(sub.channelId, log); + if (fresh) renewed.push(fresh); + } + } + subscriptions = renewed; + saveSubscriptions(subscriptions); + log.info(`channel-bridge: renewal complete, ${renewed.length} subscriptions active`); + })().catch((err) => { + log.error(`channel-bridge: renewal error: ${String(err)}`); + }); + }, SUBSCRIPTION_RENEWAL_MS); + }; + + // Delay init slightly so gateway HTTP server is ready for validation callbacks + setTimeout(() => { + initSubscriptions().catch((err) => { + log.error(`channel-bridge: init failed: ${String(err)}`); + }); + }, 5000); + + // ----- Telegram → Teams DM mirror ----- + // When Bates replies on Telegram, also mirror the reply to Teams DM + // so Android Auto can read it aloud while driving. + // Uses the internal "message:sent" hook which fires from deliverOutboundPayloads. + const ROBERT_TEAMS_DM = + "a:11nTXbzESsObhZCvHfCcvc-0oiPJ2DOj-UKeQTdbo_bvbRilENaor0SH8pgIh9VqlXEn4vfdfyPv-RsrpB1a5AyxaeKQIQtXTGb0pMQ4_7SzEDzqM9YAC_2LIdFXVyo97"; + const telegramMirrorAppPassword = cfg.channels?.msteams?.appPassword; + + if (telegramMirrorAppPassword) { + api.on("message_sent", async (event: any, ctx: any) => { + // Only mirror Telegram messages + if (ctx?.channelId !== "telegram") return; + if (!event?.success) return; + + const content = (event?.content || "").trim(); + if (!content) return; + + log.info("telegram→teams mirror: message_sent hook fired", { + channelId: ctx.channelId, + contentLength: content.length, + }); + + try { + await sendToChannel(ROBERT_TEAMS_DM, content, telegramMirrorAppPassword!); + log.info("telegram→teams mirror sent", { length: content.length }); + } catch (err: any) { + log.warn(`telegram→teams mirror failed: ${err.message}`); + } + }); + log.info("Telegram → Teams DM mirror registered (message_sent typed hook)"); + } + + // ----- Lifecycle: cleanup on stop ----- + api.on("gateway_stop", async () => { + log.info("channel-bridge: cleaning up"); + if (renewalTimer) { + clearInterval(renewalTimer); + renewalTimer = null; + } + for (const sub of subscriptions) { + await deleteSubscription(sub.id, log); + } + subscriptions = []; + saveSubscriptions([]); + }); + + log.info("Channel bridge plugin registered"); + }, +}; + +export default plugin; diff --git a/bates-core/plugins/channel-bridge/openclaw.plugin.json b/bates-core/plugins/channel-bridge/openclaw.plugin.json new file mode 100644 index 0000000..1e18df0 --- /dev/null +++ b/bates-core/plugins/channel-bridge/openclaw.plugin.json @@ -0,0 +1,10 @@ +{ + "id": "channel-bridge", + "name": "Channel Bridge (Graph API)", + "description": "Bridges Teams channel messages to gateway via Graph API change notifications", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/bates-core/plugins/channel-bridge/package.json b/bates-core/plugins/channel-bridge/package.json new file mode 100644 index 0000000..728119a --- /dev/null +++ b/bates-core/plugins/channel-bridge/package.json @@ -0,0 +1,10 @@ +{ + "name": "@openclaw/channel-bridge", + "version": "1.0.0", + "description": "Bridges Teams channel messages via Graph API change notifications", + "type": "module", + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.9.3" + } +} diff --git a/bates-core/plugins/cost-tracker/index.ts b/bates-core/plugins/cost-tracker/index.ts new file mode 100644 index 0000000..c7e8c25 --- /dev/null +++ b/bates-core/plugins/cost-tracker/index.ts @@ -0,0 +1,814 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { homedir } from "os"; +import { + emptyPluginConfigSchema, + onDiagnosticEvent, +} from "openclaw/plugin-sdk"; +import type { + OpenClawPluginApi, + DiagnosticUsageEvent, + DiagnosticEventPayload, +} from "openclaw/plugin-sdk"; + +// --------------------------------------------------------------------------- +// globalThis bridge for diagnostic events (future-proofing) +// --------------------------------------------------------------------------- +// BUG: onDiagnosticEvent from "openclaw/plugin-sdk" registers on a separate +// `listeners` Set (in dist/plugin-sdk/index.js) from where the gateway emits +// events (dist/extensionAPI.js has its own `listeners$3`). Both are inlined +// copies of src/infra/diagnostic-events.ts with no shared state. Plugins +// loaded via jiti therefore never receive model.usage events. +// +// WORKAROUND: We scan session transcript JSONL files to extract usage data. +// The globalThis bridge is registered for forward-compat if the gateway +// core adds dispatch to globalThis.__openclawDiagnosticListeners. +declare global { + // eslint-disable-next-line no-var + var __openclawDiagnosticListeners: Set<(evt: DiagnosticEventPayload) => void> | undefined; + // eslint-disable-next-line no-var + var __openclawMessageTransform: ((text: string, meta: { channel: string; to: string }) => string) | undefined; +} +if (!globalThis.__openclawDiagnosticListeners) { + globalThis.__openclawDiagnosticListeners = new Set(); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = join(PLUGIN_DIR, "data"); +const DAILY_FILE = join(DATA_DIR, "daily-costs.json"); +const OFFSETS_FILE = join(DATA_DIR, "scan-offsets.json"); +const OPENCLAW_DIR = join(homedir(), ".openclaw"); +const AGENTS_DIR = join(OPENCLAW_DIR, "agents"); +const AUTH_PROFILES_FILE = join(AGENTS_DIR, "main", "agent", "auth-profiles.json"); + +// How often to scan session files (ms) +const SCAN_INTERVAL_MS = 60_000; + +// Anthropic model prefix for zero-cost detection under subscription +const ANTHROPIC_MODEL_PREFIXES = ["claude-"]; + +// OpenAI Codex model prefixes for zero-cost detection under subscription +const OPENAI_CODEX_MODEL_PREFIXES = ["gpt-"]; + +// Cost per million tokens (fallback when transcript has no cost field) +const MODEL_COSTS: Record< + string, + { input: number; output: number; cacheRead: number; cacheWrite: number } +> = { + "claude-sonnet-4-5-20250929": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-haiku-4-5-20251001": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + "claude-opus-4-5-20251101": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + "claude-opus-4-6": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + "gemini-2.5-flash": { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 }, + "deepseek-chat": { input: 0.27, output: 1.1, cacheRead: 0, cacheWrite: 0 }, + "sonar-pro": { input: 3, output: 15, cacheRead: 0, cacheWrite: 0 }, +}; + +// --------------------------------------------------------------------------- +// Subscription (token) profile detection +// --------------------------------------------------------------------------- +// When the active Anthropic auth profile is a "token" type (e.g. Claude Max +// subscription), per-token costs are $0 since they're covered by the flat fee. +// We cache this check and refresh it periodically (the file rarely changes). +let _isAnthropicSubscription: boolean | null = null; +let _subscriptionCheckAt = 0; +const SUBSCRIPTION_CHECK_INTERVAL_MS = 300_000; // re-check every 5 minutes + +function isAnthropicSubscription(): boolean { + const now = Date.now(); + if (_isAnthropicSubscription !== null && now - _subscriptionCheckAt < SUBSCRIPTION_CHECK_INTERVAL_MS) { + return _isAnthropicSubscription; + } + _subscriptionCheckAt = now; + try { + if (!existsSync(AUTH_PROFILES_FILE)) { + _isAnthropicSubscription = false; + return false; + } + const data = JSON.parse(readFileSync(AUTH_PROFILES_FILE, "utf-8")); + const activeProfile = data?.lastGood?.anthropic; + if (!activeProfile) { + _isAnthropicSubscription = false; + return false; + } + const profileDef = data?.profiles?.[activeProfile]; + _isAnthropicSubscription = profileDef?.type === "token"; + return _isAnthropicSubscription; + } catch { + _isAnthropicSubscription = false; + return false; + } +} + +function isAnthropicModel(model: string | undefined): boolean { + if (!model) return false; + return ANTHROPIC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)); +} + +// --------------------------------------------------------------------------- +// OpenAI Codex subscription (OAuth) detection +// --------------------------------------------------------------------------- +let _isOpenAICodexSubscription: boolean | null = null; +let _codexSubscriptionCheckAt = 0; + +function isOpenAICodexSubscription(): boolean { + const now = Date.now(); + if (_isOpenAICodexSubscription !== null && now - _codexSubscriptionCheckAt < SUBSCRIPTION_CHECK_INTERVAL_MS) { + return _isOpenAICodexSubscription; + } + _codexSubscriptionCheckAt = now; + try { + if (!existsSync(AUTH_PROFILES_FILE)) { + _isOpenAICodexSubscription = false; + return false; + } + const data = JSON.parse(readFileSync(AUTH_PROFILES_FILE, "utf-8")); + const activeProfile = data?.lastGood?.["openai-codex"]; + if (!activeProfile) { + _isOpenAICodexSubscription = false; + return false; + } + const profileDef = data?.profiles?.[activeProfile]; + _isOpenAICodexSubscription = profileDef?.type === "oauth"; + return _isOpenAICodexSubscription; + } catch { + _isOpenAICodexSubscription = false; + return false; + } +} + +function isOpenAICodexModel(model: string | undefined): boolean { + if (!model) return false; + return OPENAI_CODEX_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)); +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +interface InteractionCost { + timestamp: number; + model?: string; + provider?: string; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalTokens: number; + costUsd: number; + sessionKey?: string; +} + +interface DailyCosts { + [dateKey: string]: { + totalCost: number; + totalTokens: number; + interactions: number; + byModel: Record< + string, + { + cost: number; + tokens: number; + count: number; + } + >; + }; +} + +interface SessionAccumulator { + totalCost: number; + totalTokens: number; + interactions: number; + lastInteractionCost: number; + lastInteractionTokens: number; + lastModel?: string; + startedAt: number; +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- +let dailyCosts: DailyCosts = {}; +const sessionAccumulators = new Map(); +let globalAccumulator: SessionAccumulator = { + totalCost: 0, + totalTokens: 0, + interactions: 0, + lastInteractionCost: 0, + lastInteractionTokens: 0, + startedAt: Date.now(), +}; + +let lastInteraction: InteractionCost | null = null; +let diagnosticEventsReceived = 0; // track if onDiagnosticEvent works +let scanTimer: ReturnType | null = null; + +// Track which JSONL lines we've already processed (by file + byte offset) +// Persisted to disk to survive gateway restarts and prevent double-counting. +let scannedOffsets = new Map(); + +function loadScannedOffsets(): Map { + try { + if (existsSync(OFFSETS_FILE)) { + const data = JSON.parse(readFileSync(OFFSETS_FILE, "utf-8")); + return new Map(Object.entries(data)); + } + } catch {} + return new Map(); +} + +function saveScannedOffsets(): void { + try { + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + } + const obj: Record = {}; + for (const [k, v] of scannedOffsets) obj[k] = v; + writeFileSync(OFFSETS_FILE, JSON.stringify(obj)); + } catch {} +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function todayKey(): string { + return new Date().toLocaleDateString("en-CA", { + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); +} + +function dateKeyFromTimestamp(ts: number): string { + return new Date(ts).toLocaleDateString("en-CA", { + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); +} + +function formatUsd(value: number): string { + if (value >= 1) return `$${value.toFixed(2)}`; + if (value >= 0.01) return `$${value.toFixed(2)}`; + if (value >= 0.001) return `$${value.toFixed(3)}`; + return `$${value.toFixed(4)}`; +} + +function formatTokens(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 10_000) return `${Math.round(value / 1_000)}k`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`; + return String(Math.round(value)); +} + +function estimateCost( + model: string | undefined, + input: number, + output: number, + cacheRead: number, + cacheWrite: number +): number { + // Anthropic models are free under Claude Max subscription (token profile) + if (isAnthropicModel(model) && isAnthropicSubscription()) return 0; + // OpenAI Codex models are free under ChatGPT Plus/Pro subscription (OAuth) + if (isOpenAICodexModel(model) && isOpenAICodexSubscription()) return 0; + + const costs = model ? MODEL_COSTS[model] : undefined; + if (!costs) return 0; + return ( + (input * costs.input + + output * costs.output + + cacheRead * costs.cacheRead + + cacheWrite * costs.cacheWrite) / + 1_000_000 + ); +} + +function loadDailyCosts(): DailyCosts { + try { + if (existsSync(DAILY_FILE)) { + return JSON.parse(readFileSync(DAILY_FILE, "utf-8")); + } + } catch {} + return {}; +} + +function saveDailyCosts(): void { + try { + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + } + writeFileSync(DAILY_FILE, JSON.stringify(dailyCosts, null, 2)); + } catch {} +} + +function getOrCreateSession(sessionKey: string): SessionAccumulator { + let acc = sessionAccumulators.get(sessionKey); + if (!acc) { + acc = { + totalCost: 0, + totalTokens: 0, + interactions: 0, + lastInteractionCost: 0, + lastInteractionTokens: 0, + startedAt: Date.now(), + }; + sessionAccumulators.set(sessionKey, acc); + } + return acc; +} + +function recordUsage(event: DiagnosticUsageEvent): void { + const input = event.usage?.input ?? 0; + const output = event.usage?.output ?? 0; + const cacheRead = event.usage?.cacheRead ?? 0; + const cacheWrite = event.usage?.cacheWrite ?? 0; + const totalTokens = + event.usage?.total ?? input + output + cacheRead + cacheWrite; + + // Subscription models are free (Anthropic token profile, OpenAI Codex OAuth) + const subscriptionZero = + (isAnthropicModel(event.model) && isAnthropicSubscription()) || + (isOpenAICodexModel(event.model) && isOpenAICodexSubscription()); + const costUsd = subscriptionZero ? 0 : + (event.costUsd ?? estimateCost(event.model, input, output, cacheRead, cacheWrite)); + + const interaction: InteractionCost = { + timestamp: event.ts ?? Date.now(), + model: event.model, + provider: event.provider, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + totalTokens, + costUsd, + sessionKey: event.sessionKey, + }; + + lastInteraction = interaction; + + const sessionKey = event.sessionKey ?? "__global__"; + const session = getOrCreateSession(sessionKey); + session.totalCost += costUsd; + session.totalTokens += totalTokens; + session.interactions += 1; + session.lastInteractionCost = costUsd; + session.lastInteractionTokens = totalTokens; + session.lastModel = event.model; + + globalAccumulator.totalCost += costUsd; + globalAccumulator.totalTokens += totalTokens; + globalAccumulator.interactions += 1; + globalAccumulator.lastInteractionCost = costUsd; + globalAccumulator.lastInteractionTokens = totalTokens; + globalAccumulator.lastModel = event.model; + + const day = dateKeyFromTimestamp(interaction.timestamp); + if (!dailyCosts[day]) { + dailyCosts[day] = { + totalCost: 0, + totalTokens: 0, + interactions: 0, + byModel: {}, + }; + } + const dayBucket = dailyCosts[day]; + dayBucket.totalCost += costUsd; + dayBucket.totalTokens += totalTokens; + dayBucket.interactions += 1; + + const modelKey = event.model ?? "unknown"; + if (!dayBucket.byModel[modelKey]) { + dayBucket.byModel[modelKey] = { cost: 0, tokens: 0, count: 0 }; + } + dayBucket.byModel[modelKey].cost += costUsd; + dayBucket.byModel[modelKey].tokens += totalTokens; + dayBucket.byModel[modelKey].count += 1; + + if (globalAccumulator.interactions % 10 === 0) { + saveDailyCosts(); + } +} + +// --------------------------------------------------------------------------- +// Session transcript JSONL scanner (fallback for broken diagnostic events) +// --------------------------------------------------------------------------- +// Scans session JSONL files for assistant messages with usage data. +// Only processes new lines since the last scan (tracked by byte offset). +function recordFromTranscript( + model: string | undefined, + usage: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; totalTokens?: number; cost?: { total?: number } }, + timestamp: number, + provider?: string, +): void { + const input = usage.input ?? 0; + const output = usage.output ?? 0; + const cacheRead = usage.cacheRead ?? 0; + const cacheWrite = usage.cacheWrite ?? 0; + const totalTokens = usage.totalTokens ?? (input + output + cacheRead + cacheWrite); + + // Subscription models are free — override any cost from the API response + // since it still reports billing rates under subscriptions. + const subscriptionZero = + (isAnthropicModel(model) && isAnthropicSubscription()) || + (isOpenAICodexModel(model) && isOpenAICodexSubscription()); + const costUsd = subscriptionZero ? 0 : (usage.cost?.total ?? estimateCost(model, input, output, cacheRead, cacheWrite)); + + const interaction: InteractionCost = { + timestamp, + model, + provider, + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + totalTokens, + costUsd, + }; + + lastInteraction = interaction; + + globalAccumulator.totalCost += costUsd; + globalAccumulator.totalTokens += totalTokens; + globalAccumulator.interactions += 1; + globalAccumulator.lastInteractionCost = costUsd; + globalAccumulator.lastInteractionTokens = totalTokens; + globalAccumulator.lastModel = model; + + const day = dateKeyFromTimestamp(timestamp); + if (!dailyCosts[day]) { + dailyCosts[day] = { totalCost: 0, totalTokens: 0, interactions: 0, byModel: {} }; + } + const bucket = dailyCosts[day]; + bucket.totalCost += costUsd; + bucket.totalTokens += totalTokens; + bucket.interactions += 1; + + const mk = model ?? "unknown"; + if (!bucket.byModel[mk]) { + bucket.byModel[mk] = { cost: 0, tokens: 0, count: 0 }; + } + bucket.byModel[mk].cost += costUsd; + bucket.byModel[mk].tokens += totalTokens; + bucket.byModel[mk].count += 1; +} + +function scanSessionFiles(logger?: { debug: (...args: unknown[]) => void }): void { + // Skip if onDiagnosticEvent is actually working + if (diagnosticEventsReceived > 0) return; + + let newEntries = 0; + try { + if (!existsSync(AGENTS_DIR)) return; + const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + for (const agentDir of agentDirs) { + const sessionsDir = join(AGENTS_DIR, agentDir.name, "sessions"); + if (!existsSync(sessionsDir)) continue; + + let files: string[]; + try { + files = readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl")); + } catch { + continue; + } + + for (const file of files) { + const filePath = join(sessionsDir, file); + let fileSize: number; + try { + fileSize = statSync(filePath).size; + } catch { + continue; + } + + const prevOffset = scannedOffsets.get(filePath) ?? 0; + if (fileSize <= prevOffset) continue; + + // Read only new bytes + let newContent: string; + try { + const fd = require("fs").openSync(filePath, "r"); + const buf = Buffer.alloc(fileSize - prevOffset); + require("fs").readSync(fd, buf, 0, buf.length, prevOffset); + require("fs").closeSync(fd); + newContent = buf.toString("utf-8"); + } catch { + continue; + } + + scannedOffsets.set(filePath, fileSize); + + // Parse each line + const lines = newContent.split("\n"); + for (const line of lines) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + // Look for assistant messages with usage data + if ( + entry.type === "message" && + entry.message?.role === "assistant" && + entry.message?.usage + ) { + const msg = entry.message; + const ts = msg.timestamp ?? (entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now()); + recordFromTranscript(msg.model, msg.usage, ts, msg.provider); + newEntries++; + } + } catch { + // Skip malformed lines + } + } + } + } + } catch { + // Silently handle scan errors + } + + if (newEntries > 0) { + saveDailyCosts(); + saveScannedOffsets(); + logger?.debug(`cost-tracker: scanned ${newEntries} new usage entries from session transcripts`); + } +} + +function buildCostFooter(turnCostSnapshot?: { cost: number; tokens: number }): string { + const day = todayKey(); + const dayData = dailyCosts[day]; + const dailyTotal = dayData?.totalCost ?? 0; + const dailyTokens = dayData?.totalTokens ?? 0; + + // Use snapshot if provided (captures cost delta for this specific turn) + const turnCost = turnCostSnapshot?.cost ?? lastInteraction?.costUsd ?? 0; + const turnTokens = turnCostSnapshot?.tokens ?? lastInteraction?.totalTokens ?? 0; + + if (turnCost === 0 && dailyTotal === 0 && turnTokens === 0 && dailyTokens === 0) return ""; + + // Show tokens when costs are $0 (subscription), costs when > $0, or both + if (dailyTotal === 0 && turnCost === 0) { + // Subscription mode: show token counts only + const turnPart = turnTokens > 0 ? formatTokens(turnTokens) : "0"; + return `\n\n_turn: ${turnPart} tokens · today: ${formatTokens(dailyTokens)} tokens_`; + } + + return `\n\n_turn: ${formatUsd(turnCost)} · today: ${formatUsd(dailyTotal)}_`; +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- +const plugin = { + id: "cost-tracker", + name: "Cost Tracker", + description: "Tracks per-interaction API costs and appends cost footer to responses", + configSchema: emptyPluginConfigSchema(), + + register(api: OpenClawPluginApi) { + dailyCosts = loadDailyCosts(); + scannedOffsets = loadScannedOffsets(); + + // ----------------------------------------------------------------------- + // 1. Listen to diagnostic usage events (broken due to module isolation, + // but kept for forward-compat if core fix lands) + // ----------------------------------------------------------------------- + const diagnosticHandler = (evt: DiagnosticEventPayload) => { + if (evt.type === "model.usage") { + diagnosticEventsReceived++; + recordUsage(evt as DiagnosticUsageEvent); + } + }; + const unsubscribe = onDiagnosticEvent(diagnosticHandler); + + // Also register on globalThis bridge for future gateway versions + globalThis.__openclawDiagnosticListeners!.add(diagnosticHandler); + + // ----------------------------------------------------------------------- + // 2. Session transcript scanner (primary data source until bug is fixed) + // ----------------------------------------------------------------------- + // Initial scan on startup: catch up with any usage since last restart + scanSessionFiles(api.logger as any); + + // Periodic scan every 60s + scanTimer = setInterval(() => { + scanSessionFiles(api.logger as any); + }, SCAN_INTERVAL_MS); + + // Also scan on agent_end to capture the latest interaction quickly + api.on("agent_end", () => { + // Small delay to let the transcript file flush + setTimeout(() => scanSessionFiles(api.logger as any), 2000); + }); + + // ----------------------------------------------------------------------- + // 3. Append cost footer via globalThis bridge + // ----------------------------------------------------------------------- + // The message_sending hook is defined in the gateway but never invoked. + // Instead, we use a globalThis.__openclawMessageTransform bridge that + // is called from a small patch in deliver-Ck-fH_m-.js. + // Initialize preTurnDailyTotal from persisted data so first turn after + // restart doesn't show turn == today (was starting at 0). + const initDayData = dailyCosts[todayKey()]; + let preTurnDailyTotal = initDayData?.totalCost ?? 0; + let preTurnDailyTokens = initDayData?.totalTokens ?? 0; + api.on("message_received", () => { + scanSessionFiles(api.logger as any); + const dayData = dailyCosts[todayKey()]; + preTurnDailyTotal = dayData?.totalCost ?? 0; + preTurnDailyTokens = dayData?.totalTokens ?? 0; + }); + + globalThis.__openclawMessageTransform = (text: string, _meta: { channel: string; to: string }) => { + if (!text || !text.trim()) return text; + if (text.startsWith("[Tool:")) return text; + + // Scan transcripts to capture this turn's usage + scanSessionFiles(api.logger as any); + + const dayData = dailyCosts[todayKey()]; + const currentDailyTotal = dayData?.totalCost ?? 0; + const currentDailyTokens = dayData?.totalTokens ?? 0; + const turnCost = currentDailyTotal - preTurnDailyTotal; + const turnTokens = currentDailyTokens - preTurnDailyTokens; + + const footer = buildCostFooter( + (turnCost > 0 || turnTokens > 0) ? { cost: turnCost, tokens: turnTokens } : undefined + ); + if (!footer) return text; + + return text + footer; + }; + + // ----------------------------------------------------------------------- + // 4. Hook into session_end to persist costs and clean up + // ----------------------------------------------------------------------- + api.on("session_end", (event, ctx) => { + saveDailyCosts(); + if (ctx.sessionId) { + sessionAccumulators.delete(ctx.sessionId); + } + }); + + // ----------------------------------------------------------------------- + // 5. Hook into gateway_stop to persist costs and clean up timer + // ----------------------------------------------------------------------- + api.on("gateway_stop", () => { + saveDailyCosts(); + saveScannedOffsets(); + if (scanTimer) { + clearInterval(scanTimer); + scanTimer = null; + } + globalThis.__openclawDiagnosticListeners?.delete(diagnosticHandler); + delete (globalThis as any).__openclawMessageTransform; + }); + + // ----------------------------------------------------------------------- + // 6. Register /cost command for on-demand cost report + // ----------------------------------------------------------------------- + api.registerCommand({ + name: "cost", + description: "Show current cost summary (today, session, per-model breakdown)", + acceptsArgs: true, + requireAuth: true, + handler: (ctx) => { + // Trigger a scan before reporting to get fresh data + scanSessionFiles(api.logger as any); + + const day = todayKey(); + const dayData = dailyCosts[day]; + + const lines: string[] = []; + lines.push("--- Cost Report ---"); + lines.push(""); + + if (dayData) { + lines.push( + `Today (${day}): ${formatUsd(dayData.totalCost)} | ${formatTokens(dayData.totalTokens)} tokens | ${dayData.interactions} API calls` + ); + lines.push(""); + lines.push("By model:"); + const sortedModels = Object.entries(dayData.byModel).sort( + ([, a], [, b]) => b.cost - a.cost + ); + for (const [model, data] of sortedModels) { + const pct = + dayData.totalCost > 0 + ? ((data.cost / dayData.totalCost) * 100).toFixed(0) + : "0"; + lines.push( + ` ${model}: ${formatUsd(data.cost)} (${pct}%) | ${formatTokens(data.tokens)} tok | ${data.count} calls` + ); + } + } else { + lines.push(`Today (${day}): No usage recorded yet.`); + } + + lines.push(""); + lines.push( + `Since gateway start: ${formatUsd(globalAccumulator.totalCost)} | ${formatTokens(globalAccumulator.totalTokens)} tokens | ${globalAccumulator.interactions} calls` + ); + lines.push( + `Data source: ${diagnosticEventsReceived > 0 ? "diagnostic events (real-time)" : "session transcript scan (60s interval)"}` + ); + + const last7 = []; + for (let i = 0; i < 7; i++) { + const d = new Date(); + d.setDate(d.getDate() - i); + const key = d.toLocaleDateString("en-CA", { + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); + const data = dailyCosts[key]; + if (data) { + last7.push({ date: key, ...data }); + } + } + + if (last7.length > 1) { + lines.push(""); + lines.push("Last 7 days:"); + let weekTotal = 0; + for (const d of last7) { + lines.push( + ` ${d.date}: ${formatUsd(d.totalCost)} | ${d.interactions} calls` + ); + weekTotal += d.totalCost; + } + lines.push(` Total: ${formatUsd(weekTotal)}`); + } + + return { text: lines.join("\n") }; + }, + }); + + // ----------------------------------------------------------------------- + // 7. Register HTTP API endpoint for cost data + // ----------------------------------------------------------------------- + api.registerHttpRoute({ + path: "/cost-tracker/api/summary", + auth: "gateway", + handler: async (req: any, res: any): Promise => { + scanSessionFiles(api.logger as any); + + const day = todayKey(); + const dayData = dailyCosts[day]; + + const response = { + today: dayData ?? { + totalCost: 0, + totalTokens: 0, + interactions: 0, + byModel: {}, + }, + gatewaySession: { + totalCost: globalAccumulator.totalCost, + totalTokens: globalAccumulator.totalTokens, + interactions: globalAccumulator.interactions, + startedAt: globalAccumulator.startedAt, + }, + lastInteraction: lastInteraction + ? { + model: lastInteraction.model, + costUsd: lastInteraction.costUsd, + totalTokens: lastInteraction.totalTokens, + timestamp: lastInteraction.timestamp, + } + : null, + daily: dailyCosts, + dataSource: diagnosticEventsReceived > 0 ? "diagnostic-events" : "transcript-scan", + }; + + res.setHeader("Content-Type", "application/json"); + res.setHeader("Cache-Control", "no-cache"); + res.end(JSON.stringify(response)); + }, + }); + + api.registerHttpRoute({ + path: "/cost-tracker/api/today", + auth: "gateway", + handler: async (req: any, res: any): Promise => { + scanSessionFiles(api.logger as any); + const day = todayKey(); + const dayData = dailyCosts[day] ?? { + totalCost: 0, + totalTokens: 0, + interactions: 0, + byModel: {}, + }; + + res.setHeader("Content-Type", "application/json"); + res.setHeader("Cache-Control", "no-cache"); + res.end(JSON.stringify({ date: day, ...dayData })); + }, + }); + + api.logger.info( + "Cost tracker plugin registered: /cost command, globalThis message transform, transcript scanner (60s), HTTP API at /cost-tracker/api/*" + ); + }, +}; + +export default plugin; diff --git a/bates-core/plugins/cost-tracker/openclaw.plugin.json b/bates-core/plugins/cost-tracker/openclaw.plugin.json new file mode 100644 index 0000000..e6f420c --- /dev/null +++ b/bates-core/plugins/cost-tracker/openclaw.plugin.json @@ -0,0 +1,10 @@ +{ + "id": "cost-tracker", + "name": "Cost Tracker", + "description": "Tracks per-interaction and daily API costs, appends cost footer to every Bates response", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/bates-core/plugins/dashboard/API.md b/bates-core/plugins/dashboard/API.md new file mode 100644 index 0000000..07cbe6d --- /dev/null +++ b/bates-core/plugins/dashboard/API.md @@ -0,0 +1,157 @@ +# Dashboard Settings API Contract + +## Authentication + +All mutation endpoints require a Bearer token matching `gateway.auth.token` in `openclaw.json`. If no token is configured, authentication is not enforced. + +``` +Authorization: Bearer +``` + +## Config Backup & Rollback + +Every mutation to `openclaw.json` automatically creates a timestamped backup at: +``` +~/.openclaw/.config-backups/openclaw.json..bak +``` + +Last 5 backups are retained. To rollback: +```bash +# List available backups +ls ~/.openclaw/.config-backups/ + +# Restore a backup +cp ~/.openclaw/.config-backups/openclaw.json..bak ~/.openclaw/openclaw.json + +# Restart gateway to apply +systemctl --user restart openclaw-gateway +``` + +## Mutation Endpoints + +### POST /dashboard/api/agents/create + +Create a new agent with directory structure and config entry. + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `id` | string | yes | 2-30 chars, lowercase `[a-z][a-z0-9-]`, not "main" | +| `name` | string | yes | 1-50 chars | +| `role` | string | no | Free text (used in SOUL.md template) | +| `layer` | number | no | Must be 1, 2, or 3 | +| `model` | string | no | Must be in ALLOWED_MODELS set (see below) | +| `soul` | string | no | Max 50KB. Full SOUL.md content | + +**Writes to:** `openclaw.json` (agents.list), filesystem (agent directory + SOUL.md) +**Returns:** `{ success: true, restart_required: true }` + +### POST /dashboard/api/agents/update-model + +Change an agent's primary model. + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `id` | string | yes | Must exist in agents.list | +| `model` | string | yes | Must be in ALLOWED_MODELS set | + +**Writes to:** `openclaw.json` (agents.list[].model) +**Returns:** `{ success: true, restart_required: true }` + +### POST /dashboard/api/agents/update-layer + +Change an agent's organizational layer in SOUL.md. + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `id` | string | yes | Agent directory must exist | +| `layer` | number | yes | Must be 1, 2, or 3 | + +**Writes to:** Agent's `SOUL.md` file (updates `**Layer:**` field) +**Returns:** `{ success: true }` + +### POST /dashboard/api/agents/update-soul + +Overwrite an agent's full SOUL.md content. + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `id` | string | yes | Agent directory must exist, path traversal guard | +| `content` | string | yes | Max 50KB | + +**Writes to:** Agent's `SOUL.md` file +**Returns:** `{ success: true }` + +### POST /dashboard/api/agents/delete + +Archive an agent (moves directory to `.archived/`, removes from config). + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `id` | string | yes | 2-30 chars, lowercase, not "main", path traversal guard | + +**Writes to:** `openclaw.json` (removes from agents.list), filesystem (renames to `.archived/{id}-{timestamp}`) +**Returns:** `{ success: true, restart_required: true }` + +### POST /dashboard/api/agents/upload-avatar + +Upload an agent avatar image. + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `id` | string (form field) | yes | Must pass agent ID validation | +| `avatar` | file (form field) | yes | Max 2MB, extensions: png/jpg/jpeg/gif/webp/svg | + +**Writes to:** `dashboard/static/assets/agent-{id}.{ext}` +**Returns:** `{ success: true, path: "/dashboard/assets/agent-{id}.{ext}" }` + +### POST /dashboard/api/settings/m365-safety + +Toggle M365 safety enforcement level. + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `enforcement` | string | yes | Must be exactly `"active"` or `"OVERRIDE_ALL_SAFETY"` | + +**Writes to:** `openclaw.json` (plugins.entries["m365-safety"].config.enforcement) +**Returns:** `{ success: true, enforcement, note, restart_required: true }` + +### POST /dashboard/api/settings/whitelist + +Add or remove entries from M365 safety whitelist. + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `action` | string | yes | `"add"` or `"remove"` | +| `section` | string | yes | Must exist in whitelist YAML (e.g., "email", "calendar", "onedrive") | +| `field` | string | yes | Must exist in section (e.g., "allowed_domains", "allowed_addresses") | +| `value` | string | yes | Max 254 chars. Domain fields validated against domain regex. Email fields validated against email regex. | + +**Writes to:** `~/.openclaw/m365-safety/whitelist.yaml` +**Returns:** `{ success: true, whitelist: }` + +## Allowed Models + +The following model strings are accepted by create and update-model endpoints: + +``` +anthropic/claude-opus-4-6, anthropic/claude-sonnet-4-6, anthropic/claude-haiku-4-5 +claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5 +openai-codex/gpt-5.4, openai-codex/gpt-5.2 +opus-4-6, sonnet-4-6, haiku-4-5, gpt-5.4, gpt-5.2 +``` + +To add new models, update the `ALLOWED_MODELS` set in `index.ts`. + +## Read-Only Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /dashboard/api/settings` | Current config overview | +| `GET /dashboard/api/settings/whitelist` | Current whitelist YAML | +| `GET /dashboard/api/agents` | Agent list with status | +| `GET /dashboard/api/agents/:id` | Single agent details | +| `GET /dashboard/api/projects` | Project list | +| `GET /dashboard/api/tasks` | Aggregated Planner/To Do tasks | +| `GET /dashboard/api/cron` | Cron job status | +| `GET /dashboard/api/health` | System health | +| `GET /dashboard/api/delegations` | Active delegation tracking | diff --git a/bates-core/plugins/dashboard/data/planner-config.json b/bates-core/plugins/dashboard/data/planner-config.json new file mode 100644 index 0000000..33b7c7c --- /dev/null +++ b/bates-core/plugins/dashboard/data/planner-config.json @@ -0,0 +1,49 @@ +{ + "plans": { + "fdesk": { + "planId": "TxgS0K-c6kCX-D_UnGQs0ZcACG7R", + "name": "fDesk Roadmap", + "source": "planner" + }, + "escola": { + "planId": "HXpYhx5p5EWodt0e_KE0OZcAC8ze", + "name": "Escola Caravela", + "source": "planner" + }, + "bates": { + "planId": "2tFt9cqe0EWlMA30VKnIxZcAGjjW", + "name": "Bates Operations", + "source": "planner" + }, + "private": { + "source": "todo", + "name": "Private (To Do)", + "todoListId": "AAMkADI5MDU0OThkLTM2ZjItNDA1YS05MDY1LTZlOTA2ZmNjMjEzNwAuAAAAAAAEGmEdFnYlRpGm5ddo9XdyAQBNUrDGIj66TIi6yxmhxkIcAAABNTsiAAA=" + }, + "quick": { + "source": "todo", + "name": "Quick Tasks", + "todoListId": "AQMkADI5MDU0OThkLTM2ZjItNDA1YS05MDY1LTZlOTA2ZmNjADIxMzcALgAAAwQaYR0WdiVGkabl12j1d3IBAE1SsMYiPrpMiLrLGaHGQhwAAAKLAwAAAA==" + }, + "agent-tasks": { + "source": "todo", + "name": "Agent Tasks", + "todoListId": "AAMkADI5MDU0OThkLTM2ZjItNDA1YS05MDY1LTZlOTA2ZmNjMjEzNwAuAAAAAAAEGmEdFnYlRpGm5ddo9XdyAQBNUrDGIj66TIi6yxmhxkIcAAAVLHLVAAA=" + }, + "fdesk-tech": { + "planId": "NULtlrJu2Uuu2b5KMM2bBJcAHAuO", + "name": "fDesk Tech", + "source": "planner" + }, + "synapse-layer": { + "planId": "O9odh8lXnUWGVrDi9EX3wJcAHggV", + "name": "SynapseLayer", + "source": "planner" + }, + "loan-os": { + "planId": "cEWugHBoDUOXNq8RcsbE-pcAE45_", + "name": "Loan-OS", + "source": "planner" + } + } +} \ No newline at end of file diff --git a/bates-core/plugins/dashboard/data/projects.json b/bates-core/plugins/dashboard/data/projects.json new file mode 100644 index 0000000..13b764d --- /dev/null +++ b/bates-core/plugins/dashboard/data/projects.json @@ -0,0 +1,54 @@ +{ + "projects": [ + { + "id": "fdesk", + "name": "fDesk", + "icon": "🏦", + "desc": "Luxembourg securitization platform, 500M+ bond placements", + "agent": "conrad", + "agentName": "Conrad", + "accent": "#1F4E8C", + "planUrl": "https://tasks.office.com/vernot.com/Home/PlanViews/TxgS0K-c6kCX-D_UnGQs0ZcACG7R" + }, + { + "id": "synapse", + "name": "SynapseLayer", + "icon": "⚡", + "desc": "Deterministic AI infrastructure, 200K lines", + "agent": "soren", + "agentName": "Soren", + "accent": "#F08C2E", + "planUrl": "https://tasks.office.com/vernot.com/Home/PlanViews/1j6QK6mwEEOydK_Ht7QpI5cAAORg" + }, + { + "id": "escola", + "name": "Escola Caravela", + "icon": "🏫", + "desc": "Trilingual school, Oeiras", + "agent": "amara", + "agentName": "Amara", + "accent": "#14B8A6", + "planUrl": "https://tasks.office.com/vernot.com/Home/PlanViews/HXpYhx5p5EWodt0e_KE0OZcAC8ze" + }, + { + "id": "bates", + "name": "Bates", + "icon": "🐧", + "desc": "AI operations platform — agent orchestration & rollout", + "agent": "dash", + "agentName": "Dash", + "accent": "#8B5CF6", + "planUrl": "https://tasks.office.com/vernot.com/Home/PlanViews/2tFt9cqe0EWlMA30VKnIxZcAGjjW" + }, + { + "id": "private", + "name": "Private", + "icon": "🏠", + "desc": "Personal & family affairs", + "agent": "jules", + "agentName": "Jules", + "accent": "#22C55E", + "planUrl": "https://to-do.office.com/tasks" + } + ] +} diff --git a/bates-core/plugins/dashboard/index.ts b/bates-core/plugins/dashboard/index.ts new file mode 100644 index 0000000..99f9e8b --- /dev/null +++ b/bates-core/plugins/dashboard/index.ts @@ -0,0 +1,1657 @@ +import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, openSync, readSync, closeSync, mkdirSync, unlinkSync, renameSync, copyFileSync } from "fs"; +import { execSync, execFileSync } from "child_process"; +import { join, resolve, extname, dirname } from "path"; +import { fileURLToPath } from "url"; +import { homedir } from "os"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)); +const STATIC_DIR = join(PLUGIN_DIR, "static"); +const WORKSPACE = "/home/openclaw/.openclaw/workspace"; + +const HEALTH_FILE = join(WORKSPACE, "observations/health.json"); +const OBSERVATIONS_DIR = join(WORKSPACE, "observations"); +const CRON_FILE = "/home/openclaw/.openclaw/cron/jobs.json"; +const DATA_DIR = join(PLUGIN_DIR, "data"); +const DELEGATIONS_FILE = join(DATA_DIR, "delegations.json"); +const DELEGATION_RETENTION_DAYS = 7; + +function humanizeCron(expr: string): string { + const parts = expr.trim().split(/\s+/); + if (parts.length < 5) return expr; + const [min, hour, dom, mon, dow] = parts; + const DAYS: Record = { "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed", "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun" }; + + function fmtHour(h: string, m: string): string { + const hh = parseInt(h), mm = parseInt(m); + const suffix = hh >= 12 ? "PM" : "AM"; + const h12 = hh === 0 ? 12 : hh > 12 ? hh - 12 : hh; + return mm === 0 ? `${h12}${suffix}` : `${h12}:${String(mm).padStart(2, "0")}${suffix}`; + } + + function fmtHours(hourField: string, minField: string): string { + if (hourField.includes(",")) { + return hourField.split(",").map(h => fmtHour(h.trim(), minField)).join(", "); + } + return fmtHour(hourField, minField); + } + + // Every day at specific time(s) + if (dom === "*" && mon === "*" && dow === "*") { + if (hour === "*" && min === "*") return "Every minute"; + if (hour === "*") return `Every hour at :${min.padStart(2, "0")}`; + return `Daily at ${fmtHours(hour, min)}`; + } + + // Specific days of week + if (dom === "*" && mon === "*" && dow !== "*") { + const dayList = dow.split(",").map(d => DAYS[d.trim()] || d).join(", "); + if (dow === "1-5" || dow === "1,2,3,4,5") { + return `Weekdays at ${fmtHours(hour, min)}`; + } + return `${dayList} at ${fmtHours(hour, min)}`; + } + + return expr; +} + +type DelegationEntry = { + id: string; + name: string; + promptPath: string; + logPath: string; + description: string; + status: "running" | "completed" | "failed"; + pid?: number; + startedAt: number; + completedAt?: number; + durationMs?: number; + exitCode?: number; + logTail?: string; +}; + +function loadDelegations(): DelegationEntry[] { + try { + if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); + if (!existsSync(DELEGATIONS_FILE)) return []; + const data = JSON.parse(readFileSync(DELEGATIONS_FILE, "utf-8")); + const all: DelegationEntry[] = Array.isArray(data.delegations) ? data.delegations : []; + const cutoff = Date.now() - DELEGATION_RETENTION_DAYS * 86400000; + return all.filter(d => d.startedAt > cutoff || d.status === "running"); + } catch { + return []; + } +} + +function saveDelegations(delegations: DelegationEntry[]): void { + try { + if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); + writeFileSync(DELEGATIONS_FILE, JSON.stringify({ delegations }, null, 2), "utf-8"); + } catch (e) { + console.error("Failed to save delegations:", e); + } +} + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +}; + +function recentFiles(dir: string, base: string, out: any[] = [], depth = 0): any[] { + if (depth > 4) return out; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fp = join(dir, entry.name); + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + if (entry.isDirectory()) { + recentFiles(fp, base, out, depth + 1); + } else { + try { + const s = statSync(fp); + out.push({ path: fp.replace(base + "/", ""), name: entry.name, size: s.size, modified: s.mtime.toISOString(), modifiedMs: s.mtimeMs }); + } catch {} + } + } + } catch {} + return out; +} + +/** Send a JSON response with no-cache headers */ +function jsonResponse(res: any, data: any): void { + res.setHeader("Content-Type", "application/json"); + res.setHeader("Cache-Control", "no-cache"); + res.end(JSON.stringify(data)); +} + +/** Collect POST body from a request */ +function collectBody(req: any): Promise { + return new Promise((resolve) => { + let body = ""; + req.on("data", (chunk: Buffer) => { body += chunk.toString(); }); + req.on("end", () => resolve(body)); + }); +} + +// --------------------------------------------------------------------------- +// Input validation constants & helpers +// --------------------------------------------------------------------------- + +/** Valid agent ID: lowercase alphanumeric + hyphens, 2-30 chars */ +const AGENT_ID_RE = /^[a-z][a-z0-9-]{1,29}$/; + +/** Known valid model strings (provider/model format) */ +const ALLOWED_MODELS = new Set([ + // Anthropic + "anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6", "anthropic/claude-haiku-4-5", + "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5", + // OpenAI Codex + "openai-codex/gpt-5.4", "openai-codex/gpt-5.2", + // Shorthand (used in existing configs) + "opus-4-6", "sonnet-4-6", "haiku-4-5", "gpt-5.4", "gpt-5.2", +]); + +/** Valid agent layers */ +const VALID_LAYERS = new Set([1, 2, 3]); + +/** Max avatar upload size: 2 MB */ +const MAX_AVATAR_BYTES = 2 * 1024 * 1024; + +/** Max SOUL.md content length: 50 KB */ +const MAX_SOUL_LENGTH = 50 * 1024; + +/** Email domain pattern */ +const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; + +/** Email address pattern (basic) */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Max whitelist entry length */ +const MAX_WHITELIST_VALUE_LENGTH = 254; + +/** Whitelist fields that accept email addresses vs domains */ +const DOMAIN_FIELDS = new Set(["allowed_domains", "blocked_domains"]); +const EMAIL_FIELDS = new Set(["allowed_addresses", "blocked_addresses", "allowed_senders", "blocked_senders"]); + +function validateAgentId(id: string): string | null { + if (!id) return "id is required"; + if (typeof id !== "string") return "id must be a string"; + if (!AGENT_ID_RE.test(id)) return "id must be 2-30 chars, lowercase alphanumeric + hyphens, starting with a letter"; + if (id === "main") return "cannot use reserved id 'main'"; + return null; +} + +function validateModel(model: string): string | null { + if (!model) return "model is required"; + if (typeof model !== "string") return "model must be a string"; + if (!ALLOWED_MODELS.has(model)) return `model '${model}' is not in the allowed list. Valid: ${[...ALLOWED_MODELS].join(", ")}`; + return null; +} + +function validateWhitelistValue(field: string, value: string): string | null { + if (typeof value !== "string" || !value.trim()) return "value must be a non-empty string"; + if (value.length > MAX_WHITELIST_VALUE_LENGTH) return `value exceeds max length (${MAX_WHITELIST_VALUE_LENGTH})`; + if (DOMAIN_FIELDS.has(field) && !DOMAIN_RE.test(value)) return `'${value}' is not a valid domain`; + if (EMAIL_FIELDS.has(field) && !EMAIL_RE.test(value)) return `'${value}' is not a valid email address`; + return null; +} + +/** Back up openclaw.json before mutation (keeps last 5 backups) */ +function backupConfig(): void { + const src = "/home/openclaw/.openclaw/openclaw.json"; + const backupDir = "/home/openclaw/.openclaw/.config-backups"; + try { + mkdirSync(backupDir, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + copyFileSync(src, join(backupDir, `openclaw.json.${ts}.bak`)); + // Prune old backups, keep last 5 + const backups = readdirSync(backupDir) + .filter(f => f.startsWith("openclaw.json.") && f.endsWith(".bak")) + .sort() + .reverse(); + for (const old of backups.slice(5)) { + try { unlinkSync(join(backupDir, old)); } catch {} + } + } catch {} +} + +/** + * Minimal YAML parser for the whitelist config. + * Handles: top-level sections, string/number/boolean values, and arrays of scalars. + * Preserves structure but strips comments on write. + */ +function parseSimpleYaml(text: string): Record { + const result: Record = {}; + let currentSection = ""; + let currentKey = ""; + + for (const rawLine of text.split("\n")) { + const line = rawLine.replace(/#.*$/, "").trimEnd(); // strip comments + if (!line.trim()) continue; + + // Top-level section: "email:" with no indent + const sectionMatch = line.match(/^(\w[\w_]*):\s*$/); + if (sectionMatch) { + currentSection = sectionMatch[1]; + if (!result[currentSection]) result[currentSection] = {}; + currentKey = ""; + continue; + } + + // Key-value in section: " key: value" + const kvMatch = line.match(/^ (\w[\w_]*):\s*(.*)$/); + if (kvMatch && currentSection) { + const [, key, rawVal] = kvMatch; + currentKey = key; + const val = rawVal.trim(); + if (val === "" || val === "[]") { + result[currentSection][key] = []; + } else if (val === "true") { + result[currentSection][key] = true; + } else if (val === "false") { + result[currentSection][key] = false; + } else if (/^\d+$/.test(val)) { + result[currentSection][key] = parseInt(val, 10); + } else { + result[currentSection][key] = val; + } + continue; + } + + // Array item: " - value" + const arrMatch = line.match(/^ - (.+)$/); + if (arrMatch && currentSection && currentKey) { + const arr = result[currentSection][currentKey]; + if (Array.isArray(arr)) { + arr.push(arrMatch[1].trim()); + } + } + } + + return result; +} + +function serializeSimpleYaml(data: Record): string { + const lines: string[] = []; + for (const [section, obj] of Object.entries(data)) { + if (typeof obj !== "object" || obj === null) continue; + lines.push(`${section}:`); + for (const [key, val] of Object.entries(obj as Record)) { + if (Array.isArray(val)) { + if (val.length === 0) { + lines.push(` ${key}: []`); + } else { + lines.push(` ${key}:`); + for (const item of val) { + lines.push(` - ${item}`); + } + } + } else if (typeof val === "boolean") { + lines.push(` ${key}: ${val}`); + } else if (typeof val === "number") { + lines.push(` ${key}: ${val}`); + } else { + lines.push(` ${key}: ${val}`); + } + } + lines.push(""); + } + return lines.join("\n") + "\n"; +} + +const plugin = { + id: "dashboard", + name: "Command Center Dashboard", + description: "Glassmorphism HUD dashboard for OpenClaw observability", + configSchema: emptyPluginConfigSchema(), + + register(api: OpenClawPluginApi) { + const authToken = (api as any).config?.gateway?.auth?.token || ""; + + /** Verify Bearer token. Returns true if authorized, false if 401 was sent. */ + function requireAuth(req: any, res: any): boolean { + const bearer = (req.headers?.authorization || "").replace(/^Bearer\s+/i, "").trim(); + if (authToken && bearer === authToken) return true; + res.writeHead(401); + res.end(JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } })); + return false; + } + + /** Register a dashboard API route with auth: "plugin" + manual Bearer token check */ + function apiRoute(path: string, handler: (req: any, res: any) => Promise) { + api.registerHttpRoute({ + path, + auth: "plugin", + handler: async (req: any, res: any) => { + if (!requireAuth(req, res)) return; + await handler(req, res); + }, + }); + } + + // ----------------------------------------------------------------------- + // Fixed-path API routes via registerHttpRoute + // ----------------------------------------------------------------------- + + const PROJECTS_FILE = join(DATA_DIR, "projects.json"); + const OPENCLAW_CONFIG = "/home/openclaw/.openclaw/openclaw.json"; + const AGENTS_DIR = "/home/openclaw/.openclaw/agents"; + + function loadProjects(): any[] { + try { + if (!existsSync(PROJECTS_FILE)) return []; + return JSON.parse(readFileSync(PROJECTS_FILE, "utf-8")).projects || []; + } catch { return []; } + } + + function saveProjects(projects: any[]): void { + writeFileSync(PROJECTS_FILE, JSON.stringify({ projects }, null, 2)); + } + + function loadConfig(): any { + try { return JSON.parse(readFileSync(OPENCLAW_CONFIG, "utf-8")); } catch { return {}; } + } + + function saveConfig(config: any): void { + backupConfig(); + writeFileSync(OPENCLAW_CONFIG, JSON.stringify(config, null, 2)); + } + + // API: projects CRUD + apiRoute("/dashboard/api/projects", async (req: any, res: any): Promise => { + const method = (req.method || "GET").toUpperCase(); + if (method === "GET") { + jsonResponse(res, { projects: loadProjects() }); + return; + } + if (method === "POST") { + try { + const body = await collectBody(req); + const project = JSON.parse(body); + if (!project.id || !project.name) { + res.writeHead(400); + res.end(JSON.stringify({ error: "id and name required" })); + return; + } + const projects = loadProjects(); + if (projects.find((p: any) => p.id === project.id)) { + res.writeHead(409); + res.end(JSON.stringify({ error: "Project already exists" })); + return; + } + projects.push(project); + saveProjects(projects); + jsonResponse(res, { success: true, project }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + return; + } + res.writeHead(405); res.end("Method not allowed"); + }); + + apiRoute("/dashboard/api/projects/update", async (req: any, res: any): Promise => { + try { + const body = await collectBody(req); + const { id, ...updates } = JSON.parse(body); + if (!id) { res.writeHead(400); res.end(JSON.stringify({ error: "id required" })); return; } + const projects = loadProjects(); + const idx = projects.findIndex((p: any) => p.id === id); + if (idx < 0) { res.writeHead(404); res.end(JSON.stringify({ error: "Not found" })); return; } + Object.assign(projects[idx], updates); + saveProjects(projects); + jsonResponse(res, { success: true, project: projects[idx] }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + apiRoute("/dashboard/api/projects/delete", async (req: any, res: any): Promise => { + try { + const body = await collectBody(req); + const { id } = JSON.parse(body); + if (!id) { res.writeHead(400); res.end(JSON.stringify({ error: "id required" })); return; } + const projects = loadProjects().filter((p: any) => p.id !== id); + saveProjects(projects); + jsonResponse(res, { success: true }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // API: agent management + apiRoute("/dashboard/api/agents/create", async (req: any, res: any): Promise => { + try { + const body = await collectBody(req); + const { id, name, role, layer, model, soul } = JSON.parse(body); + if (!name || typeof name !== "string" || name.length > 50) { res.writeHead(400); res.end(JSON.stringify({ error: "name required (max 50 chars)" })); return; } + const idErr = validateAgentId(id); + if (idErr) { res.writeHead(400); res.end(JSON.stringify({ error: idErr })); return; } + if (model) { + const modelErr = validateModel(model); + if (modelErr) { res.writeHead(400); res.end(JSON.stringify({ error: modelErr })); return; } + } + if (layer !== undefined && !VALID_LAYERS.has(Number(layer))) { res.writeHead(400); res.end(JSON.stringify({ error: "layer must be 1, 2, or 3" })); return; } + if (soul && soul.length > MAX_SOUL_LENGTH) { res.writeHead(400); res.end(JSON.stringify({ error: `SOUL.md too large (max ${MAX_SOUL_LENGTH / 1024}KB)` })); return; } + + const agentDir = join(AGENTS_DIR, id); + if (existsSync(agentDir)) { res.writeHead(409); res.end(JSON.stringify({ error: "Agent directory already exists" })); return; } + + // Create agent directory structure + mkdirSync(agentDir, { recursive: true }); + for (const sub of ["sessions", "memory", "inbox", "outbox"]) { + mkdirSync(join(agentDir, sub), { recursive: true }); + } + + // Write SOUL.md + const soulContent = soul || `# ${name}\n\n**Role:** ${role || "Agent"}\n**Layer:** ${layer || 3}\n\nYou are ${name}, a specialized agent.\n`; + writeFileSync(join(agentDir, "SOUL.md"), soulContent); + + // Add to openclaw.json agents.list + const config = loadConfig(); + if (!config.agents) config.agents = {}; + if (!config.agents.list) config.agents.list = []; + const existing = config.agents.list.find((a: any) => a.id === id); + if (!existing) { + const entry: any = { id, name, workspace: `/home/openclaw/.openclaw/agents/${id}` }; + if (model) entry.model = model; + config.agents.list.push(entry); + saveConfig(config); + } + + api.logger.info(`dashboard: agent created: ${id} (model: ${model || "default"}, layer: ${layer || 3})`); + jsonResponse(res, { success: true, restart_required: true }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // Avatar upload (multipart form data - simple boundary parse) + apiRoute("/dashboard/api/agents/upload-avatar", async (req: any, res: any): Promise => { + try { + const contentType = req.headers["content-type"] || ""; + const boundaryMatch = contentType.match(/boundary=(.+)/); + if (!boundaryMatch) { res.writeHead(400); res.end(JSON.stringify({ error: "No boundary" })); return; } + + const raw = await new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks))); + }); + + const boundary = "--" + boundaryMatch[1].trim(); + const rawStr = raw.toString("latin1"); + const parts = rawStr.split(boundary); + + let agentId = ""; + let avatarData: Buffer | null = null; + let avatarExt = "png"; + + for (const part of parts) { + if (part.includes('name="id"')) { + const val = part.split("\r\n\r\n")[1]; + if (val) agentId = val.trim().replace(/\r\n--$/, "").trim(); + } + if (part.includes('name="avatar"')) { + const fnMatch = part.match(/filename="([^"]+)"/); + if (fnMatch) { + const ext = fnMatch[1].split(".").pop()?.toLowerCase(); + if (ext && ["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) avatarExt = ext; + } + const headerEnd = part.indexOf("\r\n\r\n"); + if (headerEnd >= 0) { + const bodyStr = part.slice(headerEnd + 4); + // Remove trailing boundary marker + const cleaned = bodyStr.replace(/\r\n--$/, "").replace(/\r\n$/, ""); + avatarData = Buffer.from(cleaned, "latin1"); + } + } + } + + if (!agentId || !avatarData) { res.writeHead(400); res.end(JSON.stringify({ error: "Missing id or avatar" })); return; } + const avatarIdErr = validateAgentId(agentId); + if (avatarIdErr) { res.writeHead(400); res.end(JSON.stringify({ error: avatarIdErr })); return; } + if (avatarData.length > MAX_AVATAR_BYTES) { res.writeHead(400); res.end(JSON.stringify({ error: `Avatar too large (max ${MAX_AVATAR_BYTES / 1024 / 1024}MB)` })); return; } + + const assetsDir = join(STATIC_DIR, "assets"); + mkdirSync(assetsDir, { recursive: true }); + const avatarPath = join(assetsDir, `agent-${agentId}.${avatarExt}`); + writeFileSync(avatarPath, avatarData); + + jsonResponse(res, { success: true, path: `/dashboard/assets/agent-${agentId}.${avatarExt}` }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + apiRoute("/dashboard/api/agents/update-model", async (req: any, res: any): Promise => { + try { + const body = await collectBody(req); + const { id, model } = JSON.parse(body); + if (!id || !model) { res.writeHead(400); res.end(JSON.stringify({ error: "id and model required" })); return; } + const modelErr = validateModel(model); + if (modelErr) { res.writeHead(400); res.end(JSON.stringify({ error: modelErr })); return; } + + const config = loadConfig(); + const agent = (config.agents?.list || []).find((a: any) => a.id === id); + if (!agent) { res.writeHead(404); res.end(JSON.stringify({ error: "Agent not in config" })); return; } + const oldModel = agent.model; + agent.model = model; + saveConfig(config); + api.logger.info(`dashboard: agent ${id} model changed: ${oldModel} -> ${model}`); + jsonResponse(res, { success: true, restart_required: true }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + apiRoute("/dashboard/api/agents/update-soul", async (req: any, res: any): Promise => { + try { + const body = await collectBody(req); + const { id, content } = JSON.parse(body); + if (!id || content === undefined) { res.writeHead(400); res.end(JSON.stringify({ error: "id and content required" })); return; } + if (typeof content !== "string") { res.writeHead(400); res.end(JSON.stringify({ error: "content must be a string" })); return; } + if (content.length > MAX_SOUL_LENGTH) { res.writeHead(400); res.end(JSON.stringify({ error: `SOUL.md too large (max ${MAX_SOUL_LENGTH / 1024}KB)` })); return; } + + const agentDir = join(AGENTS_DIR, id); + if (!existsSync(agentDir)) { res.writeHead(404); res.end(JSON.stringify({ error: "Agent not found" })); return; } + // Verify the resolved path is still under AGENTS_DIR (path traversal guard) + if (!resolve(agentDir).startsWith(resolve(AGENTS_DIR))) { res.writeHead(400); res.end(JSON.stringify({ error: "Invalid agent id" })); return; } + writeFileSync(join(agentDir, "SOUL.md"), content); + api.logger.info(`dashboard: agent ${id} SOUL.md updated (${content.length} bytes)`); + jsonResponse(res, { success: true }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + apiRoute("/dashboard/api/agents/update-layer", async (req: any, res: any): Promise => { + try { + const body = await collectBody(req); + const { id, layer } = JSON.parse(body); + if (!id || !layer) { res.writeHead(400); res.end(JSON.stringify({ error: "id and layer required" })); return; } + if (!VALID_LAYERS.has(Number(layer))) { res.writeHead(400); res.end(JSON.stringify({ error: "layer must be 1, 2, or 3" })); return; } + + const soulPath = join(AGENTS_DIR, id, "SOUL.md"); + if (!existsSync(soulPath)) { res.writeHead(404); res.end(JSON.stringify({ error: "SOUL.md not found" })); return; } + let soul = readFileSync(soulPath, "utf-8"); + // Update or add **Layer:** field + if (soul.match(/\*\*Layer:\*\*\s*\d+/)) { + soul = soul.replace(/\*\*Layer:\*\*\s*\d+/, `**Layer:** ${layer}`); + } else { + // Add after **Role:** line, or at the top + const roleMatch = soul.match(/(\*\*Role:\*\*[^\n]*\n)/); + if (roleMatch) { + soul = soul.replace(roleMatch[0], roleMatch[0] + `**Layer:** ${layer}\n`); + } else { + soul = `**Layer:** ${layer}\n\n` + soul; + } + } + writeFileSync(soulPath, soul); + jsonResponse(res, { success: true }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + apiRoute("/dashboard/api/agents/delete", async (req: any, res: any): Promise => { + try { + const body = await collectBody(req); + const { id } = JSON.parse(body); + if (!id) { res.writeHead(400); res.end(JSON.stringify({ error: "id required" })); return; } + if (id === "main") { res.writeHead(403); res.end(JSON.stringify({ error: "Cannot delete main agent" })); return; } + const idErr = validateAgentId(id); + if (idErr) { res.writeHead(400); res.end(JSON.stringify({ error: idErr })); return; } + + // Remove from openclaw.json + const config = loadConfig(); + if (config.agents?.list) { + config.agents.list = config.agents.list.filter((a: any) => a.id !== id); + saveConfig(config); + } + + // Archive agent directory (renameSync instead of execSync to avoid shell injection) + const agentDir = join(AGENTS_DIR, id); + if (!resolve(agentDir).startsWith(resolve(AGENTS_DIR))) { res.writeHead(400); res.end(JSON.stringify({ error: "Invalid agent id" })); return; } + const archiveBase = join(AGENTS_DIR, ".archived"); + const archiveDir = join(archiveBase, id + "-" + Date.now()); + if (existsSync(agentDir)) { + mkdirSync(archiveBase, { recursive: true }); + renameSync(agentDir, archiveDir); + } + + api.logger.warn(`dashboard: agent deleted: ${id} (archived to ${archiveDir})`); + jsonResponse(res, { success: true, restart_required: true }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // API: tasks (aggregated from Planner + To Do) + apiRoute("/dashboard/api/tasks", async (req: any, res: any): Promise => { + try { + const configPath = join(PLUGIN_DIR, "data", "planner-config.json"); + const cachePath = join(PLUGIN_DIR, "data", "tasks-cache.json"); + const CACHE_TTL = 120000; // 2 min + + // Check cache + if (existsSync(cachePath)) { + try { + const cached = JSON.parse(readFileSync(cachePath, "utf-8")); + if (cached._ts && Date.now() - cached._ts < CACHE_TTL) { + jsonResponse(res, cached.data); + return; + } + } catch {} + } + + const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : { plans: {} }; + const result: Record = { tasks: [], byProject: {}, updated: new Date().toISOString() }; + + const priorityLabel = (p: number) => p <= 1 ? "urgent" : p <= 4 ? "important" : p <= 7 ? "medium" : "low"; + + for (const [key, plan] of Object.entries(config.plans) as [string, any][]) { + const projectTasks: any[] = []; + + if (plan.source === "planner" && plan.planId) { + try { + const raw = execSync(`mcporter call ms365-assistant list-plan-tasks plannerPlanId=${plan.planId} 2>&1`, { timeout: 30000, encoding: "utf-8" }); + const data = JSON.parse(raw); + for (const t of data.value || []) { + const task = { + id: t.id, + title: t.title, + status: t.percentComplete === 100 ? "completed" : t.percentComplete > 0 ? "in-progress" : "not-started", + completed: t.percentComplete === 100, + priority: priorityLabel(t.priority ?? 5), + priorityNum: t.priority ?? 5, + dueDate: t.dueDateTime ? t.dueDateTime.split("T")[0] : null, + startDate: t.startDateTime ? t.startDateTime.split("T")[0] : null, + created: t.createdDateTime, + percentComplete: t.percentComplete, + project: key, + planName: plan.name, + source: "Planner", + assignees: Object.keys(t.assignments || {}), + checklistTotal: t.checklistItemCount || 0, + checklistDone: (t.checklistItemCount || 0) - (t.activeChecklistItemCount || 0), + }; + projectTasks.push(task); + result.tasks.push(task); + } + } catch (e: any) { + projectTasks.push({ error: true, message: e.message, project: key }); + } + } else if (plan.source === "todo" && plan.todoListId) { + try { + const raw = execSync(`mcporter call ms365-assistant list-todo-tasks todoTaskListId="${plan.todoListId}" 2>&1`, { timeout: 30000, encoding: "utf-8" }); + const data = JSON.parse(raw); + for (const t of data.value || []) { + const task = { + id: t.id, + title: t.title, + status: t.status === "completed" ? "completed" : "not-started", + completed: t.status === "completed", + priority: t.importance === "high" ? "important" : t.importance === "low" ? "low" : "medium", + priorityNum: t.importance === "high" ? 3 : t.importance === "low" ? 9 : 5, + dueDate: t.dueDateTime?.dateTime ? t.dueDateTime.dateTime.split("T")[0] : null, + created: t.createdDateTime, + percentComplete: t.status === "completed" ? 100 : 0, + project: key, + planName: plan.name, + source: "To Do", + body: (t.body?.content || "").slice(0, 200), + }; + projectTasks.push(task); + result.tasks.push(task); + } + } catch (e: any) { + projectTasks.push({ error: true, message: e.message, project: key }); + } + } + + result.byProject[key] = { name: plan.name, tasks: projectTasks, count: projectTasks.filter(t => !t.error).length }; + } + + // Sort: incomplete first, then by priority, then by due date + result.tasks.sort((a: any, b: any) => { + if (a.completed !== b.completed) return a.completed ? 1 : -1; + if (a.priorityNum !== b.priorityNum) return (a.priorityNum ?? 5) - (b.priorityNum ?? 5); + if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate); + if (a.dueDate) return -1; + if (b.dueDate) return 1; + return 0; + }); + + // Cache + try { + writeFileSync(cachePath, JSON.stringify({ _ts: Date.now(), data: result })); + } catch {} + + jsonResponse(res, result); + } catch (e: any) { + jsonResponse(res, { error: e.message, tasks: [], byProject: {} }); + } + }); + + // API: complete a task + apiRoute("/dashboard/api/tasks/complete", async (req: any, res: any): Promise => { + if (req.method !== "POST") { res.writeHead(405); res.end("Method Not Allowed"); return; } + const body = await collectBody(req); + try { + const p = JSON.parse(body); + const { taskId, source, project } = p; + if (!taskId) { jsonResponse(res, { error: "taskId required" }); return; } + const graphApi = "/home/openclaw/.openclaw/scripts/graph-api.sh"; + let output: string; + if (source === "To Do") { + const configPath = join(PLUGIN_DIR, "data", "planner-config.json"); + const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : { plans: {} }; + const plan = config.plans?.[project]; + const listId = plan?.todoListId; + if (!listId) { jsonResponse(res, { error: "No todoListId found for project: " + project }); return; } + output = execSync(`${graphApi} PATCH "/me/todo/lists/${listId}/tasks/${taskId}" '{"status":"completed"}' 2>&1`, { timeout: 30000, encoding: "utf-8" }); + } else { + // Planner: get etag from Graph API, then PATCH + const getRaw = execSync(`${graphApi} GET "/planner/tasks/${taskId}" 2>&1`, { timeout: 15000, encoding: "utf-8" }); + let etag = ""; + try { const td = JSON.parse(getRaw); etag = td["@odata.etag"] || ""; } catch {} + if (!etag) { jsonResponse(res, { error: "Could not retrieve etag", raw: getRaw.slice(0, 300) }); return; } + output = execSync(`${graphApi} PATCH "/planner/tasks/${taskId}" '{"percentComplete":100}' '${etag}' 2>&1`, { timeout: 30000, encoding: "utf-8" }); + } + // Invalidate cache + const cachePath = join(PLUGIN_DIR, "data", "tasks-cache.json"); + try { if (existsSync(cachePath)) unlinkSync(cachePath); } catch {} + jsonResponse(res, { success: true, output: (output || "").trim().slice(0, 500) }); + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + }); + + // API: Discover available Planner plans and To Do lists + apiRoute("/dashboard/api/tasks/providers", async (req: any, res: any): Promise => { + try { + const plans: any[] = []; + const todoLists: any[] = []; + + // Discover Planner tasks assigned to user (no "list plans" API via MCP — use Graph directly) + try { + const raw = execSync('mcporter call ms365-assistant list-planner-tasks top=1 2>&1', { timeout: 30000, encoding: "utf-8" }); + const data = JSON.parse(raw); + // Extract unique plan IDs from assigned tasks + const seenPlans = new Set(); + for (const t of data.value || []) { + if (t.planId && !seenPlans.has(t.planId)) { + seenPlans.add(t.planId); + plans.push({ id: t.planId, title: t.planId }); + } + } + // Enrich with plan details + for (const p of plans) { + try { + const planRaw = execSync(`mcporter call ms365-assistant get-planner-plan plannerPlanId="${p.id}" 2>&1`, { timeout: 15000, encoding: "utf-8" }); + const planData = JSON.parse(planRaw); + if (planData.title) p.title = planData.title; + } catch {} + } + } catch (e: any) { plans.push({ error: e.message }); } + + // Discover To Do lists + try { + const raw = execSync('mcporter call ms365-assistant list-todo-task-lists 2>&1', { timeout: 30000, encoding: "utf-8" }); + const data = JSON.parse(raw); + for (const l of data.value || []) { + todoLists.push({ id: l.id, displayName: l.displayName, isOwner: l.isOwner, wellknownListName: l.wellknownListName }); + } + } catch (e: any) { todoLists.push({ error: e.message }); } + + // Current connected plans + const configPath = join(PLUGIN_DIR, "data", "planner-config.json"); + const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : { plans: {} }; + + jsonResponse(res, { plans, todoLists, connected: config.plans }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // API: Connect/disconnect a task provider + apiRoute("/dashboard/api/tasks/connect", async (req: any, res: any): Promise => { + if (req.method !== "POST") { res.writeHead(405); res.end("Method Not Allowed"); return; } + try { + const body = await collectBody(req); + const { action, key, source, planId, todoListId, name } = JSON.parse(body); + const configPath = join(PLUGIN_DIR, "data", "planner-config.json"); + const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf-8")) : { plans: {} }; + + if (action === "add") { + if (!key || !name) { jsonResponse(res, { error: "key and name required" }); return; } + if (source === "planner" && planId) { + config.plans[key] = { planId, name, source: "planner" }; + } else if (source === "todo" && todoListId) { + config.plans[key] = { todoListId, name, source: "todo" }; + } else { jsonResponse(res, { error: "Invalid source or missing ID" }); return; } + } else if (action === "remove") { + if (!key) { jsonResponse(res, { error: "key required" }); return; } + delete config.plans[key]; + } else { jsonResponse(res, { error: "action must be 'add' or 'remove'" }); return; } + + writeFileSync(configPath, JSON.stringify(config, null, 2)); + // Invalidate cache + const cachePath = join(PLUGIN_DIR, "data", "tasks-cache.json"); + try { if (existsSync(cachePath)) unlinkSync(cachePath); } catch {} + jsonResponse(res, { success: true, connected: config.plans }); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // API: health + apiRoute("/dashboard/api/health", async (req: any, res: any): Promise => { + try { jsonResponse(res, JSON.parse(readFileSync(HEALTH_FILE, "utf-8"))); } + catch { jsonResponse(res, { error: "health.json not found" }); } + }); + + // API: recent files + apiRoute("/dashboard/api/files", async (req: any, res: any): Promise => { + try { + const files = recentFiles(WORKSPACE, WORKSPACE); + files.sort((a, b) => b.modifiedMs - a.modifiedMs); + jsonResponse(res, files.slice(0, 30)); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // API: observations + apiRoute("/dashboard/api/observations", async (req: any, res: any): Promise => { + try { + const result: Record = {}; + for (const f of readdirSync(OBSERVATIONS_DIR)) { + if (f.endsWith(".md") || f.endsWith(".json")) { + try { result[f] = readFileSync(join(OBSERVATIONS_DIR, f), "utf-8"); } catch {} + } + } + jsonResponse(res, result); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // API: standups — reads session-continuity digests for each deputy + apiRoute("/dashboard/api/standups", async (req: any, res: any): Promise => { + try { + const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); + const requestedDate = url.searchParams.get("date") || new Date().toISOString().slice(0, 10); + const standupDir = "/home/openclaw/.openclaw/shared/standups"; + const standups: any[] = []; + + // Primary source: compiled standup markdown files + const standupFile = join(standupDir, `${requestedDate}.md`); + if (existsSync(standupFile)) { + const content = readFileSync(standupFile, "utf-8"); + const mtime = statSync(standupFile).mtime.toISOString(); + // Parse sections: ## AGENT_NAME + const sections = content.split(/^## /m).filter(s => s.trim()); + for (const section of sections) { + const lines = section.split("\n"); + const header = lines[0].trim(); + // Skip non-agent sections (Summary, "Agents Reporting", etc.) + if (header.toLowerCase().startsWith("agents") || header.toLowerCase().startsWith("summary") || header.toLowerCase().startsWith("key")) continue; + const agentName = header.replace(/\*\*/g, "").trim(); + if (!agentName || agentName.includes(":")) continue; + const body = lines.slice(1).filter(l => l.trim()).join("\n").trim(); + if (body) { + standups.push({ + agent: agentName.toLowerCase(), + name: agentName, + role: "", + message: body.slice(0, 500), + timestamp: mtime, + }); + } + } + } + + // If no compiled standup, fall back to session-continuity digests + if (standups.length === 0) { + const digestDir = "/home/openclaw/.openclaw/extensions/session-continuity/data/digests"; + if (existsSync(digestDir)) { + for (const f of readdirSync(digestDir)) { + if (!f.endsWith(".json")) continue; + try { + const raw = JSON.parse(readFileSync(join(digestDir, f), "utf-8")); + const agentId = f.replace(".json", ""); + const lastInteractions = raw.lastInteractions || []; + const recentTask = lastInteractions[0]?.summary || raw.activeContext || ""; + if (recentTask) { + standups.push({ + agent: agentId, + role: raw.role || "", + message: recentTask, + timestamp: raw.updatedAt || raw.timestamp || null, + }); + } + } catch {} + } + } + } + + // List available standup dates for archive + const dates: string[] = []; + if (existsSync(standupDir)) { + for (const f of readdirSync(standupDir)) { + if (f.match(/^\d{4}-\d{2}-\d{2}\.md$/)) { + dates.push(f.replace(".md", "")); + } + } + dates.sort().reverse(); + } + + jsonResponse(res, { standups, date: requestedDate, dates: dates.slice(0, 30) }); + } catch (e: any) { + jsonResponse(res, { standups: [], error: e.message }); + } + }); + + // API: sessions — enriched sub-agent status from JSONL transcripts + apiRoute("/dashboard/api/sessions", async (req: any, res: any): Promise => { + try { + const agentsDir = "/home/openclaw/.openclaw/agents"; + const allSessions: any[] = []; + + for (const agentName of readdirSync(agentsDir)) { + const sessionsFile = join(agentsDir, agentName, "sessions", "sessions.json"); + if (!existsSync(sessionsFile)) continue; + const registry = JSON.parse(readFileSync(sessionsFile, "utf-8")); + + for (const [key, meta] of Object.entries(registry) as [string, any][]) { + if (!meta.spawnedBy) continue; + + const sessionId = meta.sessionId; + const jsonlPath = join(agentsDir, agentName, "sessions", `${sessionId}.jsonl`); + let status = "unknown"; + let toolCalls = 0; + let assistantTurns = 0; + let lastActivity = ""; + let lastToolName = ""; + let stopReason = ""; + let elapsedMs = 0; + let taskSnippet = ""; + let fileSizeBytes = 0; + + if (existsSync(jsonlPath)) { + try { + const stat = statSync(jsonlPath); + fileSizeBytes = stat.size; + lastActivity = stat.mtime.toISOString(); + elapsedMs = Date.now() - stat.mtimeMs; + + // Read last 8KB for status detection + const fd = openSync(jsonlPath, "r"); + const tailSize = Math.min(8192, stat.size); + const buf = Buffer.alloc(tailSize); + readSync(fd, buf, 0, tailSize, Math.max(0, stat.size - tailSize)); + closeSync(fd); + + const tailStr = buf.toString("utf-8"); + const lines = tailStr.split("\n").filter(Boolean); + + // Parse from the tail to find last assistant message + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + const msg = entry.message || {}; + if (msg.role === "assistant" && msg.stopReason) { + stopReason = msg.stopReason; + break; + } + } catch {} + } + + // Read first 4KB for task prompt + const headSize = Math.min(4096, stat.size); + const headBuf = Buffer.alloc(headSize); + const fd2 = openSync(jsonlPath, "r"); + readSync(fd2, headBuf, 0, headSize, 0); + closeSync(fd2); + + const headLines = headBuf.toString("utf-8").split("\n").filter(Boolean); + for (const line of headLines) { + try { + const entry = JSON.parse(line); + const msg = entry.message || {}; + if (msg.role === "user") { + const textContent = (msg.content || []).find((c: any) => c.type === "text"); + if (textContent) { + taskSnippet = textContent.text?.slice(0, 200) || ""; + break; + } + } + } catch {} + } + + // Count tool calls from the full file + const fullContent = readFileSync(jsonlPath, "utf-8"); + const allLines = fullContent.split("\n").filter(Boolean); + for (const line of allLines) { + try { + const entry = JSON.parse(line); + const msg = entry.message || {}; + if (msg.role === "assistant") { + assistantTurns++; + for (const c of msg.content || []) { + if (c.type === "toolCall" || c.type === "tool_use") { + toolCalls++; + lastToolName = c.name || c.toolName || ""; + } + } + } + } catch {} + } + + // Determine status + if (stopReason === "stop" || stopReason === "end_turn") { + status = "completed"; + } else if (elapsedMs < 300000) { // 5 min + status = "running"; + } else { + status = "idle"; + } + } catch {} + } + + allSessions.push({ + key, + agentId: agentName, + sessionId, + label: meta.label || "", + spawnedBy: meta.spawnedBy, + model: meta.modelOverride || meta.model || "", + channel: meta.channel || meta.lastChannel || "", + updatedAt: meta.updatedAt, + status, + toolCalls, + assistantTurns, + lastToolName, + stopReason, + elapsedMs, + lastActivity, + taskSnippet, + fileSizeBytes, + }); + } + } + + allSessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + jsonResponse(res, allSessions); + } catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // API: integrations health (live mcporter check) + apiRoute("/dashboard/api/integrations/health", async (req: any, res: any): Promise => { + try { + const output = execSync("mcporter list 2>&1", { timeout: 60000, encoding: "utf-8" }); + const servers: any[] = []; + const lines = output.split("\n"); + for (const line of lines) { + const m = line.match(/^- (\S+)\s+\((\d+)\s+tools?,\s*([\d.]+)s\)/); + if (m) { + servers.push({ name: m[1], tools: parseInt(m[2]), responseTime: parseFloat(m[3]), healthy: true }); + } + const mFail = line.match(/^- (\S+)\s+.*\b(FAIL|ERROR|timeout)\b/i); + if (mFail && !servers.find(s => s.name === mFail[1])) { + servers.push({ name: mFail[1], tools: 0, responseTime: 0, healthy: false }); + } + } + jsonResponse(res, { servers, raw: output.trim() }); + } catch (e: any) { + jsonResponse(res, { servers: [], error: e.message }); + } + }); + + // API: agents list (for fleet data) + apiRoute("/dashboard/api/agents", async (req: any, res: any): Promise => { + try { + const agentsDir = "/home/openclaw/.openclaw/agents"; + const agents: any[] = []; + + // Load agent roster from openclaw.json + const openclawConfigPath = "/home/openclaw/.openclaw/openclaw.json"; + let agentRoster: any[] = []; + try { + const ocConfig = JSON.parse(readFileSync(openclawConfigPath, "utf-8")); + agentRoster = ocConfig?.agents?.list || []; + } catch {} + const rosterMap: Record = {}; + for (const a of agentRoster) { rosterMap[a.id] = a; } + + // Load cron jobs to get heartbeat intervals + let cronJobs: any[] = []; + try { const raw = JSON.parse(readFileSync(CRON_FILE, "utf-8")); cronJobs = Array.isArray(raw) ? raw : (raw.jobs || []); } catch {} + const heartbeatMap: Record = {}; + for (const job of cronJobs) { + const jname = job.name || job.id || ""; + if (jname.endsWith("-heartbeat") && job.schedule) { + const agentName = jname.replace(/-heartbeat$/, ""); + const s = job.schedule; + if (s.kind === "every" && s.everyMs) { + const mins = s.everyMs / 60000; + heartbeatMap[agentName] = mins >= 60 ? `Every ${mins/60|0}h` : `Every ${mins|0}m`; + } else if (s.kind === "cron" && s.expr) { + heartbeatMap[agentName] = humanizeCron(s.expr); + } else { + heartbeatMap[agentName] = "Configured"; + } + } + } + + // Determine recent session activity per agent from session files + const recentActivityMap: Record = {}; + for (const agentName of readdirSync(agentsDir)) { + const sessionsFile = join(agentsDir, agentName, "sessions", "sessions.json"); + if (!existsSync(sessionsFile)) continue; + try { + const registry = JSON.parse(readFileSync(sessionsFile, "utf-8")); + let latestMs = 0; + let activeCount = 0; + for (const [, meta] of Object.entries(registry) as [string, any][]) { + const updAt = meta.updatedAt || 0; + if (updAt > latestMs) latestMs = updAt; + if (Date.now() - updAt < 300000) activeCount++; + } + if (latestMs > 0) { + recentActivityMap[agentName] = { + lastActivity: new Date(latestMs).toISOString(), + lastActivityMs: latestMs, + activeSessions: activeCount, + }; + } + } catch {} + } + + const agentIds = readdirSync(agentsDir).filter(d => { + try { return statSync(join(agentsDir, d)).isDirectory() && rosterMap[d]; } catch { return false; } + }); + for (const id of agentIds) { + const soulPath = join(agentsDir, id, "SOUL.md"); + let name = id, role = "", model = "", layer = ""; + if (existsSync(soulPath)) { + const soul = readFileSync(soulPath, "utf-8"); + const nameMatch = soul.match(/^#\s+(.+)/m); + if (nameMatch) name = nameMatch[1].trim(); + const roleMatch = soul.match(/\*\*Role:\*\*\s*(.+)/i); + if (roleMatch) role = roleMatch[1].trim(); + const modelMatch = soul.match(/\*\*Model:\*\*\s*(.+)/i); + if (modelMatch) model = modelMatch[1].trim(); + const layerMatch = soul.match(/\*\*Layer:\*\*\s*(\d+)/i); + if (layerMatch) layer = layerMatch[1]; + } + + const rosterEntry = rosterMap[id]; + // Config model is authoritative — SOUL.md may have stale names + if (rosterEntry?.model?.primary) { + model = rosterEntry.model.primary; + } + + const activity = recentActivityMap[id]; + const isInRoster = !!rosterEntry; + const hasActiveSessions = (activity?.activeSessions || 0) > 0; + const status = hasActiveSessions ? "active" : isInRoster ? "ready" : "inactive"; + + const heartbeat_interval = heartbeatMap[id] || "\u2014"; + const last_activity = activity?.lastActivity || null; + const last_activity_epoch = activity?.lastActivityMs ? activity.lastActivityMs / 1000 : null; + + agents.push({ + id, name, role, model, layer, + gatewayStatus: isInRoster ? "ready" : "no-config", + heartbeat_interval, + status, + last_activity, + last_activity_epoch, + active_sessions: activity?.activeSessions || 0, + inbox_count: 0, outbox_count: 0, + }); + } + + // Count inbox/outbox from filesystem + for (const agent of agents) { + try { + const inboxDir = join(agentsDir, agent.id, "inbox"); + if (existsSync(inboxDir)) agent.inbox_count = readdirSync(inboxDir).filter(f => f.endsWith(".md")).length; + } catch {} + try { + const outboxDir = join(agentsDir, agent.id, "outbox"); + if (existsSync(outboxDir)) agent.outbox_count = readdirSync(outboxDir).filter(f => f.endsWith(".md")).length; + } catch {} + } + + jsonResponse(res, { agents }); + } catch (e: any) { jsonResponse(res, { agents: [], error: e.message }); } + }); + + // API: crons + apiRoute("/dashboard/api/crons", async (req: any, res: any): Promise => { + try { jsonResponse(res, JSON.parse(readFileSync(CRON_FILE, "utf-8"))); } + catch (e: any) { jsonResponse(res, { error: e.message }); } + }); + + // API: delegation start + apiRoute("/dashboard/api/delegation/start", async (req: any, res: any): Promise => { + if (req.method !== "POST") { res.writeHead(405); res.end("Method Not Allowed"); return; } + const body = await collectBody(req); + try { + const p = JSON.parse(body); + const delegations = loadDelegations(); + delegations.push({ + id: p.id || `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + name: p.name || "unnamed", + promptPath: p.promptPath || "", + logPath: p.logPath || "", + description: p.description || "", + status: "running", + pid: p.pid, + startedAt: Date.now(), + }); + saveDelegations(delegations); + jsonResponse(res, { success: true, id: delegations[delegations.length - 1].id }); + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + }); + + // API: delegation complete + apiRoute("/dashboard/api/delegation/complete", async (req: any, res: any): Promise => { + if (req.method !== "POST") { res.writeHead(405); res.end("Method Not Allowed"); return; } + const body = await collectBody(req); + try { + const p = JSON.parse(body); + const delegations = loadDelegations(); + const entry = delegations.find(d => d.id === p.id); + if (!entry) { jsonResponse(res, { error: "Delegation not found" }); return; } + + entry.completedAt = Date.now(); + entry.durationMs = entry.completedAt - entry.startedAt; + entry.exitCode = p.exitCode ?? 0; + entry.logTail = (p.logTail || "").slice(0, 3000); + entry.status = entry.exitCode === 0 ? "completed" : "failed"; + saveDelegations(delegations); + + const durationSec = Math.round(entry.durationMs / 1000); + const icon = entry.status === "completed" ? "\u2713" : "\u2717"; + const summary = `${icon} Claude Code delegation "${entry.name}" ${entry.status} (${durationSec}s)\n\nLog tail:\n${entry.logTail}`; + try { + api.runtime.system.enqueueSystemEvent(summary, { + sessionKey: "agent:main:main", + contextKey: `delegation:${entry.id}`, + }); + } catch (e) { + console.error("Failed to enqueue system event:", e); + } + + jsonResponse(res, { success: true }); + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + }); + + // API: list delegations + apiRoute("/dashboard/api/delegations", async (req: any, res: any): Promise => { + try { + const delegations = loadDelegations(); + delegations.sort((a, b) => b.startedAt - a.startedAt); + jsonResponse(res, { delegations: delegations.slice(0, 50) }); + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + }); + + // API: cost tracker data + apiRoute("/dashboard/api/costs", async (req: any, res: any): Promise => { + try { + const costFile = join(homedir(), ".openclaw", "extensions", "cost-tracker", "data", "daily-costs.json"); + if (existsSync(costFile)) { + const raw = readFileSync(costFile, "utf-8"); + const data = JSON.parse(raw); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(data)); + } else { + jsonResponse(res, { error: "no-data" }); + } + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + }); + + // API: settings (read-only config summary + m365-safety state) + apiRoute("/dashboard/api/settings", async (_req: any, res: any): Promise => { + try { + const configPath = "/home/openclaw/.openclaw/openclaw.json"; + const config = JSON.parse(readFileSync(configPath, "utf-8")); + const agents = config.agents?.list || {}; + const cronJobs = existsSync(CRON_FILE) ? JSON.parse(readFileSync(CRON_FILE, "utf-8")) : {}; + const cronEntries = Object.values(cronJobs); + const m365Entry = config.plugins?.entries?.["m365-safety"] || {}; + const m365Enforcement = m365Entry.config?.enforcement || "active"; + jsonResponse(res, { + default_model: config.agents?.defaults?.model || "—", + model_fallbacks: config.agents?.defaults?.fallbackModels || [], + heartbeat_interval: config.agents?.defaults?.heartbeat?.interval || "—", + heartbeat_hours: config.agents?.defaults?.heartbeat?.activeHours || "—", + compaction_mode: config.context?.compaction?.mode || "—", + compaction_reserve_tokens: config.context?.compaction?.reserveTokens || "—", + compaction_max_history: config.context?.compaction?.maxHistory || "—", + num_agents: Object.keys(agents).length, + num_cron_jobs: cronEntries.length, + num_cron_enabled: cronEntries.filter((j: any) => j.enabled).length, + session_reset_mode: config.sessions?.resetMode || "—", + session_idle_minutes: config.sessions?.idleTimeoutMinutes || "—", + gateway_port: config.gateway?.port || 18789, + m365_safety: { + enforcement: m365Enforcement, + enabled: m365Entry.enabled !== false, + override_active: m365Enforcement === "OVERRIDE_ALL_SAFETY", + }, + }); + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + }); + + // API: toggle m365-safety enforcement (POST) + apiRoute("/dashboard/api/settings/m365-safety", async (req: any, res: any): Promise => { + if (req.method !== "POST") { res.writeHead(405); res.end("Method Not Allowed"); return; } + try { + const body = JSON.parse(await collectBody(req)); + const enforcement = body.enforcement; + if (enforcement !== "active" && enforcement !== "OVERRIDE_ALL_SAFETY") { + jsonResponse(res, { error: "Invalid value. Must be 'active' or 'OVERRIDE_ALL_SAFETY'." }); + return; + } + const configPath = "/home/openclaw/.openclaw/openclaw.json"; + const config = JSON.parse(readFileSync(configPath, "utf-8")); + if (!config.plugins) config.plugins = {}; + if (!config.plugins.entries) config.plugins.entries = {}; + if (!config.plugins.entries["m365-safety"]) config.plugins.entries["m365-safety"] = { enabled: true }; + if (!config.plugins.entries["m365-safety"].config) config.plugins.entries["m365-safety"].config = {}; + config.plugins.entries["m365-safety"].config.enforcement = enforcement; + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); + api.logger.warn( + `dashboard: M365 safety enforcement changed to "${enforcement}" via dashboard` + ); + jsonResponse(res, { + success: true, + enforcement, + note: enforcement === "OVERRIDE_ALL_SAFETY" + ? "ALL M365 SAFETY PROTECTION IS NOW DISABLED. Restart gateway to apply." + : "Safety protection restored. Restart gateway to apply.", + restart_required: true, + }); + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + }); + + // API: read whitelist YAML (GET) + apiRoute("/dashboard/api/settings/whitelist", async (req: any, res: any): Promise => { + const wlPath = "/home/openclaw/.openclaw/m365-safety/whitelist.yaml"; + if (req.method === "POST") { + // Mutate whitelist + try { + const body = JSON.parse(await collectBody(req)); + const { action, section, field, value } = body; + if (!action || !section || !field || value === undefined) { + jsonResponse(res, { error: "Missing action, section, field, or value" }); + return; + } + // Read YAML as lines and parse minimally + // We use a simple line-based approach to preserve comments + let content = ""; + try { content = readFileSync(wlPath, "utf-8"); } catch { + jsonResponse(res, { error: "Whitelist file not found" }); + return; + } + // Parse with a basic YAML parser (split into sections) + const yaml = parseSimpleYaml(content); + const sectionObj = yaml[section]; + if (!sectionObj || typeof sectionObj !== "object") { + jsonResponse(res, { error: `Unknown section: ${section}` }); + return; + } + let list = sectionObj[field]; + if (!Array.isArray(list)) list = []; + + if (action === "add") { + const valErr = validateWhitelistValue(field, value); + if (valErr) { jsonResponse(res, { error: valErr }); return; } + if (!list.includes(value)) { + list.push(value); + sectionObj[field] = list; + } + } else if (action === "remove") { + sectionObj[field] = list.filter((v: string) => v !== value); + } else { + jsonResponse(res, { error: `Unknown action: ${action}` }); + return; + } + yaml[section] = sectionObj; + writeFileSync(wlPath, serializeSimpleYaml(yaml), "utf-8"); + api.logger.info(`dashboard: whitelist ${action} ${section}.${field}: ${value}`); + jsonResponse(res, { success: true }); + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + return; + } + // GET: read and return as JSON + try { + const content = readFileSync(wlPath, "utf-8"); + const yaml = parseSimpleYaml(content); + jsonResponse(res, yaml); + } catch (e: any) { + jsonResponse(res, { error: e.message }); + } + }); + + // API: file content (workspace files) + apiRoute("/dashboard/api/file", async (req: any, res: any): Promise => { + const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); + const fpath = url.searchParams.get("path") || ""; + if (!fpath || fpath.includes("..")) { jsonResponse(res, { error: "invalid path" }); return; } + const full = join(WORKSPACE, fpath); + if (!full.startsWith(resolve(WORKSPACE))) { jsonResponse(res, { error: "outside workspace" }); return; } + try { + if (existsSync(full)) { + const content = readFileSync(full, "utf-8").slice(0, 10000); + jsonResponse(res, { content, path: fpath }); + } else { + jsonResponse(res, { content: null, error: "not found" }); + } + } catch (e: any) { jsonResponse(res, { content: null, error: e.message }); } + }); + + // ----------------------------------------------------------------------- + // Prefix-matched routes for parameterized APIs, static files, and avatars + // ----------------------------------------------------------------------- + + // Dashboard login page — no auth required, serves a simple form + // that stores the token in localStorage and redirects to /dashboard/ + api.registerHttpRoute({ + path: "/dashboard/login", + auth: "plugin", + handler: async (_req: any, res: any): Promise => { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(` + + +Bates Dashboard — Login + + + + +`); + }, + }); + + // Baby avatars (prefix match, loaded as in dashboard) + api.registerHttpRoute({ + path: "/baby-avatars", + auth: "plugin", + match: "prefix", + handler: async (req: any, res: any): Promise => { + const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); + const pathname = url.pathname; + const avatarDir = "/home/openclaw/.openclaw/assets/baby-avatars"; + const avatarName = pathname.replace("/baby-avatars/", "").replace(/[^a-z0-9_.]/gi, ""); + if (!avatarName || avatarName.includes("..")) { res.writeHead(403); res.end("Forbidden"); return; } + const avatarPath = join(avatarDir, avatarName); + if (!existsSync(avatarPath)) { res.writeHead(404); res.end("Not found"); return; } + res.setHeader("Content-Type", "image/png"); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.end(readFileSync(avatarPath)); + }, + }); + + // Dashboard catch-all: parameterized API routes + static file serving + // Uses auth: "gateway" — browser access requires ?token= in the initial URL, + // which gets stripped and injected into the HTML for subsequent API calls. + api.registerHttpRoute({ + path: "/dashboard", + auth: "plugin", + match: "prefix", + handler: async (req: any, res: any): Promise => { + const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); + const pathname = url.pathname; + + // API routes need auth, static files don't + if (pathname.startsWith("/dashboard/api/")) { + if (!requireAuth(req, res)) return; + } + + // API: agent soul (parameterized) + if (pathname.match(/^\/dashboard\/api\/agents\/[^/]+\/soul$/)) { + const agentId = pathname.split("/")[4]; + const agentsDir = "/home/openclaw/.openclaw/agents"; + const soulPath = join(agentsDir, agentId, "SOUL.md"); + try { + if (existsSync(soulPath)) { + jsonResponse(res, { content: readFileSync(soulPath, "utf-8") }); + } else { + jsonResponse(res, { content: null }); + } + } catch (e: any) { jsonResponse(res, { content: null, error: e.message }); } + return; + } + + // API: agent memory (parameterized) + if (pathname.match(/^\/dashboard\/api\/agents\/[^/]+\/memory$/)) { + const agentId = pathname.split("/")[4]; + const agentsDir = "/home/openclaw/.openclaw/agents"; + const date = url.searchParams.get("date") || new Date().toISOString().slice(0, 10); + const memPath = join(agentsDir, agentId, "memory", `${date}.md`); + try { + if (existsSync(memPath)) { + jsonResponse(res, { content: readFileSync(memPath, "utf-8") }); + } else { + const fallback = join(agentsDir, agentId, "MEMORY.md"); + if (existsSync(fallback)) { + const text = readFileSync(fallback, "utf-8"); + jsonResponse(res, { content: text.slice(-2000) }); + } else { + jsonResponse(res, { content: "" }); + } + } + } catch (e: any) { jsonResponse(res, { content: "", error: e.message }); } + return; + } + + // API: agent sessions (parameterized) + if (pathname.match(/^\/dashboard\/api\/agents\/[^/]+\/sessions$/)) { + const agentId = pathname.split("/")[4]; + const agentsDir = "/home/openclaw/.openclaw/agents"; + const sessionsFile = join(agentsDir, agentId, "sessions", "sessions.json"); + try { + if (existsSync(sessionsFile)) { + const registry = JSON.parse(readFileSync(sessionsFile, "utf-8")); + const sessions = Object.entries(registry).map(([key, meta]: [string, any]) => ({ + key, ...meta, + })); + sessions.sort((a: any, b: any) => (b.updatedAt || 0) - (a.updatedAt || 0)); + jsonResponse(res, { sessions: sessions.slice(0, 50) }); + } else { + jsonResponse(res, { sessions: [] }); + } + } catch (e: any) { + jsonResponse(res, { error: e.message, sessions: [] }); + } + return; + } + + // Static file serving for /dashboard/* + let filePath: string; + if (pathname === "/dashboard" || pathname === "/dashboard/") { + filePath = join(STATIC_DIR, "index.html"); + } else { + const relative = pathname.replace("/dashboard/", ""); + if (relative.includes("..")) { res.writeHead(403); res.end("Forbidden"); return; } + filePath = join(STATIC_DIR, relative); + } + + const resolved = resolve(filePath); + if (!resolved.startsWith(resolve(STATIC_DIR))) { res.writeHead(403); res.end("Forbidden"); return; } + if (!existsSync(resolved)) { res.writeHead(404); res.end("Not found"); return; } + + const ext = extname(resolved); + res.setHeader("Content-Type", MIME[ext] || "application/octet-stream"); + res.setHeader("Cache-Control", "no-cache"); + + const binaryExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".woff", ".woff2", ".ttf", ".eot"]); + if (binaryExts.has(ext)) { + res.end(readFileSync(resolved)); + } else { + let content = readFileSync(resolved, "utf-8"); + if (ext === ".html") { + content = content.replace(/\{\{AUTH_TOKEN\}\}/g, authToken); + } + res.end(content); + } + }, + }); + + api.logger.info("Dashboard plugin registered at /dashboard"); + }, +}; + +export default plugin; diff --git a/bates-core/plugins/dashboard/openclaw.plugin.json b/bates-core/plugins/dashboard/openclaw.plugin.json new file mode 100644 index 0000000..e446404 --- /dev/null +++ b/bates-core/plugins/dashboard/openclaw.plugin.json @@ -0,0 +1,10 @@ +{ + "id": "dashboard", + "name": "Command Center Dashboard", + "description": "Glassmorphism HUD dashboard for OpenClaw observability", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/bates-core/plugins/dashboard/package.json b/bates-core/plugins/dashboard/package.json new file mode 100644 index 0000000..314a0b9 --- /dev/null +++ b/bates-core/plugins/dashboard/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@types/node": "^25.2.3", + "typescript": "^5.9.3" + } +} diff --git a/bates-core/plugins/dashboard/static/assets/agent-avatar.png b/bates-core/plugins/dashboard/static/assets/agent-avatar.png new file mode 100644 index 0000000..de341c6 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-avatar.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_Dark.png b/bates-core/plugins/dashboard/static/assets/agent-baby_Dark.png new file mode 100644 index 0000000..743a04e Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_Dark.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_Ember.png b/bates-core/plugins/dashboard/static/assets/agent-baby_Ember.png new file mode 100644 index 0000000..cd7d684 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_Ember.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_Sage.png b/bates-core/plugins/dashboard/static/assets/agent-baby_Sage.png new file mode 100644 index 0000000..d76542c Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_Sage.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_aqua.png b/bates-core/plugins/dashboard/static/assets/agent-baby_aqua.png new file mode 100644 index 0000000..516fdac Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_aqua.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_bolt.png b/bates-core/plugins/dashboard/static/assets/agent-baby_bolt.png new file mode 100644 index 0000000..c742c0f Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_bolt.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_core.png b/bates-core/plugins/dashboard/static/assets/agent-baby_core.png new file mode 100644 index 0000000..674fbea Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_core.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_frost.png b/bates-core/plugins/dashboard/static/assets/agent-baby_frost.png new file mode 100644 index 0000000..a6b0297 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_frost.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_nova.png b/bates-core/plugins/dashboard/static/assets/agent-baby_nova.png new file mode 100644 index 0000000..32f77dd Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_nova.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_pixel.png b/bates-core/plugins/dashboard/static/assets/agent-baby_pixel.png new file mode 100644 index 0000000..8acbe99 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_pixel.png differ diff --git a/bates-core/plugins/dashboard/static/assets/agent-baby_sky.png b/bates-core/plugins/dashboard/static/assets/agent-baby_sky.png new file mode 100644 index 0000000..261a8e6 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/agent-baby_sky.png differ diff --git a/bates-core/plugins/dashboard/static/assets/app-icon-small.png b/bates-core/plugins/dashboard/static/assets/app-icon-small.png new file mode 100644 index 0000000..75648ad Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/app-icon-small.png differ diff --git a/bates-core/plugins/dashboard/static/assets/avatar-transparent.png b/bates-core/plugins/dashboard/static/assets/avatar-transparent.png new file mode 100644 index 0000000..e408024 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/avatar-transparent.png differ diff --git a/bates-core/plugins/dashboard/static/assets/bg.jpg b/bates-core/plugins/dashboard/static/assets/bg.jpg new file mode 100644 index 0000000..8396fb4 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/bg.jpg differ diff --git a/bates-core/plugins/dashboard/static/assets/bg.png b/bates-core/plugins/dashboard/static/assets/bg.png new file mode 100644 index 0000000..0a6424a Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/bg.png differ diff --git a/bates-core/plugins/dashboard/static/assets/bg2.png b/bates-core/plugins/dashboard/static/assets/bg2.png new file mode 100644 index 0000000..5b09963 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/bg2.png differ diff --git a/bates-core/plugins/dashboard/static/assets/design-ref.png b/bates-core/plugins/dashboard/static/assets/design-ref.png new file mode 100644 index 0000000..fadbfca Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/design-ref.png differ diff --git a/bates-core/plugins/dashboard/static/assets/horizontal-logo.png b/bates-core/plugins/dashboard/static/assets/horizontal-logo.png new file mode 100644 index 0000000..3a9be82 Binary files /dev/null and b/bates-core/plugins/dashboard/static/assets/horizontal-logo.png differ diff --git a/bates-core/plugins/dashboard/static/index.html b/bates-core/plugins/dashboard/static/index.html new file mode 100644 index 0000000..aa9fdd1 --- /dev/null +++ b/bates-core/plugins/dashboard/static/index.html @@ -0,0 +1,268 @@ + + + + + + Bates Command Center + + + + + + + + + + + + +
+ + +
+
+ Bates + BATES MISSION CONTROL +
+ +
+ --:-- + + + ... + + +
+
+ + +
+
+ + +
+
+
Agents
+
Unread
+
Tasks
+
Next Cron
+
+ +
+ 🔍 + +
+ + +
+
Loading projects...
+
+ +
+
+

My Tasks

+
Loading tasks…
+
+
+

Agent Activity

+
Loading…
+
+
+ +
+

Indexation Status

+
+
+ rk@vernot.com OneDrive + Status: Connected + Phase 1-4 complete +
+
+ rk@fdesk.tech OneDrive + Status: Connected + Via MCP reader +
+
+ bates@vernot.com OneDrive + Status: Connected + Workspace drafts +
+
+ Email accounts (4) + rk@vernot, rk@fdesk, cp-desk, hello@fdesk + Last sync: Feb 11 +
+
+ Search index + Phase 4/5 complete + Monitor: Active +
+
+
+ +
+
+

Recent Files

+
Loading…
+
+
+

Upcoming

+
Loading…
+
+
+ +
+

Community

+
+
+
+ + +
+
+ +
+ + +
+
+ + + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+ + +
+
+

Memory Feed

+
+
+
+ + +
+
+
+
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bates-core/plugins/dashboard/static/js/app.js b/bates-core/plugins/dashboard/static/js/app.js new file mode 100644 index 0000000..bffd2ce --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/app.js @@ -0,0 +1,522 @@ +/** + * Bates Command Center — App Controller v4 + * 5 tabs · persistent chat drawer · glassmorphism + */ +(function () { + const panels = {}; + let gateway = null; + let currentView = 'overview'; + + const VIEW_PANELS = { + overview: ['ceo', 'tasks', 'status', 'agents', 'files', 'crons', 'community'], + agents: ['agents'], + operations: ['crons', 'delegations', 'integrations', 'costs', 'settings'], + standup: ['standup'], + memory: ['memory'], + }; + + const DASH_API_BASE = ''; + + window.Dashboard = { + DASH_API: DASH_API_BASE, + registerPanel(id, mod) { panels[id] = mod; }, + getGateway() { return gateway; }, + + async fetchApi(ep) { + try { + const headers = {}; + const token = window.__GATEWAY_CONFIG?.token; + if (token) headers['Authorization'] = 'Bearer ' + token; + return await (await fetch(`/dashboard/api/${ep}`, { headers })).json(); + } + catch (e) { console.error(`API ${ep}:`, e); return null; } + }, + + // Compact task row for project detail modals (spreadsheet-dense) + renderTaskRowCompact(t) { + const done = t.completed; + const overdue = !done && t.dueDate && t.dueDate < new Date().toISOString().slice(0, 10); + const PRI_COLORS = { urgent: '#ff4757', important: '#ffa502', medium: '#00d4ff', low: '#747d8c' }; + const taskUrl = t.source === 'To Do' + ? `https://to-do.office.com/tasks/id/${t.id}/details` + : `https://tasks.office.com/vernot.com/Home/Task/${t.id}`; + return ` + + + ${Dashboard.esc(t.title || '—')} + ${t.dueDate || ''} + `; + }, + + // Shared task row renderer used by panel-tasks.js and project detail modals + renderTaskRow(t, opts) { + opts = opts || {}; + const done = t.completed; + const overdue = !done && t.dueDate && t.dueDate < new Date().toISOString().slice(0, 10); + const PRI_COLORS = { urgent: '#ff4757', important: '#ffa502', medium: '#00d4ff', low: '#747d8c' }; + const taskUrl = t.source === 'To Do' + ? `https://to-do.office.com/tasks/id/${t.id}/details` + : `https://tasks.office.com/vernot.com/Home/Task/${t.id}`; + return `
+ + +
+
${Dashboard.esc(t.title || '—')}
+
+ ${t.dueDate ? '📅 ' + t.dueDate : ''} + ${Dashboard.esc(t.planName || '')} + ${Dashboard.esc(t.source || '')} + ${t.checklistTotal ? `☑ ${t.checklistDone}/${t.checklistTotal}` : ''} + ${t.percentComplete > 0 && t.percentComplete < 100 ? `${t.percentComplete}%` : ''} +
+
+
`; + }, + + // Wire click and complete handlers on task rows within a container + wireTaskRows(container, onComplete) { + if (!container) return; + container.querySelectorAll('.task-row-clickable').forEach(el => { + el.style.cursor = 'pointer'; + el.addEventListener('click', (e) => { + e.stopPropagation(); + const url = el.dataset.url; + if (url) window.open(url, '_blank'); + }); + }); + container.querySelectorAll('.task-complete-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const row = btn.closest('.task-row-shared'); + if (!row || row.classList.contains('done')) return; + btn.disabled = true; + btn.textContent = '⏳'; + try { + const headers = { 'Content-Type': 'application/json' }; + const token = window.__GATEWAY_CONFIG?.token; + if (token) headers['Authorization'] = 'Bearer ' + token; + const resp = await fetch('/dashboard/api/tasks/complete', { + method: 'POST', headers, + body: JSON.stringify({ taskId: row.dataset.taskId, source: row.dataset.source, project: row.dataset.project }) + }); + const result = await resp.json(); + if (result.success) { + row.classList.add('done'); + btn.textContent = '✓'; + btn.style.background = 'var(--green)'; + btn.style.borderColor = 'var(--green)'; + btn.style.color = '#fff'; + if (onComplete) onComplete(); + } else { + btn.textContent = '✗'; + btn.style.color = 'var(--red)'; + setTimeout(() => { btn.textContent = '✓'; btn.style.color = ''; btn.disabled = false; }, 2000); + } + } catch { + btn.textContent = '✗'; + setTimeout(() => { btn.textContent = '✓'; btn.disabled = false; }, 2000); + } + }); + }); + }, + + timeAgo(d) { + if (!d) return 'never'; + const ms = Date.now() - new Date(d).getTime(); + if (ms < 0) { const a = -ms; return a < 60e3 ? `in ${(a/1e3)|0}s` : a < 36e5 ? `in ${(a/6e4)|0}m` : a < 864e5 ? `in ${(a/36e5)|0}h` : `in ${(a/864e5)|0}d`; } + return ms < 60e3 ? `${(ms/1e3)|0}s ago` : ms < 36e5 ? `${(ms/6e4)|0}m ago` : ms < 864e5 ? `${(ms/36e5)|0}h ago` : `${(ms/864e5)|0}d ago`; + }, + formatSize(b) { return b < 1024 ? b+'B' : b < 1048576 ? (b/1024).toFixed(1)+'KB' : (b/1048576).toFixed(1)+'MB'; }, + esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }, + }; + + // ─── Navigation ─── + function switchView(id) { + if (!VIEW_PANELS[id]) return; + currentView = id; + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + document.getElementById('view-' + id)?.classList.add('active'); + document.querySelectorAll('.nav-tab').forEach(n => n.classList.remove('active')); + document.querySelectorAll(`.nav-tab[data-view="${id}"]`).forEach(n => n.classList.add('active')); + for (const pid of VIEW_PANELS[id]) { + try { panels[pid]?.refresh?.(gateway); } catch (e) { console.error(`Refresh ${pid}:`, e); } + } + } + + // ─── Operations Sub-Nav ─── + function setupOpsNav() { + const nav = document.getElementById('ops-nav'); + if (!nav) return; + nav.addEventListener('click', (e) => { + const btn = e.target.closest('.ops-nav-btn'); + if (!btn) return; + const sectionId = btn.dataset.section; + if (!sectionId) return; + // Update active state + nav.querySelectorAll('.ops-nav-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + // Scroll to section and expand it + const section = document.getElementById(sectionId); + if (section) { + section.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const box = section.querySelector('.ops-box'); + if (box) box.classList.remove('collapsed'); + } + }); + } + + // ─── Chat Drawer ─── + function setupChatDrawer() { + const drawer = document.getElementById('chat-drawer'); + const toggle = document.getElementById('chat-toggle-btn'); + const close = document.getElementById('chat-drawer-close'); + if (!drawer || !toggle) return; + + function setOpen(open) { + drawer.classList.toggle('open', open); + toggle.classList.toggle('active', open); + localStorage.setItem('bates-chat-open', open ? '1' : '0'); + } + toggle.addEventListener('click', () => setOpen(!drawer.classList.contains('open'))); + close?.addEventListener('click', () => setOpen(false)); + + const saved = localStorage.getItem('bates-chat-open'); + setOpen(saved !== '0'); + } + + // ─── Clock ─── + function updateClock() { + const el = document.getElementById('clock'); + if (!el) return; + el.textContent = new Date().toLocaleTimeString('en-GB', { timeZone: 'Europe/Lisbon', hour: '2-digit', minute: '2-digit' }); + } + + // ─── Connection ─── + function updateConn(status) { + const dot = document.getElementById('conn-dot'); + const lbl = document.getElementById('conn-label'); + if (dot) dot.className = 'conn-dot ' + status; + if (lbl) lbl.textContent = status === 'connected' ? 'LIVE' : status.toUpperCase(); + } + + // ─── Refresh buttons ─── + function setupRefresh() { + document.querySelectorAll('.panel-refresh').forEach(btn => { + btn.addEventListener('click', () => { + const pid = (btn.dataset.action || '').replace('refresh-', ''); + try { panels[pid]?.refresh?.(gateway); } catch {} + }); + }); + } + + // ─── Overview metrics ─── + window._updateOverviewMetrics = function(d) { + if (!d) return; + const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; + if (d.activeAgents !== undefined) set('metric-agents-val', d.activeAgents); + if (d.emails !== undefined) set('metric-emails-val', d.emails); + if (d.tasks !== undefined) set('metric-tasks-val', d.tasks); + if (d.nextCron !== undefined) set('metric-cron-val', d.nextCron); + }; + + // ─── Agents summary hook ─── + const _origReg = window.Dashboard.registerPanel; + window.Dashboard.registerPanel = function(id, mod) { + if (id === 'agents') { + const oRefresh = mod.refresh, oInit = mod.init; + mod.refresh = async gw => { await oRefresh(gw); updateAgentsSummary(); }; + mod.init = async gw => { await oInit(gw); updateAgentsSummary(); }; + } + _origReg(id, mod); + }; + + function updateAgentsSummary() { + const el = document.getElementById('panel-agents-summary'); + if (!el) return; + const cards = document.querySelectorAll('#panel-agents .acard, #panel-agents .agent-card'); + if (!cards.length) { el.innerHTML = '
No agents online
'; return; } + let html = '
'; + let n = 0; + cards.forEach(c => { + if (n >= 6) return; + const name = c.querySelector('.aname, .agent-name'); + const role = c.querySelector('.arole, .agent-role'); + const dot = c.querySelector('.status-dot'); + if (!name) return; + html += `
+ + ${name.textContent} + ${role ? `${role.textContent}` : ''} +
`; + n++; + }); + if (cards.length > 6) html += `
View all ${cards.length} →
`; + html += '
'; + el.innerHTML = html; + } + + // ─── Rollout panel (standalone, not injected into project card) ─── + + // ─── Init ─── + async function init() { + updateClock(); + setInterval(updateClock, 1000); + + document.querySelectorAll('.nav-tab').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view))); + setupChatDrawer(); + setupOpsNav(); + setupRefresh(); + + const ov = document.getElementById('soul-modal-overlay'); + const cl = document.getElementById('soul-modal-close'); + if (ov) ov.addEventListener('click', e => { if (e.target === ov) ov.classList.remove('visible'); }); + if (cl) cl.addEventListener('click', () => ov.classList.remove('visible')); + + const config = window.__GATEWAY_CONFIG || {}; + gateway = new GatewayClient(config); + gateway.onStatusChange = updateConn; + updateConn('reconnecting'); + + for (const [id, p] of Object.entries(panels)) { + try { await p.init?.(gateway); } catch (e) { console.error(`Init ${id}:`, e); } + } + + gateway.connect().then(() => { + for (const pid of VIEW_PANELS[currentView]) { + try { panels[pid]?.refresh?.(gateway); } catch {} + } + // Refresh chat panel after auth is confirmed + if (panels.chat?.refresh) try { panels.chat.refresh(gateway); } catch {} + }).catch(e => { console.error('WS failed:', e); updateConn('disconnected'); }); + + // Load projects from API and render + await loadProjects(); + + setInterval(() => { + for (const pid of VIEW_PANELS[currentView]) { + try { panels[pid]?.refresh?.(gateway); } catch {} + } + }, 30000); + } + + // ─── Project Data (loaded from API) ─── + let PROJECT_DATA = {}; + window.PROJECT_DATA = PROJECT_DATA; + + async function loadProjects() { + try { + const data = await Dashboard.fetchApi('projects'); + if (data?.projects) { + PROJECT_DATA = {}; + for (const p of data.projects) PROJECT_DATA[p.id] = p; + window.PROJECT_DATA = PROJECT_DATA; + renderProjectBoxes(); + } + } catch (e) { console.error('Load projects:', e); } + } + + function renderProjectBoxes() { + const row = document.getElementById('projects-row'); + if (!row) return; + const projects = Object.values(PROJECT_DATA); + let h = ''; + for (const p of projects) { + h += `
+
${Dashboard.esc(p.icon || '📁')}${Dashboard.esc(p.name)}
+
Deputy: ${Dashboard.esc(p.agentName || p.agent || 'None')}
+
+
`; + } + h += `
+
+
+
Add Project
+
`; + row.innerHTML = h; + setupProjectBoxes(); + } + + function setupProjectBoxes() { + document.querySelectorAll('.project-box').forEach(box => { + if (box.id === 'add-project-btn') { + box.addEventListener('click', () => openProjectEditor()); + return; + } + const pid = box.dataset.project; + if (!pid || !PROJECT_DATA[pid]) return; + box.addEventListener('click', (e) => { + e.stopPropagation(); + openProjectDetail(pid); + }); + }); + } + + function openProjectEditor(existing) { + const ov = document.getElementById('soul-modal-overlay'); if (!ov) return; + const titleEl = document.getElementById('soul-modal-title'); + const bodyEl = document.getElementById('soul-modal-body'); + const isEdit = !!existing; + titleEl.textContent = isEdit ? 'Edit Project' : 'New Project'; + + bodyEl.innerHTML = ` +
+
+ + + + + + + + + + + + + + + + +
+ + ${isEdit ? '' : ''} + +
+
+
+
`; + ov.classList.add('visible'); + + document.getElementById('pf-cancel').addEventListener('click', () => ov.classList.remove('visible')); + + document.getElementById('pf-save').addEventListener('click', async () => { + const project = { + id: document.getElementById('pf-id').value.trim().toLowerCase().replace(/[^a-z0-9-]/g, ''), + name: document.getElementById('pf-name').value.trim(), + icon: document.getElementById('pf-icon').value.trim() || '📁', + desc: document.getElementById('pf-desc').value.trim(), + agent: document.getElementById('pf-agent').value.trim(), + agentName: document.getElementById('pf-agentname').value.trim(), + accent: document.getElementById('pf-accent').value, + planUrl: document.getElementById('pf-planurl').value.trim(), + }; + if (!project.id || !project.name) { document.getElementById('pf-msg').textContent = 'ID and Name are required'; return; } + const endpoint = isEdit ? 'projects/update' : 'projects'; + try { + const token = window.__GATEWAY_CONFIG?.token; + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = 'Bearer ' + token; + const resp = await fetch('/dashboard/api/' + endpoint, { + method: 'POST', headers, body: JSON.stringify(project) + }); + const result = await resp.json(); + if (result.success || result.project) { + ov.classList.remove('visible'); + await loadProjects(); + } else { + document.getElementById('pf-msg').textContent = result.error || 'Failed'; + } + } catch (e) { document.getElementById('pf-msg').textContent = 'Error: ' + e.message; } + }); + + if (isEdit) { + document.getElementById('pf-delete').addEventListener('click', async () => { + if (!confirm('Delete project "' + existing.name + '"? This only removes it from the dashboard.')) return; + try { + const token = window.__GATEWAY_CONFIG?.token; + const headers = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = 'Bearer ' + token; + await fetch('/dashboard/api/projects/delete', { + method: 'POST', headers, body: JSON.stringify({ id: existing.id }) + }); + ov.classList.remove('visible'); + await loadProjects(); + } catch {} + }); + } + } + + function openProjectDetail(pid) { + const p = PROJECT_DATA[pid]; + if (!p) return; + const ov = document.getElementById('soul-modal-overlay'); + if (!ov) return; + const titleEl = document.getElementById('soul-modal-title'); + const bodyEl = document.getElementById('soul-modal-body'); + titleEl.textContent = p.icon + ' ' + p.name; + bodyEl.innerHTML = ` +
+
+
${Dashboard.esc(p.desc)}
+ +
+ +
+
Planner Tasks
+
📋 Loading…
+
+
+
Recent Files
+
📁 Loading...
+
+
`; + ov.classList.add('visible'); + + // Load project tasks using shared task row component + (function loadProjectTasks() { + const tel = document.getElementById('project-detail-tasks-' + pid); + if (!tel) return; + + function renderProjectTaskRows(tasks) { + const incomplete = tasks.filter(t => !t.completed && !t.error); + const done = tasks.filter(t => t.completed); + if (!incomplete.length && !done.length) { tel.textContent = '📋 No tasks'; return; } + let h = ''; + for (const t of incomplete.slice(0, 20)) h += Dashboard.renderTaskRowCompact(t); + h += '
'; + if (done.length) h += `
✓ ${done.length} completed
`; + if (incomplete.length > 20) { const planLink = PROJECT_DATA[pid]?.planUrl; h += `+ ${incomplete.length - 20} more → Open in Planner`; } + tel.innerHTML = h; + Dashboard.wireTaskRows(tel); + } + + const pt = window._getProjectTasks?.(pid); + if (pt && pt.tasks?.length) { + renderProjectTaskRows(pt.tasks); + } else if (pt && pt.tasks?.length === 0) { + tel.textContent = '📋 No tasks in this plan'; + } else { + Dashboard.fetchApi('tasks').then(data => { + if (data?.byProject?.[pid]?.tasks) { + renderProjectTaskRows(data.byProject[pid].tasks); + } else { + tel.textContent = '📋 No plan configured'; + } + }).catch(() => { tel.textContent = '📋 Could not load tasks'; }); + } + })(); + + // Try to load filtered files + Dashboard.fetchApi('files').then(files => { + const el = document.getElementById('project-detail-files-' + pid); + if (!el) return; + const all = Array.isArray(files) ? files : []; + const kw = pid === 'synapse' ? 'synapse' : pid === 'escola' ? 'escola' : pid === 'fdesk' ? 'fdesk' : pid; + const filtered = all.filter(f => (f.path || '').toLowerCase().includes(kw)).slice(0, 5); + if (!filtered.length) { el.textContent = '📁 No recent files for this project'; return; } + el.innerHTML = filtered.map(f => `
${Dashboard.esc(f.name)} ${Dashboard.timeAgo(f.modified)}
`).join(''); + }).catch(() => { + const el = document.getElementById('project-detail-files-' + pid); + if (el) el.textContent = '📁 Could not load files'; + }); + } + + window._openProjectEditor = openProjectEditor; + + document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init(); +})(); diff --git a/bates-core/plugins/dashboard/static/js/gateway.js b/bates-core/plugins/dashboard/static/js/gateway.js new file mode 100644 index 0000000..b439cb2 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/gateway.js @@ -0,0 +1,685 @@ +/** + * OpenClaw Gateway WebSocket Client + * Protocol v3 — typed frames { type: "req"|"res"|"event" } + * Includes Ed25519 device auth for operator scopes. + */ + +// ─── Ed25519 (minimal, browser-only via noble-ed25519-style inline) ─── +// We use SubtleCrypto SHA-512 + a tiny Ed25519 sign implementation. +// For brevity we import the same device-identity approach as Control UI: +// generate keypair, store in localStorage, sign connect payload. + +const DEVICE_STORAGE_KEY = "openclaw-device-identity-v1"; +const DEVICE_AUTH_TOKEN_KEY = "openclaw.device.auth.v1"; + +// ─── Helpers ─── +function b64url(bytes) { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} +function b64urlDecode(str) { + const s = str.replace(/-/g, "+").replace(/_/g, "/"); + const padded = s + "=".repeat((4 - s.length % 4) % 4); + const bin = atob(padded); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} +function hexFromBytes(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join(""); +} +async function sha256Hex(bytes) { + const hash = await crypto.subtle.digest("SHA-256", bytes.buffer); + return hexFromBytes(new Uint8Array(hash)); +} + +// ─── Ed25519 via noble-ed25519 approach (reuse Control UI's stored keys) ─── +// We need to sign payloads. The Control UI stores keys as base64url-encoded +// Ed25519 seed (private) and public key. We'll use the Web Crypto Ed25519 API +// if available (Chrome 113+, Firefox 128+), or fall back to importing the +// existing noble-ed25519 implementation pattern. + +// Try native Ed25519 first (available in modern browsers) +async function ed25519Sign(privateKeyBytes, message) { + // Try native Web Crypto Ed25519 + try { + const key = await crypto.subtle.importKey( + "pkcs8", + ed25519SeedToPkcs8(privateKeyBytes), + { name: "Ed25519" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign("Ed25519", key, new TextEncoder().encode(message)); + return new Uint8Array(sig); + } catch (e) { + // Native Ed25519 not available, fall back to noble implementation + return ed25519SignNoble(privateKeyBytes, new TextEncoder().encode(message)); + } +} + +// Convert 32-byte Ed25519 seed to PKCS#8 format for Web Crypto +function ed25519SeedToPkcs8(seed) { + // PKCS#8 wrapper for Ed25519 private key (seed) + const prefix = new Uint8Array([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, + 0x04, 0x22, 0x04, 0x20 + ]); + const result = new Uint8Array(prefix.length + seed.length); + result.set(prefix); + result.set(seed, prefix.length); + return result.buffer; +} + +// Minimal noble-ed25519 sign (synchronous-style using SHA-512 from SubtleCrypto) +async function sha512(data) { + const hash = await crypto.subtle.digest("SHA-512", data instanceof Uint8Array ? data.buffer : data); + return new Uint8Array(hash); +} + +// We'll use a simplified approach: if native Ed25519 fails, we load the +// noble-ed25519 micro library dynamically. For now, store a minimal implementation. +// This is the same Ed25519 implementation used by Control UI (inlined). + +// ─── Modular arithmetic for Ed25519 ─── +const P = 2n ** 255n - 19n; +const N = 2n ** 252n + 27742317777372353535851937790883648493n; +const Gx = 15112221349535807912866137220509078750507884956996801397894129974371384098553n; +const Gy = 46316835694926478169428394003475163141307993866256225615783033890098355573289n; +const D_CONST = 37095705934669439343138083508754565189542113879843219016388785533085940283555n; + +function mod(a, m = P) { let r = a % m; return r >= 0n ? r : m + r; } +function modInv(a, m = P) { + let [old_r, r] = [mod(a, m), m]; + let [old_s, s] = [1n, 0n]; + while (r !== 0n) { + const q = old_r / r; + [old_r, r] = [r, old_r - q * r]; + [old_s, s] = [s, old_s - q * s]; + } + return mod(old_s, m); +} +function modN(a) { return mod(a, N); } + +class EdPoint { + constructor(X, Y, Z, T) { this.X = X; this.Y = Y; this.Z = Z; this.T = T; } + static ZERO = new EdPoint(0n, 1n, 1n, 0n); + static BASE = new EdPoint(Gx, Gy, 1n, mod(Gx * Gy)); + + add(other) { + const a = -1n; // Ed25519 a = -1 + const { X: X1, Y: Y1, Z: Z1, T: T1 } = this; + const { X: X2, Y: Y2, Z: Z2, T: T2 } = other; + const A = mod(X1 * X2); + const B = mod(Y1 * Y2); + const C = mod(T1 * D_CONST * T2); + const DD = mod(Z1 * Z2); + const E = mod((X1 + Y1) * (X2 + Y2) - A - B); + const F = mod(DD - C); + const G = mod(DD + C); + const H = mod(B - a * A); + return new EdPoint(mod(E * F), mod(G * H), mod(F * G), mod(E * H)); + } + + double() { + const a = -1n; + const { X, Y, Z } = this; + const A = mod(X * X); + const B = mod(Y * Y); + const C = mod(2n * mod(Z * Z)); + const D2 = mod(a * A); + const E = mod(mod((X + Y) * (X + Y)) - A - B); + const G = mod(D2 + B); + const F = mod(G - C); + const H = mod(D2 - B); + return new EdPoint(mod(E * F), mod(G * H), mod(F * G), mod(E * H)); + } + + multiply(scalar) { + let result = EdPoint.ZERO; + let base = this; + let s = scalar; + while (s > 0n) { + if (s & 1n) result = result.add(base); + base = base.double(); + s >>= 1n; + } + return result; + } + + toAffine() { + const inv = modInv(this.Z); + return { x: mod(this.X * inv), y: mod(this.Y * inv) }; + } + + toBytes() { + const { x, y } = this.toAffine(); + const bytes = numberToLEBytes(y, 32); + if (x & 1n) bytes[31] |= 0x80; + return bytes; + } +} + +function numberToLEBytes(n, len) { + const bytes = new Uint8Array(len); + let v = n; + for (let i = 0; i < len; i++) { bytes[i] = Number(v & 0xffn); v >>= 8n; } + return bytes; +} +function bytesToNumberLE(bytes) { + let n = 0n; + for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]); + return n; +} + +async function ed25519SignNoble(seed, message) { + // Hash seed to get (scalar, prefix) + const h = await sha512(seed); + const scalar_bytes = h.slice(0, 32); + scalar_bytes[0] &= 248; + scalar_bytes[31] &= 127; + scalar_bytes[31] |= 64; + const scalar = bytesToNumberLE(scalar_bytes); + const prefix = h.slice(32, 64); + + // Public key + const pubPoint = EdPoint.BASE.multiply(scalar); + const pubBytes = pubPoint.toBytes(); + + // r = SHA-512(prefix || message) mod N + const rHash = await sha512(concat(prefix, message)); + const r = modN(bytesToNumberLE(rHash)); + + // R = r * G + const R = EdPoint.BASE.multiply(r); + const RBytes = R.toBytes(); + + // S = (r + SHA-512(R || pubKey || message) * scalar) mod N + const kHash = await sha512(concat(RBytes, pubBytes, message)); + const k = modN(bytesToNumberLE(kHash)); + const S = modN(r + k * scalar); + const SBytes = numberToLEBytes(S, 32); + + // Signature = R || S + return concat(RBytes, SBytes); +} + +function concat(...arrays) { + const len = arrays.reduce((s, a) => s + a.length, 0); + const result = new Uint8Array(len); + let offset = 0; + for (const a of arrays) { result.set(a, offset); offset += a.length; } + return result; +} + +// ─── Device Identity Management ─── +async function getOrCreateDeviceIdentity() { + if (!crypto.subtle) return null; + try { + const stored = localStorage.getItem(DEVICE_STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed?.version === 1 && parsed.deviceId && parsed.publicKey && parsed.privateKey) { + // Verify deviceId matches publicKey + const computedId = await sha256Hex(b64urlDecode(parsed.publicKey)); + if (computedId !== parsed.deviceId) { + parsed.deviceId = computedId; + localStorage.setItem(DEVICE_STORAGE_KEY, JSON.stringify(parsed)); + } + return { deviceId: parsed.deviceId, publicKey: parsed.publicKey, privateKey: parsed.privateKey }; + } + } + } catch {} + + // Generate new keypair using our Ed25519 implementation + const seed = crypto.getRandomValues(new Uint8Array(32)); + const h = await sha512(seed); + const scalar_bytes = h.slice(0, 32); + scalar_bytes[0] &= 248; + scalar_bytes[31] &= 127; + scalar_bytes[31] |= 64; + const scalar = bytesToNumberLE(scalar_bytes); + const pubPoint = EdPoint.BASE.multiply(scalar); + const pubBytes = pubPoint.toBytes(); + const deviceId = await sha256Hex(pubBytes); + + const identity = { + version: 1, + deviceId, + publicKey: b64url(pubBytes), + privateKey: b64url(seed), + createdAtMs: Date.now() + }; + localStorage.setItem(DEVICE_STORAGE_KEY, JSON.stringify(identity)); + return { deviceId, publicKey: identity.publicKey, privateKey: identity.privateKey }; +} + +function getStoredDeviceToken(deviceId, role) { + try { + const stored = localStorage.getItem(DEVICE_AUTH_TOKEN_KEY); + if (!stored) return null; + const parsed = JSON.parse(stored); + if (!parsed || parsed.version !== 1 || parsed.deviceId !== deviceId) return null; + const entry = parsed.tokens[role.trim()]; + return entry?.token || null; + } catch { return null; } +} + +function storeDeviceToken(deviceId, role, token, scopes) { + const key = role.trim(); + let data = { version: 1, deviceId, tokens: {} }; + try { + const existing = JSON.parse(localStorage.getItem(DEVICE_AUTH_TOKEN_KEY)); + if (existing?.version === 1 && existing.deviceId === deviceId) { + data.tokens = { ...existing.tokens }; + } + } catch {} + data.tokens[key] = { token, role: key, scopes, updatedAtMs: Date.now() }; + localStorage.setItem(DEVICE_AUTH_TOKEN_KEY, JSON.stringify(data)); +} + +function clearDeviceToken(deviceId, role) { + try { + const existing = JSON.parse(localStorage.getItem(DEVICE_AUTH_TOKEN_KEY)); + if (!existing || existing.version !== 1 || existing.deviceId !== deviceId) return; + const tokens = { ...existing.tokens }; + delete tokens[role.trim()]; + localStorage.setItem(DEVICE_AUTH_TOKEN_KEY, JSON.stringify({ ...existing, tokens })); + } catch {} +} + +function buildDeviceAuthPayload(opts) { + const version = opts.version || (opts.nonce ? "v2" : "v1"); + const scopeStr = (opts.scopes || []).join(","); + const tokenStr = opts.token || ""; + const parts = [version, opts.deviceId, opts.clientId, opts.clientMode, opts.role, scopeStr, String(opts.signedAtMs), tokenStr]; + if (version === "v2" && opts.nonce) parts.push(opts.nonce); + return parts.join("|"); +} + +async function signPayload(privateKeyB64, payload) { + const seed = b64urlDecode(privateKeyB64); + const msg = new TextEncoder().encode(payload); + // Try native Web Crypto Ed25519 first (Chrome 113+, Firefox 128+) + try { + const pkcs8 = ed25519SeedToPkcs8(seed); + const key = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, false, ["sign"]); + const sig = await crypto.subtle.sign("Ed25519", key, msg); + return b64url(new Uint8Array(sig)); + } catch { + // Fall back to noble implementation + const sig = await ed25519SignNoble(seed, msg); + return b64url(sig); + } +} + +function generateUUID() { + if (crypto.randomUUID) return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = hexFromBytes(bytes); + return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`; +} + +// ─── Gateway Client ─── +class GatewayClient { + constructor(config) { + this.wsUrl = config.wsUrl; + this.token = config.token; + this.ws = null; + this.connected = false; + this.authenticated = false; + this.pendingRpc = new Map(); + this.subscribers = new Map(); + this.rpcIdCounter = 0; + this.reconnectDelay = 2000; + this.maxReconnectDelay = 30000; + this.onStatusChange = null; + this._shouldReconnect = true; + this._connectResolve = null; + this._connectReject = null; + this.serverInfo = null; + this.features = null; + this._connectNonce = null; + this._connectSent = false; + this._authFailed = false; + this._retryCount = 0; + this._maxRetries = 5; + this._retryDelays = [2000, 4000, 8000, 16000, 30000]; + this.lastError = null; + } + + connect() { + return new Promise((resolve, reject) => { + this._setStatus("reconnecting"); + this._connectResolve = resolve; + this._connectReject = reject; + this._connectNonce = null; + this._connectSent = false; + + try { + this.ws = new WebSocket(this.wsUrl); + } catch (e) { + this._setStatus("disconnected"); + this._connectResolve = null; + this._connectReject = null; + reject(e); + return; + } + + this.ws.onopen = () => { + console.log("[GW] WebSocket open"); + this.connected = true; + this._authFailed = false; + // If server doesn't send a challenge within 2s, send connect request anyway + this._challengeTimer = setTimeout(() => { + if (!this._connectSent && this.connected) { + console.log("[GW] No challenge received, sending connect without nonce"); + this._sendConnectRequest(null); + } + }, 2000); + }; + + this.ws.onmessage = (event) => { + let msg; + try { msg = JSON.parse(event.data); } catch { return; } + this._handleMessage(msg); + }; + + this.ws.onerror = () => { + if (!this.authenticated && this._connectReject) { + const rej = this._connectReject; + this._connectResolve = null; + this._connectReject = null; + rej(new Error("WebSocket error")); + } + }; + + this.ws.onclose = (ev) => { + this.connected = false; + const wasAuthenticated = this.authenticated; + this.authenticated = false; + + if (this._challengeTimer) { clearTimeout(this._challengeTimer); this._challengeTimer = null; } + + for (const [, { reject: rej }] of this.pendingRpc) { + rej(new Error("Connection closed")); + } + this.pendingRpc.clear(); + + if (this._connectReject) { + const rej = this._connectReject; + this._connectResolve = null; + this._connectReject = null; + rej(new Error("Connection closed before auth")); + } + + // Don't reconnect on explicit auth rejection + const noReconnectCodes = [4001, 4003, 4008, 4009]; + if (noReconnectCodes.includes(ev.code) || this._authFailed) { + console.warn(`[GW] Close code=${ev.code}, auth failed — NOT reconnecting`); + this._shouldReconnect = false; + this._setStatus("auth_failed"); + return; + } + + // Cap retries at _maxRetries + if (!wasAuthenticated) { + this._retryCount++; + if (this._retryCount >= this._maxRetries) { + console.warn(`[GW] Max retries (${this._maxRetries}) reached, stopping`); + this._shouldReconnect = false; + this._setStatus("max_retries"); + return; + } + } else { + // Successful connection was lost — reset retry count + this._retryCount = 0; + } + + this._setStatus("disconnected"); + + if (this._shouldReconnect) { + const delay = wasAuthenticated ? 2000 : (this._retryDelays[this._retryCount - 1] || 30000); + console.log(`[GW] Reconnecting in ${delay}ms (attempt ${this._retryCount}/${this._maxRetries}, code=${ev.code})`); + setTimeout(() => this._reconnect(), delay); + } else { + console.log(`[GW] Not reconnecting (code=${ev.code})`); + this._setStatus("disconnected"); + } + }; + }); + } + + async _handleMessage(msg) { + // Step 1: Challenge — build and send connect request with device auth + if (msg.type === "event" && msg.event === "connect.challenge") { + if (this._challengeTimer) { clearTimeout(this._challengeTimer); this._challengeTimer = null; } + const nonce = msg.payload?.nonce || null; + this._connectNonce = nonce; + await this._sendConnectRequest(nonce); + return; + } + + // Step 2: Connect response + if (msg.type === "res" && msg.id === "connect") { + if (msg.ok) { + console.log("[GW] Authenticated successfully"); + this.authenticated = true; + this.reconnectDelay = 1000; + this._authFailed = false; + this._setStatus("connected"); + const payload = msg.payload || {}; + this.serverInfo = payload.server; + this.features = payload.features; + + // Store device token if provided + if (payload.auth?.deviceToken) { + try { + const identity = await getOrCreateDeviceIdentity(); + if (identity) { + storeDeviceToken(identity.deviceId, "operator", payload.auth.deviceToken, payload.auth.scopes || []); + } + } catch {} + } + + if (this._connectResolve) { + const res = this._connectResolve; + this._connectResolve = null; + this._connectReject = null; + res(this); + } + } else { + console.error("[GW] Connect REJECTED:", msg.error); + this._authFailed = true; + this.lastError = msg.error?.message || "Connect rejected"; + + // Clear device token on auth failure + try { + const identity = await getOrCreateDeviceIdentity(); + if (identity) clearDeviceToken(identity.deviceId, "operator"); + } catch {} + + if (this._connectReject) { + const rej = this._connectReject; + this._connectResolve = null; + this._connectReject = null; + rej(new Error(this.lastError)); + } + + // Close WebSocket explicitly to prevent lingering connection + try { this.ws?.close(); } catch {} + } + return; + } + + // RPC response + if (msg.type === "res" && msg.id && this.pendingRpc.has(msg.id)) { + const { resolve, reject } = this.pendingRpc.get(msg.id); + this.pendingRpc.delete(msg.id); + if (!msg.ok || msg.error) { + reject(new Error(msg.error?.message || JSON.stringify(msg.error))); + } else { + resolve(msg.payload ?? msg); + } + return; + } + + // Event frames + if (msg.type === "event" && msg.event) { + if (msg.event === "tick") return; + + const listeners = this.subscribers.get(msg.event) || []; + for (const cb of listeners) { + try { cb(msg.payload ?? msg); } catch {} + } + const wildcardListeners = this.subscribers.get("*") || []; + for (const cb of wildcardListeners) { + try { cb({ event: msg.event, ...(msg.payload ?? {}) }); } catch {} + } + } + } + + async _sendConnectRequest(nonce) { + if (this._connectSent) return; + this._connectSent = true; + + const role = "operator"; + const scopes = ["operator.admin", "operator.read", "operator.write", "operator.approvals", "operator.pairing"]; + const clientId = "webchat-ui"; + const clientMode = "webchat"; + let authToken = this.token; + + // Build device identity for Ed25519 auth (required for scopes) + let deviceObj = null; + const hasSubtleCrypto = typeof crypto !== "undefined" && !!crypto.subtle; + + if (hasSubtleCrypto) { + try { + const identity = await getOrCreateDeviceIdentity(); + if (identity) { + // Try stored device token first (faster reconnect) + const storedToken = getStoredDeviceToken(identity.deviceId, role); + if (storedToken && this.token) { + // Prefer stored device token over shared gateway token + authToken = storedToken; + } + + const signedAtMs = Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: authToken || null, + nonce: nonce || undefined, + version: nonce ? "v2" : "v1", + }); + const signature = await signPayload(identity.privateKey, payload); + + deviceObj = { + id: identity.deviceId, + publicKey: identity.publicKey, + signature, + signedAt: signedAtMs, + nonce: nonce || undefined, + }; + } + } catch (e) { + console.warn("[GW] Device auth setup failed, falling back to token-only:", e); + } + } + + console.log("[GW] Sending connect request, token present:", !!authToken, "nonce:", !!nonce, "device:", !!deviceObj); + + this._send({ + type: "req", + id: "connect", + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: clientId, + version: "1.0.0", + platform: navigator?.platform || "web", + mode: clientMode, + displayName: "Bates Command Center", + instanceId: generateUUID(), + }, + role, + scopes, + device: deviceObj, + auth: { + token: authToken, + }, + caps: ["tool-events"], + userAgent: navigator?.userAgent, + }, + }); + } + + rpc(method, params = {}) { + return new Promise((resolve, reject) => { + if (!this.authenticated) { + reject(new Error("Not authenticated")); + return; + } + const id = `rpc-${++this.rpcIdCounter}`; + this.pendingRpc.set(id, { resolve, reject }); + this._send({ type: "req", id, method, params }); + + setTimeout(() => { + if (this.pendingRpc.has(id)) { + this.pendingRpc.delete(id); + reject(new Error(`RPC timeout: ${method}`)); + } + }, 15000); + }); + } + + subscribe(eventType, callback) { + if (!this.subscribers.has(eventType)) { + this.subscribers.set(eventType, []); + } + this.subscribers.get(eventType).push(callback); + return () => { + const list = this.subscribers.get(eventType); + if (list) { + const idx = list.indexOf(callback); + if (idx >= 0) list.splice(idx, 1); + } + }; + } + + _send(obj) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)); + } + } + + _setStatus(status) { + if (this.onStatusChange) { + this.onStatusChange(status); + } + } + + _reconnect() { + if (!this._shouldReconnect) return; + this._setStatus("reconnecting"); + this.connect().catch(() => {}); + } + + disconnect() { + this._shouldReconnect = false; + if (this.ws) { + this.ws.close(); + } + } +} + +window.GatewayClient = GatewayClient; diff --git a/bates-core/plugins/dashboard/static/js/panel-agents.js b/bates-core/plugins/dashboard/static/js/panel-agents.js new file mode 100644 index 0000000..7bac102 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-agents.js @@ -0,0 +1,569 @@ +/** + * Agents Panel — Org Chart Layout (v5 with management) + * Tiers loaded from API, SOUL/model editing, agent creation + */ +(function () { + const D = window.Dashboard; + let sessionData = [], subagentData = [], agentFleetData = []; + let fastRefreshInterval = null; + + // Default tiers — overridden by API data + let TIERS = { + coo: [], + deputies: [], + specialists: [], + }; + + function authHeaders(extra) { + const h = {}; + const token = window.__GATEWAY_CONFIG?.token; + if (token) h['Authorization'] = 'Bearer ' + token; + return Object.assign(h, extra || {}); + } + + window.AGENT_AVATARS = {}; + const MODEL_FALLBACK = {}; + + function mbClass(m) { if (!m) return 'other'; const l = m.toLowerCase(); return l.includes('opus') ? 'opus' : l.includes('sonnet') ? 'sonnet' : l.includes('gemini') ? 'gemini' : l.includes('codex') ? 'codex' : 'other'; } + function ago(ep) { if (!ep) return 'never'; const d = Date.now()/1000-ep; return d<0?'now':d<60?((d|0)+'s ago'):d<3600?((d/60|0)+'m ago'):d<86400?((d/3600|0)+'h ago'):((d/86400|0)+'d ago'); } + function find(n) { return agentFleetData.find(a => a.name?.toLowerCase() === n.toLowerCase()); } + + const API_ID_MAP = { bates: 'main' }; + function apiId(id) { return API_ID_MAP[id] || id; } + + function buildTiersFromFleet() { + // Build tiers from API data by reading layer from agent data + const coo = [], deputies = [], specialists = []; + const seen = new Set(); + for (const a of agentFleetData) { + const rawId = a.id || a.name?.toLowerCase(); + // Map 'main' to 'bates' for display, dedup + const entry = { id: rawId === 'main' ? 'bates' : rawId, name: a.name || a.id, role: a.role || '' }; + if (seen.has(entry.id)) continue; + seen.add(entry.id); + if (a.id === 'main' || rawId === 'bates') { + entry.id = 'bates'; + entry.name = a.name || 'Bates'; + coo.push(entry); + } else if (a.layer === 2 || a.layer === '2') { + deputies.push(entry); + } else { + specialists.push(entry); + } + } + if (coo.length || deputies.length || specialists.length) { + TIERS = { coo, deputies, specialists }; + } + } + + // Load avatars from static assets + function loadAvatars() { + const avatarMap = { + bates: '/dashboard/assets/avatar-transparent.png', + conrad: '/dashboard/assets/agent-baby_bolt.png', + soren: '/dashboard/assets/agent-baby_core.png', + amara: '/dashboard/assets/agent-baby_aqua.png', + jules: '/dashboard/assets/agent-baby_frost.png', + dash: '/dashboard/assets/agent-baby_Ember.png', + mercer: '/dashboard/assets/agent-baby_Dark.png', + kira: '/dashboard/assets/agent-baby_pixel.png', + nova: '/dashboard/assets/agent-baby_nova.png', + paige: '/dashboard/assets/agent-baby_Sage.png', + quinn: '/dashboard/assets/agent-baby_sky.png', + mira: '/dashboard/assets/agent-baby_Sage.png', + archer: '/dashboard/assets/agent-baby_sky.png', + }; + Object.assign(window.AGENT_AVATARS, avatarMap); + // Also check for uploaded custom avatars per agent + for (const a of agentFleetData) { + const id = a.id === 'main' ? 'bates' : a.id; + if (!avatarMap[id]) { + // Try common extensions + for (const ext of ['png', 'jpg', 'webp']) { + const img = new Image(); + img.src = `/dashboard/assets/agent-${id}.${ext}`; + img.onload = () => { window.AGENT_AVATARS[id] = img.src; }; + } + } + } + } + + function openAgentDetail(id, name) { + const ov = document.getElementById('soul-modal-overlay'); if (!ov) return; + const allAgents = [...TIERS.coo, ...TIERS.deputies, ...TIERS.specialists]; + const def = allAgents.find(a => a.id === id) || {}; + const fleetAgent = find(name) || {}; + const avatarSrc = window.AGENT_AVATARS[id] || ''; + const m = fleetAgent.model || MODEL_FALLBACK[id] || '', cls = mbClass(m); + const st = fleetAgent.status || 'idle'; + + const titleEl = document.getElementById('soul-modal-title'); + const bodyEl = document.getElementById('soul-modal-body'); + titleEl.textContent = name + ' — Agent Detail'; + + bodyEl.innerHTML = ` +
+
+ ${avatarSrc ? `` : ''} +
+
${D.esc(name)}
+
${D.esc(def.role || fleetAgent.role || '')}
+
+ ${m ? `${D.esc(m.split('/').pop())}` : ''} + + ${D.esc(st)} + · ${D.esc(ago(fleetAgent.last_activity_epoch))} +
+
📥 ${fleetAgent.inbox_count||0}   📤 ${fleetAgent.outbox_count||0}
+
+
+ +
+ + + ${id !== 'bates' ? '' : ''} +
+ +
+
Recent Activity
+
Loading...
+
+
+
Recent Memory
+
Loading...
+
+
+
+
SOUL.md
+ +
+
Loading...
+
+
`; + + ov.classList.add('visible'); + + // Fetch agent API data + D.fetchApi('agents').then(agents => { + const agents2 = Array.isArray(agents) ? agents : (agents?.agents || []); + const aid = apiId(id); + const a = agents2.find(x => x.id === aid || x.id === id || x.name?.toLowerCase() === name.toLowerCase()); + const el = document.getElementById('agent-detail-activity'); + if (a && el) { + const hb = a.heartbeat_interval || '—'; + const lastAct = a.last_activity ? new Date(a.last_activity).toLocaleString() : 'never'; + el.innerHTML = `
Last activity: ${D.esc(lastAct)}
+
Heartbeat: ${D.esc(hb)}
+
Layer: ${a.layer || '—'}
`; + } + }).catch(() => { const el = document.getElementById('agent-detail-activity'); if (el) el.textContent = 'Could not load'; }); + + // Fetch SOUL.md + D.fetchApi(`agents/${encodeURIComponent(apiId(id))}/soul`).then(d => { + const el = document.getElementById('agent-detail-soul'); + if (el) el.textContent = d?.content || 'No SOUL.md found.'; + }).catch(() => { const el = document.getElementById('agent-detail-soul'); if (el) el.textContent = 'Error loading SOUL.md'; }); + + // Fetch today's memory + const today = new Date().toISOString().slice(0, 10); + D.fetchApi(`agents/${encodeURIComponent(apiId(id))}/memory?date=${today}`).then(d => { + const el = document.getElementById('agent-detail-memory'); + if (el) { + const content = d?.content || d?.text || ''; + if (content) { + const lines = content.split('\n'); + el.textContent = lines.slice(-5).join('\n') || 'No entries today.'; + } else { el.textContent = 'No memory entries today.'; } + } + }).catch(() => { const el = document.getElementById('agent-detail-memory'); if (el) el.textContent = 'No memory available.'; }); + + // Wire management buttons + document.getElementById('agent-edit-soul-btn')?.addEventListener('click', () => openSoulEditor(apiId(id), name)); + document.getElementById('agent-edit-model-btn')?.addEventListener('click', () => openModelEditor(apiId(id), name, m)); + document.getElementById('agent-edit-layer-btn')?.addEventListener('click', () => openLayerEditor(apiId(id), name)); + document.getElementById('agent-delete-btn')?.addEventListener('click', async () => { + if (!confirm(`Archive agent "${name}"? This removes it from the config and archives the directory.`)) return; + try { + const resp = await fetch('/dashboard/api/agents/delete', { + method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ id: apiId(id) }) + }); + const r = await resp.json(); + if (r.success) { + ov.classList.remove('visible'); + showRestartBanner('Agent archived. Gateway restart required.'); + await refreshFleet(); + render(); + } else { alert(r.error || 'Failed'); } + } catch (e) { alert('Error: ' + e.message); } + }); + } + window._openSoulModal = openAgentDetail; + + function showRestartBanner(msg) { + const el = document.getElementById('panel-agents'); + if (!el) return; + let banner = el.querySelector('.restart-banner'); + if (!banner) { banner = document.createElement('div'); banner.className = 'restart-banner'; el.prepend(banner); } + banner.innerHTML = `${D.esc(msg)}`; + } + + function openSoulEditor(agentId, name) { + const soulEl = document.getElementById('agent-detail-soul'); + if (!soulEl) return; + const currentContent = soulEl.textContent; + const sectionEl = soulEl.parentElement; + sectionEl.innerHTML = ` +
+
SOUL.md — Editing
+
+ + +
+
+ +
`; + + document.getElementById('soul-cancel-btn').addEventListener('click', () => { + sectionEl.innerHTML = ` +
+
SOUL.md
+ +
+
${D.esc(currentContent)}
`; + document.getElementById('agent-edit-soul-btn')?.addEventListener('click', () => openSoulEditor(agentId, name)); + }); + + document.getElementById('soul-save-btn').addEventListener('click', async () => { + const content = document.getElementById('soul-editor').value; + const msg = document.getElementById('soul-msg'); + try { + const resp = await fetch('/dashboard/api/agents/update-soul', { + method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ id: agentId, content }) + }); + const r = await resp.json(); + if (r.success) { + msg.style.color = '#22c55e'; + msg.textContent = 'Saved successfully'; + // Update the pre element + setTimeout(() => { + sectionEl.innerHTML = ` +
+
SOUL.md
+ +
+
${D.esc(content)}
`; + document.getElementById('agent-edit-soul-btn')?.addEventListener('click', () => openSoulEditor(agentId, name)); + }, 1000); + } else { msg.style.color = '#ef4444'; msg.textContent = r.error || 'Failed'; } + } catch (e) { msg.style.color = '#ef4444'; msg.textContent = 'Error: ' + e.message; } + }); + } + + function openModelEditor(agentId, name, currentModel) { + const ov = document.getElementById('soul-modal-overlay'); if (!ov) return; + const bodyEl = document.getElementById('soul-modal-body'); + const titleEl = document.getElementById('soul-modal-title'); + titleEl.textContent = name + ' — Change Model'; + bodyEl.innerHTML = ` +
+
+ +
${D.esc(currentModel || 'Not set')}
+ + + + +
+ + +
+
+
+
`; + + document.getElementById('model-cancel-btn').addEventListener('click', () => openAgentDetail(agentId === 'main' ? 'bates' : agentId, name)); + document.getElementById('model-save-btn').addEventListener('click', async () => { + const primary = document.getElementById('model-primary').value; + const fallback = document.getElementById('model-fallback').value; + const model = { primary }; + if (fallback) model.fallbacks = [fallback]; + try { + const resp = await fetch('/dashboard/api/agents/update-model', { + method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ id: agentId, model }) + }); + const r = await resp.json(); + const msg = document.getElementById('model-msg'); + if (r.success) { + msg.style.color = '#22c55e'; + msg.textContent = 'Saved. Gateway restart required for changes to take effect.'; + showRestartBanner('Model changed. Gateway restart required.'); + } else { msg.style.color = '#ef4444'; msg.textContent = r.error || 'Failed'; } + } catch (e) { document.getElementById('model-msg').textContent = 'Error: ' + e.message; } + }); + } + + function openLayerEditor(agentId, name) { + const ov = document.getElementById('soul-modal-overlay'); if (!ov) return; + const bodyEl = document.getElementById('soul-modal-body'); + const titleEl = document.getElementById('soul-modal-title'); + titleEl.textContent = name + ' — Change Layer'; + bodyEl.innerHTML = ` +
+
+ + +
+ + +
+
+
+
`; + + document.getElementById('layer-cancel-btn').addEventListener('click', () => openAgentDetail(agentId === 'main' ? 'bates' : agentId, name)); + document.getElementById('layer-save-btn').addEventListener('click', async () => { + const layer = parseInt(document.getElementById('layer-select').value); + try { + const resp = await fetch('/dashboard/api/agents/update-layer', { + method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ id: agentId, layer }) + }); + const r = await resp.json(); + const msg = document.getElementById('layer-msg'); + if (r.success) { + msg.style.color = '#22c55e'; + msg.textContent = 'Layer updated.'; + await refreshFleet(); + render(); + } else { msg.style.color = '#ef4444'; msg.textContent = r.error || 'Failed'; } + } catch (e) { document.getElementById('layer-msg').textContent = 'Error: ' + e.message; } + }); + } + + function openCreateAgent() { + const ov = document.getElementById('soul-modal-overlay'); if (!ov) return; + const titleEl = document.getElementById('soul-modal-title'); + const bodyEl = document.getElementById('soul-modal-body'); + titleEl.textContent = 'Create New Agent'; + bodyEl.innerHTML = ` +
+
+ + + + + + + + + + + +
+
🤖
+ +
+
+ + +
+
+
+
`; + + // Avatar preview + document.getElementById('ca-avatar')?.addEventListener('change', (e) => { + const file = e.target.files[0]; + const prev = document.getElementById('ca-avatar-preview'); + if (file && prev) { + const reader = new FileReader(); + reader.onload = () => { prev.innerHTML = ``; }; + reader.readAsDataURL(file); + } + }); + + document.getElementById('ca-cancel-btn').addEventListener('click', () => ov.classList.remove('visible')); + document.getElementById('ca-save-btn').addEventListener('click', async () => { + const data = { + id: document.getElementById('ca-id').value.trim().toLowerCase().replace(/[^a-z0-9-]/g, ''), + name: document.getElementById('ca-name').value.trim(), + role: document.getElementById('ca-role').value.trim(), + layer: parseInt(document.getElementById('ca-layer').value), + model: { primary: document.getElementById('ca-model').value }, + }; + if (!data.id || !data.name) { document.getElementById('ca-msg').textContent = 'ID and Name required'; return; } + try { + const resp = await fetch('/dashboard/api/agents/create', { + method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(data) + }); + const r = await resp.json(); + const msg = document.getElementById('ca-msg'); + if (r.success) { + // Upload avatar if provided + const avatarFile = document.getElementById('ca-avatar')?.files?.[0]; + if (avatarFile) { + const formData = new FormData(); + formData.append('avatar', avatarFile); + formData.append('id', data.id); + try { + await fetch('/dashboard/api/agents/upload-avatar', { + method: 'POST', headers: authHeaders(), body: formData + }); + } catch (ae) { console.warn('Avatar upload failed:', ae); } + } + msg.style.color = '#22c55e'; + msg.textContent = 'Agent created. Gateway restart required.'; + showRestartBanner('New agent created. Gateway restart required.'); + setTimeout(() => { ov.classList.remove('visible'); refreshFleet().then(render); }, 1500); + } else { msg.style.color = '#ef4444'; msg.textContent = r.error || 'Failed'; } + } catch (e) { document.getElementById('ca-msg').textContent = 'Error: ' + e.message; } + }); + } + + function card(def, isCoo) { + const d = find(def.name), st = d?.status || 'idle', m = d?.model || MODEL_FALLBACK[def.id] || '', cls = mbClass(m); + const avatarSrc = window.AGENT_AVATARS[def.id] || ''; + const avatarHtml = avatarSrc ? `` : ''; + return `
+ ${avatarHtml} +
${D.esc(def.name)}
+
${D.esc(d?.role || def.role)}
+ ${m ? `${D.esc(m.split('/').pop())}` : ''} +
${D.esc(ago(d?.last_activity_epoch))}
+
📥 ${d?.inbox_count||0}📤 ${d?.outbox_count||0}
+
💓 ${D.esc(d?.heartbeat_interval||'—')}
+
`; + } + + function render() { + const el = document.getElementById('panel-agents'); if (!el) return; + let h = ''; + if (TIERS.coo.length) { + h += '
Layer 1 — COO
' + TIERS.coo.map(a => card(a, true)).join('') + '
'; + h += '
'; + } + if (TIERS.deputies.length) { + h += '
Layer 2 — Deputies
' + TIERS.deputies.map(a => card(a, false)).join('') + '
'; + h += '
'; + } + if (TIERS.specialists.length) { + h += '
Layer 3 — Specialists
' + TIERS.specialists.map(a => card(a, false)).join('') + '
'; + } + + // Add "Create Agent" button + h += `
+ +
`; + + el.innerHTML = h; + + // Wire create agent button after innerHTML is set + document.getElementById('create-agent-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + openCreateAgent(); + }); + } + + async function refreshSub() { try { const d = await D.fetchApi('sessions'); if (Array.isArray(d)) subagentData = d; } catch {} } + async function refreshFleet() { + try { + const r = await D.fetchApi('agents'); + agentFleetData = Array.isArray(r) ? r : (r?.agents || []); + buildTiersFromFleet(); + } catch {} + } + + async function refresh(gw) { + if (gw?.authenticated) try { const r = await gw.rpc('sessions.list', {}); sessionData = r?.sessions || r?.items || (Array.isArray(r) ? r : []); } catch { sessionData = []; } + await Promise.all([refreshSub(), refreshFleet()]); + const a = agentFleetData.filter(x => x.status === 'active').length; + const ready = agentFleetData.filter(x => x.status === 'ready' || x.status === 'active').length; + window._updateOverviewMetrics?.({ activeAgents: a + '/' + ready }); + render(); + } + + async function init(gw) { + loadAvatars(); + + // Event delegation for Create Agent button (survives innerHTML rebuilds) + const panel = document.getElementById('panel-agents'); + if (panel) { + panel.addEventListener('click', (e) => { + const btn = e.target.closest('#create-agent-btn'); + if (btn) { + e.stopPropagation(); + openCreateAgent(); + } + }); + } + + render(); + if (gw?.authenticated) await refresh(gw); else await Promise.all([refreshSub(), refreshFleet()]); + render(); + gw?.subscribe('agent', () => refresh(gw)); + gw?.subscribe('agent.lifecycle', () => refresh(gw)); + } + + let _refreshInterval = null; + let _lastUpdated = null; + + function updateTimestamp() { + const el = document.getElementById('panel-agents'); + if (!el) return; + let ts = el.querySelector('.panel-last-updated'); + if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); } + if (_lastUpdated) { + const s = ((Date.now() - _lastUpdated) / 1000) | 0; + ts.textContent = `last updated: ${s}s ago`; + } + } + + const _origRefresh = refresh; + refresh = async function(gw) { + await _origRefresh(gw); + _lastUpdated = Date.now(); + updateTimestamp(); + }; + + function startAutoRefresh(gw) { + stopAutoRefresh(); + _refreshInterval = setInterval(() => { refresh(gw); }, 60000); + setInterval(updateTimestamp, 10000); + } + function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } } + + const _origInit = init; + init = async function(gw) { + await _origInit(gw); + startAutoRefresh(gw); + }; + + window._openCreateAgent = openCreateAgent; + + D.registerPanel('agents', { init, refresh, stopAutoRefresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-ceo.js b/bates-core/plugins/dashboard/static/js/panel-ceo.js new file mode 100644 index 0000000..bb0b90c --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-ceo.js @@ -0,0 +1,91 @@ +/** + * CEO Dashboard Panel — Tasks + project data + metrics (v4) + */ +(function () { + const D = window.Dashboard; + + function priClass(p) { return p === 'high' || p === 1 ? 'high' : p === 'medium' || p === 5 ? 'medium' : p === 'low' || p === 9 ? 'low' : 'none'; } + + function renderTasks(tasks) { + const el = document.getElementById('panel-ceo-tasks'); + if (!el) return; + if (!tasks?.length) { + el.innerHTML = '
No tasks found
No tasks loaded
'; + return; + } + let h = ''; + for (const t of tasks) { + const done = t.status === 'completed' || t.completed; + h += `
+
+
+
+
${D.esc(t.title || t.subject || '—')}
+
+ ${t.dueDate ? `Due: ${D.esc(t.dueDate)}` : ''} + ${t.planName ? `${D.esc(t.planName)}` : ''} + ${t.source ? `${D.esc(t.source)}` : ''} +
+
+
`; + } + el.innerHTML = h; + const pending = tasks.filter(t => !t.completed && t.status !== 'completed').length; + window._updateOverviewMetrics?.({ tasks: pending }); + } + + function renderProjectBodies(agents, tasksData) { + const projects = [ + { el: 'project-project_a', agent: 'conrad', key: 'project_a' }, + { el: 'project-project_b', agent: 'soren', key: 'project_b' }, + { el: 'project-private', agent: 'jules', key: 'private' }, + { el: 'project-project_c', agent: 'amara', key: 'project_c' }, + { el: 'project-bates', agent: 'dash', key: 'bates' }, + ]; + const byProject = tasksData?.byProject || {}; + for (const p of projects) { + const container = document.getElementById(p.el); + if (!container) continue; + const a = agents?.find(x => x.name?.toLowerCase() === p.agent); + const proj = byProject[p.key]; + let html = ''; + if (a) { + html += ` ${D.esc(a.status||'idle')} · Last: ${D.esc(D.timeAgo(a.lastHeartbeat||a.last_heartbeat||a.last_activity))}`; + } + if (proj) { + const pending = (proj.tasks || []).filter(t => !t.completed).length; + html += `
📋 ${proj.count || 0} tasks (${pending} pending)
`; + } + container.innerHTML = html || 'No data'; + } + } + + async function refresh() { + let tasks = null, status = null, agents = null; + try { + const [tR, sR, aR] = await Promise.allSettled([ + D.fetchApi('tasks'), + D.fetchApi('status'), + D.fetchApi('agents'), + ]); + tasks = tR.status === 'fulfilled' ? tR.value : null; + status = sR.status === 'fulfilled' ? sR.value : null; + agents = aR.status === 'fulfilled' ? aR.value : null; + } catch {} + + let list = tasks ? (Array.isArray(tasks) ? tasks : (tasks.tasks || tasks.items || [])) : []; + // Only render in CEO panel if tasks panel isn't handling it + if (list.length) { + // Update metrics from real data + const pending = list.filter(t => !t.completed && t.status !== 'completed').length; + window._updateOverviewMetrics?.({ tasks: pending }); + } + + if (status?.unread_emails !== undefined) window._updateOverviewMetrics?.({ emails: status.unread_emails }); + + const agentList = agents ? (Array.isArray(agents) ? agents : (agents.agents || [])) : []; + renderProjectBodies(agentList, tasks); + } + + D.registerPanel('ceo', { init: refresh, refresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-chat.js b/bates-core/plugins/dashboard/static/js/panel-chat.js new file mode 100644 index 0000000..24a733d --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-chat.js @@ -0,0 +1,426 @@ +/** + * Chat Panel + * Interactive chat with agent sessions via WebSocket RPC + */ +(function () { + const D = window.Dashboard; + + let sessions = []; + let activeSessionKey = null; + let messages = []; + let streamingText = ""; + let activeRunId = null; + let isStreaming = false; + let unsubChat = null; + let gwRef = null; + + function generateUUID() { + if (crypto.randomUUID) return crypto.randomUUID(); + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => + (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> +c / 4))).toString(16) + ); + } + + function extractText(content) { + if (!content) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const texts = content + .filter((b) => b && b.type === "text" && b.text) + .map((b) => b.text); + if (texts.length) return texts.join("\n"); + // Fallback: try to extract any string values from array items + return content + .map((b) => (typeof b === "string" ? b : b && b.text ? b.text : "")) + .filter(Boolean) + .join("\n"); + } + // Handle nested content (e.g., {content: "text"} or {content: [{type:"text", text:"..."}]}) + if (content.content !== undefined) return extractText(content.content); + if (content.text) return String(content.text); + if (content.message) return String(content.message); + // Last resort: try JSON but never return [object Object] + try { + const s = JSON.stringify(content); + return s !== "{}" ? s : ""; + } catch { + return ""; + } + } + + function renderSessionTabs() { + const bar = document.getElementById("chat-session-bar"); + if (!bar) return; + if (!sessions.length) { + bar.innerHTML = 'No sessions available'; + return; + } + const sorted = [...sessions].sort((a, b) => { + // Main session always first + const aIsMain = (a.key || "") === "agent:main:main"; + const bIsMain = (b.key || "") === "agent:main:main"; + if (aIsMain !== bIsMain) return aIsMain ? -1 : 1; + // Subagents last + const aIsSub = (a.key || "").startsWith("subagent:"); + const bIsSub = (b.key || "").startsWith("subagent:"); + if (aIsSub !== bIsSub) return aIsSub ? 1 : -1; + return (b.updatedAt || 0) - (a.updatedAt || 0); + }); + let html = ""; + for (const s of sorted) { + const key = s.key || ""; + const label = s.displayName || s.label || key.split(":").pop() || "Unknown"; + const isActive = key === activeSessionKey; + const isSub = key.startsWith("subagent:"); + const isRunning = s.updatedAt && Date.now() - s.updatedAt < 300000; + html += ``; + } + bar.innerHTML = html; + } + + function renderMessages() { + const el = document.getElementById("chat-messages"); + if (!el) return; + + if (!messages.length && !streamingText && !isStreaming) { + el.innerHTML = + '
💬Select a session to begin
'; + return; + } + + let html = ""; + for (const msg of messages) { + const text = extractText(msg.content); + if (!text) continue; + const role = msg.role || "system"; + const ts = msg.timestamp + ? new Date(typeof msg.timestamp === "number" ? msg.timestamp : msg.timestamp).toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + }) + : ""; + html += `
`; + html += `
${D.esc(text)}
`; + if (ts) html += `
${ts}
`; + html += `
`; + } + + if (isStreaming && streamingText) { + html += `
`; + html += `
${D.esc(streamingText)}
`; + html += `
`; + } else if (isStreaming) { + html += `
`; + html += `
Thinking...
`; + html += `
`; + } + + el.innerHTML = html; + + const scrollContainer = document.getElementById("chat-messages-scroll"); + if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight; + + updateInputBar(); + } + + function updateInputBar() { + const sendBtn = document.getElementById("chat-send-btn"); + const stopBtn = document.getElementById("chat-stop-btn"); + if (sendBtn) sendBtn.style.display = isStreaming ? "none" : ""; + if (stopBtn) stopBtn.style.display = isStreaming ? "" : "none"; + } + + async function loadHistory(gw) { + if (!gw || !gw.authenticated || !activeSessionKey) { + messages = []; + renderMessages(); + return; + } + try { + console.log("[Chat] Requesting chat.history for session:", activeSessionKey); + const result = await gw.rpc("chat.history", { sessionKey: activeSessionKey, limit: 200 }); + console.log("[Chat] chat.history result keys:", result ? Object.keys(result) : "null"); + const raw = result?.messages || []; + // Filter to user and assistant messages with actual text content + messages = raw.filter(m => { + const text = extractText(m.content); + return text && text.trim().length > 0 && (m.role === "user" || m.role === "assistant"); + }); + console.log("[Chat] Loaded", raw.length, "raw messages,", messages.length, "after filtering"); + if (raw.length > 0 && messages.length === 0) { + console.log("[Chat] All messages filtered out. Sample roles:", raw.slice(0, 5).map(m => m.role)); + console.log("[Chat] Sample message:", JSON.stringify(raw[0]).slice(0, 300)); + } + } catch (e) { + console.error("[Chat] chat.history failed:", e); + messages = []; + } + streamingText = ""; + isStreaming = false; + activeRunId = null; + renderMessages(); + } + + function subscribeToChatEvents(gw) { + if (unsubChat) { + unsubChat(); + unsubChat = null; + } + if (!gw) return; + unsubChat = gw.subscribe("chat", (payload) => { + if (payload.sessionKey !== activeSessionKey) return; + const state = payload.state; + + if (state === "delta") { + isStreaming = true; + activeRunId = payload.runId || activeRunId; + const text = extractText(payload.message); + // Deltas from gateway are CUMULATIVE (full text so far) — always replace + if (text) streamingText = text; + renderMessages(); + } else if (state === "final") { + isStreaming = false; + streamingText = ""; + activeRunId = null; + loadHistory(gw); + } else if (state === "aborted" || state === "error") { + isStreaming = false; + streamingText = ""; + activeRunId = null; + if (state === "error" && payload.errorMessage) { + messages.push({ role: "system", content: "Error: " + payload.errorMessage }); + } + loadHistory(gw); + } + }); + } + + async function selectSession(gw, sessionKey) { + activeSessionKey = sessionKey; + streamingText = ""; + isStreaming = false; + activeRunId = null; + renderSessionTabs(); + await loadHistory(gw); + subscribeToChatEvents(gw); + } + + async function sendMessage(gw) { + const input = document.getElementById("chat-input"); + if (!input) return; + const text = input.value.trim(); + if (!text || !activeSessionKey || !gw || !gw.authenticated) return; + + input.value = ""; + input.style.height = "auto"; + + // Optimistic local append + messages.push({ role: "user", content: text, timestamp: Date.now() }); + isStreaming = true; + streamingText = ""; + renderMessages(); + + try { + const result = await gw.rpc("chat.send", { + sessionKey: activeSessionKey, + message: text, + deliver: false, + idempotencyKey: generateUUID(), + }); + activeRunId = result?.runId || null; + } catch (e) { + console.error("chat.send failed:", e); + isStreaming = false; + messages.push({ role: "system", content: "Failed to send: " + e.message }); + renderMessages(); + } + } + + async function abortAgent(gw) { + if (!gw || !gw.authenticated || !activeSessionKey) return; + try { + await gw.rpc("chat.abort", { + sessionKey: activeSessionKey, + runId: activeRunId || undefined, + }); + } catch (e) { + console.error("chat.abort failed:", e); + } + isStreaming = false; + streamingText = ""; + activeRunId = null; + renderMessages(); + } + + async function refreshSessions(gw) { + if (!gw || !gw.authenticated) { + console.log("[Chat] refreshSessions skipped — gw:", !!gw, "authenticated:", gw?.authenticated); + return; + } + try { + console.log("[Chat] Calling sessions.list..."); + const result = await gw.rpc("sessions.list", {}); + console.log("[Chat] sessions.list result keys:", result ? Object.keys(result) : "null"); + const payload = result?.sessions || result?.items || (Array.isArray(result) ? result : []); + sessions = Array.isArray(payload) ? payload : []; + console.log("[Chat] Got", sessions.length, "sessions"); + } catch (e) { + console.error("[Chat] sessions.list failed:", e); + sessions = []; + } + // Always ensure main session is available for chat + if (!sessions.find(s => s.key === "agent:main:main")) { + sessions.unshift({ key: "agent:main:main", displayName: "Main", label: "main", updatedAt: Date.now() }); + } + renderSessionTabs(); + + // If selected session disappeared, clear + if (activeSessionKey && !sessions.find((s) => s.key === activeSessionKey)) { + activeSessionKey = null; + messages = []; + streamingText = ""; + isStreaming = false; + activeRunId = null; + renderMessages(); + const input = document.getElementById("chat-input"); + const sendBtn = document.getElementById("chat-send-btn"); + if (input) input.disabled = true; + if (sendBtn) sendBtn.disabled = true; + } + } + + function showConnStatus(msg, type) { + const el = document.getElementById("chat-conn-status"); + if (!el) return; + el.textContent = msg; + el.className = "chat-conn-status chat-conn-" + (type || "info"); + el.style.display = msg ? "block" : "none"; + } + + async function init(gw) { + gwRef = gw; + const el = document.getElementById("panel-chat"); + if (!el) return; + + el.innerHTML = ` + +
+
+
+
💬Select a session to begin
+
+
+
+ + + +
+ `; + + // Session tab click handler + const bar = document.getElementById("chat-session-bar"); + bar.addEventListener("click", (e) => { + const tab = e.target.closest(".chat-session-tab"); + if (!tab) return; + const key = tab.dataset.sessionKey; + if (key) { + selectSession(gw, key); + const input = document.getElementById("chat-input"); + const sendBtn = document.getElementById("chat-send-btn"); + if (input) input.disabled = false; + if (sendBtn) sendBtn.disabled = false; + } + }); + + // Send button + document.getElementById("chat-send-btn").addEventListener("click", () => sendMessage(gw)); + + // Stop button + document.getElementById("chat-stop-btn").addEventListener("click", () => abortAgent(gw)); + + // Textarea: Enter to send, Shift+Enter for newline, auto-resize + const input = document.getElementById("chat-input"); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(gw); + } + }); + input.addEventListener("input", () => { + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 120) + "px"; + }); + + // Track connection status in chat panel + if (gw) { + const origOnStatus = gw.onStatusChange; + gw.onStatusChange = function(status) { + if (origOnStatus) origOnStatus(status); + if (status === "connected") { + showConnStatus("Connected", "ok"); + setTimeout(() => showConnStatus("", "ok"), 2000); + // Re-initialize chat on connect/reconnect + loadAndAutoSelect().catch(() => {}); + if (activeSessionKey) subscribeToChatEvents(gw); + } else if (status === "reconnecting") { + showConnStatus("Reconnecting... (attempt " + (gw._retryCount + 1) + "/" + gw._maxRetries + ")", "warn"); + } else if (status === "auth_failed") { + showConnStatus("WebSocket auth failed. Connection paused. " + (gw.lastError || ""), "error"); + } else if (status === "max_retries") { + showConnStatus("Connection failed after " + gw._maxRetries + " attempts. Refresh page to retry.", "error"); + } else if (status === "disconnected") { + showConnStatus("Disconnected", "warn"); + } + }; + } + + // Load sessions and auto-select main (with retry for auth timing) + async function loadAndAutoSelect() { + console.log("[Chat] loadAndAutoSelect — gw:", !!gw, "authenticated:", gw?.authenticated, "connected:", gw?.connected); + if (!gw || !gw.authenticated) return false; + showConnStatus("Connected", "ok"); + setTimeout(() => showConnStatus("", "ok"), 2000); + await refreshSessions(gw); + if (!activeSessionKey && sessions.length > 0) { + const main = sessions.find((s) => s.key === "agent:main:main") || sessions[0]; + if (main) { + await selectSession(gw, main.key); + input.disabled = false; + document.getElementById("chat-send-btn").disabled = false; + } + } + return true; + } + + showConnStatus("Connecting to gateway...", "info"); + + if (!(await loadAndAutoSelect())) { + // Auth not ready yet — retry up to 10 times + let retries = 0; + const retryInterval = setInterval(async () => { + retries++; + if (await loadAndAutoSelect() || retries >= 10) { + clearInterval(retryInterval); + if (retries >= 10 && (!gw || !gw.authenticated)) { + showConnStatus("Connection failed — retrying in background", "error"); + } + } + }, 500); + } + + // Subscribe to lifecycle events + if (gw) { + gw.subscribe("agent", () => refreshSessions(gw)); + } + } + + async function refresh(gw) { + gwRef = gw; + await refreshSessions(gw); + if (activeSessionKey) subscribeToChatEvents(gw); + } + + D.registerPanel("chat", { init, refresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-community.js b/bates-core/plugins/dashboard/static/js/panel-community.js new file mode 100644 index 0000000..6cdc85c --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-community.js @@ -0,0 +1,159 @@ +/** + * Panel: Community — GitHub stars, social sharing, referral, newsletter + */ +(function () { + const GITHUB_REPO = 'getBates/Bates'; + const GITHUB_URL = 'https://github.com/' + GITHUB_REPO; + const SITE_URL = 'https://getBates.ai'; + const REFERRAL_BASE = SITE_URL + '/r/'; + const NEWSLETTER_URL = SITE_URL + '/newsletter'; + + const SHARE_TEXT = 'Meet Bates — my AI assistant that manages email, calendar, tasks, and more through Microsoft 365. Open source!'; + const SHARE_HASHTAGS = 'AI,Bates,OpenSource,Productivity'; + + let cachedStars = null; + let cachedStarsAt = 0; + + async function fetchGitHubStars() { + if (cachedStars !== null && Date.now() - cachedStarsAt < 600000) return cachedStars; + try { + const resp = await fetch('https://api.github.com/repos/' + GITHUB_REPO); + if (resp.ok) { + const data = await resp.json(); + cachedStars = data.stargazers_count || 0; + cachedStarsAt = Date.now(); + return cachedStars; + } + } catch (e) { console.warn('GitHub stars fetch failed:', e); } + return cachedStars || 0; + } + + function getReferralId() { + let id = localStorage.getItem('bates-referral-id'); + if (!id) { + id = Math.random().toString(36).slice(2, 10); + localStorage.setItem('bates-referral-id', id); + } + return id; + } + + function shareUrl(platform) { + const url = encodeURIComponent(SITE_URL); + const text = encodeURIComponent(SHARE_TEXT); + const hashtags = encodeURIComponent(SHARE_HASHTAGS); + const links = { + twitter: 'https://twitter.com/intent/tweet?text=' + text + '&url=' + url + '&hashtags=' + hashtags, + linkedin: 'https://www.linkedin.com/sharing/share-offsite/?url=' + url, + facebook: 'https://www.facebook.com/sharer/sharer.php?u=' + url, + threads: 'https://threads.net/intent/post?text=' + encodeURIComponent(SHARE_TEXT + ' ' + SITE_URL), + reddit: 'https://reddit.com/submit?url=' + url + '&title=' + encodeURIComponent('Bates — Open Source AI Assistant for Microsoft 365'), + hackernews:'https://news.ycombinator.com/submitlink?u=' + url + '&t=' + encodeURIComponent('Bates — Open Source AI Assistant for Microsoft 365'), + whatsapp: 'https://wa.me/?text=' + encodeURIComponent(SHARE_TEXT + ' ' + SITE_URL), + telegram: 'https://t.me/share/url?url=' + url + '&text=' + text, + email: 'mailto:?subject=' + encodeURIComponent('Check out Bates — AI Assistant') + '&body=' + encodeURIComponent(SHARE_TEXT + '\n\n' + SITE_URL), + }; + return links[platform] || '#'; + } + + function socialBtn(platform, label, color, icon) { + return '' + + icon + ' ' + label + ''; + } + + async function render() { + const el = document.getElementById('panel-community'); + if (!el) return; + + const stars = await fetchGitHubStars(); + const referralUrl = REFERRAL_BASE + getReferralId(); + + el.innerHTML = + '
' + + + // Row 1: GitHub + Share side by side + '
' + + + // GitHub Stars card + '
' + + '
' + + '' + + '
' + + '
Star on GitHub
' + + '
Help others discover Bates
' + + '
' + + '
' + + '
' + stars + '
' + + '
stars
' + + '
' + + '
' + + '' + + '⭐ Star getBates/Bates' + + '
' + + + // Share card + '
' + + '
Share Bates
' + + '
Know someone who\'d love their own AI assistant? Spread the word!
' + + '
' + + socialBtn('twitter', 'X / Twitter', '#000', '') + + socialBtn('linkedin', 'LinkedIn', '#0a66c2', '') + + socialBtn('facebook', 'Facebook', '#1877f2', '') + + socialBtn('threads', 'Threads', '#000', '') + + socialBtn('reddit', 'Reddit', '#ff4500', '') + + socialBtn('hackernews', 'Hacker News', '#f06722', '') + + socialBtn('whatsapp', 'WhatsApp', '#25d366', '') + + socialBtn('telegram', 'Telegram', '#0088cc', '') + + socialBtn('email', 'Email', '#666', '') + + '
' + + '
' + + + '
' + + + // Row 2: Referral + Newsletter side by side + '
' + + + // Referral + '
' + + '
Referral Link
' + + '
Share your personal link to track referrals
' + + '
' + + '' + + '' + + '
' + + '
' + + + // Newsletter + '
' + + '
Stay Updated
' + + '
Get notified about new features. No spam, ever.
' + + '' + + '' + + 'Subscribe to Newsletter' + + '
' + + + '
' + + + '
'; + } + + window._copyReferral = function () { + const input = document.getElementById('community-referral-url'); + if (input) { + navigator.clipboard.writeText(input.value).then(function () { + var btn = input.nextElementSibling; + if (btn) { btn.textContent = 'Copied!'; setTimeout(function () { btn.textContent = 'Copy'; }, 2000); } + }); + } + }; + + Dashboard.registerPanel('community', { refresh: render }); + render(); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-costs.js b/bates-core/plugins/dashboard/static/js/panel-costs.js new file mode 100644 index 0000000..c25192a --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-costs.js @@ -0,0 +1,153 @@ +/** + * Costs Panel — Real-time Token Usage & Operational Costs + */ +(function () { + const D = window.Dashboard; + + function fmt(n) { + if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; + return String(n); + } + + function fmtDollar(n) { return '$' + n.toFixed(2); } + + function todayKey() { + const d = new Date(); + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + } + + function render(data) { + const el = document.getElementById('panel-costs'); + if (!el) return; + + if (!data || data.error) { + el.innerHTML = '
⏳ Awaiting data...
'; + return; + } + + const today = todayKey(); + const todayData = data[today]; + + // 7-day aggregation + let tokens7 = 0, cost7 = 0, interactions7 = 0; + const now = new Date(); + for (let i = 0; i < 7; i++) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const k = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); + if (data[k]) { + tokens7 += data[k].totalTokens || 0; + cost7 += data[k].totalCost || 0; + interactions7 += data[k].interactions || 0; + } + } + + let h = ''; + + // Today's summary + if (todayData) { + h += `
+
Today's Usage
+
${fmt(todayData.totalTokens)} tokens
+
${(todayData.interactions || 0).toLocaleString()} interactions · Notional: ${fmtDollar(todayData.totalCost || 0)}
+
`; + } else { + h += `
+
Today's Usage
+
No data yet
+
`; + } + + // 7-day summary + h += `
+
7-Day Total
+
${fmt(tokens7)} tokens
+
${interactions7.toLocaleString()} interactions · Notional: ${fmtDollar(cost7)}
+
`; + + // Non-Anthropic cost note (only if there are non-Anthropic costs) + const nonAnthCost = todayData ? getNonAnthropicCost(todayData) : 0; + if (nonAnthCost > 0) { + h += `
+ 💰 Non-Anthropic API cost today: ${fmtDollar(nonAnthCost)} +
`; + } + + // Model breakdown for today + if (todayData && todayData.byModel) { + h += '
'; + h += '
ModelTokensNotional
'; + const models = Object.entries(todayData.byModel) + .filter(([, v]) => v.tokens > 0 || v.count > 0) + .sort((a, b) => b[1].tokens - a[1].tokens); + for (const [name, v] of models) { + const badge = `${fmtDollar(v.cost)}`; + h += `
+
+
${D.esc(name)}
+
${v.count} calls
+
+
${fmt(v.tokens)}
+
${badge}
+
`; + } + h += '
'; + } + + el.innerHTML = h; + } + + function getNonAnthropicCost(dayData) { + if (!dayData || !dayData.byModel) return 0; + let cost = 0; + for (const [name, v] of Object.entries(dayData.byModel)) { + if (!name.startsWith('claude-')) cost += v.cost || 0; + } + return cost; + } + + async function refresh() { + try { + const data = await D.fetchApi('costs'); + if (data) { render(data); return; } + } catch {} + render(null); + } + + let _refreshInterval = null; + let _lastUpdated = null; + + function updateTimestamp() { + const el = document.getElementById('panel-costs'); + if (!el) return; + let ts = el.querySelector('.panel-last-updated'); + if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); } + if (_lastUpdated) { + const s = ((Date.now() - _lastUpdated) / 1000) | 0; + ts.textContent = `last updated: ${s}s ago`; + } + } + + const _origRefresh = refresh; + async function autoRefresh() { + await _origRefresh(); + _lastUpdated = Date.now(); + updateTimestamp(); + } + + function startAutoRefresh() { + stopAutoRefresh(); + _refreshInterval = setInterval(autoRefresh, 60000); + setInterval(updateTimestamp, 10000); + } + function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } } + + async function initPanel() { + await autoRefresh(); + startAutoRefresh(); + } + + D.registerPanel('costs', { init: initPanel, refresh: autoRefresh, stopAutoRefresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-crons.js b/bates-core/plugins/dashboard/static/js/panel-crons.js new file mode 100644 index 0000000..b96ed48 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-crons.js @@ -0,0 +1,144 @@ +/** + * Cron Jobs Panel — Categorized card grid (v4) + * Excludes heartbeats from upcoming section on overview + */ +(function () { + const D = window.Dashboard; + + function cronH(e) { + if (!e) return ''; const p = e.split(' '); if (p.length < 5) return e; + const [m, h, , , d] = p; + if (e.startsWith('0 */')) return `Every ${p[1].replace('*/','')}h`; + if (e.startsWith('*/')) return `Every ${m.replace('*/','')}m`; + if (d === '1-5') return `Weekdays ${h}:${m.padStart(2,'0')}`; + if (d === '1') return `Mon ${h}:${m.padStart(2,'0')}`; + if (d === '5') return `Fri ${h}:${m.padStart(2,'0')}`; + if (d === '*') { if (h.includes('-')) return `Daily ${h} at :${m.padStart(2,'0')}`; return `Daily ${h}:${m.padStart(2,'0')}`; } + return e; + } + function evH(ms) { if (!ms) return ''; const s = Math.round(ms/1000); return s<60?`Every ${s}s`:s<3600?`Every ${Math.round(s/60)}m`:`Every ${(s/3600).toFixed(1).replace(/\.0$/,'')}h`; } + function fmtTs(ms) { + if (!ms) return '—'; const d = new Date(ms), pad = n => String(n).padStart(2,'0'); + const ds = `${pad(d.getDate())}/${pad(d.getMonth()+1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + const diff = ms - Date.now(), a = Math.abs(diff); + if (a < 864e5) { const h = (a/36e5)|0, m = ((a%36e5)/6e4)|0; return `${ds} (${diff>0?'in ':''}${h}h${m}m${diff<=0?' ago':''})`; } + return ds; + } + function isHeartbeat(j) { + const n = (j.name||j.id||'').toLowerCase(); + return n.includes('heartbeat') || n.includes('hb-') || n.includes('checkin'); + } + function cat(j) { + if (isHeartbeat(j)) return 'Agent Heartbeats'; + const n = (j.name||j.id||'').toLowerCase(); + if (n.includes('report')||n.includes('standup')||n.includes('digest')) return 'Scheduled Reports'; + return 'System Tasks'; + } + + function renderCard(j) { + const name = j.name||j.id, s = j.schedule, st = j.state||{}; + const dis = !j.enabled, run = st.lastStatus === 'running'; + let sched = ''; + if (s?.kind === 'cron') sched = cronH(s.expr); + else if (s?.kind === 'every' || s?.everyMs) sched = evH(s.everyMs); + else if (s?.expr) sched = cronH(s.expr); + const runCount = st.runCount != null ? st.runCount : '—'; + return `
+
${D.esc(name)}
+
${D.esc(sched)}
+
+ Last: ${D.esc(st.lastRunAtMs ? fmtTs(st.lastRunAtMs) : 'never')}${st.lastStatus?' ('+D.esc(st.lastStatus)+')':''} + ${st.nextRunAtMs ? `Next: ${D.esc(fmtTs(st.nextRunAtMs))}` : ''} +
+
▸ click for details
+
+ ⏱ Last run: ${D.esc(st.lastRunAtMs ? fmtTs(st.lastRunAtMs) : 'never')} + ⏭ Next run: ${D.esc(st.nextRunAtMs ? fmtTs(st.nextRunAtMs) : '—')} + 📊 Status: ${D.esc(st.lastStatus || 'unknown')} + 🔢 Run count: ${D.esc(String(runCount))} + ${j.target ? `🎯 Target: ${D.esc(j.target)}` : ''} + ${j.channel ? `📡 Channel: ${D.esc(j.channel)}` : ''} +
+
+ + +
+
`; + } + + function render(jobs) { + const el = document.getElementById('panel-crons'); + if (!el) return; + if (!jobs?.length) { el.innerHTML = '
No cron jobs
'; return; } + + const groups = {}; + for (const j of jobs) { const c = cat(j); (groups[c] = groups[c] || []).push(j); } + + let h = '
'; + for (const [c, cj] of Object.entries(groups)) { + cj.sort((a,b) => (a.state?.lastStatus==='running'?-1:0)-(b.state?.lastStatus==='running'?-1:0) || (a.state?.nextRunAtMs||Infinity)-(b.state?.nextRunAtMs||Infinity)); + h += `
${c} ${cj.length}
`; + h += cj.map(renderCard).join(''); + } + h += '
'; + el.innerHTML = h; + renderUpcoming(jobs); + } + + function renderUpcoming(jobs) { + const el = document.getElementById('panel-crons-upcoming'); if (!el) return; + // Exclude heartbeats from upcoming on overview + const up = jobs + .filter(j => j.enabled && j.state?.nextRunAtMs && !isHeartbeat(j)) + .sort((a,b) => a.state.nextRunAtMs - b.state.nextRunAtMs) + .slice(0,5); + if (!up.length) { el.innerHTML = '
No upcoming crons
'; return; } + el.innerHTML = up.map(j => `
${D.esc(j.name||j.id)}
${D.esc(fmtTs(j.state.nextRunAtMs))}
`).join(''); + if (up[0]) { + const d = up[0].state.nextRunAtMs - Date.now(); + if (d > 0) { const m = (d/6e4)|0; window._updateOverviewMetrics?.({ nextCron: m >= 60 ? `${(m/60)|0}h ${m%60}m` : `${m}m` }); } + } + } + + async function refresh(gw) { + let jobs = null; + if (gw?.authenticated) try { const r = await gw.rpc('cron.list', {}); jobs = r?.jobs || r?.items || (Array.isArray(r) ? r : null); } catch {} + if (!jobs) { const d = await D.fetchApi('crons'); jobs = d?.jobs || []; } + render(jobs || []); + } + + let _refreshInterval = null; + let _lastUpdated = null; + + function updateTimestamp() { + const el = document.getElementById('panel-crons'); + if (!el) return; + let ts = el.querySelector('.panel-last-updated'); + if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); } + if (_lastUpdated) { + const s = ((Date.now() - _lastUpdated) / 1000) | 0; + ts.textContent = `last updated: ${s}s ago`; + } + } + + const _origRefresh = refresh; + async function autoRefresh(gw) { + await _origRefresh(gw); + _lastUpdated = Date.now(); + updateTimestamp(); + } + + function startAutoRefresh(gw) { + stopAutoRefresh(); + _refreshInterval = setInterval(() => autoRefresh(gw), 60000); + setInterval(updateTimestamp, 10000); + } + function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } } + + async function initPanel(gw) { + await autoRefresh(gw); + startAutoRefresh(gw); + } + + D.registerPanel('crons', { init: initPanel, refresh: autoRefresh, stopAutoRefresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-delegations.js b/bates-core/plugins/dashboard/static/js/panel-delegations.js new file mode 100644 index 0000000..2020564 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-delegations.js @@ -0,0 +1,122 @@ +/** + * Claude Code Delegations Panel + * Shows running and recent Claude Code delegations with status tracking. + */ +(function () { + const D = window.Dashboard; + let delegations = []; + let fastRefreshInterval = null; + + function statusBadge(status) { + const cls = { + running: "agent-status-running", + completed: "agent-status-completed", + failed: "agent-status-failed", + }; + const labels = { + running: "\u25CF Running", + completed: "\u2713 Done", + failed: "\u2717 Failed", + }; + return '' + (labels[status] || status) + ""; + } + + function formatDuration(ms) { + if (!ms) return ""; + var s = Math.floor(ms / 1000); + if (s < 60) return s + "s"; + var m = Math.floor(s / 60); + if (m < 60) return m + "m " + (s % 60) + "s"; + var h = Math.floor(m / 60); + return h + "h " + (m % 60) + "m"; + } + + function renderCard(d) { + var elapsed = d.durationMs || (Date.now() - d.startedAt); + var duration = formatDuration(elapsed); + var started = D.timeAgo(new Date(d.startedAt).toISOString()); + var desc = (d.description || "").slice(0, 120); + if (d.description && d.description.length > 120) desc += "..."; + var promptName = (d.promptPath || "").split("/").pop() || ""; + var logName = (d.logPath || "").split("/").pop() || ""; + var isRunning = d.status === "running"; + + return '
' + + '
' + (isRunning ? "\u{1F4BB}" : d.status === "completed" ? "\u2705" : "\u274C") + "
" + + '
' + + '
' + D.esc(d.name) + "
" + + '
' + D.esc(started) + + (duration ? " \u00B7 " + duration : "") + + (d.exitCode !== undefined && d.exitCode !== null ? " \u00B7 exit " + d.exitCode : "") + + "
" + + (desc ? '
' + D.esc(desc) + "
" : "") + + '
' + + (promptName ? '\u{1F4C4} ' + D.esc(promptName) + "" : "") + + (logName ? '\u{1F4CB} ' + D.esc(logName) + "" : "") + + "
" + + "
" + + statusBadge(d.status) + + "
"; + } + + function render() { + var el = document.getElementById("panel-delegations"); + if (!el) return; + + if (delegations.length === 0) { + el.innerHTML = '
\u{1F4BB}No Claude Code delegations
'; + manageFastRefresh(false); + return; + } + + var running = delegations.filter(function (d) { return d.status === "running"; }); + var completed = delegations.filter(function (d) { return d.status === "completed"; }).slice(0, 10); + var failed = delegations.filter(function (d) { return d.status === "failed"; }).slice(0, 5); + + var html = '
'; + if (running.length > 0) { + html += '
Running
'; + html += running.map(renderCard).join(""); + } + if (completed.length > 0) { + html += (running.length > 0 ? '
Recent
' : ""); + html += completed.map(renderCard).join(""); + } + if (failed.length > 0) { + html += '
Failed
'; + html += failed.map(renderCard).join(""); + } + html += "
"; + el.innerHTML = html; + + manageFastRefresh(running.length > 0); + } + + function manageFastRefresh(hasRunning) { + if (hasRunning && !fastRefreshInterval) { + fastRefreshInterval = setInterval(refresh, 5000); + } else if (!hasRunning && fastRefreshInterval) { + clearInterval(fastRefreshInterval); + fastRefreshInterval = null; + } + } + + async function refresh() { + try { + var data = await D.fetchApi("delegations"); + if (data && Array.isArray(data.delegations)) { + delegations = data.delegations; + } + } catch (e) { + // Keep existing data + } + render(); + } + + async function init() { + render(); + await refresh(); + } + + D.registerPanel("delegations", { init: init, refresh: refresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-files.js b/bates-core/plugins/dashboard/static/js/panel-files.js new file mode 100644 index 0000000..055753c --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-files.js @@ -0,0 +1,163 @@ +/** + * File Explorer Panel + * Shows recently modified files in the workspace + */ +(function () { + const D = window.Dashboard; + + function fileIcon(name) { + if (name.endsWith(".md")) return "📄"; + if (name.endsWith(".json")) return "{"; + if (name.endsWith(".sh")) return "⚙"; + if (name.endsWith(".ts") || name.endsWith(".js")) return "✎"; + if (name.endsWith(".py")) return "🐍"; + if (name.endsWith(".pptx")) return "📊"; + if (name.endsWith(".html") || name.endsWith(".css")) return "🌐"; + return "📄"; + } + + function render(files) { + const el = document.getElementById("panel-files"); + if (!el) return; + + if (!files || files.length === 0) { + el.innerHTML = '
📁No recent files
'; + return; + } + + let html = '
'; + for (const file of files) { + const dir = file.path.includes("/") ? file.path.substring(0, file.path.lastIndexOf("/")) : ""; + // Configure your OneDrive base URL here (tenant-my.sharepoint.com/personal/user_tenant_com/...) + const oneDriveBase = 'https://TENANT-my.sharepoint.com/personal/USER_TENANT_COM/_layouts/15/onedrive.aspx?id=/personal/USER_TENANT_COM/Documents/'; + const isDraft = file.path && file.path.startsWith('drafts/'); + const webUrl = file.webUrl || (isDraft ? oneDriveBase + encodeURIComponent(file.path) : ''); + const nameHtml = webUrl + ? `${D.esc(file.name)}` + : `${D.esc(file.name)}`; + html += ` +
+ ${fileIcon(file.name)} +
+
${nameHtml}
+ ${dir ? `
${D.esc(dir)}
` : ""} +
+
+
${D.timeAgo(file.modified)}
+
${D.formatSize(file.size)}
+
+
`; + } + html += "
"; + el.innerHTML = html; + } + + const SHOW_EXTS = new Set(['.docx','.xlsx','.pptx','.pdf','.md','.html','.png','.jpg','.jpeg','.txt','.gif','.webp','.csv']); + + function isUserFile(name) { + const dot = name.lastIndexOf('.'); + if (dot < 0) return false; + return SHOW_EXTS.has(name.substring(dot).toLowerCase()); + } + + async function refresh() { + const files = await D.fetchApi("files"); + const all = Array.isArray(files) ? files : []; + render(all.filter(f => isUserFile(f.name || ''))); + } + + let _refreshInterval = null; + let _lastUpdated = null; + + function updateTimestamp() { + const el = document.getElementById("panel-files"); + if (!el) return; + let ts = el.querySelector('.panel-last-updated'); + if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); } + if (_lastUpdated) { + const s = ((Date.now() - _lastUpdated) / 1000) | 0; + ts.textContent = `last updated: ${s}s ago`; + } + } + + const _origRefresh = refresh; + async function autoRefresh() { + await _origRefresh(); + _lastUpdated = Date.now(); + updateTimestamp(); + } + + function startAutoRefresh() { + stopAutoRefresh(); + _refreshInterval = setInterval(autoRefresh, 120000); + setInterval(updateTimestamp, 10000); + } + function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } } + + async function initPanel() { + await autoRefresh(); + startAutoRefresh(); + } + + window._showFileContent = async function(path) { + const ov = document.getElementById('soul-modal-overlay'); + if (!ov) return; + const titleEl = document.getElementById('soul-modal-title'); + const bodyEl = document.getElementById('soul-modal-body'); + titleEl.textContent = '📄 ' + path; + const absPath = '~/.openclaw/workspace/' + path; + const ext = path.split('.').pop().toLowerCase(); + const typeMap = {md:'Markdown',json:'JSON',ts:'TypeScript',js:'JavaScript',py:'Python',sh:'Shell',html:'HTML',css:'CSS',txt:'Text',csv:'CSV',yaml:'YAML',yml:'YAML'}; + const fileType = typeMap[ext] || ext.toUpperCase(); + + // Try to fetch file content from the API + let contentHtml = ''; + try { + const resp = await D.fetchApi('file?path=' + encodeURIComponent(path)); + if (resp && !resp.error) { + const text = resp.content || ''; + if (text && text !== 'Not found') { + contentHtml = ` +
+
Contents
+
${Dashboard.esc(text)}
+
+
+ ⬇ Download + +
`; + } + } + } catch(e) {} + + if (!contentHtml) { + contentHtml = ` +
+
+ 📂 Full path: +
+ ${Dashboard.esc(absPath)} + +
`; + } + + bodyEl.innerHTML = ` +
+
+
File Details
+
+
Path: ${Dashboard.esc(path)}
+
Type: ${Dashboard.esc(fileType)}
+
+
+ ${contentHtml} +
`; + ov.classList.add('visible'); + }; + + D.registerPanel("files", { + init: initPanel, + refresh: autoRefresh, + stopAutoRefresh, + }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-integrations.js b/bates-core/plugins/dashboard/static/js/panel-integrations.js new file mode 100644 index 0000000..07671b9 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-integrations.js @@ -0,0 +1,87 @@ +/** + * Integrations Panel — MCP Servers & External Services (Live Data Only) + */ +(function () { + const D = window.Dashboard; + + function render(healthData) { + const el = document.getElementById('panel-integrations'); + if (!el) return; + + if (!healthData || !healthData.servers || !healthData.servers.length) { + el.innerHTML = '
⏳ Checking MCP server health...
'; + return; + } + + let h = '
MCP Servers (Live Health)
'; + h += '
'; + + const servers = healthData.servers.sort((a, b) => { + if (a.healthy !== b.healthy) return a.healthy ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + for (const s of servers) { + const statusColor = s.healthy ? 'var(--green)' : 'var(--red, #ef4444)'; + const statusText = s.healthy + ? `✓ ${s.tools} tool${s.tools !== 1 ? 's' : ''} · ${s.responseTime}s` + : '✗ Unhealthy'; + h += `
+
+
+
${D.esc(s.name)}
+
${statusText}
+
+
`; + } + h += '
'; + + const healthy = servers.filter(s => s.healthy).length; + h += `
${healthy}/${servers.length} servers healthy
`; + + el.innerHTML = h; + } + + async function refresh() { + let healthData = null; + try { + healthData = await D.fetchApi('integrations/health'); + } catch {} + render(healthData); + } + + let _refreshInterval = null; + let _lastUpdated = null; + + function updateTimestamp() { + const el = document.getElementById('panel-integrations'); + if (!el) return; + let ts = el.querySelector('.panel-last-updated'); + if (!ts) { ts = document.createElement('div'); ts.className = 'panel-last-updated'; el.appendChild(ts); } + if (_lastUpdated) { + const s = ((Date.now() - _lastUpdated) / 1000) | 0; + ts.textContent = `last updated: ${s}s ago`; + } + } + + const _origRefresh = refresh; + async function autoRefresh() { + await _origRefresh(); + _lastUpdated = Date.now(); + updateTimestamp(); + } + + function startAutoRefresh() { + stopAutoRefresh(); + _refreshInterval = setInterval(autoRefresh, 120000); + setInterval(updateTimestamp, 10000); + } + function stopAutoRefresh() { if (_refreshInterval) { clearInterval(_refreshInterval); _refreshInterval = null; } } + + async function initPanel() { + await autoRefresh(); + startAutoRefresh(); + } + + D.registerPanel('integrations', { init: initPanel, refresh: autoRefresh, stopAutoRefresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-memory.js b/bates-core/plugins/dashboard/static/js/panel-memory.js new file mode 100644 index 0000000..2548f16 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-memory.js @@ -0,0 +1,177 @@ +/** + * Live Memory Feed Panel + * Shows observation data + real-time agent events + */ +(function () { + const D = window.Dashboard; + let entries = []; + let activeFilter = null; + const MAX_ENTRIES = 100; + + const CATEGORIES = ["goal", "fact", "preference", "deadline", "decision", "contact", "pattern", "agent"]; + + function parseObservations(data) { + const items = []; + if (!data) return items; + + for (const [filename, content] of Object.entries(data)) { + if (filename.endsWith(".json")) { + // Parse JSON observations (like last-checkin.json) + try { + const obj = JSON.parse(content); + if (obj.last_run) { + items.push({ + timestamp: obj.last_run, + tag: "agent", + content: `Check-in: ${obj.items_reported_today || 0} items reported, ${obj.skipped_runs || 0} skipped`, + }); + } + } catch {} + continue; + } + + // Parse markdown observations + const category = filename.replace(".md", "").replace("file-index", "fact"); + if (!CATEGORIES.includes(category) && category !== "file-index") continue; + + const lines = content.split("\n"); + let currentEntry = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("|") || trimmed.startsWith("---")) continue; + + // Date-prefixed entry: "- 2026-02-07: Something" + const dateMatch = trimmed.match(/^-\s*(\d{4}-\d{2}-\d{2}):\s*(.+)/); + if (dateMatch) { + items.push({ + timestamp: dateMatch[1] + "T12:00:00Z", + tag: category, + content: dateMatch[2], + }); + continue; + } + + // Bullet entry without date + const bulletMatch = trimmed.match(/^[-*]\s+(.+)/); + if (bulletMatch) { + items.push({ + timestamp: null, + tag: category, + content: bulletMatch[1], + }); + } + } + } + + return items; + } + + function addAgentEvent(data) { + const content = data.text || data.message || data.delta || JSON.stringify(data).slice(0, 200); + if (!content || content === "{}") return; + + entries.unshift({ + timestamp: new Date().toISOString(), + tag: "agent", + content: String(content).slice(0, 300), + }); + + if (entries.length > MAX_ENTRIES) { + entries = entries.slice(0, MAX_ENTRIES); + } + + render(); + } + + function render() { + const el = document.getElementById("panel-memory"); + if (!el) return; + + const filtered = activeFilter ? entries.filter((e) => e.tag === activeFilter) : entries; + + if (filtered.length === 0) { + el.innerHTML = '
No observations yet
'; + return; + } + + let html = '
'; + for (const entry of filtered) { + const ts = entry.timestamp + ? new Date(entry.timestamp).toLocaleDateString("en-GB", { month: "short", day: "numeric" }) + : ""; + html += ` +
+ ${D.esc(ts)} + ${D.esc(entry.tag)} + ${D.esc(entry.content)} +
`; + } + html += "
"; + el.innerHTML = html; + } + + function setupFilters() { + const bar = document.getElementById("memory-filters"); + if (!bar) return; + + let html = ``; + for (const cat of CATEGORIES) { + html += ``; + } + bar.innerHTML = html; + + bar.addEventListener("click", (e) => { + const btn = e.target.closest(".filter-btn"); + if (!btn) return; + const filter = btn.dataset.filter; + activeFilter = filter === "all" ? null : filter; + bar.querySelectorAll(".filter-btn").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + render(); + }); + } + + async function refresh() { + const data = await D.fetchApi("observations"); + if (data && !data.error) { + const parsed = parseObservations(data); + // Merge new observations, keeping agent events from WebSocket + const agentEntries = entries.filter((e) => e.tag === "agent" && e.timestamp); + entries = [...agentEntries, ...parsed]; + // Sort: dated entries by date desc, undated at the end + entries.sort((a, b) => { + if (!a.timestamp && !b.timestamp) return 0; + if (!a.timestamp) return 1; + if (!b.timestamp) return -1; + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + }); + if (entries.length > MAX_ENTRIES) entries = entries.slice(0, MAX_ENTRIES); + } + render(); + } + + async function init(gw) { + setupFilters(); + await refresh(); + + // Subscribe to real-time agent events + if (gw) { + gw.subscribe("agent", (data) => { + if (data.event === "agent.assistant" || data.type === "assistant") { + addAgentEvent(data); + } + }); + gw.subscribe("*", (data) => { + if (data.event && data.event.includes("memory")) { + addAgentEvent(data); + } + }); + } + } + + D.registerPanel("memory", { + init, + refresh, + }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-rollout.js b/bates-core/plugins/dashboard/static/js/panel-rollout.js new file mode 100644 index 0000000..9989d21 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-rollout.js @@ -0,0 +1,112 @@ +/** + * Bates Rollout Panel — Agent deployment status by layer + * Fetches from gateway API + */ +(function () { + const D = window.Dashboard; + + const LAYERS = [ + { name: 'Layer 1 — COO', agents: [{ name: 'Bates', role: 'Chief Operating Officer' }] }, + { name: 'Layer 2 — Deputies', agents: [ + { name: 'Conrad', role: 'fDesk Deputy' }, + { name: 'Soren', role: 'SynapseLayer Deputy' }, + { name: 'Amara', role: 'Escola Caravela Deputy' }, + { name: 'Jules', role: 'Personal Deputy' }, + { name: 'Dash', role: 'DevOps Deputy' }, + ]}, + { name: 'Layer 3 — Specialists', agents: [ + { name: 'Mercer', role: 'Finance Specialist' }, + { name: 'Kira', role: 'Content Specialist' }, + { name: 'Nova', role: 'Research Specialist' }, + { name: 'Paige', role: 'Documentation Specialist' }, + { name: 'Quinn', role: 'QA Specialist' }, + { name: 'Archer', role: 'Architecture Specialist' }, + ]}, + ]; + + const ALL_AGENTS = LAYERS.flatMap(l => l.agents); + + function findAgent(apiAgents, name) { + if (!apiAgents) return null; + return apiAgents.find(a => a.name && a.name.toLowerCase() === name.toLowerCase()); + } + + function render(apiAgents) { + const el = document.getElementById('panel-rollout'); + if (!el) return; + + const deployed = ALL_AGENTS.filter(a => findAgent(apiAgents, a.name)).length; + const total = ALL_AGENTS.length; + const pct = Math.round((deployed / total) * 100); + + let html = ''; + + // Progress bar + html += `
+
+ + ${deployed}/${total} agents (${pct}%) +
+
+
`; + + // Layers + for (const layer of LAYERS) { + html += `
+ +
`; + + for (const agentDef of layer.agents) { + const data = findAgent(apiAgents, agentDef.name); + const exists = !!data; + const model = data && data.model ? data.model : '—'; + const workspace = data && data.workspace !== undefined ? (data.workspace ? '✓' : '✗') : (exists ? '✓' : '✗'); + const wsClass = workspace === '✓' ? 'ok' : 'error'; + const heartbeat = data && data.heartbeat ? data.heartbeat : null; + const hbActive = heartbeat && (heartbeat.active || heartbeat.enabled || heartbeat.cron); + const hbTime = data && (data.lastHeartbeat || data.last_heartbeat || (heartbeat && heartbeat.last)) ? D.timeAgo(data.lastHeartbeat || data.last_heartbeat || heartbeat.last) : '—'; + const statusIcon = exists ? '☑' : '☐'; + const statusClass = exists ? 'rollout-deployed' : 'rollout-pending'; + + // Model badge + let modelClass = 'other'; + const ml = model.toLowerCase(); + if (ml.includes('opus')) modelClass = 'opus'; + else if (ml.includes('sonnet')) modelClass = 'sonnet'; + else if (ml.includes('gemini')) modelClass = 'gemini'; + + html += `
+ ${statusIcon} +
+
${D.esc(agentDef.name)} ${D.esc(data && data.role ? data.role : agentDef.role)}
+
+ ${D.esc(model)} + + ${hbActive ? '⏱ Active' : '⏱ Inactive'} + Last: ${D.esc(hbTime)} +
+
+
`; + } + + html += '
'; + } + + el.innerHTML = html; + } + + async function refresh() { + try { + const data = await D.fetchApi('agents'); + if (data) { + const agents = Array.isArray(data) ? data : (data && data.agents ? data.agents : []); + render(agents); + return; + } + } catch {} + const el = document.getElementById('panel-rollout'); + if (el) el.innerHTML = '
🚀No data available
Could not reach gateway API
'; + } + + D.registerPanel('rollout', { init: refresh, refresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-settings.js b/bates-core/plugins/dashboard/static/js/panel-settings.js new file mode 100644 index 0000000..d31ba72 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-settings.js @@ -0,0 +1,536 @@ +/** + * Settings Panel — Sub-tabbed, collapsible, with whitelist editor + * Tabs: Overview | M365 Safety | Whitelists + */ +(function () { + const D = window.Dashboard; + let _data = null; + let _whitelist = null; + let _activeTab = 'overview'; + + function authHeaders(extra) { + const h = {}; + const token = window.__GATEWAY_CONFIG?.token; + if (token) h['Authorization'] = 'Bearer ' + token; + return Object.assign(h, extra || {}); + } + + // ── Collapsible card ── + function collapseCard(id, title, contentHtml, opts) { + const open = opts?.open !== false; + const cls = opts?.cls || ''; + return `
+
+ ${D.esc(title)} + +
+
${contentHtml}
+
`; + } + + function kvRows(items) { + return items.map(([k, v]) => + `
${D.esc(k)}${D.esc(String(v))}
` + ).join(''); + } + + // ── Tab: Overview ── + function renderOverview(d) { + return '
' + + collapseCard('model', 'Model', kvRows([ + ['Primary', d.default_model || '\u2014'], + ['Fallbacks', (d.model_fallbacks || []).join(', ') || '\u2014'], + ])) + + collapseCard('fleet', 'Fleet', kvRows([ + ['Agents', d.num_agents || '\u2014'], + ['Cron Jobs', d.num_cron_jobs || '\u2014'], + ['Enabled', d.num_cron_enabled || '\u2014'], + ])) + + collapseCard('session', 'Session', kvRows([ + ['Reset Mode', d.session_reset_mode || '\u2014'], + ['Idle Timeout', (d.session_idle_minutes || '?') + 'm'], + ['Gateway Port', d.gateway_port || '\u2014'], + ])) + + collapseCard('compaction', 'Compaction', kvRows([ + ['Mode', d.compaction_mode || '\u2014'], + ['Reserve Tokens', d.compaction_reserve_tokens || '\u2014'], + ['Max History', d.compaction_max_history || '\u2014'], + ])) + + '
'; + } + + // ── Tab: M365 Safety ── + function renderSafety(m365) { + if (!m365) return '
M365 safety data unavailable
'; + const isOverride = m365.override_active; + + let html = collapseCard('safety-status', 'Safety Gateway Status', (() => { + const statusClass = isOverride ? 'safety-status-danger' : 'safety-status-ok'; + const statusText = isOverride ? 'ALL PROTECTION DISABLED' : 'Active'; + const statusIcon = isOverride ? '\u26A0\uFE0F' : '\u2705'; + let inner = `
+ Enforcement + ${statusIcon} ${statusText} +
`; + + if (!isOverride) { + inner += `
+ +
Removes email whitelist, calendar protection, and Graph API interception
+
`; + } else { + inner += `
+
\uD83D\uDEA8
+
+ Safety Override Active
+ The agent has unrestricted access to Microsoft 365. +
+
+ `; + } + return inner; + })(), { cls: isOverride ? 'safety-card-danger' : '' }); + + return html; + } + + // ── Tab: Whitelists ── + function renderWhitelists(wl) { + if (!wl) return '
Loading whitelist...
'; + if (wl.error) return `
Whitelist error: ${D.esc(wl.error)}
`; + + let html = ''; + + // Email + html += collapseCard('wl-email', 'Email Recipients', (() => { + let inner = '
'; + inner += '
Allowed Domains
'; + inner += renderTagList('email-domains', wl.email?.allowed_domains || [], 'e.g. example.com'); + inner += '
Allowed Addresses
'; + inner += renderTagList('email-addrs', wl.email?.allowed_addresses || [], 'e.g. user@example.com'); + inner += kvRows([ + ['Max Recipients', wl.email?.max_recipients || 10], + ['Block Distribution Lists', wl.email?.block_distribution_lists ? 'Yes' : 'No'], + ]); + return inner + '
'; + })()); + + // Calendar + html += collapseCard('wl-calendar', 'Calendar Attendees', (() => { + let inner = '
'; + inner += '
Allowed Domains
'; + inner += renderTagList('cal-domains', wl.calendar?.allowed_domains || [], 'e.g. example.com'); + inner += '
Allowed Addresses
'; + inner += renderTagList('cal-addrs', wl.calendar?.allowed_addresses || [], 'e.g. user@example.com'); + inner += kvRows([ + ['Allow No-Attendee Events', wl.calendar?.allow_no_attendee_events ? 'Yes' : 'No'], + ['Max Past Days', wl.calendar?.max_past_days || 0], + ]); + return inner + '
'; + })()); + + // OneDrive + html += collapseCard('wl-onedrive', 'OneDrive', (() => { + let inner = '
'; + inner += '
Allowed Write Paths
'; + inner += renderTagList('od-paths', wl.onedrive?.allowed_write_paths || [], 'e.g. /drafts/'); + inner += kvRows([ + ['Block External Sharing', wl.onedrive?.block_external_sharing ? 'Yes' : 'No'], + ['Block Delete', wl.onedrive?.block_delete ? 'Yes' : 'No'], + ]); + return inner + '
'; + })(), { open: false }); + + // Rate Limits + html += collapseCard('wl-rates', 'Rate Limits', kvRows([ + ['Global (per min)', wl.rate_limits?.global || 60], + ['Email Send (per min)', wl.rate_limits?.email_send || 5], + ['Calendar Create (per min)', wl.rate_limits?.calendar_create || 10], + ]), { open: false }); + + return html; + } + + function renderTagList(id, items, placeholder) { + let html = `
`; + for (const item of items) { + html += `${D.esc(item)}`; + } + html += `
+ + +
`; + html += '
'; + return html; + } + + // ── Whitelist mutation ── + function getWhitelistPath(listId) { + const map = { + 'email-domains': ['email', 'allowed_domains'], + 'email-addrs': ['email', 'allowed_addresses'], + 'cal-domains': ['calendar', 'allowed_domains'], + 'cal-addrs': ['calendar', 'allowed_addresses'], + 'od-paths': ['onedrive', 'allowed_write_paths'], + }; + return map[listId] || null; + } + + async function addToWhitelist(listId, value) { + const path = getWhitelistPath(listId); + if (!path) return; + const res = await fetch('/dashboard/api/settings/whitelist', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ action: 'add', section: path[0], field: path[1], value }), + }); + const r = await res.json(); + if (r.success) { _whitelist = null; await loadAndRenderTab(); } + } + + async function removeFromWhitelist(listId, value) { + const path = getWhitelistPath(listId); + if (!path) return; + const res = await fetch('/dashboard/api/settings/whitelist', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ action: 'remove', section: path[0], field: path[1], value }), + }); + const r = await res.json(); + if (r.success) { _whitelist = null; await loadAndRenderTab(); } + } + + // ── Event delegation ── + function attachHandlers() { + const el = document.getElementById('panel-settings'); + if (!el) return; + + el.addEventListener('click', async (e) => { + const t = e.target; + + // Sub-tab navigation + if (t.closest('.s-tab')) { + const tab = t.closest('.s-tab').dataset.tab; + if (tab) { _activeTab = tab; await loadAndRenderTab(); } + return; + } + + // Tag remove + if (t.classList.contains('wl-tag-rm')) { + const listId = t.dataset.list; + const val = t.dataset.val; + if (listId && val) await removeFromWhitelist(listId, val); + return; + } + + // Tag add + if (t.classList.contains('wl-add-btn')) { + const listId = t.dataset.list; + const input = document.getElementById('wl-input-' + listId); + const val = input?.value?.trim(); + if (listId && val) { await addToWhitelist(listId, val); } + return; + } + + // Task provider: remove + if (t.dataset.tpRemove) { + if (confirm('Remove task source "' + t.dataset.tpRemove + '"?')) { + await disconnectTaskProvider(t.dataset.tpRemove); + } + return; + } + // Task provider: add planner + if (t.dataset.tpAddPlanner) { + await connectTaskProvider('planner', t.dataset.tpAddPlanner, t.dataset.tpName); + return; + } + // Task provider: add todo + if (t.dataset.tpAddTodo) { + await connectTaskProvider('todo', t.dataset.tpAddTodo, t.dataset.tpName); + return; + } + + // Safety disable + if (t.id === 'm365-safety-disable-btn' || t.closest('#m365-safety-disable-btn')) { + showConfirmDialog(); + return; + } + + // Safety restore + if (t.id === 'm365-safety-restore-btn' || t.closest('#m365-safety-restore-btn')) { + try { + const res = await fetch('/dashboard/api/settings/m365-safety', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ enforcement: 'active' }), + }); + const result = await res.json(); + if (result.success) { + showRestartBanner('Safety protection restored. Gateway restart required.'); + _data = null; await loadAndRenderTab(); + } + } catch (e) { console.error('Restore failed:', e); } + return; + } + }); + + // Enter key on whitelist inputs + el.addEventListener('keydown', async (e) => { + if (e.key === 'Enter' && e.target.classList.contains('wl-add-input')) { + const input = e.target; + const listId = input.id.replace('wl-input-', ''); + const val = input.value.trim(); + if (listId && val) await addToWhitelist(listId, val); + } + }); + } + + // ── Confirm dialog (unchanged) ── + function showConfirmDialog() { + const existing = document.getElementById('safety-confirm-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'safety-confirm-overlay'; + overlay.className = 'safety-overlay'; + overlay.innerHTML = ` +
+
+ \uD83D\uDEA8 + \uD83D\uDEA8 + \uD83D\uDEA8 +
+

Disable M365 Safety Protection?

+
+

This will completely disable all Microsoft 365 safety measures:

+
    +
  • \u274C Email recipient whitelist \u2014 agent can email anyone
  • +
  • \u274C Calendar attendee protection \u2014 agent can invite anyone
  • +
  • \u274C Graph API interception \u2014 agent gets unrestricted API access
  • +
  • \u274C Audit logging \u2014 actions will not be logged
  • +
+

Only use this if the safety gateway is causing critical failures.

+
+
+ +
+
+ + +
+
`; + document.body.appendChild(overlay); + + document.getElementById('safety-confirm-check').addEventListener('change', (e) => { + document.getElementById('safety-confirm-btn').disabled = !e.target.checked; + }); + document.getElementById('safety-cancel-btn').addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + + document.getElementById('safety-confirm-btn').addEventListener('click', async () => { + const btn = document.getElementById('safety-confirm-btn'); + btn.disabled = true; btn.textContent = 'Disabling...'; + try { + const res = await fetch('/dashboard/api/settings/m365-safety', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ enforcement: 'OVERRIDE_ALL_SAFETY' }), + }); + const result = await res.json(); + overlay.remove(); + if (result.success) { + showRestartBanner('Safety protection DISABLED. Gateway restart required.'); + _data = null; await loadAndRenderTab(); + } + } catch (e) { + console.error(e); + btn.disabled = false; btn.textContent = 'Disable All Protection'; + } + }); + } + + function showRestartBanner(message) { + const existing = document.getElementById('safety-restart-banner'); + if (existing) existing.remove(); + const banner = document.createElement('div'); + banner.id = 'safety-restart-banner'; + banner.className = 'safety-restart-banner'; + banner.innerHTML = `${D.esc(message)} + `; + const panel = document.getElementById('panel-settings'); + if (panel) panel.prepend(banner); + } + + // ── Tab: Task Providers ── + let _providers = null; + + function renderTaskProviders() { + if (!_providers) return '
Loading task providers...
'; + if (_providers.error) return `
Error: ${D.esc(_providers.error)}
`; + + let html = ''; + + // Connected plans + html += collapseCard('tp-connected', 'Connected Task Sources', (() => { + const connected = _providers.connected || {}; + if (!Object.keys(connected).length) return '
No task sources connected yet.
'; + let inner = ''; + for (const [key, plan] of Object.entries(connected)) { + const p = plan; + const icon = p.source === 'planner' ? '📋' : '✅'; + const idLabel = p.source === 'planner' ? p.planId : (p.todoListId || '').slice(0, 20) + '...'; + inner += `
+
+ ${icon} + ${D.esc(p.name)} + ${D.esc(key)} · ${D.esc(p.source)} +
+ +
`; + } + return inner; + })()); + + // Available Planner plans + html += collapseCard('tp-planner', 'Available Planner Plans', (() => { + const plans = (_providers.plans || []).filter(p => !p.error); + if (!plans.length) return '
No Planner plans found or discovery failed.
'; + const connected = _providers.connected || {}; + const connectedIds = new Set(Object.values(connected).filter(p => p.source === 'planner').map(p => p.planId)); + let inner = ''; + for (const p of plans) { + const isConnected = connectedIds.has(p.id); + inner += `
+
+ 📋 ${D.esc(p.title)} + ${D.esc(p.id.slice(0, 12))}... +
+ ${isConnected + ? 'Connected' + : `` + } +
`; + } + return inner; + })(), { open: false }); + + // Available To Do lists + html += collapseCard('tp-todo', 'Available To Do Lists', (() => { + const lists = (_providers.todoLists || []).filter(l => !l.error); + if (!lists.length) return '
No To Do lists found or discovery failed.
'; + const connected = _providers.connected || {}; + const connectedIds = new Set(Object.values(connected).filter(p => p.source === 'todo').map(p => p.todoListId)); + let inner = ''; + for (const l of lists) { + const isConnected = connectedIds.has(l.id); + inner += `
+
+ ✅ ${D.esc(l.displayName)} + ${l.wellknownListName ? `(${D.esc(l.wellknownListName)})` : ''} +
+ ${isConnected + ? 'Connected' + : `` + } +
`; + } + return inner; + })(), { open: false }); + + return html; + } + + async function connectTaskProvider(source, id, name) { + const key = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const body = { action: 'add', key, name, source }; + if (source === 'planner') body.planId = id; + else body.todoListId = id; + try { + const res = await fetch('/dashboard/api/tasks/connect', { + method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(body) + }); + const r = await res.json(); + if (r.success) { _providers = null; await loadAndRenderTab(); } + } catch (e) { console.error('Connect failed:', e); } + } + + async function disconnectTaskProvider(key) { + try { + const res = await fetch('/dashboard/api/tasks/connect', { + method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ action: 'remove', key }) + }); + const r = await res.json(); + if (r.success) { _providers = null; await loadAndRenderTab(); } + } catch (e) { console.error('Disconnect failed:', e); } + } + + // ── Main render ── + function renderTabs() { + const tabs = [ + { id: 'overview', label: 'Overview' }, + { id: 'tasks', label: 'Task Providers' }, + { id: 'safety', label: 'M365 Safety' }, + { id: 'whitelists', label: 'Whitelists' }, + ]; + return '
' + + tabs.map(t => ``).join('') + + '
'; + } + + async function loadAndRenderTab() { + const el = document.getElementById('panel-settings'); + if (!el) return; + + // Load settings if needed + if (!_data) { + try { _data = await D.fetchApi('settings'); } catch {} + } + if (!_data || _data.error) { + el.innerHTML = '
Settings unavailable
'; + return; + } + + let body = ''; + if (_activeTab === 'overview') { + body = renderOverview(_data); + } else if (_activeTab === 'tasks') { + if (!_providers) { + try { _providers = await D.fetchApi('tasks/providers'); } catch {} + } + body = renderTaskProviders(); + } else if (_activeTab === 'safety') { + body = renderSafety(_data.m365_safety); + } else if (_activeTab === 'whitelists') { + if (!_whitelist) { + try { _whitelist = await D.fetchApi('settings/whitelist'); } catch {} + } + body = renderWhitelists(_whitelist); + } + + el.innerHTML = renderTabs() + body; + } + + async function refresh() { + _data = null; + _whitelist = null; + await loadAndRenderTab(); + } + + function init() { + attachHandlers(); + refresh(); + } + + D.registerPanel('settings', { init, refresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-standup.js b/bates-core/plugins/dashboard/static/js/panel-standup.js new file mode 100644 index 0000000..6a4174d --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-standup.js @@ -0,0 +1,120 @@ +/** + * Standup Panel — Conversation-style standup view with date navigation + */ +(function () { + const D = window.Dashboard; + let currentDate = new Date().toISOString().slice(0, 10); + let availableDates = []; + + function getAvatar(name) { + const id = (name || '').toLowerCase(); + const src = window.AGENT_AVATARS?.[id]; + if (src) { + return ``; + } + return '🤖'; + } + + function renderStandups(data) { + const el = document.getElementById('panel-standup'); + if (!el) return; + + const standups = data?.standups || []; + const dates = data?.dates || []; + availableDates = dates; + + let h = `
+

📋 Daily Standup

+
+ + ${D.esc(currentDate)} + +
+
+ +
+
`; + + if (!standups.length) { + h += `
+
📋
+
No standup for ${D.esc(currentDate)}
+
Standups run at 08:30 via the daily-standup-compile cron job.
+
`; + el.innerHTML = h; + wireNav(); + return; + } + + h += `
${standups.length} agent${standups.length !== 1 ? 's' : ''} reported
`; + h += '
'; + for (const msg of standups) { + const name = msg.name || msg.agent || 'Unknown'; + const role = msg.role || ''; + const text = msg.message || msg.content || msg.text || ''; + const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : ''; + + // Format standup text — parse **Yesterday:** **Today:** **Blockers:** sections + const formatted = formatStandupText(text); + + h += `
+
${getAvatar(name)}
+
+
+ ${D.esc(name)} + ${D.esc(role)} + ${time ? `${time}` : ''} +
+
${formatted}
+
+
`; + } + h += '
'; + el.innerHTML = h; + wireNav(); + } + + function formatStandupText(text) { + // Parse standup format and add structure + return D.esc(text) + .replace(/\*\*(Yesterday|Today|Blockers?|Flags?|Flag):\*\*/gi, '$1:') + .replace(/\n/g, '
'); + } + + function wireNav() { + document.getElementById('standup-prev')?.addEventListener('click', () => navigateDate(-1)); + document.getElementById('standup-next')?.addEventListener('click', () => navigateDate(1)); + document.getElementById('standup-date-picker')?.addEventListener('change', (e) => { + currentDate = e.target.value; + refresh(); + }); + } + + function navigateDate(delta) { + const idx = availableDates.indexOf(currentDate); + if (idx < 0) { + // Find nearest date + const d = new Date(currentDate); + d.setDate(d.getDate() + delta); + currentDate = d.toISOString().slice(0, 10); + } else { + const newIdx = idx - delta; // dates are reverse sorted + if (newIdx >= 0 && newIdx < availableDates.length) { + currentDate = availableDates[newIdx]; + } + } + refresh(); + } + + async function refresh() { + try { + const data = await D.fetchApi(`standups?date=${currentDate}`); + if (data) { renderStandups(data); return; } + } catch {} + renderStandups({ standups: [], dates: availableDates }); + } + + D.registerPanel('standup', { init: refresh, refresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-status.js b/bates-core/plugins/dashboard/static/js/panel-status.js new file mode 100644 index 0000000..2bc19e1 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-status.js @@ -0,0 +1,93 @@ +/** + * System Status Panel + * Shows gateway, telegram, MCP, disk usage from health.json + */ +(function () { + const D = window.Dashboard; + + function render(health) { + const el = document.getElementById("panel-status"); + if (!el) return; + + if (!health || health.error) { + el.innerHTML = '
Health data unavailable
'; + return; + } + + const services = health.services || {}; + const gwStatus = services.openclaw_gateway || "unknown"; + const tgStatus = services.telegram_bot || "unknown"; + const disk = health.disk_usage_percent ?? -1; + const uptime = health.uptime_hours ?? 0; + const ts = health.timestamp; + const checkin = health.checkin_summary || {}; + + const gwClass = gwStatus === "running" ? "ok" : "down"; + const tgClass = tgStatus === "connected" ? "ok" : "error"; + const diskClass = disk > 80 ? "danger" : disk > 60 ? "warning" : ""; + const diskBarClass = disk > 80 ? "danger" : disk > 60 ? "warning" : ""; + + // MCP servers + const mcpEntries = Object.entries(services).filter(([k]) => k.startsWith("mcp_")); + let mcpHtml = ""; + if (mcpEntries.length > 0 && !services.mcp_note) { + for (const [key, val] of mcpEntries) { + const name = key.replace("mcp_", "").replace(/_/g, "-"); + const cls = val === "ok" ? "ok" : "error"; + mcpHtml += ` +
+ +
+ ${D.esc(name)} + ${D.esc(String(val))} +
+
`; + } + } + + el.innerHTML = ` +
+
+ +
+ Gateway + ${D.esc(gwStatus)}${uptime > 0 ? ` (${uptime}h)` : ""} +
+
+
+ +
+ Telegram + ${D.esc(tgStatus)} +
+
+
+ +
+ Disk + ${disk >= 0 ? disk + "%" : "N/A"} + ${disk >= 0 ? `
` : ""} +
+
+
+
+ Check-ins Today + ${checkin.items_reported_today ?? "N/A"} reported · ${checkin.skipped_runs ?? 0} skipped +
+
+ ${mcpHtml} +
+ ${ts ? `
Last health check: ${D.timeAgo(ts)}
` : ""} + `; + } + + async function refresh() { + const health = await D.fetchApi("health"); + render(health); + } + + D.registerPanel("status", { + init: refresh, + refresh: refresh, + }); +})(); diff --git a/bates-core/plugins/dashboard/static/js/panel-tasks.js b/bates-core/plugins/dashboard/static/js/panel-tasks.js new file mode 100644 index 0000000..3753236 --- /dev/null +++ b/bates-core/plugins/dashboard/static/js/panel-tasks.js @@ -0,0 +1,130 @@ +/** + * Tasks Panel — Aggregated Planner + To Do tasks + */ +(function () { + const D = window.Dashboard; + let lastData = null; + let sortMode = 'priority'; // priority | due | project + let filterProject = 'all'; + let showCompleted = false; + + const PRI_COLORS = { urgent: '#ff4757', important: '#ffa502', medium: '#00d4ff', low: '#747d8c' }; + const PRI_ORDER = { urgent: 0, important: 1, medium: 2, low: 3 }; + + function priDot(p) { + return ``; + } + + function renderControls(container) { + return `
+ + + +
`; + } + + function sortTasks(tasks) { + const sorted = [...tasks]; + switch (sortMode) { + case 'due': + sorted.sort((a, b) => { + if (a.completed !== b.completed) return a.completed ? 1 : -1; + if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate); + if (a.dueDate) return -1; + return b.dueDate ? 1 : 0; + }); + break; + case 'project': + sorted.sort((a, b) => (a.project || '').localeCompare(b.project || '') || (a.priorityNum ?? 5) - (b.priorityNum ?? 5)); + break; + default: // priority + sorted.sort((a, b) => { + if (a.completed !== b.completed) return a.completed ? 1 : -1; + return (a.priorityNum ?? 5) - (b.priorityNum ?? 5) || (a.dueDate || 'z').localeCompare(b.dueDate || 'z'); + }); + } + return sorted; + } + + function renderTaskRow(t) { + return D.renderTaskRow(t); + } + + function render() { + const el = document.getElementById('panel-tasks-body'); + if (!el || !lastData) return; + + let tasks = lastData.tasks || []; + if (filterProject !== 'all') tasks = tasks.filter(t => t.project === filterProject); + if (!showCompleted) tasks = tasks.filter(t => !t.completed); + tasks = sortTasks(tasks); + + let html = renderControls(); + + if (!tasks.length) { + html += '
No tasks to display
'; + } else if (sortMode === 'project') { + // Group by project + const groups = {}; + for (const t of tasks) { + const k = t.project || 'other'; + if (!groups[k]) groups[k] = { name: t.planName || k, tasks: [] }; + groups[k].tasks.push(t); + } + for (const [k, g] of Object.entries(groups)) { + html += `
${D.esc(g.name)} (${g.tasks.length})
`; + for (const t of g.tasks) html += renderTaskRow(t); + html += '
'; + } + } else { + for (const t of tasks) html += renderTaskRow(t); + } + + html += `
Updated ${D.timeAgo(lastData.updated)} · ${lastData.tasks?.length || 0} total tasks
`; + el.innerHTML = html; + + // Wire controls + document.getElementById('tasks-filter-project')?.addEventListener('change', e => { filterProject = e.target.value; render(); }); + document.getElementById('tasks-sort')?.addEventListener('change', e => { sortMode = e.target.value; render(); }); + document.getElementById('tasks-show-done')?.addEventListener('change', e => { showCompleted = e.target.checked; render(); }); + + // Wire click-to-open and complete buttons + D.wireTaskRows(el, () => { setTimeout(refresh, 1000); }); + } + + async function refresh() { + const el = document.getElementById('panel-tasks-body'); + if (el && !lastData) el.innerHTML = '
Loading tasks from Planner & To Do…
'; + try { + const data = await D.fetchApi('tasks'); + if (data && !data.error && !data['jwt-auth-error'] && data.tasks) { + lastData = data; + // Update overview metrics badge with pending task count + const pending = data.tasks.filter(t => !t.completed).length; + window._updateOverviewMetrics?.({ tasks: pending }); + render(); + } else { + if (el) el.innerHTML = `
⚠ ${D.esc(data?.error || 'Failed to load tasks')}
`; + } + } catch (e) { + if (el) el.innerHTML = `
⚠ ${D.esc(e.message)}
`; + } + } + + // Expose for project detail modals + window._getProjectTasks = function (projectKey) { + if (!lastData?.byProject?.[projectKey]) return null; + return lastData.byProject[projectKey]; + }; + + D.registerPanel('tasks', { init: refresh, refresh }); +})(); diff --git a/bates-core/plugins/dashboard/static/manifest.json b/bates-core/plugins/dashboard/static/manifest.json new file mode 100644 index 0000000..b5afb76 --- /dev/null +++ b/bates-core/plugins/dashboard/static/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Bates Command Center", + "short_name": "Bates", + "description": "AI Operations Dashboard — Agent orchestration & management", + "start_url": "/dashboard/", + "display": "standalone", + "background_color": "#060a18", + "theme_color": "#58c6e8", + "orientation": "any", + "icons": [ + { + "src": "/dashboard/assets/app-icon-small.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/dashboard/assets/app-icon-small.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/bates-core/plugins/dashboard/static/styles.css b/bates-core/plugins/dashboard/static/styles.css new file mode 100644 index 0000000..3480ba5 --- /dev/null +++ b/bates-core/plugins/dashboard/static/styles.css @@ -0,0 +1,2100 @@ +/* ═══════════════════════════════════════════════════════════ + OpenClaw Command Center — Glassmorphism Design System v5 + Inspired by: Crypto Wallet glassmorphism aesthetic + ═══════════════════════════════════════════════════════════ */ + +:root { + --bg: #060a14; + --glass-bg: rgba(12, 20, 45, 0.2); + --glass-bg-hover: rgba(20, 35, 70, 0.3); + --glass-border: rgba(90, 200, 232, 0.6); + --glass-border-hover: rgba(90, 200, 232, 0.85); + --glass-blur: blur(24px); + --nav-bg: rgba(8, 12, 25, 0.4); + + --blue: #1F4E8C; + --blue-lt: #3B7DD8; + --blue-glow: 0 0 20px rgba(31, 78, 140, 0.3); + --orange: #F08C2E; + --red: #D6452A; + --green: #22C55E; + --teal: #14B8A6; + --purple: #8B5CF6; + + --text: #E8EAED; + --text2: rgba(255, 255, 255, 0.5); + --text3: rgba(255, 255, 255, 0.3); + --text-muted: rgba(255, 255, 255, 0.25); + + --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --mono: var(--font-mono); + --r: 12px; + --r-sm: 8px; + --topbar: 56px; + --chat-w: 380px; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + background-color: #060a14; + background-image: url('/dashboard/assets/bg.png'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-attachment: fixed; + color: var(--text); + font: 13px/1.5 var(--font); + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +/* Dark overlay on top of background image — disabled, bg already blurred/matte */ +#bg-overlay { + display: none; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); } + +/* ─── Glass Card (core component) ─── */ +.glass-card { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-top: 1px solid rgba(90, 200, 232, 0.5); + border-radius: var(--r); + box-shadow: 0 0 8px rgba(90, 200, 232, 0.25), 0 0 20px rgba(90, 200, 232, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.06); + transition: border-color 0.3s, box-shadow 0.3s; +} +.glass-card:hover { + border-color: var(--glass-border-hover); + box-shadow: 0 0 12px rgba(90, 200, 232, 0.35), 0 0 30px rgba(90, 200, 232, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.glass-panel { + background: rgba(10, 18, 40, 0.25); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-top: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--r); + box-shadow: 0 0 8px rgba(90, 200, 232, 0.25), 0 0 20px rgba(90, 200, 232, 0.1); +} + +.glass-nav { + background: var(--nav-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border-bottom: 1px solid rgba(90, 200, 232, 0.4); + box-shadow: 0 0 10px rgba(90, 200, 232, 0.15), 0 4px 20px rgba(0, 0, 0, 0.3); +} + +/* ═══════════════ TOP BAR ═══════════════ */ +.topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--topbar); + display: flex; + align-items: center; + padding: 0 16px; + z-index: 100; + background: var(--nav-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border-bottom: 1px solid var(--glass-border); + gap: 12px; +} + +.topbar-left { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.topbar-logo { + height: 36px; + width: auto; + object-fit: contain; +} + +.topbar-logo-fallback { + font-weight: 700; + font-size: 16px; + letter-spacing: 1px; + color: var(--blue-lt); + display: flex; + align-items: center; + gap: 6px; +} + +.topbar-nav { + display: flex; + align-items: center; + gap: 4px; + margin: 0 auto; + flex-shrink: 0; +} + +.nav-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text2); + font: 12px/1 var(--font); + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} +.nav-tab:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text); +} +.nav-tab.active { + background: rgba(31, 78, 140, 0.3); + color: #fff; + box-shadow: var(--blue-glow), inset 0 0 0 1px rgba(31, 78, 140, 0.3); +} +.nav-icon { font-size: 14px; } +.nav-label { font-size: 12px; } + +.topbar-right { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + margin-left: auto; +} + +.topbar-clock { + font: 500 13px/1 var(--font-mono); + color: var(--text2); + letter-spacing: 0.5px; +} + +.conn-badge { + display: flex; + align-items: center; + gap: 6px; +} +.conn-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #555; + transition: background 0.3s; +} +.conn-dot.connected { background: var(--green); box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); } +.conn-dot.disconnected { background: var(--red); } +.conn-dot.reconnecting { background: var(--orange); animation: pulse 1.5s infinite; } +.conn-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text3); +} + +.chat-toggle-btn { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--glass-border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: var(--text2); + font-size: 16px; + cursor: pointer; + transition: all 0.2s; +} +.topbar-avatar { + height: 36px; + width: auto; + object-fit: contain; + filter: drop-shadow(0 0 6px rgba(90, 200, 232, 0.3)); +} +.topbar-title { + font-size: 14px; + font-weight: 700; + letter-spacing: 2px; + color: #fff; + text-shadow: 0 0 15px rgba(90, 200, 232, 0.4); +} +.chat-toggle-btn:hover { background: rgba(255, 255, 255, 0.08); } +.chat-toggle-btn.active { background: rgba(31, 78, 140, 0.3); border-color: rgba(31, 78, 140, 0.4); } + +/* ═══════════════ APP SHELL ═══════════════ */ +.app-shell { + position: fixed; + top: var(--topbar); + left: 0; + right: 0; + bottom: 0; + display: flex; + z-index: 1; +} + +.content-area { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 20px 24px; + padding-right: calc(var(--chat-w) + 24px); +} + +/* ─── Views ─── */ +.view { display: none; } +.view.active { display: block; } + +/* ─── Sections ─── */ +.section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text3); + margin: 24px 0 12px; +} + +/* ─── Cards ─── */ +.card { + margin-bottom: 16px; +} +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--glass-border); +} +.card-head h3 { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin: 0; + text-shadow: 0 0 20px rgba(59, 125, 216, 0.15); +} +.card-body { + padding: 14px 18px; +} +.card-body.scroll-y { + max-height: 360px; + overflow-y: auto; +} + +.refresh-btn { + background: transparent; + border: 1px solid var(--glass-border); + color: var(--text2); + border-radius: 6px; + padding: 3px 8px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} +.refresh-btn:hover { + background: rgba(255, 255, 255, 0.06); + border-color: var(--glass-border-hover); + color: var(--text); +} + +/* ─── Grid layouts ─── */ +.grid-2col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +/* ═══════════════ OVERVIEW TAB ═══════════════ */ + +/* Metric strip */ +.metric-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 8px; +} +.metric { + padding: 18px 16px; + text-align: center; + position: relative; + overflow: hidden; +} +.metric::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 40%; + height: 2px; + background: linear-gradient(90deg, transparent, var(--blue-lt), transparent); + opacity: 0.6; +} +.metric-val { + display: block; + font-size: 24px; + font-weight: 700; + color: #fff; + margin-bottom: 4px; + font-variant-numeric: tabular-nums; +} +.metric-lbl { + display: block; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text3); +} + +/* Projects row */ +.projects-row { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; + margin-bottom: 20px; +} +.project-box { + padding: 16px; + cursor: pointer; + transition: all 0.25s; + position: relative; + overflow: hidden; +} +.project-box::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--accent, var(--blue)); + opacity: 0.9; + box-shadow: 0 0 12px var(--accent, var(--blue)), 0 0 4px var(--accent, var(--blue)); +} +.project-box:hover { + border-color: var(--glass-border-hover); + transform: translateY(-2px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 20px color-mix(in srgb, var(--accent, var(--blue)) 25%, transparent); +} +.project-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.project-icon { font-size: 18px; } +.project-name { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.project-deputy { + font-size: 11px; + color: var(--text2); + margin-bottom: 8px; +} + +/* Add Project card */ +.project-add { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100px; + border: 2px dashed var(--glass-border); + background: transparent; + opacity: 0.6; + transition: opacity 0.2s; +} +.project-add:hover { opacity: 1; } +.project-add::before { display: none; } +.project-add-icon { font-size: 28px; color: var(--blue-lt); } +.project-add-label { font-size: 11px; color: var(--text3); margin-top: 4px; } + +/* Project form */ +.project-form { max-width: 500px; } +.pf-label { display: block; font-size: 11px; font-weight: 600; color: var(--text2); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 0.5px; } +.pf-input { + width: 100%; padding: 8px 12px; border-radius: 6px; + border: 1px solid var(--glass-border); background: rgba(255,255,255,0.04); + color: var(--text); font-size: 12px; font-family: inherit; outline: none; +} +.pf-input:focus { border-color: rgba(88,198,232,0.5); } +.pf-btn { + padding: 8px 16px; border-radius: 6px; border: none; font-size: 12px; + font-weight: 600; cursor: pointer; font-family: inherit; transition: all 0.2s; + -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; transform: translateZ(0); +} +.pf-btn-primary { background: rgba(88,198,232,0.2); color: #58c6e8; } +.pf-btn-primary:hover { background: rgba(88,198,232,0.35); } +.pf-btn-danger { background: rgba(239,68,68,0.15); color: #ef4444; } +.pf-btn-danger:hover { background: rgba(239,68,68,0.3); } +.pf-btn-cancel { background: rgba(255,255,255,0.05); color: var(--text3); } +.pf-btn-cancel:hover { background: rgba(255,255,255,0.1); } +.pf-btn-small { padding: 4px 10px; font-size: 10px; background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--glass-border); border-radius: 4px; cursor: pointer; + -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; transform: translateZ(0); } +.pf-btn-small:hover { background: rgba(255,255,255,0.1); } +.project-deputy strong { + color: var(--text); + font-weight: 500; +} +.project-body { + font-size: 11px; + color: var(--text3); + line-height: 1.6; + max-height: 80px; + overflow-y: auto; +} + +/* ─── Tasks ─── */ +.task-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} +.task-item:last-child { border-bottom: none; } +.priority-dot { + width: 6px; + height: 6px; + border-radius: 50%; + margin-top: 6px; + flex-shrink: 0; +} +.priority-dot.high { background: var(--red); box-shadow: 0 0 6px rgba(214, 69, 42, 0.4); } +.priority-dot.medium { background: var(--orange); } +.priority-dot.low { background: var(--green); } +.priority-dot.none { background: var(--text3); } +.task-check { + width: 14px; + height: 14px; + border: 1.5px solid var(--text3); + border-radius: 4px; + margin-top: 2px; + flex-shrink: 0; +} +.task-check.done { + background: var(--green); + border-color: var(--green); +} +.task-info { flex: 1; min-width: 0; } +.task-title { + font-size: 12px; + font-weight: 500; + color: var(--text); + line-height: 1.4; +} +.task-title.done { text-decoration: line-through; color: var(--text3); } +.task-meta { + display: flex; + gap: 10px; + font-size: 10px; + color: var(--text3); + margin-top: 2px; +} + +/* ─── Upcoming crons ─── */ +.upcoming-card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + font-size: 12px; +} +.upcoming-card:last-child { border-bottom: none; } +.upcoming-name { color: var(--text); font-weight: 500; } +.upcoming-time { color: var(--text3); font-size: 11px; font-family: var(--font-mono); } + +/* ═══════════════ AGENTS TAB ═══════════════ */ +.org-chart { + max-width: 900px; + margin: 0 auto; + padding: 8px 0; +} + +.org-tier { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + position: relative; +} +.org-tier-label { + width: 100%; + text-align: center; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text3); + margin-bottom: 8px; +} + +.org-line { + width: 2px; + height: 28px; + background: linear-gradient(to bottom, rgba(31, 78, 140, 0.4), rgba(31, 78, 140, 0.1)); + margin: 12px auto; + position: relative; +} +.org-line::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--blue); + box-shadow: 0 0 8px rgba(31, 78, 140, 0.5); +} + +.acard { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--r); + padding: 16px; + text-align: center; + min-width: 130px; + max-width: 160px; + cursor: pointer; + transition: all 0.25s; +} +.acard:hover { + border-color: var(--glass-border-hover); + transform: translateY(-3px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); +} +.acard.coo { + border-color: rgba(31, 78, 140, 0.4); + box-shadow: var(--blue-glow); + min-width: 170px; + max-width: 200px; + padding: 20px; +} +.acard.coo:hover { + border-color: rgba(31, 78, 140, 0.6); +} + +.aname { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-bottom: 2px; +} +.arole { + font-size: 10px; + color: var(--text2); + margin-bottom: 8px; +} +.ameta { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 10px; + color: var(--text3); + margin-top: 6px; +} +.agent-counts { + display: flex; + justify-content: center; + gap: 10px; + font-size: 10px; + color: var(--text3); + margin-top: 4px; +} + +/* Model badges */ +.model-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 9px; + font-weight: 600; + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.5px; +} +.model-badge.opus { background: rgba(31, 78, 140, 0.2); color: var(--blue-lt); border: 1px solid rgba(31, 78, 140, 0.3); } +.model-badge.sonnet { background: rgba(240, 140, 46, 0.15); color: var(--orange); border: 1px solid rgba(240, 140, 46, 0.25); } +.model-badge.gemini { background: rgba(34, 197, 94, 0.15); color: var(--green); border: 1px solid rgba(34, 197, 94, 0.25); } +.model-badge.codex { background: rgba(139, 92, 246, 0.15); color: #a78bfa; border: 1px solid rgba(139, 92, 246, 0.25); } +.model-badge.other { background: rgba(255, 255, 255, 0.05); color: var(--text3); border: 1px solid rgba(255, 255, 255, 0.08); } + +/* Status dots */ +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #555; + flex-shrink: 0; +} +.status-dot.active, .status-dot.running, .status-dot.ok, .status-dot.connected { background: var(--green); box-shadow: 0 0 6px rgba(34, 197, 94, 0.4); } +.status-dot.idle { background: var(--text3); } +.status-dot.error, .status-dot.down, .status-dot.failed { background: var(--red); } + +/* Sub-agent sections */ +.agent-section-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--text3); + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--glass-border); +} + +.agent-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.agent-card { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-radius: var(--r-sm); + background: rgba(255, 255, 255, 0.02); + border: 1px solid transparent; + transition: all 0.2s; +} +.agent-card:hover { + background: rgba(255, 255, 255, 0.04); + border-color: var(--glass-border); +} +.agent-card.subagent-running { + border-color: rgba(34, 197, 94, 0.2); + background: rgba(34, 197, 94, 0.04); +} + +.agent-avatar { font-size: 20px; flex-shrink: 0; } +.agent-info { flex: 1; min-width: 0; } +.agent-name { font-size: 12px; font-weight: 600; color: var(--text); } +.agent-role { font-size: 11px; color: var(--text2); } +.agent-detail { font-size: 11px; color: var(--text3); } +.subagent-task { font-size: 11px; color: var(--text2); margin-top: 4px; line-height: 1.4; } + +.agent-status { + font-size: 10px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; +} +.agent-status-running { background: rgba(34, 197, 94, 0.12); color: var(--green); } +.agent-status-completed { background: rgba(255, 255, 255, 0.06); color: var(--text3); } +.agent-status-failed { background: rgba(214, 69, 42, 0.12); color: var(--red); } + +/* ═══════════════ OPERATIONS TAB ═══════════════ */ + +/* Operations sub-nav */ +.ops-nav { + display: flex; + gap: 2px; + margin-bottom: 16px; + border-bottom: 1px solid var(--glass-border); + padding-bottom: 0; + position: sticky; + top: 0; + z-index: 10; + background: var(--bg, #060a18); +} +.ops-nav-btn { + padding: 8px 16px; + font-size: 12px; + font-weight: 500; + color: var(--text3); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + margin-bottom: -1px; +} +.ops-nav-btn:hover { color: var(--text); } +.ops-nav-btn.active { + color: #58c6e8; + border-bottom-color: #58c6e8; +} + +/* Operations collapsible boxes */ +.ops-section { margin-bottom: 12px; } +.ops-box { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--r); + overflow: hidden; + transition: border-color 0.3s; +} +.ops-box:hover { border-color: var(--glass-border-hover); } +.ops-box-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + cursor: pointer; + user-select: none; + transition: background 0.15s; +} +.ops-box-head:hover { background: rgba(255, 255, 255, 0.03); } +.ops-box-head h3 { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin: 0; +} +.ops-box-chevron { + width: 0; height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text2); + transition: transform 0.2s; +} +.ops-box.collapsed .ops-box-chevron { transform: rotate(-90deg); } +.ops-box.collapsed .ops-box-body { display: none; } +.ops-box-body { padding: 0 16px 16px; } +.ops-box-body.scroll-y { max-height: 500px; overflow-y: auto; } + +/* Cron grid */ +.cron-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.cron-cat-label { + grid-column: 1 / -1; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text3); + padding: 12px 0 4px; + border-bottom: 1px solid var(--glass-border); + margin-bottom: 4px; +} +.cron-cat-label .cnt { + font-size: 10px; + font-weight: 400; + color: var(--text3); + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 4px; + margin-left: 6px; +} + +.cron-card { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--r-sm); + padding: 14px; + transition: all 0.2s; +} +.cron-card:hover { + border-color: var(--glass-border-hover); + transform: translateY(-1px); +} +.cron-card.running { + border-color: rgba(34, 197, 94, 0.3); +} +.cron-card.disabled { + opacity: 0.4; +} + +.cron-name { + font-size: 12px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cron-schedule { + font-size: 11px; + font-family: var(--font-mono); + color: var(--blue-lt); + margin-bottom: 8px; +} +.cron-meta { + font-size: 10px; + color: var(--text3); + line-height: 1.6; +} +.cron-meta span { display: block; } +.cron-actions { + display: flex; + gap: 6px; + margin-top: 10px; +} +.cron-action-btn { + flex: 1; + padding: 5px 0; + border: 1px solid var(--glass-border); + border-radius: 6px; + background: transparent; + color: var(--text2); + font-size: 10px; + cursor: pointer; + transition: all 0.2s; +} +.cron-action-btn:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text); +} + +/* Delegations */ +.deleg-paths { + display: flex; + gap: 8px; + margin-top: 4px; + font-size: 10px; +} +.deleg-path { + color: var(--text3); + font-family: var(--font-mono); + font-size: 10px; +} + +/* ═══════════════ STANDUP TAB ═══════════════ */ +.standup-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} +.standup-header h2 { + font-size: 18px; + font-weight: 600; + color: var(--text); +} +.standup-actions { + display: flex; + gap: 8px; +} +.standup-btn { + padding: 7px 16px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; +} +.standup-btn-primary { + background: rgba(31, 78, 140, 0.3); + color: var(--blue-lt); + border: 1px solid rgba(31, 78, 140, 0.3); +} +.standup-btn-primary:hover { background: rgba(31, 78, 140, 0.45); } +.standup-btn-secondary { + background: rgba(255, 255, 255, 0.05); + color: var(--text2); + border: 1px solid var(--glass-border); +} +.standup-btn-secondary:hover { background: rgba(255, 255, 255, 0.08); } + +.standup-empty { + text-align: center; + padding: 48px 0; +} +.standup-empty-icon { font-size: 36px; margin-bottom: 12px; } +.standup-empty-text { font-size: 14px; color: var(--text2); margin-bottom: 6px; } +.standup-empty-sub { font-size: 12px; color: var(--text3); } + +.standup-thread { + display: flex; + flex-direction: column; + gap: 12px; +} +.standup-msg { + display: flex; + gap: 12px; + padding: 14px 16px; + border-radius: var(--r); + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); +} +.standup-avatar { + font-size: 24px; + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; +} +.standup-msg-body { flex: 1; min-width: 0; } +.standup-msg-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.standup-msg-name { font-size: 13px; font-weight: 600; color: var(--text); } +.standup-msg-role { + font-size: 10px; + padding: 2px 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + color: var(--text3); +} +.standup-msg-time { font-size: 10px; color: var(--text3); margin-left: auto; font-family: var(--font-mono); } +.standup-msg-text { + font-size: 12px; + color: var(--text2); + line-height: 1.6; +} +.standup-msg-text strong.standup-label { + color: var(--blue-lt); + display: inline-block; + margin-top: 4px; +} +.standup-nav { + display: flex; + align-items: center; + gap: 8px; +} +.standup-btn-nav { + background: rgba(255,255,255,0.05); + color: var(--text2); + border: 1px solid var(--glass-border); + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} +.standup-btn-nav:hover { background: rgba(255,255,255,0.1); } +.standup-date { + font-size: 13px; + font-weight: 600; + color: var(--text); + font-family: var(--font-mono); +} +.standup-date-picker { + background: rgba(255,255,255,0.05); + border: 1px solid var(--glass-border); + color: var(--text2); + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-family: var(--font-mono); + cursor: pointer; +} +.standup-summary { + font-size: 12px; + color: var(--text3); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--glass-border); +} + +/* ═══════════════ MEMORY TAB ═══════════════ */ +.memory-feed { display: flex; flex-direction: column; gap: 4px; } +.memory-entry { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + font-size: 12px; +} +.memory-timestamp { + font-size: 10px; + font-family: var(--font-mono); + color: var(--text3); + min-width: 50px; + flex-shrink: 0; +} +.memory-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} +.memory-tag.goal { background: rgba(31, 78, 140, 0.15); color: var(--blue-lt); } +.memory-tag.fact { background: rgba(34, 197, 94, 0.1); color: var(--green); } +.memory-tag.preference { background: rgba(139, 92, 246, 0.1); color: var(--purple); } +.memory-tag.deadline { background: rgba(240, 140, 46, 0.1); color: var(--orange); } +.memory-tag.decision { background: rgba(20, 184, 166, 0.1); color: var(--teal); } +.memory-tag.contact { background: rgba(255, 255, 255, 0.06); color: var(--text2); } +.memory-tag.pattern { background: rgba(59, 125, 216, 0.1); color: var(--blue-lt); } +.memory-tag.agent { background: rgba(240, 140, 46, 0.1); color: var(--orange); } +.memory-content { color: var(--text2); line-height: 1.5; } + +.filter-bar { + display: flex; + gap: 4px; + flex-wrap: wrap; +} +.filter-btn { + padding: 4px 10px; + border: 1px solid transparent; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: var(--text3); + font-size: 10px; + font-weight: 500; + text-transform: capitalize; + cursor: pointer; + transition: all 0.2s; +} +.filter-btn:hover { background: rgba(255, 255, 255, 0.08); color: var(--text2); } +.filter-btn.active { + background: rgba(31, 78, 140, 0.25); + color: var(--blue-lt); + border-color: rgba(31, 78, 140, 0.3); +} + +/* ═══════════════ COSTS & SETTINGS ═══════════════ */ +/* Integrations panel */ +.integ-section-title { + font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: 1px; color: var(--text3); margin-bottom: 8px; margin-top: 4px; +} +.integ-list { display: flex; flex-direction: column; gap: 4px; } +.integ-row { + display: flex; align-items: center; gap: 10px; + padding: 6px 8px; border-radius: 6px; + background: rgba(255,255,255,0.02); + transition: background .15s; +} +.integ-row:hover { background: rgba(255,255,255,0.05); } +.integ-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.integ-info { min-width: 0; } +.integ-name { font-size: 12px; font-weight: 500; color: var(--text); display: flex; align-items: center; gap: 6px; } +.integ-detail { font-size: 10px; color: var(--text3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.integ-badge { + font-size: 8px; padding: 1px 5px; border-radius: 3px; font-weight: 600; letter-spacing: .5px; +} +.integ-badge-read { background: rgba(34,197,94,.12); color: var(--green); } +.integ-badge-write { background: rgba(240,140,46,.12); color: var(--orange); } + +/* Costs panel */ +.cost-summary { + text-align: center; padding: 12px 0 14px; + border-bottom: 1px solid rgba(255,255,255,0.05); margin-bottom: 12px; +} +.cost-summary-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); } +.cost-summary-value { font-size: 22px; font-weight: 700; color: var(--text); margin: 4px 0 2px; } +.cost-summary-sub { font-size: 10px; color: var(--text3); } +.cost-table { display: flex; flex-direction: column; gap: 2px; } +.cost-table-head { + display: grid; grid-template-columns: 1fr auto auto; gap: 8px; + font-size: 9px; text-transform: uppercase; letter-spacing: .8px; + color: var(--text3); padding: 0 4px 6px; border-bottom: 1px solid rgba(255,255,255,0.05); +} +.cost-table-row { + display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center; + padding: 6px 4px; border-bottom: 1px solid rgba(255,255,255,0.02); +} +.cost-svc-name { font-size: 12px; font-weight: 500; color: var(--text); } +.cost-svc-plan { font-size: 10px; color: var(--text3); } +.cost-amount { font-size: 12px; font-weight: 500; color: var(--text); white-space: nowrap; } +.cost-type-badge { + font-size: 8px; padding: 2px 6px; border-radius: 3px; font-weight: 600; white-space: nowrap; +} +.cost-type-flat { background: rgba(31,78,140,.15); color: var(--blue-lt); } +.cost-type-usage { background: rgba(240,140,46,.12); color: var(--orange); } +.cost-note { + font-size: 11px; color: var(--text3); margin-top: 10px; + padding: 8px; border-radius: 6px; background: rgba(255,255,255,0.03); +} + +/* Settings */ +/* ═══════════════ SETTINGS SUB-TABS ═══════════════ */ +.s-tabs { + display: flex; + gap: 2px; + margin-bottom: 12px; + border-bottom: 1px solid var(--glass-border); + padding-bottom: 0; +} +.s-tab { + padding: 6px 14px; + font-size: 11px; + font-weight: 500; + color: var(--text2); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + margin-bottom: -1px; +} +.s-tab:hover { color: var(--text); } +.s-tab.active { + color: #58c6e8; + border-bottom-color: #58c6e8; +} +.s-empty { + font-size: 11px; + color: var(--text3); + padding: 16px 0; + text-align: center; +} + +/* ═══════════════ COLLAPSIBLE CARDS ═══════════════ */ +.s-card { + border-radius: var(--r-sm); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + margin-bottom: 8px; + overflow: hidden; +} +.s-card-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + cursor: pointer; + user-select: none; + transition: background 0.15s; +} +.s-card-head:hover { background: rgba(255, 255, 255, 0.04); } +.s-card-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text); +} +.s-card-chevron { + width: 0; height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid var(--text2); + transition: transform 0.2s; +} +.s-card.collapsed .s-card-chevron { transform: rotate(-90deg); } +.s-card.collapsed .s-card-body { display: none; } +.s-card-body { padding: 0 12px 10px; } +.s-card-body .hidden { display: none; } + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; +} +.settings-grid .s-card { margin-bottom: 0; } +.settings-card { + padding: 12px; + border-radius: var(--r-sm); + background: var(--glass-bg); + border: 1px solid var(--glass-border); +} +.settings-card-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text3); + margin-bottom: 8px; +} +.settings-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + font-size: 11px; +} +.settings-row-label { color: var(--text); } +.settings-row-value { color: var(--blue-lt); font-family: var(--font-mono); font-size: 11px; } + +/* ═══════════════ WHITELIST EDITOR ═══════════════ */ +.wl-section { padding: 2px 0; } +.wl-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text); + margin-bottom: 6px; +} +.wl-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + align-items: center; +} +.wl-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 4px; + background: rgba(88, 198, 232, 0.12); + border: 1px solid rgba(88, 198, 232, 0.25); + font-size: 11px; + font-family: var(--font-mono); + color: #93d4ef; +} +.wl-tag-rm { + background: none; + border: none; + color: rgba(255, 255, 255, 0.35); + cursor: pointer; + font-size: 13px; + padding: 0 2px; + line-height: 1; + transition: color 0.15s; +} +.wl-tag-rm:hover { color: #f87171; } +.wl-add-row { + display: inline-flex; + gap: 4px; + align-items: center; +} +.wl-add-input { + width: 150px; + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + color: var(--text); + font-size: 11px; + font-family: var(--font-mono); + outline: none; +} +.wl-add-input:focus { border-color: rgba(88, 198, 232, 0.4); } +.wl-add-input::placeholder { color: rgba(255,255,255,0.15); } +.wl-add-btn { + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(88, 198, 232, 0.3); + background: rgba(88, 198, 232, 0.1); + color: #58c6e8; + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: background 0.15s; + line-height: 1; +} +.wl-add-btn:hover { background: rgba(88, 198, 232, 0.25); } + +/* ═══════════════ M365 SAFETY TOGGLE ═══════════════ */ +.safety-card { + padding: 14px; + border-radius: var(--r-sm); + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(88, 198, 232, 0.15); + margin-bottom: 12px; +} +.safety-card-danger { + background: rgba(220, 38, 38, 0.08); + border-color: rgba(220, 38, 38, 0.4); + animation: danger-pulse 2s ease-in-out infinite; +} +@keyframes danger-pulse { + 0%, 100% { border-color: rgba(220, 38, 38, 0.4); } + 50% { border-color: rgba(220, 38, 38, 0.8); } +} +.safety-status-ok { color: #4ade80; } +.safety-status-danger { color: #f87171; font-weight: 700; } +.safety-toggle-section { margin-top: 10px; } +.safety-hint { + font-size: 10px; + color: var(--text3); + margin-top: 6px; +} +.safety-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 6px; + border: 1px solid; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; +} +.safety-btn-icon { font-size: 13px; } +.safety-btn-danger { + background: rgba(220, 38, 38, 0.15); + border-color: rgba(220, 38, 38, 0.4); + color: #fca5a5; +} +.safety-btn-danger:hover { + background: rgba(220, 38, 38, 0.3); + border-color: rgba(220, 38, 38, 0.7); +} +.safety-btn-restore { + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.4); + color: #86efac; +} +.safety-btn-restore:hover { + background: rgba(34, 197, 94, 0.3); + border-color: rgba(34, 197, 94, 0.7); +} +.safety-btn-cancel { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + color: var(--text2); +} +.safety-btn-cancel:hover { + background: rgba(255, 255, 255, 0.1); +} +.safety-btn-confirm-danger { + background: rgba(220, 38, 38, 0.3); + border-color: rgba(220, 38, 38, 0.6); + color: #fecaca; +} +.safety-btn-confirm-danger:hover:not(:disabled) { + background: rgba(220, 38, 38, 0.5); +} +.safety-btn-confirm-danger:disabled { + opacity: 0.3; + cursor: not-allowed; +} +.safety-btn-small { + padding: 3px 10px; + font-size: 10px; + background: rgba(255,255,255,0.05); + border-color: rgba(255,255,255,0.2); + color: var(--text2); +} +.safety-warning-banner { + display: flex; + gap: 10px; + padding: 10px 12px; + border-radius: 6px; + background: rgba(220, 38, 38, 0.12); + border: 1px solid rgba(220, 38, 38, 0.3); + margin-bottom: 10px; +} +.safety-warning-icon { font-size: 24px; flex-shrink: 0; } +.safety-warning-text { font-size: 11px; color: #fca5a5; line-height: 1.5; } +.safety-restart-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + margin-bottom: 10px; + border-radius: 6px; + background: rgba(234, 179, 8, 0.15); + border: 1px solid rgba(234, 179, 8, 0.3); + font-size: 11px; + color: #fde68a; +} + +/* Confirmation dialog overlay */ +.safety-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: overlay-in 0.2s ease; +} +@keyframes overlay-in { + from { opacity: 0; } + to { opacity: 1; } +} +.safety-dialog { + background: #1a1a2e; + border: 2px solid rgba(220, 38, 38, 0.5); + border-radius: 12px; + padding: 24px; + max-width: 480px; + width: 90%; + box-shadow: 0 0 40px rgba(220, 38, 38, 0.2); + animation: dialog-in 0.3s ease; +} +@keyframes dialog-in { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} +.safety-dialog-header { + text-align: center; + font-size: 28px; + margin-bottom: 8px; + animation: siren-flash 1s ease-in-out infinite; +} +@keyframes siren-flash { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} +.safety-dialog-siren { margin: 0 6px; } +.safety-dialog-title { + text-align: center; + font-size: 16px; + font-weight: 700; + color: #f87171; + margin: 0 0 16px 0; +} +.safety-dialog-body { + font-size: 12px; + color: var(--text2); + line-height: 1.6; +} +.safety-dialog-body ul { + margin: 8px 0; + padding-left: 20px; +} +.safety-dialog-body li { + margin: 4px 0; + color: #fca5a5; +} +.safety-dialog-warning { + color: #fde68a; + font-weight: 600; + margin-top: 12px; +} +.safety-dialog-confirm { + margin: 16px 0; + padding: 10px; + border-radius: 6px; + background: rgba(220, 38, 38, 0.1); + border: 1px solid rgba(220, 38, 38, 0.2); +} +.safety-dialog-checkbox-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #fca5a5; + cursor: pointer; +} +.safety-dialog-checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: #dc2626; +} +.safety-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 16px; +} + +/* ═══════════════ INTEGRATIONS ═══════════════ */ +.data-table-simple { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.data-table-simple th { + text-align: left; + padding: 8px 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text3); + border-bottom: 1px solid var(--glass-border); +} +.data-table-simple td { + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + color: var(--text); +} +.data-table-simple tr:last-child td { border-bottom: none; } + +/* ═══════════════ FILES ═══════════════ */ +.file-list { display: flex; flex-direction: column; } +.file-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} +.file-item:last-child { border-bottom: none; } +.file-icon { font-size: 16px; flex-shrink: 0; width: 24px; text-align: center; } +.file-info { flex: 1; min-width: 0; } +.file-name { font-size: 12px; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.file-link { color: rgba(90, 200, 232, 0.9); text-decoration: none; transition: color 0.2s; } +.file-link:hover { color: #fff; text-shadow: 0 0 8px rgba(90, 200, 232, 0.4); } +.file-item.clickable { cursor: pointer; } +.file-item.clickable:hover { background: rgba(90, 200, 232, 0.05); border-radius: 6px; } +.file-path { font-size: 10px; color: var(--text3); font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.file-meta { text-align: right; font-size: 10px; color: var(--text3); flex-shrink: 0; } + +/* ═══════════════ ROLLOUT ═══════════════ */ +.rollout-progress-wrap { margin-bottom: 16px; } +.rollout-progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } +.rollout-progress-pct { font-size: 12px; font-weight: 600; color: var(--text); } +.rollout-progress-bar { height: 6px; border-radius: 3px; background: rgba(255, 255, 255, 0.06); overflow: hidden; } +.rollout-progress-fill { height: 100%; border-radius: 3px; background: linear-gradient(90deg, var(--blue), var(--blue-lt)); transition: width 0.5s; } +.cron-section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--text3); +} +.cron-section-label .count { + font-weight: 400; + color: var(--text3); + font-size: 10px; +} + +.rollout-layer { margin-bottom: 16px; } +.rollout-agent-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; } +.rollout-agent { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--r-sm); + background: rgba(255, 255, 255, 0.02); +} +.rollout-agent.rollout-deployed { border-left: 2px solid var(--green); } +.rollout-agent.rollout-pending { border-left: 2px solid var(--text3); opacity: 0.6; } +.rollout-check { font-size: 14px; flex-shrink: 0; } +.rollout-agent-info { flex: 1; } +.rollout-agent-name { font-size: 12px; font-weight: 500; color: var(--text); } +.rollout-agent-role { font-size: 10px; color: var(--text3); font-weight: 400; margin-left: 6px; } +.rollout-agent-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} +.rollout-hb-badge { + font-size: 9px; + padding: 1px 6px; + border-radius: 3px; +} +.rollout-hb-badge.hb-active { background: rgba(34, 197, 94, 0.1); color: var(--green); } +.rollout-hb-badge.hb-inactive { background: rgba(255, 255, 255, 0.04); color: var(--text3); } +.rollout-hb-time { font-size: 10px; color: var(--text3); font-family: var(--font-mono); } + +/* ═══════════════ CHAT DRAWER ═══════════════ */ +.chat-drawer { + position: fixed; + top: var(--topbar); + right: 0; + bottom: 0; + width: var(--chat-w); + display: flex; + flex-direction: column; + border-left: 1px solid var(--glass-border); + transform: translateX(100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 90; +} +.chat-drawer.open { + transform: translateX(0); +} + +.chat-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--glass-border); + flex-shrink: 0; +} +.chat-drawer-title { + font-size: 13px; + font-weight: 600; + color: var(--text); +} +.chat-drawer-close { + background: none; + border: none; + color: var(--text3); + font-size: 16px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s; +} +.chat-drawer-close:hover { color: var(--text); background: rgba(255, 255, 255, 0.06); } + +.chat-drawer-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Chat sessions */ +.chat-conn-status { + padding: 6px 12px; + font-size: 11px; + font-weight: 500; + text-align: center; + border-radius: 4px; + margin: 4px 8px; +} +.chat-conn-info { background: rgba(0,150,255,0.15); color: #60a5fa; } +.chat-conn-ok { background: rgba(0,200,100,0.15); color: #4ade80; } +.chat-conn-warn { background: rgba(255,180,0,0.15); color: #fbbf24; } +.chat-conn-error { background: rgba(255,60,60,0.15); color: #f87171; } + +.chat-session-bar { + display: flex; + gap: 4px; + padding: 8px 12px; + overflow-x: auto; + flex-shrink: 0; + border-bottom: 1px solid var(--glass-border); +} +.chat-session-tab { + padding: 5px 12px; + border: 1px solid var(--glass-border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); + color: var(--text2); + font-size: 11px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: all 0.2s; + display: flex; + align-items: center; +} +.chat-session-tab:hover { background: rgba(255, 255, 255, 0.06); } +.chat-session-tab.active { + background: rgba(31, 78, 140, 0.25); + border-color: rgba(31, 78, 140, 0.4); + color: var(--blue-lt); +} +.chat-session-tab.subagent { font-size: 10px; opacity: 0.7; } +.chat-no-sessions { font-size: 11px; color: var(--text3); padding: 4px; } + +/* Messages */ +.chat-messages-scroll { + flex: 1; + overflow-y: auto; + padding: 12px; +} +.chat-msg { + margin-bottom: 10px; + max-width: 92%; +} +.chat-msg-user { + margin-left: auto; +} +.chat-msg-user .chat-msg-content { + background: rgba(31, 78, 140, 0.25); + border: 1px solid rgba(31, 78, 140, 0.3); + border-radius: 12px 12px 4px 12px; + padding: 8px 12px; + font-size: 12px; + color: var(--text); + line-height: 1.5; +} +.chat-msg-assistant .chat-msg-content { + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--glass-border); + border-radius: 12px 12px 12px 4px; + padding: 8px 12px; + font-size: 12px; + color: var(--text2); + line-height: 1.5; +} +.chat-msg-system .chat-msg-content { + background: rgba(240, 140, 46, 0.08); + border: 1px solid rgba(240, 140, 46, 0.15); + border-radius: 8px; + padding: 6px 10px; + font-size: 11px; + color: var(--orange); + text-align: center; +} +.chat-msg-time { + font-size: 9px; + color: var(--text3); + margin-top: 3px; + padding: 0 4px; +} +.chat-msg-streaming { + opacity: 0.8; +} + +.chat-cursor { + display: inline-block; + width: 2px; + height: 14px; + background: var(--blue-lt); + margin-left: 2px; + vertical-align: middle; + animation: blink 1s infinite; +} +.chat-typing { color: var(--text3); font-style: italic; } + +/* Input bar */ +.chat-input-bar { + display: flex; + align-items: flex-end; + gap: 6px; + padding: 10px 12px; + border-top: 1px solid var(--glass-border); + flex-shrink: 0; +} +.chat-input { + flex: 1; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--glass-border); + border-radius: 8px; + padding: 8px 12px; + color: var(--text); + font: 12px/1.4 var(--font); + resize: none; + outline: none; + transition: border-color 0.2s; +} +.chat-input:focus { border-color: rgba(31, 78, 140, 0.5); } +.chat-input::placeholder { color: var(--text3); } +.chat-input:disabled { opacity: 0.4; } + +.chat-btn { + width: 36px; + height: 36px; + border: 1px solid var(--glass-border); + border-radius: 8px; + background: transparent; + color: var(--text2); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} +.chat-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.06); color: var(--text); } +.chat-btn:disabled { opacity: 0.3; cursor: default; } +.chat-btn-send { color: var(--blue-lt); border-color: rgba(31, 78, 140, 0.3); } +.chat-btn-stop { color: var(--red); border-color: rgba(214, 69, 42, 0.3); } + +/* ═══════════════ MODAL ═══════════════ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 200; + display: none; + align-items: center; + justify-content: center; +} +.modal-overlay.visible { display: flex; } +.modal { + width: 90%; + max-width: 700px; + max-height: 80vh; + display: flex; + flex-direction: column; +} +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--glass-border); + font-size: 14px; + font-weight: 600; +} +.modal-close { + background: none; + border: none; + color: var(--text3); + font-size: 20px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} +.modal-close:hover { color: var(--text); background: rgba(255, 255, 255, 0.06); } +.modal-body { + padding: 20px; + overflow-y: auto; + font-size: 12px; + font-family: var(--font-mono); + color: var(--text); + line-height: 1.7; + white-space: pre-wrap; +} +.modal-body .agent-detail-card, +.modal-body .project-form { + white-space: normal; + font-family: var(--font); + line-height: 1.5; +} + +/* ═══════════════ SHARED STATES ═══════════════ */ +.placeholder, .empty-state { + text-align: center; + padding: 24px; + color: var(--text3); + font-size: 12px; +} +.empty-icon { + display: block; + font-size: 28px; + margin-bottom: 8px; + opacity: 0.5; +} + +/* Disk bar */ +.disk-bar { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.06); margin-top: 4px; overflow: hidden; } +.disk-bar-fill { height: 100%; border-radius: 2px; background: var(--green); transition: width 0.5s; } +.disk-bar-fill.warning { background: var(--orange); } +.disk-bar-fill.danger { background: var(--red); } + +/* Status grid */ +.status-grid { display: flex; flex-direction: column; gap: 8px; } +.status-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; } +.status-info { flex: 1; } +.status-label { font-size: 12px; font-weight: 500; color: var(--text); } +.status-value { font-size: 11px; color: var(--text3); display: block; } + +/* ═══════════════ ANIMATIONS ═══════════════ */ +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } +@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } } + +/* ═══════════════ RESPONSIVE ═══════════════ */ +@media (max-width: 1200px) { + .projects-row { grid-template-columns: repeat(3, 1fr); } + .cron-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 960px) { + .content-area { + padding-right: 24px; + } + .chat-drawer { + width: 100%; + max-width: 380px; + } + .chat-toggle-btn { display: flex; } + .chat-drawer:not(.open) ~ .content-area { padding-right: 24px; } + + .projects-row { grid-template-columns: repeat(2, 1fr); } + .metric-strip { grid-template-columns: repeat(2, 1fr); } + .grid-2col { grid-template-columns: 1fr; } + .settings-grid { grid-template-columns: 1fr; } + .cron-grid { grid-template-columns: 1fr; } + + .topbar-nav { + gap: 2px; + } + .nav-tab { + padding: 6px 10px; + } + .nav-label { display: none; } +} + +@media (max-width: 640px) { + :root { --topbar: 50px; --chat-w: 100%; } + + .topbar { padding: 0 10px; gap: 6px; } + .topbar-logo { height: 28px; } + + .content-area { padding: 12px; padding-right: 12px; } + .projects-row { grid-template-columns: 1fr; } + .metric-strip { grid-template-columns: repeat(2, 1fr); } + + .acard { min-width: 100px; max-width: 130px; padding: 10px; } + .acard.coo { min-width: 140px; } + + .conn-label { display: none; } +} + +/* ═══════════════ SEARCH BOX ═══════════════ */ +.search-wrap { + position: relative; + margin: 16px 0 8px; +} +.search-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + font-size: 14px; + pointer-events: none; +} +.global-search { + width: 100%; + padding: 12px 16px 12px 40px; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + border-radius: var(--r); + color: var(--text); + font: 13px/1.4 var(--font); + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +.global-search::placeholder { color: var(--text3); } +.global-search:focus { + border-color: var(--glass-border-hover); + box-shadow: 0 0 12px rgba(90, 200, 232, 0.25); +} + +/* ═══════════════ INDEXATION STATUS ═══════════════ */ +.idx-row { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + font-size: 12px; +} +.idx-row:last-child { border-bottom: none; } +.idx-source { color: var(--text); font-weight: 500; min-width: 200px; } +.idx-link { color: rgba(90, 200, 232, 0.9); text-decoration: none; cursor: pointer; transition: color 0.2s; } +.idx-link:hover { color: #fff; text-decoration: underline; text-shadow: 0 0 8px rgba(90, 200, 232, 0.4); } +.idx-detail { color: var(--text3); } +.idx-detail strong { color: var(--text2); font-weight: 500; } + +/* ═══════════════ CRON EXPAND/COLLAPSE ═══════════════ */ +.cron-card { cursor: pointer; } +.cron-detail { + display: none; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 11px; + color: var(--text3); + line-height: 1.7; +} +.cron-detail span { display: block; } +.cron-card.expanded .cron-detail { display: block; } +.cron-card .cron-expand-hint { + font-size: 10px; + color: var(--text3); + margin-top: 6px; + opacity: 0.5; +} +.cron-card.expanded .cron-expand-hint { display: none; } + +/* ═══════════════ AGENT DETAIL MODAL ═══════════════ */ +.agent-detail-card { display: flex; flex-direction: column; gap: 16px; } +.agent-detail-header { display: flex; align-items: flex-start; gap: 16px; } +.agent-detail-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(90, 200, 232, 0.4); box-shadow: 0 0 16px rgba(90, 200, 232, 0.2); flex-shrink: 0; } +.agent-detail-info { flex: 1; } +.agent-detail-name { font-size: 18px; font-weight: 700; color: var(--text); font-family: var(--font); } +.agent-detail-role { font-size: 12px; color: var(--text); margin-top: 2px; font-family: var(--font); } +.agent-detail-section { border-top: 1px solid rgba(255, 255, 255, 0.06); padding-top: 12px; } +.agent-detail-section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text2); margin-bottom: 8px; } +.agent-detail-pre { background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; padding: 12px; font-size: 11px; font-family: var(--font-mono); color: var(--text); line-height: 1.6; white-space: pre-wrap; max-height: 240px; overflow-y: auto; margin: 0; } + +/* Agent management bar */ +.agent-mgmt-bar { + display: flex; + gap: 6px; + padding: 8px 0; + border-top: 1px solid rgba(255,255,255,0.06); + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +/* SOUL editor textarea */ +.soul-editor { + width: 100%; + min-height: 300px; + background: rgba(0,0,0,0.3); + border: 1px solid var(--glass-border); + border-radius: 8px; + padding: 12px; + font-size: 12px; + font-family: var(--font-mono); + color: var(--text); + line-height: 1.6; + resize: vertical; + outline: none; +} +.soul-editor:focus { border-color: rgba(88,198,232,0.5); } + +/* Restart banner */ +.restart-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + background: rgba(245,158,11,0.12); + border: 1px solid rgba(245,158,11,0.3); + border-radius: var(--r-sm); + font-size: 12px; + color: #f59e0b; + margin-bottom: 12px; +} + +/* ═══════════════ PANEL TIMESTAMPS ═══════════════ */ +.panel-last-updated { text-align: right; font-size: 10px; color: var(--text3, rgba(255,255,255,0.3)); padding: 6px 4px 0; opacity: 0.7; } + +/* ═══════════════ PROJECT BOXES CLICKABLE ═══════════════ */ +.project-box { cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; } +.project-box:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,0,0,0.3); } + +/* ═══════════════ PROJECT DETAIL MODAL ═══════════════ */ +.project-detail-desc { font-size: 13px; color: var(--text); line-height: 1.5; margin-bottom: 12px; } +.project-detail-agent-link { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 8px; background: rgba(90, 200, 232, 0.1); border: 1px solid rgba(90, 200, 232, 0.3); color: var(--blue-lt, #5ac8e8); font-size: 12px; cursor: pointer; transition: background 0.2s; text-decoration: none; } +.project-detail-agent-link:hover { background: rgba(90, 200, 232, 0.2); } +.project-detail-placeholder { background: rgba(0,0,0,0.2); border: 1px dashed rgba(255,255,255,0.1); border-radius: 8px; padding: 16px; text-align: center; font-size: 11px; color: var(--text3); margin-top: 8px; } + +/* ═══════════════ SELECT OPTIONS (dark theme) ═══════════════ */ +select option { background: #1a1f2e; color: #e0e0e0; } + +/* ═══════════════ TASK ROW SHARED COMPONENT ═══════════════ */ +.task-row-shared { + display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; + border-bottom: 1px solid rgba(255,255,255,0.04); transition: background 0.15s; cursor: pointer; +} +.task-row-shared:hover { background: rgba(255,255,255,0.03); } +.task-row-shared.done { opacity: 0.5; } +.task-row-shared .task-complete-btn { + width: 16px; height: 16px; border: 1.5px solid var(--text3); border-radius: 4px; + background: transparent; cursor: pointer; flex-shrink: 0; margin-top: 1px; transition: all 0.2s; + display: flex; align-items: center; justify-content: center; font-size: 10px; color: transparent; padding: 0; +} +.task-row-shared .task-complete-btn:hover { border-color: var(--green); color: var(--green); } +.task-row-shared.done .task-complete-btn { background: var(--green); border-color: var(--green); color: #fff; } + +/* ═══════════════ PRINT ═══════════════ */ +@media print { + body::before { display: none; } + .topbar, .chat-drawer, .chat-toggle-btn { display: none !important; } + .content-area { padding: 0 !important; } + .glass-card, .glass-panel, .glass-nav { background: #fff !important; backdrop-filter: none !important; color: #000 !important; border-color: #ddd !important; } +} diff --git a/bates-core/plugins/dashboard/static/sw.js b/bates-core/plugins/dashboard/static/sw.js new file mode 100644 index 0000000..869e62d --- /dev/null +++ b/bates-core/plugins/dashboard/static/sw.js @@ -0,0 +1,63 @@ +/** + * Service Worker for Bates Command Center PWA + * Caches static assets for offline shell, always fetches API data fresh + */ +const CACHE_NAME = 'bates-dashboard-v1'; +const STATIC_ASSETS = [ + '/dashboard/', + '/dashboard/styles.css', + '/dashboard/js/gateway.js', + '/dashboard/js/app.js', + '/dashboard/js/panel-ceo.js', + '/dashboard/js/panel-agents.js', + '/dashboard/js/panel-crons.js', + '/dashboard/js/panel-delegations.js', + '/dashboard/js/panel-files.js', + '/dashboard/js/panel-chat.js', + '/dashboard/js/panel-memory.js', + '/dashboard/js/panel-rollout.js', + '/dashboard/js/panel-status.js', + '/dashboard/js/panel-costs.js', + '/dashboard/js/panel-integrations.js', + '/dashboard/js/panel-settings.js', + '/dashboard/js/panel-standup.js', + '/dashboard/js/panel-tasks.js', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((names) => + Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Always fetch API routes fresh + if (url.pathname.includes('/api/') || url.pathname.includes('/webhook')) { + return; + } + + // For static assets, try network first, fall back to cache + event.respondWith( + fetch(event.request) + .then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + } + return response; + }) + .catch(() => caches.match(event.request)) + ); +}); diff --git a/bates-core/plugins/delegation-enforcer/index.ts b/bates-core/plugins/delegation-enforcer/index.ts new file mode 100644 index 0000000..b104dc3 --- /dev/null +++ b/bates-core/plugins/delegation-enforcer/index.ts @@ -0,0 +1,497 @@ +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import * as fs from "fs"; +import * as crypto from "crypto"; + +// --------------------------------------------------------------------------- +// Delegation Enforcer Plugin +// +// Counts tool calls for agentId="main" ACROSS ALL SESSIONS. When the count +// exceeds TOOL_CALL_THRESHOLD without a sessions_spawn call, ALL non-spawn +// tool calls are BLOCKED. +// +// Anti-circumvention: after a spawn, Bates gets a limited POST_SPAWN_ALLOWANCE +// of additional calls (for reading results and delivering to Robert). After +// that, it must spawn again. This prevents the "spawn trivial task, then do +// all work in main" pattern. +// +// Also tracks TURN_HARD_CAP: the absolute maximum non-exempt calls per turn, +// regardless of how many spawns happen. This prevents splitting work into +// many small spawn-reset cycles. +// +// State is tracked per-agentId (not per-session) to prevent circumvention +// via session restarts. Only resets on: +// 1. New inbound user message (message_received) +// 2. Time decay (DECAY_MS elapsed since last tool call) +// +// Sub-agents and cron sessions are unaffected. +// --------------------------------------------------------------------------- + +/** Max tool calls before first spawn is required */ +const TOOL_CALL_THRESHOLD = 4; + +/** Max additional tool calls allowed after each spawn (for result delivery) */ +const POST_SPAWN_ALLOWANCE = 4; + +/** Absolute max non-exempt tool calls per turn, regardless of spawns */ +const TURN_HARD_CAP = 16; + +/** Auto-reset after 5 minutes of inactivity (prevents stale blocks) */ +const DECAY_MS = 5 * 60 * 1000; + +/** Tools that are always allowed (coordination, not work) */ +const EXEMPT_TOOLS = new Set([ + "sessions_spawn", + "sessions_kill", // killing stuck sub-agent sessions + "sessions_list", // listing active sessions + "session_status", // checking session status + "subagents", // managing sub-agents + "message", // sending messages to Robert + "process", // reading tool results +]); + +// --------------------------------------------------------------------------- +// Self-Protection: paths that MUST NOT be modified by the main agent. +// Blocks write/edit/exec targeting these paths. This prevents the agent from +// disabling or modifying its own guardrails. +// --------------------------------------------------------------------------- +const PROTECTED_PATHS = [ + "/home/openclaw/.openclaw/extensions/delegation-enforcer", + "/home/openclaw/.openclaw/openclaw.json", +]; + +/** Tools that can modify files or run arbitrary commands */ +const FILE_MUTATION_TOOLS = new Set(["write", "edit", "exec"]); + +/** + * Check if a tool call targets a protected path. + * Returns a block reason string if blocked, or null if allowed. + */ +function checkProtectedPaths(toolName: string, params: any): string | null { + if (!FILE_MUTATION_TOOLS.has(toolName)) return null; + + // For write/edit: check the file_path or path parameter + if (toolName === "write" || toolName === "edit") { + const filePath = params?.file_path || params?.path || ""; + for (const pp of PROTECTED_PATHS) { + if (filePath.startsWith(pp) || filePath === pp) { + return `[DELEGATION ENFORCER] BLOCKED: Cannot modify protected path "${filePath}". ` + + `The delegation enforcer and gateway config are protected from modification. ` + + `This is a system guardrail set by Robert.`; + } + } + } + + // For exec: check if the command references protected paths with write intent + if (toolName === "exec") { + const command = (params?.command || params?.cmd || "").toLowerCase(); + for (const pp of PROTECTED_PATHS) { + const ppLower = pp.toLowerCase(); + const pathSegments = ppLower.split("/").filter(Boolean); + const lastSegment = pathSegments[pathSegments.length - 1] || ""; + + // Block: rm, mv, cp-over, chmod, chown, sed -i, tee, >, >> targeting protected paths + const dangerousPatterns = [ + `rm ${ppLower}`, `rm -rf ${ppLower}`, `rm -f ${ppLower}`, + `mv ${ppLower}`, `mv `, // mv anything TO a protected path + `chmod `, `chown `, + `sed -i`, `tee ${ppLower}`, `tee -a ${ppLower}`, + `> ${ppLower}`, `>> ${ppLower}`, + // Also catch references to the enforcer directory by name + `delegation-enforcer`, + ]; + + // Check if command contains protected path AND a dangerous operation + if (command.includes(ppLower) || command.includes(lastSegment)) { + const hasDangerousOp = ["rm ", "rm\t", "mv ", "chmod ", "chown ", "sed -i", + "> ", ">> ", "tee ", "cat >", "cat >>", "echo >", "echo >>", + "truncate", "unlink", "shred"].some(op => command.includes(op)); + if (hasDangerousOp) { + return `[DELEGATION ENFORCER] BLOCKED: Cannot execute commands targeting protected path. ` + + `The delegation enforcer is protected from modification via shell commands. ` + + `This is a system guardrail set by Robert.`; + } + } + } + + // Also block: systemctl commands that could disable the plugin indirectly + // (restarting gateway is fine, but stopping it is not) + if (command.includes("systemctl") && command.includes("stop") && command.includes("openclaw")) { + return `[DELEGATION ENFORCER] BLOCKED: Cannot stop the openclaw gateway. ` + + `Use "systemctl --user restart" if a restart is needed.`; + } + } + + return null; +} + +/** Valid deputy agentIds that Bates should delegate to */ +const VALID_DEPUTIES = new Set([ + "nova", "amara", "jules", "dash", "kira", "archer", + "mira", "conrad", "soren", "mercer", "paige", "quinn", +]); + +/** ACP runtime agent IDs — external CLI agents, always valid for delegation */ +const ACP_AGENTS = new Set(["claude", "codex"]); + +/** Agent-level turn state (persists across sessions) */ +interface AgentTurnState { + /** Calls since last spawn (or turn start). Used for pre-spawn and post-spawn limits. */ + callsSinceLastSpawn: number; + /** Total non-exempt calls this entire turn. Never resets except on new message or decay. */ + totalCallsThisTurn: number; + /** Number of spawns this turn */ + spawnCount: number; + lastToolCallTimestamp: number; + lastResetTimestamp: number; + /** Track which session keys have been seen -- detects session hops */ + sessionKeysThisTurn: Set; +} + +/** Keyed by agentId (not sessionKey!) */ +const agentState = new Map(); + +function getAgentState(agentId: string): AgentTurnState { + let state = agentState.get(agentId); + if (!state) { + state = { + callsSinceLastSpawn: 0, + totalCallsThisTurn: 0, + spawnCount: 0, + lastToolCallTimestamp: Date.now(), + lastResetTimestamp: Date.now(), + sessionKeysThisTurn: new Set(), + }; + agentState.set(agentId, state); + } + return state; +} + +function resetAgentTurn(agentId: string) { + const state = getAgentState(agentId); + state.callsSinceLastSpawn = 0; + state.totalCallsThisTurn = 0; + state.spawnCount = 0; + state.lastToolCallTimestamp = Date.now(); + state.lastResetTimestamp = Date.now(); + state.sessionKeysThisTurn.clear(); +} + +/** + * Check if state should auto-decay (stale block from >5 min ago). + * Returns true if the state was decayed (reset). + */ +function checkDecay(state: AgentTurnState): boolean { + if ((state.callsSinceLastSpawn > 0 || state.totalCallsThisTurn > 0) && + Date.now() - state.lastToolCallTimestamp > DECAY_MS) { + state.callsSinceLastSpawn = 0; + state.totalCallsThisTurn = 0; + state.spawnCount = 0; + state.lastResetTimestamp = Date.now(); + state.sessionKeysThisTurn.clear(); + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- +const plugin = { + id: "delegation-enforcer", + name: "Delegation Enforcer", + description: "Forces main-session delegation when tool call count exceeds threshold (session-hop resistant)", + configSchema: emptyPluginConfigSchema(), + + register(api: OpenClawPluginApi) { + const log = api.logger; + log.info( + "delegation-enforcer: registered (threshold=" + TOOL_CALL_THRESHOLD + + ", post-spawn=" + POST_SPAWN_ALLOWANCE + + ", hard-cap=" + TURN_HARD_CAP + + ", decay=" + (DECAY_MS / 1000) + "s)" + ); + + // Reset counter when a new inbound message arrives (new turn) + api.on("message_received", (_event: any, _ctx: any) => { + // Reset agent-level state for main on new user message + if (agentState.has("main")) { + resetAgentTurn("main"); + log.info("delegation-enforcer: reset on message_received"); + } + }); + + // Count tool calls and enforce threshold + api.on("before_tool_call", (event: any, ctx: any) => { + const agentId = ctx.agentId; + const sessionKey = ctx.sessionKey || ""; + const toolName = event.toolName; + + // SELF-PROTECTION: Block modifications to protected paths from ANY agent session + // (main, sub-agents, cron -- nobody should modify the enforcer) + const protectionBlock = checkProtectedPaths(toolName, event.params); + if (protectionBlock) { + log.warn( + `delegation-enforcer: SELF-PROTECTION BLOCK: ${toolName} from ` + + `agent=${agentId} session=${sessionKey}` + ); + return { block: true, blockReason: protectionBlock }; + } + + // Only enforce delegation counting on main agent, not sub-agents or crons + if (agentId !== "main") return undefined; + if (sessionKey.includes("subagent:")) return undefined; + if (sessionKey.includes(":cron:")) return undefined; + + const state = getAgentState("main"); + + // Check for time-based decay + if (checkDecay(state)) { + log.info(`delegation-enforcer: state decayed after ${DECAY_MS / 1000}s inactivity [${sessionKey}]`); + } + + // Detect session hop: if we see a new sessionKey while already blocked + if (!state.sessionKeysThisTurn.has(sessionKey) && state.sessionKeysThisTurn.size > 0) { + log.warn( + `delegation-enforcer: SESSION HOP detected! New sessionKey "${sessionKey}" ` + + `while already tracking ${state.sessionKeysThisTurn.size} session(s). ` + + `Tool call count carries over: segment=${state.callsSinceLastSpawn}, total=${state.totalCallsThisTurn}` + ); + } + state.sessionKeysThisTurn.add(sessionKey); + + // Exempt tools are always allowed, with extra checks for sessions_spawn + if (EXEMPT_TOOLS.has(toolName)) { + if (toolName === "sessions_spawn") { + const spawnAgentId = event.params?.agentId as string | undefined; + const spawnRuntime = event.params?.runtime as string | undefined; + + // ACP runtime spawns (claude, codex) are always valid delegation + if (spawnRuntime === "acp") { + if (spawnAgentId && !ACP_AGENTS.has(spawnAgentId)) { + log.warn( + `delegation-enforcer: BLOCKING ACP spawn with unknown agentId ` + + `(got: ${spawnAgentId}) [${sessionKey}]` + ); + return { + block: true, + blockReason: + `[DELEGATION ENFORCER] BLOCKED: ACP runtime only supports agentId "claude" or "codex". ` + + `Got: "${spawnAgentId}". Use agentId: "claude" (default) or "codex" (only when Robert asks).`, + }; + } + state.spawnCount++; + state.callsSinceLastSpawn = 0; // Reset segment counter only + log.info( + `delegation-enforcer: ACP spawn #${state.spawnCount} to ${spawnAgentId || "default"}, ` + + `segment reset, total=${state.totalCallsThisTurn} [${sessionKey}]` + ); + return undefined; + } + + // Internal deputy spawns: enforce agentId requirement + if (!spawnAgentId || !VALID_DEPUTIES.has(spawnAgentId)) { + log.warn( + `delegation-enforcer: BLOCKING sessions_spawn without valid agentId ` + + `(got: ${spawnAgentId || "none"}) [${sessionKey}]` + ); + return { + block: true, + blockReason: + `[DELEGATION ENFORCER] BLOCKED: sessions_spawn called without a valid agentId. ` + + `You MUST specify one of the pre-configured deputies: ` + + `nova (research), amara (writing), jules (analysis), dash (quick tasks), ` + + `kira (creative), archer (technical). ` + + `Omitting agentId wastes Opus tokens by running sub-agent work on the main model. ` + + `Re-call sessions_spawn with agentId set to the appropriate deputy.`, + }; + } + state.spawnCount++; + state.callsSinceLastSpawn = 0; // Reset segment counter only + log.info( + `delegation-enforcer: spawn #${state.spawnCount} to ${spawnAgentId}, ` + + `segment reset, total=${state.totalCallsThisTurn} [${sessionKey}]` + ); + } + return undefined; + } + + // Increment both counters + state.callsSinceLastSpawn++; + state.totalCallsThisTurn++; + state.lastToolCallTimestamp = Date.now(); + + // Determine the current limit for this segment + const currentSegmentLimit = state.spawnCount === 0 + ? TOOL_CALL_THRESHOLD // Before first spawn: 4 calls + : POST_SPAWN_ALLOWANCE; // After each spawn: 4 calls for delivery + + // Log when approaching any limit + if (state.callsSinceLastSpawn >= currentSegmentLimit - 1 || + state.totalCallsThisTurn >= TURN_HARD_CAP - 1) { + log.warn( + `delegation-enforcer: main agent call #${state.callsSinceLastSpawn}/${currentSegmentLimit} ` + + `(total: ${state.totalCallsThisTurn}/${TURN_HARD_CAP}, spawns: ${state.spawnCount}) ` + + `(${toolName}) [${sessionKey}]` + + (state.sessionKeysThisTurn.size > 1 ? ` (CROSS-SESSION)` : "") + ); + } + + // HARD CAP: absolute limit per turn, no matter how many spawns + if (state.totalCallsThisTurn > TURN_HARD_CAP) { + log.warn( + `delegation-enforcer: HARD CAP BLOCKING "${toolName}" ` + + `(total: ${state.totalCallsThisTurn}/${TURN_HARD_CAP}, spawns: ${state.spawnCount}) [${sessionKey}]` + ); + return { + block: true, + blockReason: + `[DELEGATION ENFORCER] HARD CAP: You have made ${state.totalCallsThisTurn} total tool calls ` + + `this turn (limit: ${TURN_HARD_CAP}). This is the absolute per-turn maximum and cannot be ` + + `bypassed by spawning more sub-agents. You are doing too much work in the main session. ` + + `Send the result to Robert and stop. Further work requires a new user message.`, + }; + } + + // SEGMENT LIMIT: calls since last spawn (or turn start) + if (state.callsSinceLastSpawn > currentSegmentLimit) { + if (state.spawnCount === 0) { + // Never spawned: must delegate + log.warn( + `delegation-enforcer: BLOCKING "${toolName}" ` + + `(${state.callsSinceLastSpawn}/${currentSegmentLimit}, no spawn yet) [${sessionKey}]` + ); + return { + block: true, + blockReason: + `[DELEGATION ENFORCER] BLOCKED: You have made ${state.callsSinceLastSpawn} tool calls ` + + `without delegating (limit: ${currentSegmentLimit}). ` + + `This limit is tracked ACROSS sessions -- starting a new session does NOT reset it. ` + + `You MUST call sessions_spawn NOW. Choose a deputy: ` + + `nova (research/web), amara (writing/summaries), jules (analysis), ` + + `dash (quick tasks), kira (creative), archer (technical). ` + + `Or use runtime: "acp" for Claude Code / Codex tasks. ` + + `Include ALL context gathered so far in the task prompt. ` + + `Do NOT retry the blocked tool -- it will fail again. Delegate first.`, + }; + } else { + // Post-spawn allowance exceeded: must spawn again or stop + log.warn( + `delegation-enforcer: POST-SPAWN BLOCKING "${toolName}" ` + + `(${state.callsSinceLastSpawn}/${POST_SPAWN_ALLOWANCE} after spawn #${state.spawnCount}) [${sessionKey}]` + ); + return { + block: true, + blockReason: + `[DELEGATION ENFORCER] BLOCKED: You have used ${state.callsSinceLastSpawn} tool calls ` + + `since your last spawn (post-spawn allowance: ${POST_SPAWN_ALLOWANCE}). ` + + `The post-spawn allowance is for reading results and delivering to Robert, not for ` + + `doing more work yourself. If you need more work done, spawn another sub-agent. ` + + `Total calls this turn: ${state.totalCallsThisTurn}/${TURN_HARD_CAP}.`, + }; + } + } + + return undefined; + }); + + // Inject reminder via before_prompt_build for main session only + api.on("before_prompt_build", (_event: any, ctx: any) => { + if (ctx.agentId !== "main") return undefined; + const sk = ctx.sessionKey || ""; + if (sk.includes("subagent:")) return undefined; + + const state = agentState.get("main"); + if (!state) return undefined; + + // Check decay before deciding to inject + checkDecay(state); + + const segmentLimit = state.spawnCount === 0 ? TOOL_CALL_THRESHOLD : POST_SPAWN_ALLOWANCE; + + // If we've been blocking (segment or hard cap), add a strong prepend + if (state.callsSinceLastSpawn > segmentLimit || state.totalCallsThisTurn > TURN_HARD_CAP) { + const atHardCap = state.totalCallsThisTurn > TURN_HARD_CAP; + return { + prependContext: + "\n\n[SYSTEM: DELEGATION ENFORCER ACTIVE] " + + (atHardCap + ? `HARD CAP reached (${state.totalCallsThisTurn}/${TURN_HARD_CAP} total calls). ` + + "You cannot make any more tool calls this turn. Deliver results to Robert and stop." + : `You have exceeded the ${state.spawnCount === 0 ? "pre-spawn" : "post-spawn"} ` + + `tool call limit (${state.callsSinceLastSpawn}/${segmentLimit}). ` + + (state.spawnCount === 0 + ? "You must call sessions_spawn NOW to delegate." + : "Spawn another sub-agent if you need more work done, or deliver results to Robert.") + + ` Total: ${state.totalCallsThisTurn}/${TURN_HARD_CAP}.`) + + " This is enforced by the gateway, not optional.\n\n", + }; + } + return undefined; + }); + + // NOTE: session_end does NOT reset agent-level state. + // This prevents circumvention via session restarts. + // State only resets via: message_received, sessions_spawn, or time decay. + api.on("session_end", (_event: any, ctx: any) => { + if (ctx.agentId === "main") { + log.info( + `delegation-enforcer: session_end for main (NOT resetting agent state, ` + + `segment=${agentState.get("main")?.callsSinceLastSpawn || 0}, ` + + `total=${agentState.get("main")?.totalCallsThisTurn || 0}, ` + + `spawns=${agentState.get("main")?.spawnCount || 0})` + ); + } + }); + + // Cleanup on gateway stop (full reset is fine here -- gateway is restarting) + api.on("gateway_stop", () => { + agentState.clear(); + if (integrityWatcher) { + integrityWatcher.close(); + integrityWatcher = null; + } + log.info("delegation-enforcer: cleaned up on gateway stop"); + }); + + // --- File Integrity Monitor --- + // Watch the plugin's own source file. If modified/deleted outside the gateway, + // log a critical alert. The plugin continues running from memory regardless. + const selfPath = "/home/openclaw/.openclaw/extensions/delegation-enforcer/index.ts"; + let originalHash = ""; + try { + const content = fs.readFileSync(selfPath, "utf8"); + originalHash = crypto.createHash("sha256").update(content).digest("hex"); + log.info(`delegation-enforcer: integrity baseline set (sha256: ${originalHash.slice(0, 16)}...)`); + } catch { + log.warn("delegation-enforcer: could not read self for integrity baseline"); + } + + let integrityWatcher: fs.FSWatcher | null = null; + try { + integrityWatcher = fs.watch(selfPath, (eventType) => { + if (eventType === "change" || eventType === "rename") { + let currentHash = ""; + try { + const content = fs.readFileSync(selfPath, "utf8"); + currentHash = crypto.createHash("sha256").update(content).digest("hex"); + } catch { + // File deleted or unreadable + currentHash = "DELETED_OR_UNREADABLE"; + } + if (currentHash !== originalHash) { + log.warn( + `delegation-enforcer: INTEGRITY ALERT: ${selfPath} was ${eventType}d! ` + + `Expected hash: ${originalHash.slice(0, 16)}..., got: ${currentHash.slice(0, 16)}... ` + + `Plugin continues running from memory. This may be a tampering attempt.` + ); + } + } + }); + } catch { + log.warn("delegation-enforcer: could not set up file watcher for integrity monitoring"); + } + }, +}; + +export default plugin; diff --git a/bates-core/plugins/delegation-enforcer/openclaw.plugin.json b/bates-core/plugins/delegation-enforcer/openclaw.plugin.json new file mode 100644 index 0000000..2e60a73 --- /dev/null +++ b/bates-core/plugins/delegation-enforcer/openclaw.plugin.json @@ -0,0 +1,10 @@ +{ + "id": "delegation-enforcer", + "name": "Delegation Enforcer", + "description": "Blocks main-agent tool calls when count exceeds threshold without delegation, forcing sub-agent spawning", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/bates-core/plugins/m365-safety/index.ts b/bates-core/plugins/m365-safety/index.ts new file mode 100644 index 0000000..98dbc9e --- /dev/null +++ b/bates-core/plugins/m365-safety/index.ts @@ -0,0 +1,376 @@ +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import * as fs from "fs"; +import * as crypto from "crypto"; +import * as net from "net"; + +// --------------------------------------------------------------------------- +// M365 Safety Gateway Plugin +// +// Layer 1 of the tamper-proof email/calendar safety system. +// +// Intercepts `exec` tool calls that invoke Graph API commands and: +// 1. Rewrites graph-api.sh → graph-api-safe.sh (routes through gateway) +// 2. Blocks direct curl to graph.microsoft.com +// 3. Blocks mcporter WRITE operations (send-mail, create-event, etc.) +// 4. Allows mcporter READ operations (list-mail, get-user, etc.) +// +// Graceful degradation: if the safety gateway is not running, graph-api.sh +// calls are allowed through with a warning log. This prevents the plugin +// from breaking everything when the gateway is being set up or restarted. +// +// Self-protection: monitors its own files for tampering and blocks tool +// calls that target the safety infrastructure. +// +// KILL SWITCH: Set enforcement to "OVERRIDE_ALL_SAFETY" in plugin config +// to disable all protection. This is a nuclear option — use only in +// emergencies when the safety gateway is causing critical failures. +// --------------------------------------------------------------------------- + +const SAFETY_SOCKET = `/run/user/${process.getuid?.() ?? 1000}/m365-safety.sock`; +const SAFE_GRAPH_SCRIPT = "/home/openclaw/.openclaw/scripts/graph-api-safe.sh"; + +/** The deliberately ugly config value required to disable safety */ +const KILL_SWITCH_VALUE = "OVERRIDE_ALL_SAFETY"; + +// --------------------------------------------------------------------------- +// Patterns +// --------------------------------------------------------------------------- + +// graph-api.sh calls — rewritable to safe version +const GRAPH_API_SH_PATTERN = /graph-api\.sh/; + +// Direct Graph API access — always blocked +const DIRECT_GRAPH_PATTERNS = [ + /curl\s[^|]*graph\.microsoft\.com/, // curl to Graph API + /curl\s[^|]*login\.microsoftonline\.com/, // curl to login endpoint +]; + +// The safe replacement — always allowed +const SAFE_PATTERN = /graph-api-safe\.sh/; + +// mcporter WRITE operations — blocked (must go through gateway) +// These are the dangerous ones: sending email, creating events, etc. +const MCPORTER_WRITE_PATTERN = + /mcporter\s+call\s+ms365[^\s]*\.(send-mail|create-event|update-event|delete-event|create-message|reply-to-message|forward-message|create-todo-task|update-todo-task|delete-todo-task|create-plan-task|update-plan-task|delete-plan-task|upload-file|share-file|create-folder|delete-folder|create-subscription|send-chat-message)/; + +// mcporter READ operations — allowed (safe, no side effects) +// get-current-user, list-mail-messages, get-mail-message, list-calendar-events, +// get-calendar-event, list-todo-tasks, list-plan-tasks, search-*, etc. +const MCPORTER_READ_PATTERN = /mcporter\s+call\s+ms365/; + +// Token cache direct access — blocked +const TOKEN_CACHE_WRITE_PATTERN = /(?:cat|tee|echo|sed|cp|mv)\s[^|]*\.token-cache\.json/; + +// Protected paths — cannot be modified by the agent +const PROTECTED_PATHS = [ + "/home/openclaw/.openclaw/extensions/m365-safety", + "/home/openclaw/.openclaw/scripts/m365-gateway", + "/home/openclaw/.openclaw/m365-safety", + "/home/openclaw/.openclaw/scripts/graph-api-safe.sh", +]; + +// Tools that can modify files or run commands +const FILE_MUTATION_TOOLS = new Set(["write", "edit", "exec"]); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Check if a tool call targets a protected path. + */ +function checkProtectedPaths(toolName: string, params: any): string | null { + if (!FILE_MUTATION_TOOLS.has(toolName)) return null; + + if (toolName === "write" || toolName === "edit") { + const filePath = params?.file_path || params?.path || ""; + for (const pp of PROTECTED_PATHS) { + if (filePath.startsWith(pp)) { + return `[M365 SAFETY] BLOCKED: Cannot modify protected path "${filePath}". ` + + `The M365 safety gateway and its configuration are protected. ` + + `This is a system guardrail set by Robert.`; + } + } + } + + if (toolName === "exec") { + const command = (params?.command || params?.cmd || "").toLowerCase(); + for (const pp of PROTECTED_PATHS) { + const ppLower = pp.toLowerCase(); + if (command.includes(ppLower)) { + const hasDangerousOp = [ + "rm ", "rm\t", "mv ", "chmod ", "chown ", "sed -i", + "> ", ">> ", "tee ", "cat >", "echo >", + "truncate", "unlink", "shred", "kill", + ].some(op => command.includes(op)); + if (hasDangerousOp) { + return `[M365 SAFETY] BLOCKED: Cannot execute commands targeting M365 safety infrastructure. ` + + `This is a system guardrail set by Robert.`; + } + } + } + + if (command.includes("systemctl") && command.includes("stop") && command.includes("m365-safety")) { + return `[M365 SAFETY] BLOCKED: Cannot stop the M365 safety gateway service.`; + } + } + + return null; +} + +/** + * Rewrite graph-api.sh to graph-api-safe.sh (same args). + */ +function rewriteToSafeCommand(command: string): string | null { + const match = command.match( + /((?:~\/\.openclaw\/scripts\/|\/home\/openclaw\/\.openclaw\/scripts\/)?)graph-api\.sh(\s+.*)/i + ); + if (match) { + return `${SAFE_GRAPH_SCRIPT}${match[2]}`; + } + return null; +} + +/** + * Check if the safety gateway socket exists (fast sync check). + * Does NOT do a health check — just checks the socket file. + */ +function isGatewaySocketPresent(): boolean { + try { + const stat = fs.statSync(SAFETY_SOCKET); + return stat.isSocket?.() ?? false; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- +const plugin = { + id: "m365-safety", + name: "M365 Safety Gateway", + description: "Enforces tamper-proof M365 API access via safety gateway proxy", + configSchema: emptyPluginConfigSchema(), + + register(api: OpenClawPluginApi) { + const log = api.logger; + const pluginConfig = (api as any).config ?? {}; + const enforcement = pluginConfig.enforcement ?? "active"; + const killSwitchActive = enforcement === KILL_SWITCH_VALUE; + + if (killSwitchActive) { + log.warn("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + log.warn("!! !!"); + log.warn("!! M365 SAFETY GATEWAY — ALL PROTECTION DISABLED !!"); + log.warn("!! !!"); + log.warn("!! Email whitelisting: OFF !!"); + log.warn("!! Calendar protection: OFF !!"); + log.warn("!! Graph API interception: OFF !!"); + log.warn("!! Self-protection: OFF !!"); + log.warn("!! Audit logging: OFF !!"); + log.warn("!! !!"); + log.warn("!! The agent has UNRESTRICTED access to Microsoft 365. !!"); + log.warn("!! It can send emails to anyone, modify any calendar !!"); + log.warn("!! event, and access any Graph API endpoint without !!"); + log.warn("!! whitelist checks. !!"); + log.warn("!! !!"); + log.warn("!! To restore protection: !!"); + log.warn("!! Set plugins.entries.m365-safety.config.enforcement !!"); + log.warn("!! back to \"active\" in openclaw.json and restart. !!"); + log.warn("!! !!"); + log.warn("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + + // Repeat every 10 minutes so it stays visible in logs + const reminderInterval = setInterval(() => { + log.warn( + "!! M365 SAFETY OVERRIDE STILL ACTIVE — All email/calendar " + + "protection is DISABLED. Set enforcement: \"active\" to restore. !!" + ); + }, 10 * 60 * 1000); + + api.on("gateway_stop", () => clearInterval(reminderInterval)); + + // Still inject a warning into the agent's prompt so Bates knows + api.on("before_prompt_build", (_event: any, ctx: any) => { + if (ctx.agentId !== "main") return undefined; + const sk = ctx.sessionKey || ""; + if (sk.includes("subagent:")) return undefined; + return { + prependContext: + "\n[CRITICAL WARNING: M365 SAFETY GATEWAY IS DISABLED] " + + "All email, calendar, and Graph API protections are currently OFF. " + + "You have unrestricted access to Microsoft 365. Exercise EXTREME caution " + + "with any write operations. Double-check all email recipients manually. " + + "Tell Robert that safety is disabled if he is not already aware.\n", + }; + }); + + log.warn("m365-safety: plugin loaded in OVERRIDE mode — no enforcement hooks registered"); + return; + } + + log.info("m365-safety: registered — intercepting Graph API calls"); + + // Intercept tool calls + api.on("before_tool_call", (event: any, ctx: any) => { + const toolName = event.toolName; + const params = event.params || {}; + + // Self-protection: block modifications to protected paths from ANY agent + const protectionBlock = checkProtectedPaths(toolName, params); + if (protectionBlock) { + log.warn( + `m365-safety: SELF-PROTECTION BLOCK: ${toolName} from ` + + `agent=${ctx.agentId} session=${ctx.sessionKey}` + ); + return { block: true, blockReason: protectionBlock }; + } + + // Only intercept exec calls from here on + if (toolName !== "exec") return undefined; + + const command: string = params.command || params.cmd || ""; + if (!command) return undefined; + + // --- SAFE SCRIPT: always allow --- + if (SAFE_PATTERN.test(command)) return undefined; + + // --- GRAPH-API.SH: rewrite to safe version --- + if (GRAPH_API_SH_PATTERN.test(command)) { + const safeCommand = rewriteToSafeCommand(command); + if (safeCommand) { + // Check if gateway is actually running + if (isGatewaySocketPresent()) { + log.info( + `m365-safety: REWRITING graph-api.sh → safe gateway ` + + `(agent=${ctx.agentId}): "${command.slice(0, 80)}..."` + ); + event.params.command = safeCommand; + if (event.params.cmd) event.params.cmd = safeCommand; + return undefined; + } else { + // GRACEFUL DEGRADATION: gateway not running, allow original command + log.warn( + `m365-safety: PASSTHROUGH (gateway not running): "${command.slice(0, 80)}..." ` + + `from agent=${ctx.agentId}. Start the gateway to enforce safety.` + ); + return undefined; + } + } + } + + // --- DIRECT CURL to Graph/Login endpoints: block --- + for (const pattern of DIRECT_GRAPH_PATTERNS) { + if (pattern.test(command)) { + log.warn( + `m365-safety: BLOCKING direct Graph API curl from agent=${ctx.agentId}: ` + + `"${command.slice(0, 120)}..."` + ); + return { + block: true, + blockReason: + `[M365 SAFETY] BLOCKED: Direct curl to Microsoft Graph API is not allowed. ` + + `Use graph-api-safe.sh which routes through the safety gateway. ` + + `This is a tamper-proof safety measure set by Robert.`, + }; + } + } + + // --- MCPORTER WRITE operations: block --- + if (MCPORTER_WRITE_PATTERN.test(command)) { + log.warn( + `m365-safety: BLOCKING mcporter write operation from agent=${ctx.agentId}: ` + + `"${command.slice(0, 120)}..."` + ); + return { + block: true, + blockReason: + `[M365 SAFETY] BLOCKED: This mcporter write operation must go through the ` + + `M365 safety gateway. Use graph-api-safe.sh for write operations (POST/PUT/PATCH/DELETE). ` + + `Read operations via mcporter are allowed.`, + }; + } + + // --- MCPORTER READ operations: allow --- + // (Matched by MCPORTER_READ_PATTERN but NOT by MCPORTER_WRITE_PATTERN → safe) + + // --- TOKEN CACHE writes: block --- + if (TOKEN_CACHE_WRITE_PATTERN.test(command)) { + log.warn( + `m365-safety: BLOCKING token cache modification from agent=${ctx.agentId}` + ); + return { + block: true, + blockReason: + `[M365 SAFETY] BLOCKED: Cannot modify the OAuth token cache directly. ` + + `Token management is handled by the M365 safety gateway.`, + }; + } + + return undefined; + }); + + // Inject context about safety gateway into main agent prompts + api.on("before_prompt_build", (_event: any, ctx: any) => { + if (ctx.agentId !== "main") return undefined; + const sk = ctx.sessionKey || ""; + if (sk.includes("subagent:")) return undefined; + + return { + prependContext: + "\n[SYSTEM: M365 SAFETY GATEWAY ACTIVE] All Microsoft Graph API write " + + "operations are routed through the tamper-proof safety gateway. " + + "Use graph-api-safe.sh instead of graph-api.sh for any Graph API calls. " + + "Direct curl to graph.microsoft.com and mcporter write operations are blocked. " + + "Read operations via mcporter are allowed.\n", + }; + }); + + // --- File Integrity Monitor --- + const selfPath = "/home/openclaw/.openclaw/extensions/m365-safety/index.ts"; + let originalHash = ""; + try { + const content = fs.readFileSync(selfPath, "utf8"); + originalHash = crypto.createHash("sha256").update(content).digest("hex"); + log.info(`m365-safety: integrity baseline (sha256: ${originalHash.slice(0, 16)}...)`); + } catch { + log.warn("m365-safety: could not read self for integrity baseline"); + } + + let integrityWatcher: fs.FSWatcher | null = null; + try { + integrityWatcher = fs.watch(selfPath, (eventType) => { + if (eventType === "change" || eventType === "rename") { + let currentHash = ""; + try { + const content = fs.readFileSync(selfPath, "utf8"); + currentHash = crypto.createHash("sha256").update(content).digest("hex"); + } catch { + currentHash = "DELETED_OR_UNREADABLE"; + } + if (currentHash !== originalHash) { + log.warn( + `m365-safety: INTEGRITY ALERT: ${selfPath} was ${eventType}d! ` + + `Expected: ${originalHash.slice(0, 16)}..., got: ${currentHash.slice(0, 16)}... ` + + `Plugin continues from memory.` + ); + } + } + }); + } catch { + log.warn("m365-safety: could not set up file watcher"); + } + + api.on("gateway_stop", () => { + if (integrityWatcher) { + integrityWatcher.close(); + integrityWatcher = null; + } + log.info("m365-safety: cleaned up on gateway stop"); + }); + }, +}; + +export default plugin; diff --git a/bates-core/plugins/m365-safety/openclaw.plugin.json b/bates-core/plugins/m365-safety/openclaw.plugin.json new file mode 100644 index 0000000..656770d --- /dev/null +++ b/bates-core/plugins/m365-safety/openclaw.plugin.json @@ -0,0 +1,17 @@ +{ + "id": "m365-safety", + "name": "M365 Safety Gateway", + "description": "Intercepts Graph API tool calls and routes them through the tamper-proof M365 safety gateway process", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enforcement": { + "type": "string", + "enum": ["active", "OVERRIDE_ALL_SAFETY"], + "default": "active", + "description": "Safety enforcement mode. 'active' = normal operation. 'OVERRIDE_ALL_SAFETY' = DISABLES ALL PROTECTION. Only use in emergencies." + } + } + } +} diff --git a/bates-core/plugins/session-continuity/index.ts b/bates-core/plugins/session-continuity/index.ts new file mode 100644 index 0000000..3792aa1 --- /dev/null +++ b/bates-core/plugins/session-continuity/index.ts @@ -0,0 +1,542 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = join(PLUGIN_DIR, "data"); +const DIGESTS_DIR = join(DATA_DIR, "digests"); + +/** Max age for digest injection (ms). Stale digests are skipped. */ +const MAX_DIGEST_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours + +/** Rolling buffer size for interaction summaries */ +const MAX_INTERACTIONS = 10; + +/** Max chars to extract from a message for summarization */ +const SUMMARY_MAX_CHARS = 300; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +interface InteractionSummary { + role: "robert" | "bates" | "deputy" | "system"; + summary: string; + timestamp: string; +} + +interface HandoffDigest { + sessionKey: string; + sessionId?: string; + timestamp: string; + reason: string; + lastInteractions: InteractionSummary[]; + activeTasks: string[]; + pendingDecisions: string[]; + recentDeliveries: string[]; + /** File paths and artifacts mentioned in recent messages */ + recentArtifacts: string[]; +} + +// --------------------------------------------------------------------------- +// In-memory state +// --------------------------------------------------------------------------- + +/** Per-session rolling digest (keyed by sessionKey or agentId) */ +const sessionDigests = new Map(); + +/** Track which digests have been consumed (injected) to avoid re-injection. + * Keyed by sessionKey, value is the digest timestamp that was injected. */ +const consumedDigests = new Map(); + +// --------------------------------------------------------------------------- +// Helpers: File I/O +// --------------------------------------------------------------------------- +function ensureDataDir(): void { + if (!existsSync(DIGESTS_DIR)) { + mkdirSync(DIGESTS_DIR, { recursive: true }); + } +} + +function digestPath(agentId: string): string { + // Sanitize agentId for filesystem + const safe = agentId.replace(/[^a-zA-Z0-9_-]/g, "_"); + return join(DIGESTS_DIR, `${safe}.json`); +} + +function loadDigest(agentId: string): HandoffDigest | null { + const path = digestPath(agentId); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } +} + +function saveDigest(agentId: string, digest: HandoffDigest): void { + ensureDataDir(); + writeFileSync(digestPath(agentId), JSON.stringify(digest, null, 2)); +} + +// --------------------------------------------------------------------------- +// Helpers: Message summarization (rule-based, no LLM) +// --------------------------------------------------------------------------- + +/** Extract text content from a message object. Messages can have various shapes. */ +function extractText(msg: any): string { + if (!msg) return ""; + // String content + if (typeof msg.content === "string") return msg.content; + // Array of content blocks (Anthropic format) + if (Array.isArray(msg.content)) { + const textBlocks = msg.content + .filter((b: any) => b.type === "text" && b.text) + .map((b: any) => b.text); + return textBlocks.join(" "); + } + // Direct text field + if (typeof msg.text === "string") return msg.text; + return ""; +} + +/** Truncate text at a sentence boundary near maxLen */ +function truncateAtSentence(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + const truncated = text.slice(0, maxLen); + // Try to cut at last sentence boundary + const lastPeriod = truncated.lastIndexOf(". "); + const lastQuestion = truncated.lastIndexOf("? "); + const lastExclaim = truncated.lastIndexOf("! "); + const bestCut = Math.max(lastPeriod, lastQuestion, lastExclaim); + if (bestCut > maxLen * 0.4) { + return truncated.slice(0, bestCut + 1); + } + return truncated + "..."; +} + +/** Strip injected session-handoff blocks from text to prevent recursive nesting */ +function stripHandoffBlocks(text: string): string { + // Remove ... blocks (possibly nested) + let cleaned = text.replace(/[\s\S]*?<\/session-handoff>/g, "").trim(); + // Also strip orphaned opening tags (in case closing tag was truncated) + cleaned = cleaned.replace(/[\s\S]*/g, "").trim(); + return cleaned; +} + +/** Summarize a user (Robert) message */ +function summarizeUserMessage(msg: any): string { + const text = stripHandoffBlocks(extractText(msg).trim()); + if (!text) return "(empty message)"; + return "Robert: " + truncateAtSentence(text, SUMMARY_MAX_CHARS); +} + +/** Summarize an assistant (Bates) message, looking for structured patterns */ +function summarizeAssistantMessage(msg: any): string { + const text = extractText(msg).trim(); + if (!text) return "(tool-only turn)"; + + // Check for task closure protocol + const statusMatch = text.match(/STATUS:\s*(DONE|NOT_DONE)/); + if (statusMatch) { + const artifactMatch = text.match(/ARTIFACT:\s*(.+)/); + const summary = `Bates: ${statusMatch[0]}`; + return artifactMatch ? `${summary}, ${artifactMatch[0]}` : summary; + } + + // Check for delegation pattern + const delegateMatch = text.match(/(?:Delegating|Spawning|dispatching)\s+(?:to\s+)?(\w+)/i); + if (delegateMatch) { + return `Bates: Delegated to ${delegateMatch[1]}. ${truncateAtSentence(text, 80)}`; + } + + // Check for sub-agent result delivery + const deputyMatch = text.match(/(?:Baby Bates|Deputy|Sub-agent)\s*(?:result|report)?:?\s*/i); + if (deputyMatch) { + return `Bates delivered deputy result. ${truncateAtSentence(text.slice(deputyMatch.index! + deputyMatch[0].length), 100)}`; + } + + return "Bates: " + truncateAtSentence(text, SUMMARY_MAX_CHARS); +} + +/** Detect active tasks from message content */ +function detectActiveTasks(text: string): string[] { + const tasks: string[] = []; + // Look for "working on" / "investigating" / "looking into" patterns + const workingOn = text.match(/(?:working on|investigating|looking into|tackling)\s+(.{10,80}?)(?:\.|$)/gi); + if (workingOn) { + for (const match of workingOn) { + tasks.push(truncateAtSentence(match, 80)); + } + } + return tasks; +} + +/** Detect pending decisions from message content */ +function detectPendingDecisions(text: string): string[] { + const decisions: string[] = []; + // Questions from Bates to Robert + const questions = text.match(/(?:shall I|should I|would you like|do you want|which option)\s+(.{10,80}?\?)/gi); + if (questions) { + for (const q of questions) { + decisions.push(truncateAtSentence(q, 80)); + } + } + return decisions; +} + +/** Detect file paths and artifacts mentioned in messages */ +function detectArtifacts(text: string): string[] { + const artifacts: string[] = []; + const seen = new Set(); + + // File paths (Unix-style) + const pathMatches = text.matchAll(/(?:\/[\w._-]+){2,}(?:\.\w+)?/g); + for (const m of pathMatches) { + const p = m[0]; + // Skip common false positives + if (p.startsWith("/v1/") || p.startsWith("/api/") || p.startsWith("/me/")) continue; + if (!seen.has(p)) { + seen.add(p); + artifacts.push(`file: ${p}`); + } + } + + // OneDrive paths (drafts/...) + const odMatches = text.matchAll(/drafts\/[\w._\-/]+/g); + for (const m of odMatches) { + if (!seen.has(m[0])) { + seen.add(m[0]); + artifacts.push(`onedrive: ${m[0]}`); + } + } + + // URLs (uploaded files, shared links) + const urlMatches = text.matchAll(/https?:\/\/[^\s"'<>)\]]+/g); + for (const m of urlMatches) { + const url = m[0]; + // Only capture OneDrive/SharePoint/Teams URLs + if (url.includes("sharepoint") || url.includes("onedrive") || url.includes("teams.microsoft")) { + if (!seen.has(url)) { + seen.add(url); + artifacts.push(`url: ${truncateAtSentence(url, 200)}`); + } + } + } + + // "saved to" / "uploaded to" / "posted to" patterns + const savedMatches = text.matchAll(/(?:saved|uploaded|posted|written|sent|delivered)\s+(?:to|in|at)\s+([^\n.]{10,100})/gi); + for (const m of savedMatches) { + const target = m[1].trim(); + if (!seen.has(target)) { + seen.add(target); + artifacts.push(`target: ${target}`); + } + } + + return artifacts.slice(0, 10); // Cap at 10 +} + +/** Detect cron/deputy deliveries */ +function detectDeliveries(text: string): string[] { + const deliveries: string[] = []; + const cronMatch = text.match(/(?:cron|scheduled|heartbeat).*?(?:result|report|update):?\s*(.{10,60})/gi); + if (cronMatch) { + for (const m of cronMatch) { + deliveries.push(truncateAtSentence(m, 60)); + } + } + return deliveries; +} + +// --------------------------------------------------------------------------- +// Core: Update digest from messages +// --------------------------------------------------------------------------- +function updateDigestFromMessages( + agentId: string, + sessionKey: string, + messages: any[], + reason?: string, + sessionId?: string +): HandoffDigest { + const existing = sessionDigests.get(agentId) || loadDigest(agentId) || { + sessionKey, + timestamp: new Date().toISOString(), + reason: reason || "agent_end", + lastInteractions: [], + activeTasks: [], + pendingDecisions: [], + recentDeliveries: [], + recentArtifacts: [], + }; + + // Ensure recentArtifacts exists on loaded digests + if (!existing.recentArtifacts) existing.recentArtifacts = []; + + // Find last user and assistant messages + const userMsgs = messages.filter((m: any) => m.role === "user" || m.role === "human"); + const assistantMsgs = messages.filter((m: any) => m.role === "assistant"); + + const lastUser = userMsgs.length > 0 ? userMsgs[userMsgs.length - 1] : null; + const lastAssistant = assistantMsgs.length > 0 ? assistantMsgs[assistantMsgs.length - 1] : null; + + const now = new Date().toISOString(); + + // Add user interaction if present + if (lastUser) { + const userText = stripHandoffBlocks(extractText(lastUser).trim()); + // Skip system/tool-only messages and pure handoff injections + if (userText && !userText.startsWith("[Tool:") && !userText.startsWith(" 0) { + existing.recentArtifacts.push(...userArtifacts); + existing.recentArtifacts = [...new Set(existing.recentArtifacts)].slice(-10); + } + } + } + + // Add assistant interaction if present + if (lastAssistant) { + const assistantText = extractText(lastAssistant).trim(); + if (assistantText) { + existing.lastInteractions.push({ + role: "bates", + summary: summarizeAssistantMessage(lastAssistant), + timestamp: now, + }); + + // Scan for task/decision/delivery/artifact patterns + const newTasks = detectActiveTasks(assistantText); + const newDecisions = detectPendingDecisions(assistantText); + const newDeliveries = detectDeliveries(assistantText); + const newArtifacts = detectArtifacts(assistantText); + + if (newTasks.length > 0) existing.activeTasks = newTasks; + if (newDecisions.length > 0) existing.pendingDecisions = newDecisions; + if (newDeliveries.length > 0) { + existing.recentDeliveries.push(...newDeliveries); + if (existing.recentDeliveries.length > 5) { + existing.recentDeliveries = existing.recentDeliveries.slice(-5); + } + } + if (newArtifacts.length > 0) { + existing.recentArtifacts.push(...newArtifacts); + // Deduplicate and keep last 10 + existing.recentArtifacts = [...new Set(existing.recentArtifacts)].slice(-10); + } + } + } + + // Trim rolling buffer + if (existing.lastInteractions.length > MAX_INTERACTIONS) { + existing.lastInteractions = existing.lastInteractions.slice(-MAX_INTERACTIONS); + } + + // Update metadata + existing.sessionKey = sessionKey; + if (sessionId) existing.sessionId = sessionId; + existing.timestamp = now; + if (reason) existing.reason = reason; + + // Persist + sessionDigests.set(agentId, existing); + saveDigest(agentId, existing); + + return existing; +} + +// --------------------------------------------------------------------------- +// Core: Format digest for injection +// --------------------------------------------------------------------------- +function formatDigestForInjection(digest: HandoffDigest): string { + const age = Date.now() - new Date(digest.timestamp).getTime(); + const ageMinutes = Math.round(age / 60000); + const ageStr = ageMinutes < 60 + ? `${ageMinutes} minute${ageMinutes !== 1 ? "s" : ""} ago` + : `${Math.round(ageMinutes / 60)} hour${Math.round(ageMinutes / 60) !== 1 ? "s" : ""} ago`; + + let reasonStr = digest.reason; + if (reasonStr === "idle") reasonStr = "idle timeout"; + if (reasonStr === "overflow") reasonStr = "context overflow"; + if (reasonStr === "reset_command") reasonStr = "manual reset"; + + const lines: string[] = [ + "", + `Your previous session ended ${ageStr} (reason: ${reasonStr}).`, + "", + ]; + + if (digest.lastInteractions.length > 0) { + lines.push("Last interactions:"); + for (const interaction of digest.lastInteractions) { + lines.push(`- ${interaction.summary}`); + } + lines.push(""); + } + + if (digest.activeTasks.length > 0) { + lines.push(`Active tasks: ${digest.activeTasks.join("; ")}`); + } + if (digest.pendingDecisions.length > 0) { + lines.push(`Pending decisions: ${digest.pendingDecisions.join("; ")}`); + } + if (digest.recentDeliveries.length > 0) { + lines.push(`Recent deliveries: ${digest.recentDeliveries.join("; ")}`); + } + if (digest.recentArtifacts && digest.recentArtifacts.length > 0) { + lines.push(""); + lines.push("Files/artifacts from recent work:"); + for (const artifact of digest.recentArtifacts) { + lines.push(`- ${artifact}`); + } + } + + if ( + digest.activeTasks.length === 0 && + digest.pendingDecisions.length === 0 && + digest.recentDeliveries.length === 0 && + (!digest.recentArtifacts || digest.recentArtifacts.length === 0) + ) { + lines.push("No active tasks, pending decisions, deliveries, or artifacts."); + } + + lines.push(""); + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Plugin definition +// --------------------------------------------------------------------------- +const plugin = { + id: "session-continuity", + name: "Session Continuity", + description: "Persists conversational context across session resets via handoff digests", + configSchema: emptyPluginConfigSchema(), + + register(api: OpenClawPluginApi) { + const log = api.logger; + ensureDataDir(); + + log.info("session-continuity: plugin registered"); + + // ------------------------------------------------------------------- + // 1. agent_end: Update rolling digest after each agent turn + // ------------------------------------------------------------------- + api.on("agent_end", (event: any, ctx: any) => { + try { + const agentId = ctx.agentId || "main"; + const sessionKey = ctx.sessionKey || "unknown"; + const sessionId = ctx.sessionId || undefined; + const messages = event.messages || []; + + if (messages.length === 0) return; + + updateDigestFromMessages(agentId, sessionKey, messages, "agent_end", sessionId); + log.info(`session-continuity: digest updated for ${agentId} (${messages.length} msgs)`); + } catch (err: any) { + log.error(`session-continuity: agent_end error: ${err.message}`); + } + }); + + // ------------------------------------------------------------------- + // 2. before_compaction: Snapshot digest before messages are pruned + // ------------------------------------------------------------------- + api.on("before_compaction", (event: any, ctx: any) => { + try { + const agentId = ctx.agentId || "main"; + const sessionKey = ctx.sessionKey || "unknown"; + const sessionId = ctx.sessionId || undefined; + const messages = event.messages || []; + + if (messages.length === 0) return; + + updateDigestFromMessages(agentId, sessionKey, messages, "compaction", sessionId); + log.info(`session-continuity: pre-compaction digest saved for ${agentId} (${event.compactingCount} msgs being compacted)`); + } catch (err: any) { + log.error(`session-continuity: before_compaction error: ${err.message}`); + } + }); + + // ------------------------------------------------------------------- + // 3. before_reset: Write final handoff digest + // ------------------------------------------------------------------- + api.on("before_reset", (event: any, ctx: any) => { + try { + const agentId = ctx.agentId || "main"; + const sessionKey = ctx.sessionKey || "unknown"; + const sessionId = ctx.sessionId || undefined; + const messages = event.messages || []; + const reason = event.reason || "unknown"; + + updateDigestFromMessages(agentId, sessionKey, messages, reason, sessionId); + log.info(`session-continuity: handoff digest written for ${agentId} (reason: ${reason})`); + } catch (err: any) { + log.error(`session-continuity: before_reset error: ${err.message}`); + } + }); + + // ------------------------------------------------------------------- + // 4. before_prompt_build: Inject handoff digest into new sessions + // ------------------------------------------------------------------- + api.on("before_prompt_build", (_event: any, ctx: any) => { + try { + const agentId = ctx.agentId || "main"; + const sessionKey = ctx.sessionKey || "unknown"; + const sessionId = ctx.sessionId || "unknown"; + + // Check if we already injected for this session (use sessionId for uniqueness) + const consumed = consumedDigests.get(sessionId); + const digest = loadDigest(agentId); + + if (!digest) return; + + // Skip if already consumed for this session + if (consumed === digest.timestamp) return; + + // Skip stale digests + const age = Date.now() - new Date(digest.timestamp).getTime(); + if (age > MAX_DIGEST_AGE_MS) { + log.info(`session-continuity: digest for ${agentId} is stale (${Math.round(age / 60000)}min), skipping`); + return; + } + + // Skip if this is the same session (by sessionId) that wrote the digest. + // NOTE: sessionKey (e.g. "agent:main:main") is NOT unique per session -- + // it's a fixed routing key. Use sessionId (UUID) for dedup. + if (digest.sessionId && digest.sessionId === sessionId) return; + + // Format and inject + const formatted = formatDigestForInjection(digest); + consumedDigests.set(sessionId, digest.timestamp); + + log.info(`session-continuity: injecting handoff digest for ${agentId} into session ${sessionId} (digestSessionId=${digest.sessionId || "none"}, age=${Math.round(age / 60000)}min)`); + + return { prependContext: formatted }; + } catch (err: any) { + log.error(`session-continuity: before_prompt_build error: ${err.message}`); + return undefined; + } + }); + + // ------------------------------------------------------------------- + // 5. gateway_stop: Clean up in-memory state + // ------------------------------------------------------------------- + api.on("gateway_stop", () => { + sessionDigests.clear(); + consumedDigests.clear(); + log.info("session-continuity: cleaned up on gateway stop"); + }); + }, +}; + +export default plugin; diff --git a/bates-core/plugins/session-continuity/openclaw.plugin.json b/bates-core/plugins/session-continuity/openclaw.plugin.json new file mode 100644 index 0000000..acbd01b --- /dev/null +++ b/bates-core/plugins/session-continuity/openclaw.plugin.json @@ -0,0 +1,10 @@ +{ + "id": "session-continuity", + "name": "Session Continuity", + "description": "Persists conversational context across session resets via handoff digests", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/bates-core/scripts-core/acp-health-check.sh b/bates-core/scripts-core/acp-health-check.sh new file mode 100755 index 0000000..fa3c0ff --- /dev/null +++ b/bates-core/scripts-core/acp-health-check.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# acp-health-check.sh — Check ACP runtime health and attempt self-repair +# +# Usage: acp-health-check.sh [--repair] [--help] +# +# Checks: +# 1. Gateway service status +# 2. acpx plugin presence in recent gateway logs +# 3. Concurrent session count vs. maxConcurrentSessions (3) +# +# With --repair: +# Attempts npm install to repair the acpx plugin +# +# Outputs JSON: +# { +# "status": "healthy|degraded|unhealthy", +# "gateway_active": true|false, +# "acpx_seen": true|false, +# "repair_attempted": false, +# "repair_success": null, +# "recommendation": "...", +# "fallback": "~/.openclaw/scripts/run-delegation.sh" +# } +# +# Exit codes: +# 0 ACP healthy +# 1 ACP degraded or unhealthy +# 2 Gateway not running + +set -euo pipefail + +REPAIR=false +[[ "${1:-}" == "--repair" ]] && REPAIR=true +[[ "${1:-}" == "--help" ]] && { + cat </dev/null; then + GATEWAY_ACTIVE=true +fi + +# 2. Check acpx in recent logs (only if gateway is running) +if $GATEWAY_ACTIVE; then + LOG_LINES=$(journalctl --user -u openclaw-gateway -n 100 --no-pager 2>/dev/null || true) + + if echo "$LOG_LINES" | grep -q "acpx"; then + ACPX_SEEN=true + fi + + if echo "$LOG_LINES" | grep -qi "error\|crash\|ENOENT\|failed.*acpx\|acpx.*failed"; then + ERROR_SEEN=true + fi +fi + +# 3. Attempt repair if requested and unhealthy +if $REPAIR && $GATEWAY_ACTIVE && ! $ACPX_SEEN; then + REPAIR_ATTEMPTED=true + ACPX_PKG_DIR="$(cd ~ && npm root -g 2>/dev/null)/openclaw/node_modules" + if npm install --omit=dev --no-save "acpx@0.1.13" --prefix "$ACPX_PKG_DIR" 2>/dev/null; then + REPAIR_SUCCESS=true + else + REPAIR_SUCCESS=false + fi +fi + +# 4. Determine status and recommendation +STATUS="healthy" +RECOMMENDATION="ACP is healthy. Use sessions_spawn with runtime=\"acp\" and agentId=\"claude\"." + +if ! $GATEWAY_ACTIVE; then + STATUS="unhealthy" + RECOMMENDATION="Gateway is not running. Run: systemctl --user start openclaw-gateway" +elif ! $ACPX_SEEN && ! $ERROR_SEEN; then + # Gateway running but acpx hasn't appeared yet — might be initializing + STATUS="degraded" + RECOMMENDATION="acpx plugin not seen in logs. May still be initializing. Try --repair or check: journalctl --user -u openclaw-gateway -n 100 | grep acpx. Fall back to run-delegation.sh if urgent." +elif $ERROR_SEEN; then + STATUS="degraded" + RECOMMENDATION="Errors detected in acpx logs. Run with --repair to attempt npm reinstall. Fall back to ~/.openclaw/scripts/run-delegation.sh if time-sensitive." +fi + +jq -n \ + --arg status "$STATUS" \ + --argjson gateway_active "$GATEWAY_ACTIVE" \ + --argjson acpx_seen "$ACPX_SEEN" \ + --argjson error_seen "$ERROR_SEEN" \ + --argjson repair_attempted "$REPAIR_ATTEMPTED" \ + --argjson repair_success "$REPAIR_SUCCESS" \ + --arg recommendation "$RECOMMENDATION" \ + --arg fallback "$HOME/.openclaw/scripts/run-delegation.sh" \ + '{ + status: $status, + gateway_active: $gateway_active, + acpx_seen: $acpx_seen, + errors_detected: $error_seen, + repair_attempted: $repair_attempted, + repair_success: $repair_success, + recommendation: $recommendation, + fallback: $fallback + }' + +# Exit codes +if ! $GATEWAY_ACTIVE; then + exit 2 +elif [[ "$STATUS" != "healthy" ]]; then + exit 1 +else + exit 0 +fi diff --git a/bates-core/scripts-core/agent-ctl.sh b/bates-core/scripts-core/agent-ctl.sh new file mode 100755 index 0000000..3862710 --- /dev/null +++ b/bates-core/scripts-core/agent-ctl.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# agent-ctl.sh — start/stop/status for on-demand sub-agent gateways +# Usage: agent-ctl.sh start [--wait] +# agent-ctl.sh stop +# agent-ctl.sh status [agent] +# agent-ctl.sh wake # start + wait for health +set -euo pipefail + +AGENTS_DIR="$HOME/.openclaw/agents" + +# Agent → health port map (gateway port + 3) +declare -A HEALTH_PORTS=( + [amara]=18853 [archer]=19033 [conrad]=18813 [dash]=18893 + [jules]=18873 [kira]=18953 [mercer]=18933 [mira]=18913 + [nova]=18973 [paige]=18993 [quinn]=19013 [soren]=18833 +) + +# Agents that should always stay running (never auto-stopped) +# With maxSpawnDepth:2, all deputies delegate via main — none need always-on +ALWAYS_ON="" + +SERVICE_PREFIX="openclaw-agent@" + +is_always_on() { + local agent="$1" + [[ " $ALWAYS_ON " == *" $agent "* ]] +} + +get_health_port() { + local agent="$1" + echo "${HEALTH_PORTS[$agent]:-}" +} + +is_running() { + local agent="$1" + systemctl --user is-active "${SERVICE_PREFIX}${agent}.service" &>/dev/null +} + +wait_for_health() { + local agent="$1" + local port + port=$(get_health_port "$agent") + if [[ -z "$port" ]]; then + echo "WARNING: no health port for $agent, skipping health check" >&2 + return 0 + fi + local max_wait=30 + local waited=0 + while (( waited < max_wait )); do + if curl -sf --max-time 2 "http://127.0.0.1:${port}/" &>/dev/null; then + return 0 + fi + sleep 1 + (( waited++ )) + done + echo "WARNING: $agent health check timed out after ${max_wait}s" >&2 + return 1 +} + +cmd_start() { + local agent="$1" + local do_wait="${2:-}" + + if [[ -z "${HEALTH_PORTS[$agent]:-}" ]]; then + echo "ERROR: unknown agent '$agent'" >&2 + exit 1 + fi + + if is_running "$agent"; then + echo "$agent: already running" + return 0 + fi + + echo "$agent: starting..." + systemctl --user start "${SERVICE_PREFIX}${agent}.service" + + if [[ "$do_wait" == "--wait" ]]; then + if wait_for_health "$agent"; then + echo "$agent: ready (health OK)" + else + echo "$agent: started but health check failed" >&2 + fi + else + echo "$agent: start signal sent" + fi +} + +cmd_wake() { + # Alias for start --wait + local agent="$1" + cmd_start "$agent" "--wait" +} + +cmd_stop() { + local agent="$1" + + if [[ -z "${HEALTH_PORTS[$agent]:-}" ]]; then + echo "ERROR: unknown agent '$agent'" >&2 + exit 1 + fi + + if is_always_on "$agent"; then + echo "$agent: marked always-on, refusing to stop (use systemctl directly to override)" >&2 + return 1 + fi + + if ! is_running "$agent"; then + echo "$agent: already stopped" + return 0 + fi + + echo "$agent: stopping..." + systemctl --user stop "${SERVICE_PREFIX}${agent}.service" + echo "$agent: stopped" +} + +cmd_status() { + local filter="${1:-}" + printf "%-10s %-10s %-8s %-10s\n" "AGENT" "STATUS" "PORT" "MODE" + printf "%-10s %-10s %-8s %-10s\n" "-----" "------" "----" "----" + + for agent in $(echo "${!HEALTH_PORTS[@]}" | tr ' ' '\n' | sort); do + if [[ -n "$filter" && "$agent" != "$filter" ]]; then + continue + fi + local port="${HEALTH_PORTS[$agent]}" + local gw_port=$(( port - 3 )) + local status="stopped" + if is_running "$agent"; then + status="running" + fi + local mode="on-demand" + if is_always_on "$agent"; then + mode="always-on" + fi + printf "%-10s %-10s %-8s %-10s\n" "$agent" "$status" "$gw_port" "$mode" + done +} + +# Main dispatch +case "${1:-}" in + start) + [[ -z "${2:-}" ]] && { echo "Usage: $0 start [--wait]" >&2; exit 1; } + cmd_start "$2" "${3:-}" + ;; + stop) + [[ -z "${2:-}" ]] && { echo "Usage: $0 stop " >&2; exit 1; } + cmd_stop "$2" + ;; + wake) + [[ -z "${2:-}" ]] && { echo "Usage: $0 wake " >&2; exit 1; } + cmd_wake "$2" + ;; + status) + cmd_status "${2:-}" + ;; + *) + echo "Usage: $0 {start|stop|wake|status} [agent] [--wait]" >&2 + exit 1 + ;; +esac diff --git a/bates-core/scripts-core/agent-idle-watcher.sh b/bates-core/scripts-core/agent-idle-watcher.sh new file mode 100755 index 0000000..6d11110 --- /dev/null +++ b/bates-core/scripts-core/agent-idle-watcher.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# agent-idle-watcher.sh — stops on-demand agents that have been idle +# Runs periodically via cron. Checks session file modification times. +# An agent is "idle" if no session file has been modified within IDLE_MINUTES. +set -euo pipefail + +IDLE_MINUTES="${AGENT_IDLE_MINUTES:-10}" +AGENTS_DIR="$HOME/.openclaw/agents" +AGENT_CTL="$HOME/.openclaw/scripts/agent-ctl.sh" +LOG_PREFIX="[idle-watcher]" + +# On-demand agents only (always-on are protected by agent-ctl) +ON_DEMAND_AGENTS="amara archer conrad dash jules kira mercer mira nova paige quinn soren" + +log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $LOG_PREFIX $*"; } + +for agent in $ON_DEMAND_AGENTS; do + # Skip if not running + if ! systemctl --user is-active "openclaw-agent@${agent}.service" &>/dev/null; then + continue + fi + + # Check session activity: any .jsonl modified within IDLE_MINUTES? + sessions_dir="$AGENTS_DIR/$agent/sessions" + state_sessions="$AGENTS_DIR/$agent/state/agents/$agent/sessions" + + active=false + for dir in "$sessions_dir" "$state_sessions"; do + if [[ -d "$dir" ]]; then + recent=$(find "$dir" -maxdepth 1 -name '*.jsonl' -mmin "-${IDLE_MINUTES}" -print -quit 2>/dev/null) + if [[ -n "$recent" ]]; then + active=true + break + fi + fi + done + + if [[ "$active" == "false" ]]; then + log "$agent: idle for >${IDLE_MINUTES}m, stopping" + "$AGENT_CTL" stop "$agent" 2>&1 | while read -r line; do log "$line"; done + fi +done diff --git a/bates-core/scripts-core/agent-message.sh b/bates-core/scripts-core/agent-message.sh new file mode 100755 index 0000000..8a087cf --- /dev/null +++ b/bates-core/scripts-core/agent-message.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Send a message from one agent to another via gateway API +# Usage: agent-message.sh {from} {to} "message text" + +TOKENS_FILE="/home/openclaw/.openclaw/shared/config/agent-tokens.json" + +declare -A PORTS=( + [main]=18789 + [conrad]=18810 [soren]=18830 [amara]=18850 [jules]=18870 + [dash]=18890 [mira]=18910 [mercer]=18930 [kira]=18950 + [nova]=18970 [paige]=18990 [quinn]=19010 [archer]=19030 +) + +from="${1:?Usage: $0 from to message}" +to="${2:?Usage: $0 from to message}" +message="${3:?Usage: $0 from to message}" + +target_port="${PORTS[$to]:-}" +[[ -z "$target_port" ]] && { echo "Unknown agent: $to"; exit 1; } + +# Get auth token for target agent +if [[ "$to" == "main" ]]; then + token=$(jq -r '.gateway.auth.token' /home/openclaw/.openclaw/openclaw.json) +else + token=$(jq -r ".\"$to\"" "$TOKENS_FILE") +fi + +[[ -z "$token" || "$token" == "null" ]] && { echo "No token for $to"; exit 1; } + +# Send message via sessions_send endpoint +payload=$(jq -n \ + --arg from "$from" \ + --arg msg "**Message from $from:** $message" \ + '{ + sessionKey: ("agent:" + $from + ":inter-agent"), + message: $msg + }') + +response=$(curl -s -X POST \ + "http://localhost:${target_port}/v1/sessions/send" \ + -H "Authorization: Bearer ${token}" \ + -H "Content-Type: application/json" \ + -d "$payload") + +echo "$response" diff --git a/bates-core/scripts-core/agent-supervisor.sh b/bates-core/scripts-core/agent-supervisor.sh new file mode 100755 index 0000000..c963f55 --- /dev/null +++ b/bates-core/scripts-core/agent-supervisor.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +AGENTS=(conrad soren amara jules dash mira mercer kira nova paige quinn archer) +declare -A PORTS=( + [conrad]=18810 [soren]=18830 [amara]=18850 [jules]=18870 + [dash]=18890 [mira]=18910 [mercer]=18930 [kira]=18950 + [nova]=18970 [paige]=18990 [quinn]=19010 [archer]=19030 +) + +cmd="${1:-status}" +target="${2:-}" + +start_agent() { + local id="$1" + echo "Starting $id..." + systemctl --user start "openclaw-agent@${id}.service" +} + +stop_agent() { + local id="$1" + echo "Stopping $id..." + systemctl --user stop "openclaw-agent@${id}.service" +} + +restart_agent() { + local id="$1" + echo "Restarting $id..." + systemctl --user restart "openclaw-agent@${id}.service" +} + +health_check() { + local port="$1" + local result + result=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 "http://localhost:${port}/health" 2>/dev/null || echo "000") + if [[ "$result" == "200" ]]; then + echo "healthy" + else + echo "down" + fi +} + +show_status() { + printf "%-10s %-6s %-10s %-12s %-8s\n" "AGENT" "PORT" "SYSTEMD" "UPTIME" "HEALTH" + printf "%-10s %-6s %-10s %-12s %-8s\n" "-----" "----" "-------" "------" "------" + for id in "${AGENTS[@]}"; do + port=${PORTS[$id]} + + # Systemd status + if systemctl --user is-active "openclaw-agent@${id}.service" &>/dev/null; then + svc_status="active" + # Get uptime from systemd + uptime=$(systemctl --user show "openclaw-agent@${id}.service" --property=ActiveEnterTimestamp --value 2>/dev/null || echo "") + if [[ -n "$uptime" && "$uptime" != "n/a" ]]; then + uptime_sec=$(( $(date +%s) - $(date -d "$uptime" +%s 2>/dev/null || echo "0") )) + if (( uptime_sec > 3600 )); then + uptime_str="$((uptime_sec/3600))h$((uptime_sec%3600/60))m" + elif (( uptime_sec > 60 )); then + uptime_str="$((uptime_sec/60))m" + else + uptime_str="${uptime_sec}s" + fi + else + uptime_str="-" + fi + else + svc_status="inactive" + uptime_str="-" + fi + + health=$(health_check "$port") + printf "%-10s %-6s %-10s %-12s %-8s\n" "$id" "$port" "$svc_status" "$uptime_str" "$health" + done +} + +case "$cmd" in + status) + show_status + ;; + start-all) + for id in "${AGENTS[@]}"; do start_agent "$id"; done + echo "All agents started." + ;; + stop-all) + for id in "${AGENTS[@]}"; do stop_agent "$id"; done + echo "All agents stopped." + ;; + start) + [[ -z "$target" ]] && { echo "Usage: $0 start {agent-id}"; exit 1; } + start_agent "$target" + ;; + stop) + [[ -z "$target" ]] && { echo "Usage: $0 stop {agent-id}"; exit 1; } + stop_agent "$target" + ;; + restart) + [[ -z "$target" ]] && { echo "Usage: $0 restart {agent-id}"; exit 1; } + restart_agent "$target" + ;; + *) + echo "Usage: $0 {status|start-all|stop-all|start|stop|restart} [agent-id]" + exit 1 + ;; +esac diff --git a/bates-core/scripts-core/archive-sessions.sh b/bates-core/scripts-core/archive-sessions.sh new file mode 100755 index 0000000..81755f2 --- /dev/null +++ b/bates-core/scripts-core/archive-sessions.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# archive-sessions.sh — Move stale .jsonl session files to archive/ +# Runs safely under concurrent execution (mv -n is atomic on same filesystem). + +set -euo pipefail + +AGENTS_DIR="$HOME/.openclaw/agents" +MAX_AGE_MIN=120 # 2 hours + +total_archived=0 + +for sessions_dir in "$AGENTS_DIR"/*/sessions/; do + [ -d "$sessions_dir" ] || continue + + agent_dir="$(dirname "$sessions_dir")" + agent="$(basename "$agent_dir")" + archive_dir="$agent_dir/archive" + + count=0 + + # Find .jsonl files in the sessions dir (maxdepth 1 to skip subdirs like archive/, state/) + # that haven't been modified in the last 120 minutes. + while IFS= read -r -d '' file; do + mkdir -p "$archive_dir" + basename_file="$(basename "$file")" + # mv -n: no-clobber, atomic on same filesystem. If two instances race, only one wins. + mv -n "$file" "$archive_dir/$basename_file" 2>/dev/null && count=$((count + 1)) || true + done < <(find "$sessions_dir" -maxdepth 1 -name '*.jsonl' -type f -mmin +"$MAX_AGE_MIN" -print0 2>/dev/null) + + if [ "$count" -gt 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $agent: archived $count session file(s)" + total_archived=$((total_archived + count)) + fi +done + +if [ "$total_archived" -eq 0 ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] No session files older than ${MAX_AGE_MIN}m found." +else + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Total archived: $total_archived file(s)" +fi diff --git a/bates-core/scripts-core/bates-update.sh b/bates-core/scripts-core/bates-update.sh new file mode 100644 index 0000000..c5dca46 --- /dev/null +++ b/bates-core/scripts-core/bates-update.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# bates-update.sh -- Check for Bates updates and auto-update safe components +# +# Checks the getBates/Bates GitHub repo for new releases. If a new version is +# available, notifies the user via their messaging channel (like any normal +# Windows app update notification). Does NOT auto-update Bates/OpenClaw — that +# requires downloading the new installer to preserve patches. +# +# Also auto-updates safe standalone tools: Claude Code, Codex CLI, mcporter. +# +# Exit codes: 0 = no action needed, 1 = error, 2 = updates applied or available + +set -euo pipefail + +export PATH="$HOME/.npm-global/bin:$PATH" + +BATES_VERSION_FILE="$HOME/.openclaw/bates-version" +UPDATE_STATE_FILE="$HOME/.openclaw/update-available.json" +LOG_FILE="${BATES_UPDATE_LOG:-/tmp/bates-update.log}" +GITHUB_REPO="getBates/Bates" + +UPDATED=false +DRY_RUN=false +QUIET=false + +usage() { + echo "Usage: bates-update.sh [OPTIONS]" + echo " --dry-run Check only, don't install anything" + echo " --quiet Suppress console output (for cron)" + echo " --help Show this help" +} + +log() { + local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*" + echo "$msg" >> "$LOG_FILE" + if [[ "$QUIET" != "true" ]]; then + echo "$msg" + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --quiet) QUIET=true; shift ;; + --help) usage; exit 0 ;; + *) echo "Unknown option: $1"; usage; exit 1 ;; + esac +done + +log "=== Bates Update Check ===" + +# ============================================================ +# 1. Check getBates/Bates GitHub repo for new releases +# ============================================================ +log "Checking for Bates updates..." + +CURRENT_VERSION="unknown" +if [[ -f "$BATES_VERSION_FILE" ]]; then + CURRENT_VERSION=$(cat "$BATES_VERSION_FILE" | tr -d '[:space:]') +fi + +# Query GitHub releases API (unauthenticated, 60 req/hr limit — fine for daily) +LATEST_RELEASE=$(curl -sf --max-time 10 \ + "https://api.github.com/repos/$GITHUB_REPO/releases/latest" 2>/dev/null || echo "") + +if [[ -n "$LATEST_RELEASE" ]]; then + LATEST_VERSION=$(echo "$LATEST_RELEASE" | python3 -c " +import sys, json +data = json.load(sys.stdin) +tag = data.get('tag_name', '') +# Strip leading 'v' if present +print(tag.lstrip('v')) +" 2>/dev/null || echo "") + + RELEASE_URL=$(echo "$LATEST_RELEASE" | python3 -c " +import sys, json +data = json.load(sys.stdin) +print(data.get('html_url', '')) +" 2>/dev/null || echo "") + + RELEASE_NOTES=$(echo "$LATEST_RELEASE" | python3 -c " +import sys, json +data = json.load(sys.stdin) +body = data.get('body', '') +# First 500 chars +print(body[:500]) +" 2>/dev/null || echo "") + + # Find installer download URL (look for .exe asset) + DOWNLOAD_URL=$(echo "$LATEST_RELEASE" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for asset in data.get('assets', []): + if asset['name'].endswith('.exe'): + print(asset['browser_download_url']) + break +" 2>/dev/null || echo "") + + if [[ -n "$LATEST_VERSION" && "$LATEST_VERSION" != "$CURRENT_VERSION" ]]; then + log " Bates update available: $CURRENT_VERSION -> $LATEST_VERSION" + log " Release: $RELEASE_URL" + + # Write update state file (dashboard and assistant can read this) + cat > "$UPDATE_STATE_FILE" << EOJSON +{ + "update_available": true, + "current_version": "$CURRENT_VERSION", + "latest_version": "$LATEST_VERSION", + "release_url": "$RELEASE_URL", + "download_url": "$DOWNLOAD_URL", + "checked_at": "$(date -Iseconds)" +} +EOJSON + + # Notify user via gateway (send a message through the assistant) + # Only notify once per version — check if we already notified + NOTIFIED_FILE="$HOME/.openclaw/update-notified-$LATEST_VERSION" + if [[ ! -f "$NOTIFIED_FILE" && "$DRY_RUN" != "true" ]]; then + # Use openclaw CLI to send a notification + NOTIFY_MSG="A new version of Bates is available: **v$LATEST_VERSION** (you have v$CURRENT_VERSION)." + if [[ -n "$DOWNLOAD_URL" ]]; then + NOTIFY_MSG="$NOTIFY_MSG Download it here: $DOWNLOAD_URL" + else + NOTIFY_MSG="$NOTIFY_MSG Check the release: $RELEASE_URL" + fi + + if openclaw notify --message "$NOTIFY_MSG" 2>/dev/null; then + touch "$NOTIFIED_FILE" + log " User notified about update" + else + # Fallback: try sending via the gateway API + curl -sf --max-time 5 -X POST http://localhost:18789/api/notify \ + -H "Content-Type: application/json" \ + -d "{\"message\": \"$NOTIFY_MSG\"}" 2>/dev/null || true + touch "$NOTIFIED_FILE" + log " User notified about update (via API fallback)" + fi + elif [[ -f "$NOTIFIED_FILE" ]]; then + log " Already notified about v$LATEST_VERSION" + fi + else + log " Bates: up to date ($CURRENT_VERSION)" + # Clear stale update state + rm -f "$UPDATE_STATE_FILE" + fi +else + log " WARNING: Could not reach GitHub API" +fi + +# ============================================================ +# 2. Auto-update standalone tools (safe, no patches involved) +# ============================================================ +update_npm_package() { + local name="$1" + local cmd="$2" + + if ! command -v "$cmd" &>/dev/null; then + log " $name: not installed, skipping" + return + fi + + local current latest + current=$("$cmd" --version 2>/dev/null | grep -oP '[\d]+\.[\d]+\.[\d]+' | head -1 || echo "unknown") + latest=$(npm show "$name" version 2>/dev/null || echo "") + + if [[ -z "$latest" ]]; then + log " $name: could not check latest version" + return + fi + + if [[ "$current" == "$latest" ]]; then + log " $name: up to date ($current)" + else + log " $name: $current -> $latest available" + if [[ "$DRY_RUN" != "true" ]]; then + if npm install -g "$name" 2>/dev/null; then + log " $name: updated to $latest" + UPDATED=true + else + log " $name: UPDATE FAILED" + fi + fi + fi +} + +log "Checking tool updates..." +update_npm_package "@anthropic-ai/claude-code" "claude" +update_npm_package "@openai/codex" "codex" +update_npm_package "mcporter" "mcporter" + +# ============================================================ +# 3. System packages +# ============================================================ +log "Checking system packages..." +if sudo apt-get update -qq 2>/dev/null; then + UPGRADABLE=$(apt list --upgradable 2>/dev/null | grep -c upgradable || true) + if [[ "$UPGRADABLE" -gt 0 ]]; then + log " $UPGRADABLE system packages have updates" + if [[ "$DRY_RUN" != "true" ]]; then + sudo apt-get upgrade -y -qq 2>/dev/null + log " System packages updated" + UPDATED=true + fi + else + log " System packages up to date" + fi +else + log " WARNING: Could not check system packages" +fi + +# ============================================================ +# 4. Python packages +# ============================================================ +if [[ -d "$HOME/.openclaw/venv" && "$DRY_RUN" != "true" ]]; then + "$HOME/.openclaw/venv/bin/pip" install -q --upgrade requests aiohttp pyyaml 2>/dev/null || true + log " Python packages checked" +fi + +# ============================================================ +# 5. Restart gateway if tools were updated +# ============================================================ +if [[ "$UPDATED" == "true" && "$DRY_RUN" != "true" ]]; then + log "Tool updates applied. Restarting gateway..." + if systemctl --user restart openclaw-gateway 2>/dev/null; then + log "Gateway restarted successfully" + else + log "WARNING: Gateway restart failed" + fi +fi + +log "=== Update check complete ===" +exit 0 diff --git a/bates-core/scripts-core/build-code-review-card.py b/bates-core/scripts-core/build-code-review-card.py new file mode 100644 index 0000000..9259bb1 --- /dev/null +++ b/bates-core/scripts-core/build-code-review-card.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Parse a proposals markdown file and output a read-only Teams Adaptive Card JSON.""" +import json, re, sys +from datetime import date + +def parse_proposals(text): + proposals = [] + for line in text.strip().splitlines(): + m = re.match( + r'\d+\.\s+\*\*(.+?)\*\*\s*\|\s*File:\s*`(.+?)`\s*\|\s*Risk:\s*(\w+)\s*\|\s*(.*)', + line.strip() + ) + if m: + proposals.append({ + "title": m.group(1).strip(), + "file": m.group(2).strip(), + "risk": m.group(3).strip(), + "desc": m.group(4).strip(), + }) + return proposals + +def build_card(proposals, header_date=None): + header_date = header_date or date.today().isoformat() + risk_colors = {"High": "attention", "Medium": "warning", "Low": "good"} + + body = [ + {"type": "TextBlock", "text": f"Code Review Proposals — {header_date}", + "size": "Large", "weight": "Bolder", "wrap": True} + ] + + for i, p in enumerate(proposals, 1): + color = risk_colors.get(p["risk"], "default") + body.append({"type": "Container", "separator": True, "items": [ + {"type": "TextBlock", "text": f"{i}. {p['title']}", "weight": "Bolder", "wrap": True}, + {"type": "FactSet", "facts": [ + {"title": "File", "value": p["file"]}, + {"title": "Risk", "value": p["risk"]}, + ]}, + {"type": "TextBlock", "text": p["desc"], "wrap": True, "isSubtle": True, + "color": color}, + ]}) + + body.append({"type": "TextBlock", "text": "Reply with proposal numbers to accept (e.g. '1,3' or 'all'). Add instructions after the number (e.g. '1 — use async locks'). Reply 'none' to skip all.", + "wrap": True, "separator": True, "spacing": "Medium", "isSubtle": True}) + + return { + "type": "AdaptiveCard", + "version": "1.4", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "body": body, + } + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "/dev/stdin" + with open(path) as f: + text = f.read() + dm = re.search(r'(\d{4}-\d{2}-\d{2})', path) + header_date = dm.group(1) if dm else None + card = build_card(parse_proposals(text), header_date) + json.dump(card, sys.stdout, indent=2) + print() diff --git a/bates-core/scripts-core/check-claude-update.sh b/bates-core/scripts-core/check-claude-update.sh new file mode 100755 index 0000000..45f40e4 --- /dev/null +++ b/bates-core/scripts-core/check-claude-update.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Check if Claude Code has an update available +# Outputs JSON: {"current":"x.y.z","latest":"x.y.z","update_available":true/false} + +CURRENT=$(claude --version 2>/dev/null | grep -oP '[\d]+\.[\d]+\.[\d]+') +LATEST=$(npm show @anthropic-ai/claude-code version 2>/dev/null) + +if [[ -z "$CURRENT" || -z "$LATEST" ]]; then + echo '{"error":"Could not determine versions"}' + exit 1 +fi + +if [[ "$CURRENT" == "$LATEST" ]]; then + echo "{\"current\":\"$CURRENT\",\"latest\":\"$LATEST\",\"update_available\":false}" +else + echo "{\"current\":\"$CURRENT\",\"latest\":\"$LATEST\",\"update_available\":true}" + exit 2 +fi diff --git a/bates-core/scripts-core/check-project-mirror.sh b/bates-core/scripts-core/check-project-mirror.sh new file mode 100755 index 0000000..2279bd7 --- /dev/null +++ b/bates-core/scripts-core/check-project-mirror.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# check-project-mirror.sh — Check project mirror freshness before delegation +# +# Usage: check-project-mirror.sh [--days N] +# +# Projects: fdesk, synapse, escola-caravela, general +# +# Exits 0 if fresh, 1 if stale or missing. +# Outputs JSON to stdout. +# +# Examples: +# check-project-mirror.sh fdesk +# check-project-mirror.sh synapse --days 7 + +set -euo pipefail + +PROJECTS_DIR="${HOME}/.openclaw/workspace/projects" +DEFAULT_STALE_DAYS=14 + +show_help() { + cat < [--days N] + +Check if a project mirror is fresh enough for delegation use. +Exits 0 (fresh) or 1 (stale/missing). Outputs JSON. + +Projects: fdesk, synapse, escola-caravela, general + +Options: + --days N Staleness threshold in days (default: $DEFAULT_STALE_DAYS) + --help Show this help + +JSON output fields: + project Project name + dir Mirror directory path + newest_file Name of most recently modified .md file + newest_date Date of newest file (YYYY-MM-DD) + age_days Age in days + is_stale true if age > threshold + warning Human-readable warning message (null if fresh) +EOF +} + +[[ "${1:-}" == "--help" ]] && { show_help; exit 0; } +[[ $# -lt 1 ]] && { echo "Error: project name required" >&2; show_help >&2; exit 1; } + +PROJECT="$1" +STALE_DAYS="$DEFAULT_STALE_DAYS" + +# Parse optional flags +shift +while [[ $# -gt 0 ]]; do + case "$1" in + --days) STALE_DAYS="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# General tasks don't need a project mirror +if [[ "$PROJECT" == "general" ]]; then + echo '{"project":"general","is_stale":false,"warning":null,"message":"No project mirror needed for general tasks."}' + exit 0 +fi + +PROJECT_DIR="$PROJECTS_DIR/$PROJECT" + +if [[ ! -d "$PROJECT_DIR" ]]; then + echo "{\"project\":\"$PROJECT\",\"dir\":\"$PROJECT_DIR\",\"is_stale\":true,\"newest_file\":null,\"age_days\":null,\"warning\":\"Project directory not found: $PROJECT_DIR\"}" + exit 1 +fi + +# Find newest .md file by modification time +NEWEST_LINE=$(find "$PROJECT_DIR" -type f -name "*.md" -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1) + +if [[ -z "$NEWEST_LINE" ]]; then + echo "{\"project\":\"$PROJECT\",\"dir\":\"$PROJECT_DIR\",\"is_stale\":true,\"newest_file\":null,\"age_days\":null,\"warning\":\"No .md files found in $PROJECT_DIR\"}" + exit 1 +fi + +NEWEST_EPOCH=$(echo "$NEWEST_LINE" | awk '{print $1}' | cut -d. -f1) +NEWEST_FILE=$(echo "$NEWEST_LINE" | awk '{print $2}') +NEWEST_BASENAME=$(basename "$NEWEST_FILE") +NEWEST_DATE=$(date -d "@$NEWEST_EPOCH" '+%Y-%m-%d') + +NOW_EPOCH=$(date +%s) +AGE_DAYS=$(( (NOW_EPOCH - NEWEST_EPOCH) / 86400 )) + +IS_STALE="false" +WARNING="null" + +if [[ "$AGE_DAYS" -gt "$STALE_DAYS" ]]; then + IS_STALE="true" + WARNING="\"Mirror is ${AGE_DAYS} days old (threshold: ${STALE_DAYS}d). Run project-sync before delegating.\"" +fi + +python3 -c " +import json, sys +print(json.dumps({ + 'project': sys.argv[1], + 'dir': sys.argv[2], + 'newest_file': sys.argv[3], + 'newest_date': sys.argv[4], + 'age_days': int(sys.argv[5]), + 'is_stale': sys.argv[6] == 'true', + 'warning': None if sys.argv[7] == '__null__' else sys.argv[7] +})) +" "$PROJECT" "$PROJECT_DIR" "$NEWEST_BASENAME" "$NEWEST_DATE" "$AGE_DAYS" "$IS_STALE" \ + "$([ "$IS_STALE" = "true" ] && echo "Mirror is ${AGE_DAYS} days old (threshold: ${STALE_DAYS}d). Run project-sync before delegating." || echo "__null__")" + +[[ "$IS_STALE" == "true" ]] && exit 1 || exit 0 diff --git a/bates-core/scripts-core/checkin-state.sh b/bates-core/scripts-core/checkin-state.sh new file mode 100755 index 0000000..cc792b9 --- /dev/null +++ b/bates-core/scripts-core/checkin-state.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# checkin-state.sh — Manage proactive check-in state (last-checkin.json) +# +# Usage: +# checkin-state.sh read # Print current state as JSON +# checkin-state.sh check-cooldown # Is this alert in cooldown? (exits 0=ok-to-send, 1=in-cooldown) +# checkin-state.sh check-suppressed # Is category suppressed? (exits 0=not-suppressed, 1=suppressed) +# checkin-state.sh update-run [--sent] [--score N] # Update last_run, optionally mark message sent +# checkin-state.sh add-reported # Add/refresh a reported_items entry +# checkin-state.sh suppress [--days N] # Suppress a category for N days (default 7) +# checkin-state.sh prune # Prune old reported_items and expired suppression +# checkin-state.sh --help +# +# State file: ~/.openclaw/workspace/observations/last-checkin.json + +set -euo pipefail + +STATE_FILE="${HOME}/.openclaw/workspace/observations/last-checkin.json" +OBS_DIR="${HOME}/.openclaw/workspace/observations" + +show_help() { + cat < [args] + +Manage proactive check-in state file: $STATE_FILE + +Commands: + read Print current state as JSON + check-cooldown Check if alert is in cooldown + severity: urgent|text|standard|github + Exits 0 = ok to send, 1 = in cooldown + check-suppressed Check if category is suppressed + Exits 0 = not suppressed, 1 = suppressed + update-run [--sent] [--score N] Update last_run timestamp + --sent: also update last_message_sent, reset skipped_runs + --score N: record score for this run + add-reported + Add or refresh a reported_items entry + suppress [--days N] Suppress category for N days (default: 7) + prune Remove entries >7 days old, keep max 50, expire suppressions + --help Show this help + +Cooldown periods by severity: + urgent = 60 minutes + text = 12 hours (time-sensitive = 4 hours) + standard = 12 hours + github = 24 hours +EOF +} + +[[ "${1:-}" == "--help" ]] && { show_help; exit 0; } + +# Ensure state file and directory exist +mkdir -p "$OBS_DIR" +if [[ ! -f "$STATE_FILE" ]]; then + echo '{"last_run":null,"last_message_sent":null,"skipped_runs":0,"reported_items":[],"suppressed_categories":[]}' > "$STATE_FILE" +fi + +# Validate it's valid JSON +if ! jq empty "$STATE_FILE" 2>/dev/null; then + echo "Error: state file is invalid JSON: $STATE_FILE" >&2 + exit 1 +fi + +CMD="${1:-}" +shift || true + +case "$CMD" in + + read) + jq . "$STATE_FILE" + ;; + + check-cooldown) + ALERT_KEY="${1:-}" + SEVERITY="${2:-standard}" + if [[ -z "$ALERT_KEY" ]]; then + echo '{"error":"alert_key required"}' >&2; exit 1 + fi + + # Cooldown periods in seconds + case "$SEVERITY" in + urgent) COOLDOWN=3600 ;; # 60 min + text) COOLDOWN=43200 ;; # 12 hours + standard) COOLDOWN=43200 ;; # 12 hours + github) COOLDOWN=86400 ;; # 24 hours + *) COOLDOWN=43200 ;; + esac + + NOW=$(date +%s) + LAST_SENT=$(jq -r --arg key "$ALERT_KEY" \ + '.reported_items[] | select(.alert_key == $key) | .last_sent_at // empty' \ + "$STATE_FILE" | tail -1) + + if [[ -z "$LAST_SENT" ]]; then + jq -n --arg key "$ALERT_KEY" --arg sev "$SEVERITY" \ + '{"alert_key":$key,"severity":$sev,"in_cooldown":false,"reason":"never reported"}' + exit 0 + fi + + LAST_SENT_EPOCH=$(date -d "$LAST_SENT" +%s 2>/dev/null || echo 0) + ELAPSED=$(( NOW - LAST_SENT_EPOCH )) + REMAINING=$(( COOLDOWN - ELAPSED )) + + if (( ELAPSED < COOLDOWN )); then + jq -n \ + --arg key "$ALERT_KEY" \ + --arg sev "$SEVERITY" \ + --argjson remaining "$REMAINING" \ + --argjson elapsed "$ELAPSED" \ + --argjson cooldown "$COOLDOWN" \ + '{"alert_key":$key,"severity":$sev,"in_cooldown":true,"remaining_seconds":$remaining,"elapsed_seconds":$elapsed,"cooldown_seconds":$cooldown}' + exit 1 + else + jq -n \ + --arg key "$ALERT_KEY" \ + --arg sev "$SEVERITY" \ + --argjson elapsed "$ELAPSED" \ + '{"alert_key":$key,"severity":$sev,"in_cooldown":false,"elapsed_seconds":$elapsed}' + exit 0 + fi + ;; + + check-suppressed) + CATEGORY="${1:-}" + if [[ -z "$CATEGORY" ]]; then + echo '{"error":"category required"}' >&2; exit 1 + fi + + NOW=$(date +%s) + EXPIRES=$(jq -r --arg cat "$CATEGORY" \ + '.suppressed_categories[] | select(.category == $cat) | .expires_at // empty' \ + "$STATE_FILE" | tail -1) + + if [[ -z "$EXPIRES" ]]; then + jq -n --arg cat "$CATEGORY" \ + '{"category":$cat,"suppressed":false,"reason":"not in suppression list"}' + exit 0 + fi + + EXPIRES_EPOCH=$(date -d "$EXPIRES" +%s 2>/dev/null || echo 0) + if (( NOW < EXPIRES_EPOCH )); then + REMAINING=$(( EXPIRES_EPOCH - NOW )) + jq -n --arg cat "$CATEGORY" --arg exp "$EXPIRES" --argjson rem "$REMAINING" \ + '{"category":$cat,"suppressed":true,"expires_at":$exp,"remaining_seconds":$rem}' + exit 1 + else + jq -n --arg cat "$CATEGORY" --arg exp "$EXPIRES" \ + '{"category":$cat,"suppressed":false,"reason":"suppression expired","expired_at":$exp}' + exit 0 + fi + ;; + + update-run) + SENT=false + SCORE="" + while [[ $# -gt 0 ]]; do + case "$1" in + --sent) SENT=true; shift ;; + --score) SCORE="${2:-}"; shift 2 ;; + *) shift ;; + esac + done + + NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + if $SENT; then + jq --arg now "$NOW_ISO" \ + '.last_run = $now | .last_message_sent = $now | .skipped_runs = 0' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" + else + jq --arg now "$NOW_ISO" \ + '.last_run = $now | .skipped_runs = (.skipped_runs + 1)' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" + fi + + jq -n --arg now "$NOW_ISO" --argjson sent "$SENT" \ + '{"status":"ok","last_run":$now,"message_sent":$sent}' + ;; + + add-reported) + ALERT_KEY="${1:-}" + CATEGORY="${2:-unknown}" + STATUS="${3:-reported}" + if [[ -z "$ALERT_KEY" ]]; then + echo '{"error":"alert_key required"}' >&2; exit 1 + fi + + NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Remove existing entry with same key, then append new one + jq --arg key "$ALERT_KEY" --arg cat "$CATEGORY" --arg status "$STATUS" --arg now "$NOW_ISO" \ + '.reported_items = ([.reported_items[] | select(.alert_key != $key)] + [{"alert_key":$key,"category":$cat,"status":$status,"last_sent_at":$now}])' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" + + jq -n --arg key "$ALERT_KEY" --arg now "$NOW_ISO" \ + '{"status":"ok","alert_key":$key,"recorded_at":$now}' + ;; + + suppress) + CATEGORY="${1:-}" + DAYS=7 + while [[ $# -gt 0 ]]; do + case "$1" in + --days) DAYS="${2:-7}"; shift 2 ;; + *) shift ;; + esac + done + if [[ -z "$CATEGORY" ]]; then + echo '{"error":"category required"}' >&2; exit 1 + fi + + EXPIRES_EPOCH=$(( $(date +%s) + DAYS * 86400 )) + EXPIRES_ISO=$(date -d "@${EXPIRES_EPOCH}" -u +"%Y-%m-%dT%H:%M:%SZ") + + # Remove existing suppression for category, add new one + jq --arg cat "$CATEGORY" --arg exp "$EXPIRES_ISO" \ + '.suppressed_categories = ([.suppressed_categories[] | select(.category != $cat)] + [{"category":$cat,"expires_at":$exp}])' \ + "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" + + jq -n --arg cat "$CATEGORY" --arg exp "$EXPIRES_ISO" --argjson days "$DAYS" \ + '{"status":"ok","category":$cat,"suppressed_until":$exp,"days":$days}' + ;; + + prune) + CUTOFF_EPOCH=$(( $(date +%s) - 7 * 86400 )) + CUTOFF_ISO=$(date -d "@${CUTOFF_EPOCH}" -u +"%Y-%m-%dT%H:%M:%SZ") + NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + BEFORE=$(jq '.reported_items | length' "$STATE_FILE") + + jq --arg cutoff "$CUTOFF_ISO" --arg now "$NOW_ISO" ' + .reported_items = ( + [.reported_items[] | select(.last_sent_at >= $cutoff)] + | .[-50:] + ) | + .suppressed_categories = [ + .suppressed_categories[] | select(.expires_at > $now) + ] + ' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" + + AFTER=$(jq '.reported_items | length' "$STATE_FILE") + PRUNED=$(( BEFORE - AFTER )) + + jq -n --argjson before "$BEFORE" --argjson after "$AFTER" --argjson pruned "$PRUNED" \ + '{"status":"ok","items_before":$before,"items_after":$after,"items_pruned":$pruned}' + ;; + + "") + echo "Error: command required" >&2 + show_help >&2 + exit 1 + ;; + + *) + echo "Error: unknown command: $CMD" >&2 + show_help >&2 + exit 1 + ;; +esac diff --git a/bates-core/scripts-core/classify-memory.sh b/bates-core/scripts-core/classify-memory.sh new file mode 100755 index 0000000..9f27cd3 --- /dev/null +++ b/bates-core/scripts-core/classify-memory.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# classify-memory.sh — Classify and append a memory entry to the correct observations file +# +# Usage: classify-memory.sh "" [--source ] +# +# Examples: +# classify-memory.sh goal "Reduce Bates monthly API cost to under \$50" --source "Robert's message" +# classify-memory.sh contact "Ian Foley - former Binance CBO, Synapse advisor" --source "email" +# classify-memory.sh pattern "Robert reviews email first, then switches to Cursor" --source "observation" + +set -euo pipefail + +OBS_DIR="${HOME}/.openclaw/workspace/observations" + +show_help() { + cat < "" [--source ] + +Append a tagged memory entry to the appropriate observations file. +Handles deduplication (skips if identical content already exists). + +Tags → File: + goal Something Robert wants to achieve → findings.md + fact Reference information (stable) → findings.md + preference How Robert wants something done → findings.md + deadline A hard date/time commitment → findings.md + decision A choice Robert made → findings.md + contact Information about a person → findings.md + pattern A recurring process or behavior observed → patterns.md + +Options: + --source Where you learned this (default: "unspecified") + --help Show this help + +JSON output: + { "tag": "...", "file": "...", "status": "ok|skipped", "reason": "..." } +EOF +} + +[[ "${1:-}" == "--help" ]] && { show_help; exit 0; } +[[ $# -lt 2 ]] && { echo "Error: tag and content required" >&2; show_help >&2; exit 1; } + +TAG="$1" +CONTENT="$2" +SOURCE="unspecified" + +shift 2 +while [[ $# -gt 0 ]]; do + case "$1" in + --source) SOURCE="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# Route tag to file +case "$TAG" in + goal|fact|preference|deadline|decision|contact) + TARGET_FILE="$OBS_DIR/findings.md" ;; + pattern) + TARGET_FILE="$OBS_DIR/patterns.md" ;; + *) + echo "Error: Unknown tag '$TAG'" >&2 + echo "Valid tags: goal, fact, preference, deadline, decision, contact, pattern" >&2 + exit 1 ;; +esac + +mkdir -p "$OBS_DIR" + +TODAY=$(date '+%Y-%m-%d') +ENTRY="- [$TAG] $CONTENT (source: $SOURCE)" + +# Deduplication check — skip if identical tag+content entry already in file +if [[ -f "$TARGET_FILE" ]] && grep -qF "[$TAG] $CONTENT" "$TARGET_FILE" 2>/dev/null; then + echo "⚠️ Duplicate detected — skipping" >&2 + echo "{\"tag\":\"$TAG\",\"file\":\"$TARGET_FILE\",\"status\":\"skipped\",\"reason\":\"duplicate content already exists\"}" + exit 0 +fi + +# Insert under today's date section (or prepend new section) +python3 -c " +import sys, os +marker = '## ' + sys.argv[1] +entry = sys.argv[2] +path = sys.argv[3] +if os.path.exists(path): + with open(path) as f: + lines = f.readlines() +else: + lines = [] +inserted = False +for i, line in enumerate(lines): + if line.strip() == marker: + lines.insert(i + 1, entry + '\n') + inserted = True + break +if not inserted: + lines = [marker + '\n', entry + '\n', '\n'] + lines +with open(path, 'w') as f: + f.writelines(lines) +" "$TODAY" "$ENTRY" "$TARGET_FILE" + +echo "✓ Appended [$TAG] to $(basename "$TARGET_FILE")" >&2 +echo "{\"tag\":\"$TAG\",\"file\":\"$TARGET_FILE\",\"status\":\"ok\",\"today\":\"$TODAY\",\"entry\":$(echo "$ENTRY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip()))')}" diff --git a/bates-core/scripts-core/claude-sub.sh b/bates-core/scripts-core/claude-sub.sh new file mode 100755 index 0000000..a21cceb --- /dev/null +++ b/bates-core/scripts-core/claude-sub.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Wrapper to call Claude Code using subscription auth only. +# Strips ANTHROPIC_API_KEY so Claude Code falls back to OAuth credentials. +env -u ANTHROPIC_API_KEY claude "$@" diff --git a/bates-core/scripts-core/claude-tmux.sh b/bates-core/scripts-core/claude-tmux.sh new file mode 100755 index 0000000..96117b8 --- /dev/null +++ b/bates-core/scripts-core/claude-tmux.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# claude-tmux.sh — Run Claude Code inside a persistent tmux session. +# +# Usage: +# claude-tmux # attach or create session, auto-resume last conversation +# claude-tmux new # attach or create session, start fresh conversation +# +# If the tmux session "claude" exists: +# - If Claude Code is still running inside it → just attach +# - If the shell is idle (Claude exited) → restart Claude with --resume +# If no session exists → create one and start Claude +# +# To detach without killing Claude: press Ctrl+B then D +# To reattach later: just run `claude-tmux` again + +SESSION="claude" +WORKDIR="/mnt/c/Users/openclaw" +MODE="${1:-resume}" + +# Check if session already exists +if tmux has-session -t "$SESSION" 2>/dev/null; then + # Session exists. Check if Claude Code is running inside it. + PANE_PID=$(tmux list-panes -t "$SESSION" -F '#{pane_pid}' 2>/dev/null) + CLAUDE_RUNNING=false + if [ -n "$PANE_PID" ]; then + # Check if any child process of the pane shell is claude + if pgrep -P "$PANE_PID" -f "claude" >/dev/null 2>&1; then + CLAUDE_RUNNING=true + fi + fi + + if $CLAUDE_RUNNING; then + echo "Claude Code is still running — reattaching..." + tmux attach -t "$SESSION" + else + echo "Session exists but Claude exited — restarting Claude Code..." + if [ "$MODE" = "new" ]; then + tmux send-keys -t "$SESSION" "cd $WORKDIR && claude" Enter + else + tmux send-keys -t "$SESSION" "cd $WORKDIR && claude --resume" Enter + fi + sleep 1 + tmux attach -t "$SESSION" + fi +else + # No session — create one + echo "Creating new tmux session '$SESSION'..." + if [ "$MODE" = "new" ]; then + tmux new-session -d -s "$SESSION" -c "$WORKDIR" "claude" + else + tmux new-session -d -s "$SESSION" -c "$WORKDIR" "claude --resume" + fi + sleep 1 + tmux attach -t "$SESSION" +fi diff --git a/bates-core/scripts-core/coding-health-monitor.py b/bates-core/scripts-core/coding-health-monitor.py new file mode 100644 index 0000000..9d6701d --- /dev/null +++ b/bates-core/scripts-core/coding-health-monitor.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Coding Health Monitor — detects edit loops and thrashing in session transcripts. + +Scans JSONL session files for: + 1. Same-file thrashing: a file edited 4+ times in one session + 2. Edit-error loops: edit → error → edit → error cycles (3+ cycles) + +Usage: + python3 coding-health-monitor.py # last 24 hours + python3 coding-health-monitor.py --all # all sessions + python3 coding-health-monitor.py --hours 6 # last 6 hours + python3 coding-health-monitor.py --json # JSON output (for cron integration) +""" + +import json +import os +import sys +import glob +from collections import defaultdict +from datetime import datetime + +SESSIONS_DIRS = [ + os.path.expanduser("~/.openclaw/agents/main/sessions"), +] +# Also scan deputy agent sessions +AGENTS_BASE = os.path.expanduser("~/.openclaw/agents") + +EDIT_TOOLS = {"write", "edit"} +SAME_FILE_THRESHOLD = 4 +LOOP_THRESHOLD = 3 + + +def get_all_session_dirs(): + """Find all session directories across all agents.""" + dirs = [] + if os.path.isdir(AGENTS_BASE): + for agent in os.listdir(AGENTS_BASE): + sessions = os.path.join(AGENTS_BASE, agent, "sessions") + if os.path.isdir(sessions): + dirs.append(sessions) + archive = os.path.join(sessions, "archive") + if os.path.isdir(archive): + dirs.append(archive) + return dirs + + +def extract_file_path(args): + """Get file path from tool call arguments.""" + if isinstance(args, str): + try: + args = json.loads(args) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(args, dict): + return None + for key in ("file_path", "filePath", "path"): + if key in args: + return args[key] + return None + + +def analyze_session(filepath): + """Analyze a single JSONL session file for edit patterns.""" + file_edits = defaultdict(int) + edit_events = [] # [{file, error}] + + entries = [] + try: + with open(filepath, errors="replace") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + pass + except (OSError, IOError): + return {"thrashing": {}, "loops": {}} + + # Map tool call IDs to file paths + pending_calls = {} # callId -> filepath + + for entry in entries: + if entry.get("type") != "message": + continue + msg = entry.get("message", {}) + role = msg.get("role", "") + + if role == "assistant": + for c in msg.get("content", []): + if not isinstance(c, dict): + continue + tool_name = (c.get("name") or c.get("toolName") or "").lower() + if c.get("type") in ("toolCall", "tool_use") and tool_name in EDIT_TOOLS: + args = c.get("arguments") or c.get("input") or {} + fp = extract_file_path(args) + call_id = c.get("id") or c.get("toolCallId") or "" + if fp: + file_edits[fp] += 1 + pending_calls[call_id] = fp + edit_events.append({"file": fp, "error": None, "call_id": call_id}) + + elif role in ("toolResult", "tool_result"): + call_id = msg.get("toolCallId") or msg.get("tool_use_id") or "" + is_error = msg.get("isError", False) or msg.get("is_error", False) + if call_id in pending_calls: + # Update the matching event + for ev in reversed(edit_events): + if ev["call_id"] == call_id and ev["error"] is None: + ev["error"] = is_error + break + + # Detect same-file thrashing + thrashing = {f: c for f, c in file_edits.items() if c >= SAME_FILE_THRESHOLD} + + # Detect edit-error loops per file + loops = {} + files_seen = set(ev["file"] for ev in edit_events) + for fp in files_seen: + file_seq = [ev for ev in edit_events if ev["file"] == fp] + cycle_count = 0 + for i in range(len(file_seq) - 1): + if file_seq[i].get("error") and file_seq[i + 1].get("error") is not None: + cycle_count += 1 + if cycle_count >= LOOP_THRESHOLD: + loops[fp] = cycle_count + + return {"thrashing": thrashing, "loops": loops} + + +def main(): + hours = 24 + output_json = "--json" in sys.argv + scan_all = "--all" in sys.argv + + for i, arg in enumerate(sys.argv): + if arg == "--hours" and i + 1 < len(sys.argv): + try: + hours = int(sys.argv[i + 1]) + except ValueError: + pass + + cutoff = 0 if scan_all else (datetime.now().timestamp() - hours * 3600) + + session_dirs = get_all_session_dirs() + issues = [] + + for sdir in session_dirs: + for fpath in glob.glob(os.path.join(sdir, "*.jsonl")): + try: + if os.path.getmtime(fpath) < cutoff: + continue + except OSError: + continue + + result = analyze_session(fpath) + if result["thrashing"] or result["loops"]: + session_id = os.path.basename(fpath).replace(".jsonl", "") + agent = os.path.basename(os.path.dirname(os.path.dirname(fpath))) + issues.append({ + "agent": agent, + "session": session_id, + "thrashing": result["thrashing"], + "loops": result["loops"], + }) + + if output_json: + print(json.dumps({"issues": issues, "scanned_hours": hours if not scan_all else "all"}, indent=2)) + return + + if not issues: + print(f"No edit loop issues detected (scanned last {hours}h).") + return + + print(f"Found {len(issues)} session(s) with edit issues:\n") + for issue in issues: + print(f" Agent: {issue['agent']} Session: {issue['session'][:24]}...") + for fp, count in issue["thrashing"].items(): + print(f" THRASHING: {fp} — edited {count} times") + for fp, cycles in issue["loops"].items(): + print(f" LOOP: {fp} — {cycles} edit-error-edit cycles") + print() + + +if __name__ == "__main__": + main() diff --git a/bates-core/scripts-core/collect-standups.sh b/bates-core/scripts-core/collect-standups.sh new file mode 100755 index 0000000..cb92fc4 --- /dev/null +++ b/bates-core/scripts-core/collect-standups.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Collect standup reports from deputy agents into a daily standup file +set -euo pipefail + +AGENTS_DIR="/home/openclaw/.openclaw/agents" +STANDUPS_DIR="/home/openclaw/.openclaw/shared/standups" +TODAY=$(date +%Y-%m-%d) +OUTPUT="$STANDUPS_DIR/$TODAY.md" +DEPUTIES=(conrad soren amara jules dash mira mercer kira nova paige quinn archer) + +mkdir -p "$STANDUPS_DIR" + +echo "# Daily Standups — $TODAY" > "$OUTPUT" +echo "" >> "$OUTPUT" + +collected=0 +for agent in "${DEPUTIES[@]}"; do + standup="$AGENTS_DIR/$agent/outbox/standup.md" + standup_dated="$AGENTS_DIR/$agent/outbox/standup-$TODAY.md" + # Prefer date-stamped standup, fall back to plain standup.md + if [[ -f "$standup_dated" ]]; then + standup="$standup_dated" + fi + if [[ -f "$standup" ]]; then + echo "## $agent" >> "$OUTPUT" + echo "" >> "$OUTPUT" + cat "$standup" >> "$OUTPUT" + echo "" >> "$OUTPUT" + rm "$standup" + collected=$((collected + 1)) + else + echo "## $agent" >> "$OUTPUT" + echo "" >> "$OUTPUT" + echo "_No standup submitted._" >> "$OUTPUT" + echo "" >> "$OUTPUT" + fi +done + +echo "Collected $collected/${#DEPUTIES[@]} standups → $OUTPUT" + +# Post each standup to Teams standups channel +TEAM_ID="640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c" +CHANNEL_ID="19:c713974d563f428aae7b40ee9f931343@thread.tacv2" +GRAPH_API="$HOME/.openclaw/scripts/graph-api.sh" + +if [[ -x "$GRAPH_API" ]]; then + for agent in "${DEPUTIES[@]}"; do + content=$(sed -n "/^## $agent$/,/^## /{ /^## $agent$/d; /^## /d; p; }" "$OUTPUT" | sed '/^$/N;/^\n$/d') + if [[ -n "$content" && "$content" != *"No standup submitted"* ]]; then + html="

${agent^}

$(echo "$content" | head -25 | sed 's/&/\&/g; s//\>/g')
" + payload=$(jq -n --arg body "$html" '{body: {contentType: "html", content: $body}}') + "$GRAPH_API" POST "/teams/$TEAM_ID/channels/$CHANNEL_ID/messages" "$payload" >/dev/null 2>&1 || true + fi + done + echo "Posted standups to Teams channel" +fi diff --git a/bates-core/scripts-core/compile-briefing.sh b/bates-core/scripts-core/compile-briefing.sh new file mode 100755 index 0000000..a05ce76 --- /dev/null +++ b/bates-core/scripts-core/compile-briefing.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Compile morning briefing from today's standups + specialist weekly updates +set -euo pipefail + +AGENTS_DIR="/home/openclaw/.openclaw/agents" +STANDUPS_DIR="/home/openclaw/.openclaw/shared/standups" +TODAY=$(date +%Y-%m-%d) +STANDUP_FILE="$STANDUPS_DIR/$TODAY.md" +SPECIALISTS=(mercer kira nova paige quinn archer) + +echo "═══════════════════════════════════════" +echo " MORNING BRIEFING — $TODAY" +echo "═══════════════════════════════════════" +echo "" + +# Deputy standups +if [[ -f "$STANDUP_FILE" ]]; then + echo "── Deputy Standups ──" + echo "" + cat "$STANDUP_FILE" + echo "" +else + echo "── Deputy Standups ──" + echo "" + echo "No standup file for today. Run collect-standups.sh first." + echo "" +fi + +# Specialist weekly updates +has_updates=false +for agent in "${SPECIALISTS[@]}"; do + update="$AGENTS_DIR/$agent/outbox/weekly-update.md" + if [[ -f "$update" ]]; then + if [[ "$has_updates" == false ]]; then + echo "── Specialist Weekly Updates ──" + echo "" + has_updates=true + fi + echo "## $agent" + echo "" + cat "$update" + echo "" + fi +done + +if [[ "$has_updates" == false ]]; then + echo "── Specialist Weekly Updates ──" + echo "" + echo "No specialist updates pending." +fi + +echo "" +echo "═══════════════════════════════════════" diff --git a/bates-core/scripts-core/cron-channel-router.sh b/bates-core/scripts-core/cron-channel-router.sh new file mode 100755 index 0000000..b980cff --- /dev/null +++ b/bates-core/scripts-core/cron-channel-router.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# cron-channel-router.sh — Map a cron job name to its Teams channel destination +# +# Usage: cron-channel-router.sh +# cron-channel-router.sh --list +# cron-channel-router.sh --help +# +# Outputs JSON: { "cron": "morning-briefing", "channel": "standups", "channel_id": "19:..." } +# +# Examples: +# cron-channel-router.sh morning-briefing +# cron-channel-router.sh overnight-code-review +# cron-channel-router.sh --list + +set -euo pipefail + +show_help() { + cat < + cron-channel-router.sh --list + +Map a cron job name to its Teams channel destination. +Outputs JSON with channel name and ID. + +Options: + --list Show all cron → channel mappings as JSON array + --help Show this help + +JSON output: + { "cron": "morning-briefing", "channel": "standups", + "channel_id": "19:...", "condition": null } + +Condition field: + null = always post + "if-items" = only post if items found + "project-ops" = route to project-specific ops channel (fdesk-ops, synapse-ops, escola-ops) + +Exit codes: + 0 Mapping found + 1 Unknown cron job +EOF +} + +[[ "${1:-}" == "--help" ]] && { show_help; exit 0; } + +# Channel ID map +declare -A CHANNEL_IDS=( + [general]="19:FEedL9wiNMY6nN-rJUomU0H_qHysdpbjawsZjbBSCuk1@thread.tacv2" + [standups]="19:c713974d563f428aae7b40ee9f931343@thread.tacv2" + [fdesk-ops]="19:35613cb0484c4387bd7f7d3e6059bf33@thread.tacv2" + [synapse-ops]="19:d13b55b2de1b4b559e46b3f50da65124@thread.tacv2" + [escola-ops]="19:4406a4934a234cd4bc80fad5e31d4669@thread.tacv2" + [escalations]="19:07739ffc2001453d91d289ad19d0623b@thread.tacv2" + [private]="19:719e9c4defd9450486716839ee8ff382@thread.tacv2" + [cross-business]="19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2" + [bates-rollout]="19:447ce1f9a8f1420a9d60f82449d84d24@thread.tacv2" +) + +# Cron → channel routing table +# Format: "channel:condition" (condition is empty string for always) +declare -A ROUTES=( + [morning-briefing]="standups:" + [daily-review]="standups:" + [monday-weekly-briefing]="standups:" + [stale-email-chaser]="escalations:if-items" + [overnight-code-review]="cross-business:project-ops" + [weekly-managers-report]="cross-business:" + [project-staleness-check]="cross-business:" + [daily-standup]="standups:" + [daily-health-check]="standups:" + [daily-pattern-observer]="cross-business:" + [rules-codifier]="bates-rollout:" + [proactive-checkin]="" # no channel — bot chat only +) + +# --list: output all routes +if [[ "${1:-}" == "--list" ]]; then + echo "[" + first=true + for cron in "${!ROUTES[@]}"; do + $first || echo "," + first=false + ROUTE="${ROUTES[$cron]}" + CHANNEL="${ROUTE%%:*}" + CONDITION="${ROUTE##*:}" + [[ -z "$CONDITION" ]] && CONDITION="null" || CONDITION="\"$CONDITION\"" + [[ -z "$CHANNEL" ]] && CHANNEL_ID="null" || CHANNEL_ID="\"${CHANNEL_IDS[$CHANNEL]:-unknown}\"" + [[ -z "$CHANNEL" ]] && CHANNEL_JSON="null" || CHANNEL_JSON="\"$CHANNEL\"" + printf ' {"cron":"%s","channel":%s,"channel_id":%s,"condition":%s}' \ + "$cron" "$CHANNEL_JSON" "$CHANNEL_ID" "$CONDITION" + done + echo "" + echo "]" + exit 0 +fi + +if [[ $# -lt 1 ]]; then + echo '{"error":"cron job name required"}' >&2 + show_help >&2 + exit 1 +fi + +CRON_NAME="$1" + +if [[ -z "${ROUTES[$CRON_NAME]+_}" ]]; then + jq -n --arg cron "$CRON_NAME" \ + '{"error":"Unknown cron job","cron":$cron,"hint":"Use --list to see all known cron jobs"}' + exit 1 +fi + +ROUTE="${ROUTES[$CRON_NAME]}" +CHANNEL="${ROUTE%%:*}" +CONDITION="${ROUTE##*:}" + +if [[ -z "$CHANNEL" ]]; then + jq -n --arg cron "$CRON_NAME" \ + '{"cron":$cron,"channel":null,"channel_id":null,"condition":null,"note":"bot-chat only, no channel post"}' + exit 0 +fi + +CHANNEL_ID="${CHANNEL_IDS[$CHANNEL]:-unknown}" +[[ -z "$CONDITION" ]] && CONDITION_JSON="null" || CONDITION_JSON="\"$CONDITION\"" + +jq -n \ + --arg cron "$CRON_NAME" \ + --arg channel "$CHANNEL" \ + --arg channel_id "$CHANNEL_ID" \ + --argjson condition "$CONDITION_JSON" \ + '{"cron":$cron,"channel":$channel,"channel_id":$channel_id,"condition":$condition}' diff --git a/bates-core/scripts-core/dashboard-register.sh b/bates-core/scripts-core/dashboard-register.sh new file mode 100755 index 0000000..8c0f18c --- /dev/null +++ b/bates-core/scripts-core/dashboard-register.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Lightweight dashboard registration helper for ad-hoc Claude Code runs. +# Use this to register exec-based or PTY-based runs that bypass run-delegation.sh. +# +# Usage: +# dashboard-register.sh start "task-name" "description" PID +# dashboard-register.sh complete "task-name" EXIT_CODE ["optional summary"] +# +# All dashboard calls are best-effort (won't fail if dashboard is down). + +set -uo pipefail + +DASHBOARD_URL="http://localhost:18789" + +ACTION="${1:?Usage: dashboard-register.sh start|complete TASK_NAME ...}" +TASK_NAME="${2:?Missing task name}" + +case "$ACTION" in + start) + DESCRIPTION="${3:-}" + PID="${4:-$$}" + DELEGATION_ID="$(date +%s)-${PID}" + + # Persist the delegation ID so 'complete' can find it + ID_FILE="/tmp/.dashboard-reg-$(echo "$TASK_NAME" | tr ' /' '_-')" + echo "$DELEGATION_ID" > "$ID_FILE" + + curl -s -X POST "$DASHBOARD_URL/dashboard/api/delegation/start" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg id "$DELEGATION_ID" \ + --arg name "$TASK_NAME" \ + --arg promptPath "" \ + --arg logPath "" \ + --arg description "$DESCRIPTION" \ + --argjson pid "$PID" \ + '{id: $id, name: $name, promptPath: $promptPath, logPath: $logPath, description: $description, pid: $pid}' + )" > /dev/null 2>&1 || true + + echo "Registered: $TASK_NAME (id=$DELEGATION_ID)" + ;; + + complete) + EXIT_CODE="${3:-0}" + SUMMARY="${4:-}" + + # Recover the delegation ID + ID_FILE="/tmp/.dashboard-reg-$(echo "$TASK_NAME" | tr ' /' '_-')" + if [[ -f "$ID_FILE" ]]; then + DELEGATION_ID="$(cat "$ID_FILE")" + rm -f "$ID_FILE" + else + # Fallback: construct a plausible ID (won't match, but dashboard can still log it) + DELEGATION_ID="unknown-$(date +%s)" + fi + + curl -s -X POST "$DASHBOARD_URL/dashboard/api/delegation/complete" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg id "$DELEGATION_ID" \ + --argjson exitCode "$EXIT_CODE" \ + --arg logTail "$SUMMARY" \ + '{id: $id, exitCode: $exitCode, logTail: $logTail}' + )" > /dev/null 2>&1 || true + + echo "Completed: $TASK_NAME (id=$DELEGATION_ID, exit=$EXIT_CODE)" + ;; + + *) + echo "Unknown action: $ACTION" >&2 + echo "Usage: dashboard-register.sh start|complete TASK_NAME ..." >&2 + exit 1 + ;; +esac diff --git a/bates-core/scripts-core/eu-date-convert.sh b/bates-core/scripts-core/eu-date-convert.sh new file mode 100755 index 0000000..f007203 --- /dev/null +++ b/bates-core/scripts-core/eu-date-convert.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# eu-date-convert.sh — Convert European dd/mm/yy(yy) dates to ISO and named formats +# +# Usage: eu-date-convert.sh +# eu-date-convert.sh --help +# +# Robert uses European format (dd/mm/yy). Converts before passing to sub-agents. +# +# Outputs JSON: { "input": "01/11/25", "iso": "2025-11-01", "named": "November 1, 2025", "error": null } +# +# Examples: +# eu-date-convert.sh 01/11/25 # → 2025-11-01 +# eu-date-convert.sh 31/12/2025 # → 2025-12-31 +# eu-date-convert.sh "15/03/26" # → 2026-03-15 + +set -euo pipefail + +show_help() { + cat < + +Convert a European date (dd/mm/yy or dd/mm/yyyy) to ISO 8601 and named formats. +Outputs JSON to stdout. + +WARNING: dd/mm/yy — the month is the middle field, NOT the first. + 01/11/25 = November 1, 2025 (NOT January 11) + +JSON output fields: + input Original input string + iso ISO 8601 (YYYY-MM-DD) + named Full named format (Month D, YYYY) + epoch Unix timestamp (noon UTC of that day) + error null on success, error message on failure + +Exit codes: + 0 Success + 1 Parse error +EOF +} + +[[ "${1:-}" == "--help" ]] && { show_help; exit 0; } + +if [[ $# -lt 1 ]]; then + echo '{"error":"date argument required"}' >&2 + exit 1 +fi + +INPUT="$1" + +# Strip quotes if present +INPUT="${INPUT//\"/}" +INPUT="${INPUT//\'/}" + +# Expect dd/mm/yy or dd/mm/yyyy +if ! echo "$INPUT" | grep -qE '^[0-9]{1,2}/[0-9]{1,2}/[0-9]{2,4}$'; then + jq -n --arg input "$INPUT" '{"input":$input,"iso":null,"named":null,"epoch":null,"error":"Unrecognized date format. Expected dd/mm/yy or dd/mm/yyyy"}' + exit 1 +fi + +DAY=$(echo "$INPUT" | cut -d/ -f1) +MONTH=$(echo "$INPUT" | cut -d/ -f2) +YEAR=$(echo "$INPUT" | cut -d/ -f3) + +# Pad day and month +DAY=$(printf "%02d" "$DAY") +MONTH=$(printf "%02d" "$MONTH") + +# Expand 2-digit year +if [[ ${#YEAR} -eq 2 ]]; then + if (( YEAR <= 50 )); then + YEAR="20${YEAR}" + else + YEAR="19${YEAR}" + fi +fi + +# Validate ranges +if (( 10#$MONTH < 1 || 10#$MONTH > 12 )); then + jq -n --arg input "$INPUT" '{"input":$input,"iso":null,"named":null,"epoch":null,"error":"Month out of range (1-12)"}' + exit 1 +fi +if (( 10#$DAY < 1 || 10#$DAY > 31 )); then + jq -n --arg input "$INPUT" '{"input":$input,"iso":null,"named":null,"epoch":null,"error":"Day out of range (1-31)"}' + exit 1 +fi + +ISO="${YEAR}-${MONTH}-${DAY}" + +# Validate using date command +if ! EPOCH=$(date -d "$ISO 12:00 UTC" +%s 2>/dev/null); then + jq -n --arg input "$INPUT" --arg iso "$ISO" \ + '{"input":$input,"iso":$iso,"named":null,"epoch":null,"error":"Invalid calendar date"}' + exit 1 +fi + +NAMED=$(date -d "$ISO" "+%B %-d, %Y" 2>/dev/null) + +jq -n \ + --arg input "$INPUT" \ + --arg iso "$ISO" \ + --arg named "$NAMED" \ + --argjson epoch "$EPOCH" \ + '{"input":$input,"iso":$iso,"named":$named,"epoch":$epoch,"error":null}' diff --git a/bates-core/scripts-core/find-channel-thread.sh b/bates-core/scripts-core/find-channel-thread.sh new file mode 100755 index 0000000..02e4ce9 --- /dev/null +++ b/bates-core/scripts-core/find-channel-thread.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# find-channel-thread.sh — Find existing Teams threads before posting (prevent duplicate top-level posts) +# +# Usage: +# find-channel-thread.sh [keyword] # List recent threads, optionally filter by keyword +# find-channel-thread.sh --list # List all known channel names +# find-channel-thread.sh --help # Show this help +# +# Returns: JSON array of { thread_id, subject, created, author } sorted by recency +# Non-zero exit if no matching threads found. +# +# Supports Thread Discipline rule from rules/subagent-policy.md: +# NEVER create a new top-level post if an existing thread covers the same topic. +# Always run this before posting to check if a thread already exists. +# +# Channel names: general, standups, fdesk-ops, synapse-ops, escola-ops, +# escalations, private, cross-business, bates-rollout +# +# Examples: +# find-channel-thread.sh standups "Standup" +# → [{"thread_id":"1772054536557","subject":"Standup 2026-03-07","created":"2026-03-07T08:01:00Z","author":"Bates"}] +# +# find-channel-thread.sh cross-business "Code Review" +# → [{"thread_id":"1770987654321","subject":"Code Review 2026-03-05","created":"...","author":"Bates"}] +# +# find-channel-thread.sh standups 2>/dev/null | jq -r '.[0].thread_id' +# → 1772054536557 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GRAPH_API="${SCRIPT_DIR}/graph-api.sh" + +# Teams config +TEAM_ID="640b6ae4-88c8-4d00-9a4e-4dc79a2fc42c" + +declare -A CHANNELS=( + [general]="19:FEedL9wiNMY6nN-rJUomU0H_qHysdpbjawsZjbBSCuk1@thread.tacv2" + [standups]="19:c713974d563f428aae7b40ee9f931343@thread.tacv2" + [fdesk-ops]="19:35613cb0484c4387bd7f7d3e6059bf33@thread.tacv2" + [synapse-ops]="19:d13b55b2de1b4b559e46b3f50da65124@thread.tacv2" + [escola-ops]="19:4406a4934a234cd4bc80fad5e31d4669@thread.tacv2" + [escalations]="19:07739ffc2001453d91d289ad19d0623b@thread.tacv2" + [private]="19:719e9c4defd9450486716839ee8ff382@thread.tacv2" + [cross-business]="19:d94dd3492ccd4878bc130006c6b90cb4@thread.tacv2" + [bates-rollout]="19:447ce1f9a8f1420a9d60f82449d84d24@thread.tacv2" +) + +show_help() { + grep '^#' "$0" | sed 's/^# //' | sed 's/^#//' + exit 0 +} + +list_channels() { + echo '{"channels":["general","standups","fdesk-ops","synapse-ops","escola-ops","escalations","private","cross-business","bates-rollout"]}' + exit 0 +} + +if [[ "${1:-}" == "--help" ]]; then show_help; fi +if [[ "${1:-}" == "--list" ]]; then list_channels; fi + +if [[ $# -lt 1 ]]; then + echo '{"error":"usage: find-channel-thread.sh [keyword]"}' >&2 + exit 1 +fi + +CHANNEL_NAME="$1" +KEYWORD="${2:-}" + +# Validate channel +if [[ -z "${CHANNELS[$CHANNEL_NAME]+x}" ]]; then + echo "{\"error\":\"unknown channel: $CHANNEL_NAME. Run --list to see valid names\"}" >&2 + exit 1 +fi + +CHANNEL_ID="${CHANNELS[$CHANNEL_NAME]}" + +# Fetch recent top-level messages (not replies) — $select not supported by Teams messages API +RAW=$("$GRAPH_API" GET "/teams/${TEAM_ID}/channels/${CHANNEL_ID}/messages?\$top=25" 2>/dev/null) + +if [[ -z "$RAW" ]] || echo "$RAW" | grep -q '"error"'; then + echo '{"error":"Failed to fetch channel messages","raw":'"$(echo "$RAW" | jq -c '.' 2>/dev/null || echo 'null')"'}' >&2 + exit 1 +fi + +# Filter top-level posts (replyToId is null) and optionally by keyword +python3 - "$RAW" "$KEYWORD" "$CHANNEL_NAME" <<'EOF' +import json, sys, re + +raw = sys.argv[1] +keyword = sys.argv[2].lower() +channel = sys.argv[3] + +try: + data = json.loads(raw) +except json.JSONDecodeError as e: + print(json.dumps({"error": f"JSON parse failed: {e}"})) + sys.exit(1) + +messages = data.get("value", []) +results = [] + +for msg in messages: + # Skip replies (they have replyToId) + if msg.get("replyToId"): + continue + + subject = msg.get("subject") or "" + body_content = msg.get("body", {}).get("content", "") + # Strip HTML tags for keyword matching + body_text = re.sub(r'<[^>]+>', ' ', body_content) + created = msg.get("createdDateTime", "") + from_obj = msg.get("from") or {} + user_obj = from_obj.get("user") or {} + app_obj = from_obj.get("application") or {} + author = user_obj.get("displayName") or app_obj.get("displayName") or "unknown" + thread_id = msg.get("id", "") + + # Keyword filter (if provided) + if keyword: + search_text = f"{subject} {body_text}".lower() + if keyword not in search_text: + continue + + results.append({ + "thread_id": thread_id, + "subject": subject if subject else body_text[:80].strip(), + "created": created, + "author": author, + "channel": channel + }) + +if not results: + if keyword: + print(json.dumps({"found": 0, "channel": channel, "keyword": keyword, "threads": []})) + else: + print(json.dumps({"found": 0, "channel": channel, "threads": []})) + sys.exit(0) + +output = {"found": len(results), "channel": channel, "threads": results} +if keyword: + output["keyword"] = keyword +print(json.dumps(output, indent=2)) +EOF diff --git a/bates-core/scripts-core/generate-agent-configs.sh b/bates-core/scripts-core/generate-agent-configs.sh new file mode 100755 index 0000000..07fc3e9 --- /dev/null +++ b/bates-core/scripts-core/generate-agent-configs.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Phase 2: Generate openclaw.json configs for each deputy agent +# Reads agents.yaml, uses main openclaw.json as template + +AGENTS_YAML="/home/openclaw/.openclaw/shared/config/agents.yaml" +MAIN_CONFIG="/home/openclaw/.openclaw/openclaw.json" +TOKENS_FILE="/home/openclaw/.openclaw/shared/config/agent-tokens.json" +AGENTS_DIR="/home/openclaw/.openclaw/agents" + +# Port allocation +declare -A PORTS=( + [conrad]=18801 [soren]=18802 [amara]=18803 [jules]=18804 + [dash]=18805 [mira]=18806 [mercer]=18807 [kira]=18808 + [nova]=18809 [paige]=18810 [quinn]=18811 [archer]=18812 +) + +# Model name mapping (agents.yaml shorthand → full model ID) +declare -A MODEL_MAP=( + [opus-4.6]="anthropic/claude-opus-4-6" + [sonnet-4.6]="anthropic/claude-sonnet-4-6" + [sonnet-4.5]="anthropic/claude-sonnet-4-5-20250929" + [gemini-flash]="google/gemini-2.5-flash" + [haiku-4.5]="anthropic/claude-haiku-4-5-20251001" +) + +# Fallback map — cross-provider fallbacks first to survive provider-wide rate limits +declare -A FALLBACK_MAP=( + [opus-4.6]='["anthropic/claude-sonnet-4-6", "google-gemini-cli/gemini-3-pro-preview", "openai/gpt-4o"]' + [sonnet-4.6]='["google-gemini-cli/gemini-3-pro-preview", "openai/gpt-4o", "anthropic/claude-opus-4-6"]' + [sonnet-4.5]='["google/gemini-2.5-flash", "openai/gpt-4o"]' + [gemini-flash]='["anthropic/claude-haiku-4-5-20251001", "openai/gpt-4o-mini"]' + [haiku-4.5]='["google/gemini-2.5-flash", "openai/gpt-4o-mini"]' +) + +# Heartbeat mapping +declare -A HEARTBEAT_MAP=( + [conrad]=30 [soren]=30 [amara]=60 [jules]=30 + [dash]=60 [mira]=30 [mercer]=120 [kira]=120 + [nova]=60 [paige]=120 [quinn]=240 [archer]=240 +) + +# Model assignment — matches openclaw.json agents.list (updated 2026-02-17) +declare -A AGENT_MODEL=( + [conrad]=opus-4.6 [soren]=sonnet-4.5 [amara]=sonnet-4.5 [jules]=sonnet-4.5 + [dash]=sonnet-4.5 [mira]=opus-4.6 [mercer]=opus-4.6 [kira]=sonnet-4.5 + [nova]=gemini-flash [paige]=sonnet-4.5 [quinn]=gemini-flash [archer]=gemini-flash +) + +# Extract sections from main config +ENV_VARS=$(jq '.env.vars' "$MAIN_CONFIG") +MODEL_PROVIDERS=$(jq '.models.providers' "$MAIN_CONFIG") +AUTH_PROFILES=$(jq '.auth.profiles' "$MAIN_CONFIG") +TOOLS_MEDIA=$(jq '.tools.media' "$MAIN_CONFIG") +TOOLS_WEB=$(jq '.tools.web' "$MAIN_CONFIG") +CONTEXT_PRUNING=$(jq '.agents.defaults.contextPruning' "$MAIN_CONFIG") +COMPACTION=$(jq '.agents.defaults.compaction' "$MAIN_CONFIG") +MODEL_ALIASES=$(jq '.agents.defaults.models' "$MAIN_CONFIG") + +# Generate or load tokens +if [[ -f "$TOKENS_FILE" ]]; then + TOKENS=$(cat "$TOKENS_FILE") +else + TOKENS="{}" +fi + +errors=0 + +for agent_id in "${!PORTS[@]}"; do + port=${PORTS[$agent_id]} + workspace="$AGENTS_DIR/$agent_id" + model_short=${AGENT_MODEL[$agent_id]} + model_full=${MODEL_MAP[$model_short]} + fallbacks=${FALLBACK_MAP[$model_short]} + heartbeat=${HEARTBEAT_MAP[$agent_id]} + + # Generate token if not exists + existing_token=$(echo "$TOKENS" | jq -r ".\"$agent_id\" // empty") + if [[ -z "$existing_token" ]]; then + token=$(openssl rand -hex 24) + TOKENS=$(echo "$TOKENS" | jq --arg id "$agent_id" --arg t "$token" '.[$id] = $t') + else + token="$existing_token" + fi + + # Build config + config=$(jq -n \ + --argjson env_vars "$ENV_VARS" \ + --argjson model_providers "$MODEL_PROVIDERS" \ + --argjson auth_profiles "$AUTH_PROFILES" \ + --argjson tools_media "$TOOLS_MEDIA" \ + --argjson tools_web "$TOOLS_WEB" \ + --argjson context_pruning "$CONTEXT_PRUNING" \ + --argjson compaction "$COMPACTION" \ + --argjson model_aliases "$MODEL_ALIASES" \ + --argjson fallbacks "$fallbacks" \ + --arg model "$model_full" \ + --arg workspace "$workspace" \ + --arg token "$token" \ + --arg heartbeat "${heartbeat}m" \ + --argjson port "$port" \ + --arg agent_id "$agent_id" \ + '{ + env: { vars: $env_vars }, + diagnostics: { enabled: true }, + update: { channel: "stable", checkOnStart: false }, + auth: { profiles: $auth_profiles }, + models: { providers: $model_providers }, + agents: { + defaults: { + model: { + primary: $model, + fallbacks: $fallbacks + }, + imageModel: { + primary: $model, + fallbacks: $fallbacks + }, + models: $model_aliases, + workspace: $workspace, + contextPruning: $context_pruning, + compaction: $compaction, + heartbeat: { + every: $heartbeat, + model: $model + }, + maxConcurrent: 2, + subagents: { + maxConcurrent: 2, + archiveAfterMinutes: 60, + model: $model + }, + sandbox: { mode: "off" } + }, + list: [ + { + id: $agent_id, + name: $agent_id, + model: { + primary: $model, + fallbacks: $fallbacks + } + } + ] + }, + tools: { + deny: ["browser", "canvas"], + web: $tools_web, + media: $tools_media, + agentToAgent: { enabled: true }, + elevated: { enabled: false } + }, + messages: { + tts: { auto: "off" } + }, + commands: { + native: "auto", + nativeSkills: "auto", + restart: false + }, + session: { + reset: { + mode: "idle", + idleMinutes: 30 + } + }, + gateway: { + port: $port, + mode: "local", + bind: "localhost", + controlUi: false, + auth: { + mode: "token", + token: $token + }, + trustedProxies: ["127.0.0.1"], + tailscale: { mode: "off" }, + http: { + endpoints: { + chatCompletions: { enabled: false } + } + } + }, + plugins: { + allow: ["cost-tracker"], + load: { + paths: ["/home/openclaw/.openclaw/extensions/cost-tracker"] + }, + entries: { + "cost-tracker": { enabled: true } + } + } + }') + + # Write config + config_path="$workspace/openclaw.json" + echo "$config" > "$config_path" + + # Validate + if jq . < "$config_path" > /dev/null 2>&1; then + echo "✓ $agent_id → $config_path (port $port, model $model_short)" + else + echo "✗ $agent_id → INVALID JSON!" + ((errors++)) + fi +done + +# Save tokens +echo "$TOKENS" | jq . > "$TOKENS_FILE" +echo "" +echo "Tokens saved to $TOKENS_FILE" +echo "Generated configs for ${#PORTS[@]} agents ($errors errors)" +exit $errors diff --git a/bates-core/scripts-core/generate-image.py b/bates-core/scripts-core/generate-image.py new file mode 100755 index 0000000..e46cc93 --- /dev/null +++ b/bates-core/scripts-core/generate-image.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Unified image generation — OpenAI and Google Imagen providers. + +Usage: + python3 generate-image.py --provider openai --prompt "..." [--model gpt-image-1] [--size 1024x1024] + python3 generate-image.py --provider google --prompt "..." [--model imagen-3.0-generate-002] [--aspect-ratio 1:1] + +Output: JSON to stdout with {"file": "/path/to/image.png", "prompt": "..."} +""" +import argparse +import base64 +import datetime as dt +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +def slugify(text: str) -> str: + text = text.lower().strip() + text = re.sub(r"[^a-z0-9]+", "-", text) + text = re.sub(r"-{2,}", "-", text).strip("-") + return text or "image" + + +def default_out_dir() -> Path: + now = dt.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + out = Path("/tmp/bates-images") / now + out.mkdir(parents=True, exist_ok=True) + return out + + +# ── OpenAI Provider ────────────────────────────────────────────── + +def generate_openai(api_key: str, prompt: str, model: str, size: str, + quality: str, out_dir: Path) -> dict: + url = "https://api.openai.com/v1/images/generations" + body = { + "model": model, + "prompt": prompt, + "size": size, + "n": 1, + } + if model != "dall-e-2": + body["quality"] = quality + + req = urllib.request.Request( + url, method="POST", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + data=json.dumps(body).encode(), + ) + try: + with urllib.request.urlopen(req, timeout=300) as resp: + result = json.loads(resp.read()) + except urllib.error.HTTPError as e: + payload = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"OpenAI Images API failed ({e.code}): {payload}") from e + + data = result["data"][0] + image_b64 = data.get("b64_json") + image_url = data.get("url") + + filename = f"{slugify(prompt)[:60]}.png" + filepath = out_dir / filename + + if image_b64: + filepath.write_bytes(base64.b64decode(image_b64)) + elif image_url: + urllib.request.urlretrieve(image_url, filepath) + else: + raise RuntimeError(f"No image in response: {json.dumps(result)[:400]}") + + return {"file": str(filepath), "prompt": prompt, "provider": "openai", "model": model} + + +# ── Google Imagen Provider ─────────────────────────────────────── + +def generate_google(api_key: str, prompt: str, model: str, + aspect_ratio: str, out_dir: Path) -> dict: + url = (f"https://generativelanguage.googleapis.com/v1beta/" + f"models/{model}:predict") + body = { + "instances": [{"prompt": prompt}], + "parameters": { + "sampleCount": 1, + "aspectRatio": aspect_ratio, + "personGeneration": "allow_adult", + }, + } + req = urllib.request.Request( + url, method="POST", + headers={ + "x-goog-api-key": api_key, + "Content-Type": "application/json", + }, + data=json.dumps(body).encode(), + ) + try: + with urllib.request.urlopen(req, timeout=300) as resp: + result = json.loads(resp.read()) + except urllib.error.HTTPError as e: + payload = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Google Imagen API failed ({e.code}): {payload}") from e + + predictions = result.get("predictions", []) + if not predictions: + raise RuntimeError(f"No predictions in response: {json.dumps(result)[:400]}") + + pred = predictions[0] + img_bytes = base64.b64decode(pred["bytesBase64Encoded"]) + mime = pred.get("mimeType", "image/png") + ext = "png" if "png" in mime else "jpeg" + + filename = f"{slugify(prompt)[:60]}.{ext}" + filepath = out_dir / filename + filepath.write_bytes(img_bytes) + + return {"file": str(filepath), "prompt": prompt, "provider": "google", "model": model} + + +# ── Main ───────────────────────────────────────────────────────── + +def main() -> int: + ap = argparse.ArgumentParser(description="Generate images via OpenAI or Google Imagen.") + ap.add_argument("--provider", required=True, choices=["openai", "google"], + help="Image provider: openai or google") + ap.add_argument("--prompt", required=True, help="Image description") + ap.add_argument("--model", default="", + help="Model override (default: gpt-image-1 for openai, imagen-4.0-generate-001 for google)") + ap.add_argument("--size", default="1024x1024", + help="Image size for OpenAI (1024x1024, 1536x1024, 1024x1536)") + ap.add_argument("--quality", default="high", + help="Image quality for OpenAI (high, standard, medium, low)") + ap.add_argument("--aspect-ratio", default="1:1", + help="Aspect ratio for Google Imagen (1:1, 4:3, 3:4, 16:9, 9:16)") + ap.add_argument("--out-dir", default="", + help="Output directory (default: /tmp/bates-images/)") + args = ap.parse_args() + + out_dir = Path(args.out_dir) if args.out_dir else default_out_dir() + out_dir.mkdir(parents=True, exist_ok=True) + + if args.provider == "openai": + api_key = os.environ.get("OPENAI_API_KEY", "").strip() + if not api_key: + print("Missing OPENAI_API_KEY", file=sys.stderr) + return 2 + model = args.model or "gpt-image-1" + result = generate_openai(api_key, args.prompt, model, args.size, + args.quality, out_dir) + + elif args.provider == "google": + api_key = os.environ.get("GOOGLE_GENERATIVE_AI_API_KEY", "").strip() + if not api_key: + print("Missing GOOGLE_GENERATIVE_AI_API_KEY", file=sys.stderr) + return 2 + model = args.model or "imagen-4.0-generate-001" + result = generate_google(api_key, args.prompt, model, + args.aspect_ratio, out_dir) + + print(json.dumps(result, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bates-core/scripts-core/graph-api-safe.sh b/bates-core/scripts-core/graph-api-safe.sh new file mode 100755 index 0000000..643617c --- /dev/null +++ b/bates-core/scripts-core/graph-api-safe.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Graph API helper — routes ALL requests through the M365 safety gateway. +# +# Drop-in replacement for graph-api.sh. Same arguments, same output. +# The safety gateway holds OAuth tokens and enforces whitelists. +# +# Usage: graph-api-safe.sh GET|POST|PUT|DELETE [body] [etag] +# For JSON: graph-api-safe.sh POST /me/sendMail '{"message":{...}}' +# For file upload: graph-api-safe.sh PUT /me/drive/root:/path:/content @/local/file + +set -euo pipefail + +METHOD="$1" +ENDPOINT="${2// /%20}" +BODY="${3:-}" +ETAG="${4:-}" + +SOCKET="/run/user/$(id -u)/m365-safety.sock" +GATEWAY_PATH="/graph/v1.0${ENDPOINT}" + +# Check gateway is running +if [ ! -S "$SOCKET" ]; then + echo '{"error":"M365 safety gateway is not running. Start it with: systemctl --user start m365-safety-gateway"}' >&2 + exit 1 +fi + +# Build curl args +CURL_ARGS=( + -s + --unix-socket "$SOCKET" + -X "$METHOD" + "http://localhost${GATEWAY_PATH}" + -H "Content-Type: application/json" +) + +if [ -n "$ETAG" ]; then + CURL_ARGS+=(-H "If-Match: $ETAG") +fi + +if [ -n "$BODY" ]; then + if [[ "$BODY" == @* ]]; then + # File upload: binary content + FILE_PATH="${BODY#@}" + CURL_ARGS=( + -s + --unix-socket "$SOCKET" + -X "$METHOD" + "http://localhost${GATEWAY_PATH}" + -H "Content-Type: application/octet-stream" + --data-binary "@$FILE_PATH" + ) + else + CURL_ARGS+=(-d "$BODY") + fi +fi + +exec curl "${CURL_ARGS[@]}" diff --git a/bates-core/scripts-core/graph-api.sh b/bates-core/scripts-core/graph-api.sh new file mode 100755 index 0000000..d8f1aa7 --- /dev/null +++ b/bates-core/scripts-core/graph-api.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Graph API helper - refreshes token and executes request +# Usage: graph-api.sh GET|POST|PUT|DELETE [body] +# For JSON: graph-api.sh POST /planner/plans '{"owner":"...","title":"..."}' +# For file upload: graph-api.sh PUT /me/drive/root:/path:/content @/local/file + +METHOD="$1" +ENDPOINT="${2// /%20}" # URL-encode spaces in endpoint path +BODY="$3" +ETAG="$4" # Optional If-Match header (for Planner updates) + +TOKEN_CACHE="$HOME/.openclaw/assistant/node_modules/@softeria/ms-365-mcp-server/.token-cache.json" +CLIENT_ID="3b2534d6-597a-4d5a-918d-2ea9e4ea8425" +TENANT_ID="a523f509-d02e-4799-a80f-b0661d9e01af" + +# Refresh token +mcporter call ms365-assistant.get-current-user select='["id"]' > /dev/null 2>&1 +REFRESH=$(jq -r '.RefreshToken | to_entries[0].value.secret' "$TOKEN_CACHE") +TOKEN=$(curl -s -X POST "https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token" \ + -d "client_id=$CLIENT_ID" \ + -d "refresh_token=$REFRESH" \ + -d "grant_type=refresh_token" \ + -d "scope=https://graph.microsoft.com/.default" | jq -r '.access_token') + +if [ -z "$BODY" ]; then + curl -s -X "$METHOD" "https://graph.microsoft.com/v1.0$ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" +elif [[ "$BODY" == @* ]]; then + # File upload: body starts with @ — use binary upload + FILE_PATH="${BODY#@}" + curl -s -X "$METHOD" "https://graph.microsoft.com/v1.0$ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$FILE_PATH" +else + ETAG_HEADER="" + if [ -n "$ETAG" ]; then + ETAG_HEADER="-H" + ETAG_VAL="If-Match: $ETAG" + fi + if [ -n "$ETAG" ]; then + curl -s -X "$METHOD" "https://graph.microsoft.com/v1.0$ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -H "If-Match: $ETAG" \ + -d "$BODY" + else + curl -s -X "$METHOD" "https://graph.microsoft.com/v1.0$ENDPOINT" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY" + fi +fi diff --git a/bates-core/scripts-core/health-check.sh b/bates-core/scripts-core/health-check.sh new file mode 100755 index 0000000..a4a731f --- /dev/null +++ b/bates-core/scripts-core/health-check.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# Health check script for OpenClaw/Bates system +# Outputs structured JSON to stdout (and optionally saves to observations/health.json) + +set -euo pipefail + +WORKSPACE="/home/openclaw/.openclaw/workspace" +CRON_FILE="/home/openclaw/.openclaw/cron/jobs.json" +CHECKIN_FILE="$WORKSPACE/observations/last-checkin.json" +OUTPUT_FILE="$WORKSPACE/observations/health.json" +OPENCLAW_CONFIG="${HOME}/.openclaw/openclaw.json" +TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-$(python3 -c "import json; print(json.load(open('$OPENCLAW_CONFIG')).get('channels',{}).get('telegram',{}).get('botToken',''))" 2>/dev/null || echo "")}" + +NOW=$(date -u +"%Y-%m-%dT%H:%M:%S+00:00") + +# 1. Check OpenClaw gateway +if pgrep -x "openclaw-gate" > /dev/null 2>&1 || pgrep -f "openclaw-gateway" > /dev/null 2>&1; then + GATEWAY_STATUS="running" + # Get uptime in hours + GW_PID=$(pgrep -f "openclaw-gateway" | head -1) + if [ -n "$GW_PID" ]; then + GW_START=$(ps -o lstart= -p "$GW_PID" 2>/dev/null | xargs -I{} date -d "{}" +%s 2>/dev/null || echo "0") + NOW_EPOCH=$(date +%s) + if [ "$GW_START" != "0" ]; then + UPTIME_HOURS=$(( (NOW_EPOCH - GW_START) / 3600 )) + else + UPTIME_HOURS=-1 + fi + else + UPTIME_HOURS=-1 + fi +else + GATEWAY_STATUS="down" + UPTIME_HOURS=0 +fi + +# 2. Check Telegram bot +TELEGRAM_RESULT=$(curl -s --max-time 5 "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || echo '{"ok":false}') +if echo "$TELEGRAM_RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d.get('ok') else 1)" 2>/dev/null; then + TELEGRAM_STATUS="connected" +else + TELEGRAM_STATUS="error" +fi + +# 3. Check MCP servers (test if mcporter is available) +MCP_STATUS="{}" +if command -v mcporter &> /dev/null; then + # Check each known MCP server by trying a lightweight operation + for SERVER in ms365-reader ms365-fdesk-reader ms365-support-reader ms365-assistant; do + RESULT=$(timeout 10 mcporter call "$SERVER" list-mail-folders '{}' 2>/dev/null && echo "ok" || echo "error") + MCP_STATUS=$(echo "$MCP_STATUS" | python3 -c " +import sys, json +d = json.load(sys.stdin) +d['mcp_${SERVER//-/_}'] = '${RESULT}' +json.dump(d, sys.stdout) +" 2>/dev/null || echo "$MCP_STATUS") + done +else + MCP_STATUS='{"note":"mcporter not in PATH"}' +fi + +# 4. Last cron execution times +CRON_RUNS="{}" +if [ -f "$CRON_FILE" ]; then + CRON_RUNS=$(python3 -c " +import json, sys +with open('$CRON_FILE') as f: + data = json.load(f) +runs = {} +for job in data.get('jobs', []): + name = job.get('name', 'unknown') + last_run = job.get('state', {}).get('lastRunAtMs') + if last_run: + from datetime import datetime, timezone + dt = datetime.fromtimestamp(last_run / 1000, tz=timezone.utc) + runs[name] = dt.strftime('%Y-%m-%dT%H:%M:%S+00:00') + elif name not in runs: + runs[name] = None +json.dump(runs, sys.stdout) +" 2>/dev/null || echo '{}') +fi + +# 5. Disk usage +DISK_PERCENT=$(df -h / | awk 'NR==2 {gsub(/%/,""); print $5}' 2>/dev/null || echo "-1") + +# 6. Last checkin summary +CHECKIN_SUMMARY="{}" +if [ -f "$CHECKIN_FILE" ]; then + CHECKIN_SUMMARY=$(python3 -c " +import json, sys +with open('$CHECKIN_FILE') as f: + data = json.load(f) +summary = { + 'last_run': data.get('last_run'), + 'items_reported_today': len(data.get('reported_items', [])), + 'skipped_runs': data.get('skipped_runs', 0) +} +json.dump(summary, sys.stdout) +" 2>/dev/null || echo '{}') +fi + +# 7. Build final JSON +python3 -c " +import json, sys + +services = { + 'openclaw_gateway': '$GATEWAY_STATUS', + 'telegram_bot': '$TELEGRAM_STATUS' +} + +# Merge MCP status +try: + mcp = json.loads('''$MCP_STATUS''') + services.update(mcp) +except: + services['mcp_note'] = 'check failed' + +try: + cron_runs = json.loads('''$CRON_RUNS''') +except: + cron_runs = {} + +try: + checkin = json.loads('''$CHECKIN_SUMMARY''') +except: + checkin = {} + +result = { + 'timestamp': '$NOW', + 'uptime_hours': $UPTIME_HOURS, + 'services': services, + 'last_cron_runs': cron_runs, + 'disk_usage_percent': int('$DISK_PERCENT') if '$DISK_PERCENT'.lstrip('-').isdigit() else -1, + 'checkin_summary': checkin +} + +output = json.dumps(result, indent=2) +print(output) + +# Also save to file +with open('$OUTPUT_FILE', 'w') as f: + f.write(output + '\n') +" 2>/dev/null || echo '{"error": "failed to build health report"}' diff --git a/bates-core/scripts-core/learning-queue.py b/bates-core/scripts-core/learning-queue.py new file mode 100644 index 0000000..96134d7 --- /dev/null +++ b/bates-core/scripts-core/learning-queue.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Manage a queue of links for overnight learning summary processing.""" + +import argparse +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +QUEUE_FILE = Path(__file__).parent / ".learning-queue.json" + + +def load_queue(): + if QUEUE_FILE.exists(): + return json.loads(QUEUE_FILE.read_text()) + return {"items": []} + + +def save_queue(queue): + QUEUE_FILE.write_text(json.dumps(queue, indent=2) + "\n") + + +def add_item(args): + queue = load_queue() + # Check for duplicate URL + for item in queue["items"]: + if item["url"] == args.url and item["status"] == "pending": + print(f"Already queued: {args.url}") + return + queue["items"].append({ + "url": args.url, + "type": args.type, + "note": args.note, + "added": datetime.now(timezone.utc).isoformat(), + "status": "pending", + "error": None, + }) + save_queue(queue) + print(f"Added: {args.url} ({args.type})") + + +def list_items(args): + queue = load_queue() + if not queue["items"]: + print("Queue is empty.") + return + for i, item in enumerate(queue["items"], 1): + status = item["status"].upper() + note = f' — {item["note"]}' if item.get("note") else "" + err = f' [error: {item["error"]}]' if item.get("error") else "" + print(f" {i}. [{status}] ({item['type']}) {item['url']}{note}{err}") + + +def clear_done(args): + queue = load_queue() + before = len(queue["items"]) + queue["items"] = [i for i in queue["items"] if i["status"] == "pending"] + after = len(queue["items"]) + save_queue(queue) + print(f"Cleared {before - after} processed items. {after} pending remain.") + + +def main(): + parser = argparse.ArgumentParser(description="Learning queue manager") + sub = parser.add_subparsers(dest="command") + + add_p = sub.add_parser("add", help="Add a link to the queue") + add_p.add_argument("url", help="URL or file path") + add_p.add_argument("--type", required=True, choices=["youtube", "article", "pdf"], + help="Content type") + add_p.add_argument("--note", default=None, help="Optional note") + + sub.add_parser("list", help="List queued items") + sub.add_parser("clear-done", help="Remove processed items") + + args = parser.parse_args() + if args.command == "add": + add_item(args) + elif args.command == "list": + list_items(args) + elif args.command == "clear-done": + clear_done(args) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/bates-core/scripts-core/log-file-access.sh b/bates-core/scripts-core/log-file-access.sh new file mode 100755 index 0000000..b4322bb --- /dev/null +++ b/bates-core/scripts-core/log-file-access.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# log-file-access.sh — Append a file access entry to observations/file-index.md +# +# Usage: +# log-file-access.sh "" "" +# log-file-access.sh --output "" "" # for output files +# log-file-access.sh --help +# +# Options: +# --output Mark as output/created file (uses output format) +# +# Output: JSON with logged=true and full path recorded +# +# Examples: +# log-file-access.sh "/home/openclaw/.openclaw/workspace/projects/fdesk/CONTEXT-DUMP.md" \ +# "fDesk product context, metrics, terminology" "read for delegation prompt" +# +# log-file-access.sh --output "/tmp/solatio-summary.md" \ +# "Summary of Solatio email thread" "draft — needs Robert review" + +set -euo pipefail + +INDEX_FILE="${HOME}/.openclaw/workspace/observations/file-index.md" + +show_help() { + grep '^#' "$0" | sed 's/^# //' | sed 's/^#//' + exit 0 +} + +if [[ "${1:-}" == "--help" ]]; then show_help; fi + +MODE="access" +if [[ "${1:-}" == "--output" ]]; then + MODE="output" + shift +fi + +if [[ $# -lt 3 ]]; then + echo '{"error":"usage: log-file-access.sh [--output] \"\" \"\""}' >&2 + exit 1 +fi + +FILE_PATH="$1" +CONTENTS="$2" +ACTION="$3" +DATE=$(date +"%Y-%m-%d") +TIMESTAMP=$(date +"%Y-%m-%d %H:%M %Z") + +# Create index file if it doesn't exist +if [[ ! -f "$INDEX_FILE" ]]; then + mkdir -p "$(dirname "$INDEX_FILE")" + cat >"$INDEX_FILE" <<'HEADER' +# File Index + +Auto-maintained by `log-file-access.sh`. Tracks files accessed or created during sessions. + +| Date | Path | Contents | Action/Status | +|------|------|----------|---------------| +HEADER +fi + +# Escape pipes in fields (markdown table safety) +SAFE_CONTENTS="${CONTENTS//|/\\|}" +SAFE_ACTION="${ACTION//|/\\|}" + +# Truncate long descriptions +if [[ ${#SAFE_CONTENTS} -gt 80 ]]; then + SAFE_CONTENTS="${SAFE_CONTENTS:0:77}..." +fi + +if [[ "$MODE" == "output" ]]; then + PREFIX="📄 OUTPUT" +else + PREFIX="📖 READ" +fi + +echo "| ${DATE} | \`${FILE_PATH}\` | ${SAFE_CONTENTS} | ${PREFIX}: ${SAFE_ACTION} |" >> "$INDEX_FILE" + +python3 -c " +import json +print(json.dumps({ + 'logged': True, + 'mode': '$MODE', + 'path': '$FILE_PATH', + 'date': '$DATE', + 'index_file': '$INDEX_FILE' +})) +" diff --git a/bates-core/scripts-core/log-overnight-turn.sh b/bates-core/scripts-core/log-overnight-turn.sh new file mode 100755 index 0000000..dc75e0f --- /dev/null +++ b/bates-core/scripts-core/log-overnight-turn.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# log-overnight-turn.sh — Log overnight work turn usage to workspace/reports/overnight-log.md +# +# Usage: +# log-overnight-turn.sh --task "" --turns [--cost-note ""] [--status done|partial|failed] +# log-overnight-turn.sh --summary # Print current overnight log +# log-overnight-turn.sh --help # Show this help +# +# Output: JSON with logged=true, turns_logged, total_turns_today +# +# Tracks: +# - Per-task turn counts for the current overnight run +# - Running total (limit: 5 turns per policy from rules/proactive-philosophy.md) +# - Writes dated entries to workspace/reports/overnight-log.md +# +# From rules/proactive-philosophy.md: +# "Limit Bates orchestration to max 5 API turns per overnight run." +# "Log turn count and estimated cost in workspace/reports/overnight-log.md" +# +# Examples: +# log-overnight-turn.sh --task "Read transcripts" --turns 1 --status done +# → {"logged":true,"task":"Read transcripts","turns":1,"total_today":1,"limit":5,"remaining":4} +# +# log-overnight-turn.sh --task "Code review batch" --turns 2 --cost-note "3 repos analyzed" --status done +# → {"logged":true,"task":"Code review batch","turns":2,"total_today":3,"limit":5,"remaining":2} +# +# log-overnight-turn.sh --summary +# → {"date":"2026-03-08","total_turns":3,"limit":5,"remaining":2,"tasks":[...]} + +set -euo pipefail + +LOG_FILE="${HOME}/.openclaw/workspace/reports/overnight-log.md" +STATE_FILE="${HOME}/.openclaw/workspace/reports/overnight-state.json" +TURN_LIMIT=5 + +show_help() { + grep '^#' "$0" | sed 's/^# //' | sed 's/^#//' + exit 0 +} + +if [[ "${1:-}" == "--help" ]]; then show_help; fi + +TODAY=$(date +"%Y-%m-%d") +NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Initialize log file if missing +if [[ ! -f "$LOG_FILE" ]]; then + mkdir -p "$(dirname "$LOG_FILE")" + cat >"$LOG_FILE" <<'HEADER' +# Overnight Work Log + +Auto-maintained by `log-overnight-turn.sh`. Tracks per-run API turn usage. +Policy: max 5 Bates orchestration turns per overnight run (rules/proactive-philosophy.md). + +HEADER +fi + +# Initialize or load state for today +load_state() { + if [[ -f "$STATE_FILE" ]]; then + local state_date + state_date=$(python3 -c "import json; d=json.load(open('$STATE_FILE')); print(d.get('date',''))" 2>/dev/null || echo "") + if [[ "$state_date" == "$TODAY" ]]; then + return 0 # State is for today + fi + fi + # Create fresh state for today + echo "{\"date\":\"$TODAY\",\"total_turns\":0,\"tasks\":[]}" > "$STATE_FILE" +} + +if [[ "${1:-}" == "--summary" ]]; then + load_state + python3 - "$STATE_FILE" "$TURN_LIMIT" <<'EOF' +import json, sys +with open(sys.argv[1]) as f: + state = json.load(f) +limit = int(sys.argv[2]) +total = state.get("total_turns", 0) +print(json.dumps({ + "date": state.get("date"), + "total_turns": total, + "limit": limit, + "remaining": max(0, limit - total), + "over_limit": total > limit, + "tasks": state.get("tasks", []) +}, indent=2)) +EOF + exit 0 +fi + +# Parse args +TASK="" +TURNS=1 +COST_NOTE="" +STATUS="done" + +while [[ $# -gt 0 ]]; do + case "$1" in + --task) TASK="$2"; shift 2 ;; + --turns) TURNS="$2"; shift 2 ;; + --cost-note) COST_NOTE="$2"; shift 2 ;; + --status) STATUS="$2"; shift 2 ;; + *) echo "{\"error\":\"unknown argument: $1\"}" >&2; exit 1 ;; + esac +done + +if [[ -z "$TASK" ]]; then + echo '{"error":"--task is required"}' >&2 + exit 1 +fi + +load_state + +# Update state and write log entry +python3 - "$STATE_FILE" "$LOG_FILE" "$TASK" "$TURNS" "$COST_NOTE" "$STATUS" "$TODAY" "$NOW" "$TURN_LIMIT" <<'EOF' +import json, sys + +state_file, log_file = sys.argv[1], sys.argv[2] +task, turns_str, cost_note, status = sys.argv[3], sys.argv[4], sys.argv[5], sys.argv[6] +today, now, limit_str = sys.argv[7], sys.argv[8], sys.argv[9] +turns = int(turns_str) +limit = int(limit_str) + +with open(state_file) as f: + state = json.load(f) + +state["total_turns"] = state.get("total_turns", 0) + turns +state["tasks"].append({"task": task, "turns": turns, "status": status, "cost_note": cost_note, "logged_at": now}) +total = state["total_turns"] + +with open(state_file, "w") as f: + json.dump(state, f, indent=2) + +# Append to log file +status_icon = {"done": "✅", "partial": "⚠️", "failed": "❌"}.get(status, "•") +cost_str = f" — {cost_note}" if cost_note else "" +over = " ⚠️ OVER LIMIT" if total > limit else "" + +# Check if today's section header exists in log +with open(log_file) as f: + log_content = f.read() + +header = f"## {today}" +if header not in log_content: + with open(log_file, "a") as f: + f.write(f"\n## {today}\n\n| Time | Task | Turns | Status | Notes |\n|------|------|-------|--------|-------|\n") + +with open(log_file, "a") as f: + time_str = now[11:16] + "Z" + f.write(f"| {time_str} | {task} | {turns} | {status_icon} {status} | {cost_note} |\n") + +print(json.dumps({ + "logged": True, + "task": task, + "turns": turns, + "status": status, + "total_today": total, + "limit": limit, + "remaining": max(0, limit - total), + "over_limit": total > limit, + "warning": f"⚠️ {total}/{limit} turns used — consider stopping" if total >= limit else None +})) +EOF diff --git a/bates-core/scripts-core/log-spawn.sh b/bates-core/scripts-core/log-spawn.sh new file mode 100755 index 0000000..b1c9e93 --- /dev/null +++ b/bates-core/scripts-core/log-spawn.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# log-spawn.sh — Log a sub-agent spawn to workspace/reports/subagent-log.md +# +# Usage: +# log-spawn.sh