From 6410db1fad56992a9fab730c38651b8efb369d5d Mon Sep 17 00:00:00 2001
From: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Date: Thu, 9 Apr 2026 23:01:56 -0700
Subject: [PATCH] feat(thu-fullrun): overlay attention, skills sync, credits &
settings refresh (#479)
* Update Conversations component to enhance user messaging for budget limits. Changed the warning text for exhausted weekly inference budget to improve clarity and user experience.
* feat(schemas): add new configuration option for vision model usage
- Introduced a new optional boolean field `use_vision_model` in the schemas for enabling vision LLM for screenshot analysis.
- Updated the screen intelligence schemas to include a required `consent` field for starting sessions, replacing the previous `sample_interval_ms` field.
- Enhanced the `ttl_secs` field description for clarity and modified the `capture_policy` to `screen_monitoring` for better understanding of its purpose.
* feat(CoreStateProvider): enhance state management with optimistic updates and error handling
- Implemented optimistic local commits for `setAnalyticsEnabled` and `setOnboardingCompletedFlag` to provide instant UI feedback while ensuring state consistency through authoritative snapshot refreshes.
- Added error handling for the `refresh` function calls in `setAnalyticsEnabled`, `setOnboardingCompletedFlag`, and `clearSession` to log failures, improving robustness in state management during user interactions.
- Updated dependencies in the `useCallback` hooks to include `refresh`, ensuring proper state updates and synchronization with the core.
* feat(paths): centralize runtime path resolution for user-scoped skills data
- Introduced a new module `paths.rs` to handle the resolution of runtime paths for skills, ensuring that `skills_data` and `workspace` directories are scoped per user.
- Updated `bootstrap_skill_runtime` and `bootstrap_skills_runtime` functions to utilize the new path resolution logic, improving consistency and clarity in directory management.
- Enhanced error logging for directory creation failures to include the specific path that failed, aiding in debugging.
- Added a new optional field `overlay_ttl_ms` in the autocomplete schemas to support overlay time-to-live configuration.
* feat(ScreenIntelligencePanel): optimize config synchronization to prevent user edit clobbering
- Introduced a reference to track the last synced configuration signature, ensuring that user edits are preserved during periodic updates from the CoreStateProvider.
- Updated the effect to compare serialized configuration values, allowing for re-sync only when actual changes occur, enhancing user experience and preventing unintended data loss.
* feat(SkillManager): implement initial sync after OAuth completion
- Added functionality to trigger an initial data sync immediately after OAuth completion, ensuring users see fresh data without waiting for the next scheduled sync.
- Updated comments to clarify the change in sync behavior due to recent modifications in the Rust core, which no longer auto-triggers sync on OAuth completion.
* fix(UsageLimitModal, Conversations): enhance user messaging for budget limits
- Updated warning messages in both UsageLimitModal and Conversations components to provide clearer information regarding weekly limits and reset times.
- Improved clarity in user notifications to enhance overall experience when budget limits are reached.
* refactor(SkillSetupModal): improve session mode handling for skill configuration
- Updated the SkillSetupModal component to lock the mode at mount time, ensuring users remain in the setup wizard during their session even if the skill is marked as complete.
- Simplified mode management by replacing the forceSetup state with a sessionMode state, allowing explicit mode switching while maintaining a consistent user experience.
* feat(SkillManager): enhance setup flow for OAuth-based skills
- Updated the `startSetup` method to handle OAuth-based skills more effectively by implementing a fallback to core RPC for skills without a frontend runtime.
- Improved error handling to treat missing `onSetupStart` implementations as successful completion for pure OAuth skills, allowing the setup wizard to display the "Connected!" screen.
- Added detailed logging for both local runtime and core RPC fallback scenarios to improve traceability during the setup process.
* feat(Home): enhance local AI status handling and asset management
- Introduced a new state for local AI assets, allowing for better tracking of model file readiness.
- Updated the loading logic to fetch both local AI status and assets concurrently, improving performance and error handling.
- Implemented a mechanism to hide the Local Model Runtime card once all models are fully downloaded, enhancing user experience.
- Added comprehensive comments to clarify the logic behind model readiness checks based on asset states.
* refactor(Credits): update credit balance structure and terminology
- Renamed credit categories in the RewardsCouponSection and PayAsYouGoCard components for clarity, changing "General credits" to "Promo credits" and "Top-up credits" to "Team top-up."
- Updated the credit balance API to reflect the new structure, replacing `balanceUsd` and `topUpBalanceUsd` with `promotionBalanceUsd` and `teamTopupUsd`.
- Adjusted normalization logic in the credits API to accommodate the new credit balance fields.
- Modified tests to ensure correct handling of the updated credit balance structure.
* feat(Settings): reorganize billing settings and update descriptions
- Added a new top-level billing section to the settings, promoting it out of the Account & Security category for better visibility.
- Updated the description for the Account & Security section to remove billing references, focusing on recovery phrase, team management, and linked account access.
- Adjusted the settings navigation to accommodate the new billing section, ensuring proper routing and user experience.
* refactor(Config): change logging level from info to debug for environment overrides
- Updated logging statements in the Config implementation to use debug level instead of info, reducing verbosity during runtime while maintaining necessary traceability for configuration loading.
* feat(Overlay): implement overlay attention event handling and refactor overlay app structure
- Introduced a new overlay module to manage attention events, allowing the core to publish messages to the overlay window.
- Enhanced the OverlayApp component to handle dictation and attention events, improving user interaction with the overlay.
- Refactored the overlay state management to support different modes (idle, stt, attention) and added auto-dismiss functionality for attention messages.
- Removed the Browser Access Toggle from the Skills page, streamlining the UI and focusing on core functionalities.
- Updated tests to reflect changes in the Skills component and removed unnecessary mocks related to browser access.
* fix(OverlayBubbleChip): reset typewriter animation on new bubble identity
- Updated the OverlayBubbleChip component to reset the typewriter animation correctly when a new bubble is displayed by using the `key` prop.
- Refactored the cleanup logic in the useEffect hook to ensure proper interval management and state reset, enhancing the user experience with bubble transitions.
* refactor(rest): streamline key_bytes_from_string function and improve readability
- Simplified the condition for checking the ASCII key length and character restrictions in the key_bytes_from_string function.
- Consolidated the import statements for base64 engines into a single line for better clarity.
- Adjusted test data formatting for improved readability in the key_bytes_from_string_tests module.
* enhance(logging): improve color detection logic for terminal output
- Updated the color detection logic in the logging module to prioritize environment variables (`NO_COLOR`, `FORCE_COLOR`, `CLICOLOR_FORCE`) for better control over color output.
- Added detailed comments explaining the color resolution order, enhancing code clarity and maintainability.
* test(Home): add mock for openhumanLocalAiAssetsStatus in tests
- Enhanced the Home and HomeBootstrapButtons test files by adding a mock implementation for openhumanLocalAiAssetsStatus, which resolves to an object with null result and empty logs. This improves the test setup for local AI asset status handling.
* refactor(SkillSetupModal): improve session mode handling and loading state
- Updated the SkillSetupModal component to ensure session mode is determined after the first snapshot resolution, preventing premature defaults to the setup wizard.
- Introduced a loading state to display a message while waiting for the skill setup status, enhancing user experience during the modal's initial render.
- Refactored the SkillManager to throw errors for real failures during setup, ensuring proper error handling and user feedback.
* refactor(Config): simplify logging for invalid proxy scope values
- Consolidated the logging statement for invalid OPENHUMAN_PROXY_SCOPE values into a single line, improving code readability while maintaining the warning functionality.
---
app/src-tauri/src/core_process.rs | 20 ++
.../rewards/RewardsCouponSection.tsx | 8 +-
.../__tests__/RewardsCouponSection.test.tsx | 14 +-
app/src/components/settings/SettingsHome.tsx | 19 +-
.../settings/hooks/useSettingsNavigation.ts | 5 +-
.../panels/ScreenIntelligencePanel.tsx | 13 +-
.../panels/billing/PayAsYouGoCard.tsx | 42 +--
app/src/components/skills/SkillSetupModal.tsx | 42 ++-
app/src/components/upsell/UsageLimitModal.tsx | 2 +-
app/src/lib/skills/manager.ts | 69 +++-
app/src/overlay/OverlayApp.tsx | 312 +++++++++++++++---
app/src/pages/Conversations.tsx | 4 +-
app/src/pages/Home.tsx | 45 ++-
app/src/pages/Settings.tsx | 18 +-
app/src/pages/Skills.tsx | 56 +---
app/src/pages/__tests__/Home.test.tsx | 1 +
.../__tests__/HomeBootstrapButtons.test.tsx | 1 +
.../Skills.third-party-gmail-sync.test.tsx | 11 -
...ls.third-party-notion-debug-tools.test.tsx | 11 -
app/src/providers/CoreStateProvider.tsx | 29 +-
.../services/api/__tests__/billingApi.test.ts | 4 +-
app/src/services/api/creditsApi.ts | 33 +-
app/src/utils/desktopDeepLinkListener.ts | 5 +-
src/api/rest.rs | 114 ++++++-
src/core/jsonrpc.rs | 119 ++++---
src/core/logging.rs | 17 +-
src/core/skills_cli.rs | 28 +-
src/core/socketio.rs | 39 ++-
src/openhuman/about_app/catalog.rs | 9 -
src/openhuman/autocomplete/schemas.rs | 6 +
src/openhuman/config/schema/load.rs | 19 +-
src/openhuman/config/schemas.rs | 4 +
src/openhuman/mod.rs | 1 +
src/openhuman/overlay/bus.rs | 75 +++++
src/openhuman/overlay/mod.rs | 26 ++
src/openhuman/overlay/types.rs | 73 ++++
src/openhuman/screen_intelligence/schemas.rs | 16 +-
src/openhuman/skills/mod.rs | 1 +
src/openhuman/skills/paths.rs | 173 ++++++++++
39 files changed, 1151 insertions(+), 333 deletions(-)
create mode 100644 src/openhuman/overlay/bus.rs
create mode 100644 src/openhuman/overlay/mod.rs
create mode 100644 src/openhuman/overlay/types.rs
create mode 100644 src/openhuman/skills/paths.rs
diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs
index 9be5b6be1..2b1834d7c 100644
--- a/app/src-tauri/src/core_process.rs
+++ b/app/src-tauri/src/core_process.rs
@@ -1,3 +1,4 @@
+use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
@@ -7,6 +8,23 @@ use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};
+/// Propagate ANSI color hints to the spawned core child.
+///
+/// Core's tracing formatter auto-detects color via `stderr.is_terminal()`,
+/// but when core runs as a grandchild under `yarn tauri dev` the inherited
+/// stderr may not register as a TTY even though the ultimate terminal
+/// supports ANSI. If the Tauri process itself is attached to a TTY we
+/// forward `FORCE_COLOR=1` so core emits colored log lines; `NO_COLOR`
+/// (user opt-out) always wins and short-circuits the propagation.
+fn apply_core_color_env(cmd: &mut Command) {
+ if std::env::var_os("NO_COLOR").is_some() {
+ return;
+ }
+ if std::io::stderr().is_terminal() {
+ cmd.env("FORCE_COLOR", "1");
+ }
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CoreRunMode {
InProcess,
@@ -119,6 +137,7 @@ impl CoreProcessHandle {
.arg(self.port.to_string());
cmd
};
+ apply_core_color_env(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
@@ -156,6 +175,7 @@ impl CoreProcessHandle {
cmd
};
+ apply_core_color_env(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
diff --git a/app/src/components/rewards/RewardsCouponSection.tsx b/app/src/components/rewards/RewardsCouponSection.tsx
index 3d8fee0fa..dfed4ad88 100644
--- a/app/src/components/rewards/RewardsCouponSection.tsx
+++ b/app/src/components/rewards/RewardsCouponSection.tsx
@@ -163,18 +163,18 @@ const RewardsCouponSection = () => {
- General credits
+ Promo credits
- {creditBalance ? formatUsd(creditBalance.balanceUsd) : loading ? '…' : '—'}
+ {creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'}
- Top-up credits
+ Team top-up
- {creditBalance ? formatUsd(creditBalance.topUpBalanceUsd) : loading ? '…' : '—'}
+ {creditBalance ? formatUsd(creditBalance.teamTopupUsd) : loading ? '…' : '—'}
diff --git a/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx b/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx
index 7cea75897..cd753e99a 100644
--- a/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx
+++ b/app/src/components/rewards/__tests__/RewardsCouponSection.test.tsx
@@ -28,8 +28,8 @@ describe('RewardsCouponSection', () => {
it('loads balances and refreshes history after a successful redemption', async () => {
mocks.mockCreditsApi.getBalance
- .mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 1, topUpBaselineUsd: null })
- .mockResolvedValueOnce({ balanceUsd: 8, topUpBalanceUsd: 1, topUpBaselineUsd: null });
+ .mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 1 })
+ .mockResolvedValueOnce({ promotionBalanceUsd: 8, teamTopupUsd: 1 });
mocks.mockCreditsApi.getUserCoupons
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
@@ -70,11 +70,7 @@ describe('RewardsCouponSection', () => {
});
it('shows backend redemption errors without clearing the existing state', async () => {
- mocks.mockCreditsApi.getBalance.mockResolvedValue({
- balanceUsd: 3,
- topUpBalanceUsd: 0,
- topUpBaselineUsd: null,
- });
+ mocks.mockCreditsApi.getBalance.mockResolvedValue({ promotionBalanceUsd: 3, teamTopupUsd: 0 });
mocks.mockCreditsApi.getUserCoupons.mockResolvedValue([]);
mocks.mockCreditsApi.redeemCoupon.mockRejectedValueOnce({
error: 'This coupon has already been used.',
@@ -94,8 +90,8 @@ describe('RewardsCouponSection', () => {
it('shows pending coupon copy and keeps the current balance until the reward is fulfilled', async () => {
mocks.mockCreditsApi.getBalance
- .mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 0, topUpBaselineUsd: null })
- .mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 0, topUpBaselineUsd: null });
+ .mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 0 })
+ .mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 0 });
mocks.mockCreditsApi.getUserCoupons
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx
index f4f0dcc15..9e2d7bad2 100644
--- a/app/src/components/settings/SettingsHome.tsx
+++ b/app/src/components/settings/SettingsHome.tsx
@@ -79,7 +79,7 @@ const SettingsHome = () => {
{
id: 'account',
title: 'Account & Security',
- description: 'Billing, recovery phrase, team management, and linked account access',
+ description: 'Recovery phrase, team management, and linked account access',
icon: (
@@ -88,6 +88,23 @@ const SettingsHome = () => {
onClick: () => navigateToSettings('account'),
dangerous: false,
},
+ {
+ id: 'billing',
+ title: 'Billing & Usage',
+ description: 'Subscription plan, pay-as-you-go credits, and payment methods',
+ icon: (
+
+
+
+ ),
+ onClick: () => navigateToSettings('billing'),
+ dangerous: false,
+ },
{
id: 'automation',
title: 'Automation & Channels',
diff --git a/app/src/components/settings/hooks/useSettingsNavigation.ts b/app/src/components/settings/hooks/useSettingsNavigation.ts
index 794a00ea7..f5a56f49c 100644
--- a/app/src/components/settings/hooks/useSettingsNavigation.ts
+++ b/app/src/components/settings/hooks/useSettingsNavigation.ts
@@ -155,8 +155,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'ai-tools':
return [settingsCrumb];
- // Leaf panels under account
+ // Top-level billing leaf (promoted out of Account & Security)
case 'billing':
+ return [settingsCrumb];
+
+ // Leaf panels under account
case 'recovery-phrase':
case 'team':
case 'connections':
diff --git a/app/src/components/settings/panels/ScreenIntelligencePanel.tsx b/app/src/components/settings/panels/ScreenIntelligencePanel.tsx
index 96a4bbe6d..610a820f0 100644
--- a/app/src/components/settings/panels/ScreenIntelligencePanel.tsx
+++ b/app/src/components/settings/panels/ScreenIntelligencePanel.tsx
@@ -1,4 +1,4 @@
-import { type ComponentProps, useEffect, useMemo, useState } from 'react';
+import { type ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
@@ -79,10 +79,21 @@ const ScreenIntelligencePanel = () => {
const [isSavingConfig, setIsSavingConfig] = useState(false);
const [configError, setConfigError] = useState(null);
+ // CoreStateProvider polls every 2s (CoreStateProvider.tsx POLL_MS), producing a
+ // new `status` object reference on every tick even when the underlying config is
+ // unchanged. Keying this effect on `status?.config` identity would therefore
+ // clobber in-progress user edits every 2 seconds. Compare the serialized value
+ // instead, so we only re-sync when the server config has actually changed.
+ const lastSyncedConfigSigRef = useRef(null);
useEffect(() => {
if (!status?.config) {
return;
}
+ const sig = JSON.stringify(status.config);
+ if (lastSyncedConfigSigRef.current === sig) {
+ return;
+ }
+ lastSyncedConfigSigRef.current = sig;
setEnabled(status.config.enabled ?? false);
setPolicyMode(
status.config.policy_mode === 'whitelist_only' ? 'whitelist_only' : 'all_except_blacklist'
diff --git a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx
index 7e23eac69..b7afab80e 100644
--- a/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx
+++ b/app/src/components/settings/panels/billing/PayAsYouGoCard.tsx
@@ -20,10 +20,15 @@ const PayAsYouGoCard = ({
onTopUp,
onBalanceRefresh,
}: PayAsYouGoCardProps) => {
- const promoCredits = creditBalance?.balanceUsd ?? 0;
- const topUpCredits = creditBalance?.topUpBalanceUsd ?? 0;
- const topUpBaseline = creditBalance?.topUpBaselineUsd ?? null;
- const availableCredits = promoCredits + topUpCredits;
+ // Backend `GET /payments/credits/balance` returns
+ // { promotionBalanceUsd, teamTopupUsd }
+ // `promotionBalanceUsd` lives on the user document
+ // (`IUserUsage.promotionBalanceUsd`) and unifies signup bonus, coupons,
+ // and referral rewards. `teamTopupUsd` is the team-level paid top-up pool.
+ // Together they make the pay-as-you-go spendable balance.
+ const promoCredits = creditBalance?.promotionBalanceUsd ?? 0;
+ const teamTopupCredits = creditBalance?.teamTopupUsd ?? 0;
+ const availableCredits = promoCredits + teamTopupCredits;
// Coupon state (local — no need to share with other sections)
const [couponCode, setCouponCode] = useState('');
@@ -78,30 +83,11 @@ const PayAsYouGoCard = ({
Signup + promo credits
${promoCredits.toFixed(2)}
-
-
- Top-up credits
-
- ${topUpCredits.toFixed(2)}
- {topUpBaseline != null && topUpBaseline > 0 && (
- / ${topUpBaseline.toFixed(2)}
- )}
-
-
- {topUpBaseline != null && topUpBaseline > 0 && (
-
- )}
+
+ Team top-up credits
+
+ ${teamTopupCredits.toFixed(2)}
+
) : isLoadingCredits ? (
diff --git a/app/src/components/skills/SkillSetupModal.tsx b/app/src/components/skills/SkillSetupModal.tsx
index b79d4f795..d36ca431e 100644
--- a/app/src/components/skills/SkillSetupModal.tsx
+++ b/app/src/components/skills/SkillSetupModal.tsx
@@ -31,13 +31,29 @@ export default function SkillSetupModal({
const modalRef = useRef
(null);
const snap = useSkillSnapshot(skillId);
const setupComplete = snap?.setup_complete ?? false;
- // Track whether the user has explicitly chosen to reconfigure (setup mode)
- // even though setup is already complete.
- const [forceSetup, setForceSetup] = useState(false);
- // Derive mode: show manage if setup is complete (or no setup needed),
- // unless the user explicitly chose to reconfigure.
- const mode = forceSetup ? "setup" : (!hasSetup || setupComplete ? "manage" : "setup");
- const setMode = (m: "manage" | "setup") => setForceSetup(m === "setup");
+ // Lock the mode in once we have a concrete snapshot — `useSkillSnapshot`
+ // returns `null` on the first render while it fetches, so reading
+ // `setup_complete` at mount time would always see `false` and wrongly
+ // default an already-connected skill into the setup wizard.
+ //
+ // We keep `sessionMode` stable after the first resolution so that an
+ // OAuth flow that flips `setup_complete` to true mid-wizard does not
+ // yank the user out of the wizard's own "complete" success screen.
+ // The user can still switch modes explicitly via `setMode` below
+ // (e.g. SkillManagementPanel's "Reconfigure" button).
+ const [sessionMode, setSessionMode] = useState<"manage" | "setup" | null>(
+ () => (!hasSetup ? "manage" : null),
+ );
+
+ useEffect(() => {
+ if (sessionMode !== null) return;
+ // Wait for the first concrete snapshot before deciding.
+ if (snap === null) return;
+ setSessionMode(setupComplete ? "manage" : "setup");
+ }, [sessionMode, snap, setupComplete]);
+
+ const setMode = (m: "manage" | "setup") => setSessionMode(m);
+ const mode = sessionMode;
// Handle escape key
useEffect(() => {
@@ -71,7 +87,11 @@ export default function SkillSetupModal({
};
const headerTitle =
- mode === "manage" ? `Manage ${skillName}` : `Connect ${skillName}`;
+ mode === null
+ ? skillName
+ : mode === "manage"
+ ? `Manage ${skillName}`
+ : `Connect ${skillName}`;
const modalContent = (
- {mode === "manage" ? (
+ {mode === null ? (
+
+ Loading…
+
+ ) : mode === "manage" ? (
{
console.log("[SkillManager] startSetup", skillId);
const runtime = this.runtimes.get(skillId);
- if (!runtime) {
- console.log("[SkillManager] runtime not found", skillId);
- throw new Error(`Skill ${skillId} runtime not found`);
+ if (runtime) {
+ emitSkillStateChange(skillId);
+ console.log("[SkillManager] setup started (local runtime)", skillId);
+ return runtime.setupStart();
}
- emitSkillStateChange(skillId);
- console.log("[SkillManager] setup started", skillId);
- return runtime.setupStart();
+ // No frontend runtime — dispatch via core RPC pass-through.
+ //
+ // The core side returns `null` (not an error) when the skill has no
+ // `onSetupStart` handler — see `handle_js_call` in
+ // `src/openhuman/skills/qjs_skill_instance/js_handlers.rs`, which
+ // falls through to `return "null"` when the function is missing.
+ // So any exception thrown here is a *real* failure (skill not
+ // running, RPC transport error, JS exception in the handler, …)
+ // and must be propagated so the caller can surface it to the user
+ // instead of silently pretending setup succeeded.
+ try {
+ const result = (await callCoreRpc({
+ method: "openhuman.skills_setup_start",
+ params: { skill_id: skillId },
+ })) as { step?: SetupStep } | null;
+ emitSkillStateChange(skillId);
+ console.log(
+ "[SkillManager] setup started (core RPC fallback)",
+ skillId,
+ result,
+ );
+ if (!result || !result.step) {
+ return null;
+ }
+ return result.step;
+ } catch (err) {
+ console.warn(
+ "[SkillManager] setup_start core fallback failed",
+ skillId,
+ err,
+ );
+ emitSkillStateChange(skillId);
+ throw err;
+ }
}
/**
@@ -344,6 +385,22 @@ class SkillManager {
}
}
+ // Kick off an initial sync so the user sees fresh data immediately
+ // after connecting, rather than waiting for the next cron tick.
+ // The Rust core no longer auto-triggers sync on oauth/complete
+ // (removed in commit 840b1d3c), so the frontend drives it here.
+ // Fire-and-forget: any failure is logged but must not block the
+ // OAuth completion flow.
+ try {
+ console.log(`[SkillManager] kicking initial sync after OAuth for '${skillId}'`);
+ await this.triggerSync(skillId);
+ } catch (syncErr) {
+ console.warn(
+ `[SkillManager] initial post-OAuth sync failed for '${skillId}':`,
+ syncErr,
+ );
+ }
+
emitSkillStateChange(skillId);
}
diff --git a/app/src/overlay/OverlayApp.tsx b/app/src/overlay/OverlayApp.tsx
index ee3b54e36..327c66748 100644
--- a/app/src/overlay/OverlayApp.tsx
+++ b/app/src/overlay/OverlayApp.tsx
@@ -1,12 +1,40 @@
+/**
+ * OverlayApp
+ *
+ * Standalone React root rendered inside the Tauri `overlay` window (see
+ * `app/src-tauri/tauri.conf.json`). The overlay lives in its own WebView
+ * and cannot share Redux state with the main window, so it reacts to
+ * signals from the Rust core over a dedicated, unauthenticated Socket.IO
+ * connection (same pattern as `useDictationHotkey`).
+ *
+ * The overlay activates in two cases:
+ *
+ * 1. **STT / dictation** — when the user presses the dictation hotkey.
+ * The core emits `dictation:toggle` with `{type: "pressed" | "released"}`
+ * and `dictation:transcription` with `{text}`. "Pressed" opens the
+ * overlay into STT mode; "released" (or the final transcription)
+ * dismisses it.
+ *
+ * 2. **Attention message** — when the core (subconscious loop, heartbeat,
+ * …) publishes an `OverlayAttentionEvent` via
+ * `openhuman::overlay::publish_attention(...)`. The bridge in
+ * `core::socketio` forwards this as an `overlay:attention` event.
+ * The bubble auto-dismisses after its ttl.
+ *
+ * There is **no** demo loop — the overlay is entirely event-driven.
+ */
+import { invoke, isTauri } from '@tauri-apps/api/core';
import {
currentMonitor,
getCurrentWindow,
LogicalPosition,
LogicalSize,
} from '@tauri-apps/api/window';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { io, Socket } from 'socket.io-client';
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
+import { CORE_RPC_URL } from '../utils/config';
const OVERLAY_IDLE_WIDTH = 50;
const OVERLAY_IDLE_HEIGHT = 50;
@@ -15,19 +43,50 @@ const OVERLAY_ACTIVE_HEIGHT = 208;
const OVERLAY_IDLE_MARGIN = 10;
const OVERLAY_ACTIVE_MARGIN = 20;
const OVERLAY_IDLE_OPACITY = 0.6;
-const SCENARIO_THREE_TEXT = '"Noted. Need milk."';
-type OverlayStatus = 'idle' | 'active';
-type OverlayScenario = 1 | 2 | 3;
+/** Default auto-dismiss for an attention bubble when no ttl is supplied. */
+const DEFAULT_ATTENTION_TTL_MS = 6000;
+/** Grace period after STT `released` before returning to idle, giving the
+ * final transcription time to arrive and the user a moment to read it. */
+const STT_RELEASE_LINGER_MS = 1500;
+/** Placeholder bubble text while waiting for the first transcription. */
+const STT_LISTENING_PLACEHOLDER = '"Listening…"';
+
+// ── State model ──────────────────────────────────────────────────────────
+
+type OverlayMode = 'idle' | 'stt' | 'attention';
+type BubbleTone = 'neutral' | 'accent' | 'success';
interface OverlayBubble {
id: string;
text: string;
- tone: 'neutral' | 'accent' | 'success';
+ tone: BubbleTone;
compact?: boolean;
}
-function bubbleToneClass(tone: OverlayBubble['tone']) {
+// ── Socket payload types ─────────────────────────────────────────────────
+
+interface DictationTogglePayload {
+ type?: string;
+ hotkey?: string;
+ activation_mode?: string;
+}
+
+interface DictationTranscriptionPayload {
+ text?: string;
+}
+
+interface OverlayAttentionPayload {
+ id?: string;
+ message?: string;
+ tone?: BubbleTone;
+ ttl_ms?: number;
+ source?: string;
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────────
+
+function bubbleToneClass(tone: BubbleTone) {
switch (tone) {
case 'accent':
return 'bg-blue-700 text-white';
@@ -38,7 +97,27 @@ function bubbleToneClass(tone: OverlayBubble['tone']) {
}
}
+/** Resolve the core process base URL (without /rpc suffix) for Socket.IO.
+ * Mirrors `useDictationHotkey.resolveCoreSocketUrl`. */
+async function resolveCoreSocketUrl(): Promise {
+ let rpcUrl = CORE_RPC_URL;
+ if (isTauri()) {
+ try {
+ const url = await invoke('core_rpc_url');
+ if (url) rpcUrl = String(url);
+ } catch {
+ // fall through to default
+ }
+ }
+ const trimmed = rpcUrl.trim().replace(/\/+$/, '');
+ return trimmed.endsWith('/rpc') ? trimmed.slice(0, -4) : trimmed;
+}
+
+// ── Bubble chip with typewriter animation ────────────────────────────────
+
function OverlayBubbleChip({ bubble }: { bubble: OverlayBubble }) {
+ // Reset the typewriter on every new bubble identity via `key` at the
+ // call site — that avoids a cascading setState inside this effect.
const [displayedText, setDisplayedText] = useState('');
const indexRef = useRef(0);
@@ -46,27 +125,25 @@ function OverlayBubbleChip({ bubble }: { bubble: OverlayBubble }) {
if (!bubble.text) {
return () => {
indexRef.current = 0;
- setDisplayedText('');
};
}
- const timeoutId = window.setInterval(
+ const intervalId = window.setInterval(
() => {
indexRef.current += 1;
setDisplayedText(bubble.text.slice(0, indexRef.current));
if (indexRef.current >= bubble.text.length) {
- window.clearInterval(timeoutId);
+ window.clearInterval(intervalId);
}
},
bubble.compact ? 28 : 32
);
return () => {
- window.clearInterval(timeoutId);
+ window.clearInterval(intervalId);
indexRef.current = 0;
- setDisplayedText('');
};
- }, [bubble.compact, bubble.id, bubble.text]);
+ }, [bubble.compact, bubble.text]);
return (
(1);
+ const [mode, setMode] = useState
('idle');
+ const [bubble, setBubble] = useState(null);
const [isHovered, setIsHovered] = useState(false);
- useEffect(() => {
- const timeoutId = window.setTimeout(() => {
- setScenario(current => {
- if (current === 1) return 2;
- if (current === 2) return 3;
- return 1;
+ /** Timer that returns the overlay to idle after a ttl (attention) or a
+ * grace period (stt release). We clear it whenever the mode changes. */
+ const dismissTimerRef = useRef(null);
+
+ const clearDismissTimer = useCallback(() => {
+ if (dismissTimerRef.current !== null) {
+ window.clearTimeout(dismissTimerRef.current);
+ dismissTimerRef.current = null;
+ }
+ }, []);
+
+ const scheduleDismiss = useCallback(
+ (ms: number) => {
+ clearDismissTimer();
+ dismissTimerRef.current = window.setTimeout(() => {
+ console.debug('[overlay] auto-dismiss → idle');
+ setMode('idle');
+ setBubble(null);
+ dismissTimerRef.current = null;
+ }, ms);
+ },
+ [clearDismissTimer]
+ );
+
+ const goIdle = useCallback(() => {
+ clearDismissTimer();
+ setMode('idle');
+ setBubble(null);
+ }, [clearDismissTimer]);
+
+ // ── Dictation: pressed / released ──────────────────────────────────────
+ const handleDictationToggle = useCallback(
+ (payload: DictationTogglePayload) => {
+ const type = payload?.type ?? 'pressed';
+ console.debug(`[overlay] dictation:toggle type=${type}`);
+
+ if (type === 'pressed') {
+ clearDismissTimer();
+ setMode('stt');
+ setBubble({
+ id: `stt-${Date.now()}`,
+ text: STT_LISTENING_PLACEHOLDER,
+ tone: 'accent',
+ compact: true,
+ });
+ return;
+ }
+
+ if (type === 'released') {
+ // Linger briefly so any final transcription arriving shortly after
+ // has a chance to land in the bubble before we go idle.
+ scheduleDismiss(STT_RELEASE_LINGER_MS);
+ }
+ },
+ [clearDismissTimer, scheduleDismiss]
+ );
+
+ // ── Dictation: final transcription text ────────────────────────────────
+ const handleDictationTranscription = useCallback(
+ (payload: DictationTranscriptionPayload) => {
+ const text = payload?.text?.trim();
+ if (!text) return;
+ console.debug(`[overlay] dictation:transcription chars=${text.length}`);
+
+ setMode('stt');
+ setBubble({
+ id: `stt-final-${Date.now()}`,
+ text: `"${text}"`,
+ tone: 'accent',
+ compact: true,
});
- }, 5000);
+ // Show the result briefly then dismiss, regardless of hotkey state.
+ scheduleDismiss(STT_RELEASE_LINGER_MS);
+ },
+ [scheduleDismiss]
+ );
+
+ // ── Attention from subconscious / core ─────────────────────────────────
+ const handleAttention = useCallback(
+ (payload: OverlayAttentionPayload) => {
+ const message = payload?.message?.trim();
+ if (!message) {
+ console.debug('[overlay] attention event with empty message — ignoring');
+ return;
+ }
+ console.debug(
+ `[overlay] attention source=${payload?.source ?? 'unknown'} tone=${payload?.tone ?? 'neutral'} chars=${message.length}`
+ );
+
+ const ttl =
+ typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0
+ ? payload.ttl_ms
+ : DEFAULT_ATTENTION_TTL_MS;
+
+ setMode('attention');
+ setBubble({
+ id: payload?.id ?? `attention-${Date.now()}`,
+ text: `"${message}"`,
+ // Match the Rust-side `OverlayAttentionTone::default()` (Neutral)
+ // so missing/legacy payloads render as the neutral slate bubble.
+ tone: payload?.tone ?? 'neutral',
+ });
+ scheduleDismiss(ttl);
+ },
+ [scheduleDismiss]
+ );
+
+ // ── Socket.IO subscription lifecycle ───────────────────────────────────
+ useEffect(() => {
+ let socket: Socket | null = null;
+ let disposed = false;
+
+ const connect = async () => {
+ try {
+ const baseUrl = await resolveCoreSocketUrl();
+ if (disposed) return;
+
+ console.debug(`[overlay] connecting to core socket at ${baseUrl}`);
+ socket = io(baseUrl, {
+ path: '/socket.io/',
+ transports: ['websocket', 'polling'],
+ reconnection: true,
+ reconnectionDelay: 2000,
+ reconnectionAttempts: Infinity,
+ forceNew: true,
+ });
+
+ socket.on('connect', () => {
+ console.debug('[overlay] socket connected', socket?.id);
+ });
+
+ socket.on('connect_error', (err: Error) => {
+ console.debug('[overlay] socket connect error:', err.message);
+ });
+
+ socket.on('disconnect', (reason: string) => {
+ console.debug('[overlay] socket disconnected:', reason);
+ });
+
+ // Core emits each event under both colon and underscore forms
+ // (see `emit_with_aliases` in `src/core/socketio.rs`). Subscribe
+ // only to the canonical colon-delimited form so each signal fires
+ // the handler exactly once.
+ socket.on('dictation:toggle', handleDictationToggle);
+ socket.on('dictation:transcription', handleDictationTranscription);
+ socket.on('overlay:attention', handleAttention);
+
+ socket.connect();
+ } catch (err) {
+ console.warn('[overlay] failed to open core socket', err);
+ }
+ };
+
+ void connect();
return () => {
- window.clearTimeout(timeoutId);
+ disposed = true;
+ if (socket) {
+ socket.disconnect();
+ socket = null;
+ }
+ clearDismissTimer();
};
- }, [scenario]);
+ }, [clearDismissTimer, handleAttention, handleDictationToggle, handleDictationTranscription]);
- const status: OverlayStatus = scenario === 1 ? 'idle' : 'active';
+ // ── Window framing: resize / reposition on mode change ────────────────
+ const status: 'idle' | 'active' = mode === 'idle' ? 'idle' : 'active';
useEffect(() => {
const appWindow = getCurrentWindow();
@@ -141,23 +373,8 @@ export default function OverlayApp() {
void updateWindowFrame();
}, [status]);
- const bubbles = useMemo(() => {
- if (scenario === 1) {
- return [];
- }
-
- if (scenario === 2) {
- return [
- {
- id: 'assistant',
- text: '"Hey I think your coffee is getting cold. Want me to get you a new one?"',
- tone: 'accent',
- },
- ];
- }
-
- return [{ id: 'stt', text: SCENARIO_THREE_TEXT, tone: 'accent' }];
- }, [scenario]);
+ // ── Render ────────────────────────────────────────────────────────────
+ const bubbles = useMemo(() => (bubble ? [bubble] : []), [bubble]);
const orbClassName = useMemo(() => {
if (status === 'active') {
@@ -177,9 +394,10 @@ export default function OverlayApp() {
className={`relative flex select-none flex-col items-end ${status === 'active' ? 'gap-3' : 'gap-0'}`}>
- {bubbles.map(bubble => (
-
-
+ {bubbles.map(b => (
+
+ {/* key on the chip itself remounts the typewriter for each new bubble */}
+
))}
@@ -187,10 +405,8 @@ export default function OverlayApp() {
{
- setScenario(2);
- }}
+ aria-label="Overlay orb"
+ onClick={goIdle}
onMouseEnter={() => {
setIsHovered(true);
}}
@@ -199,7 +415,7 @@ export default function OverlayApp() {
}}
className={`group relative flex cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-all duration-200 ${orbClassName} ${orbSizeClassName}`}
style={orbStyle}
- title="Click to start the demo.">
+ title="Click to dismiss">
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 50d5771d3..9c6ef6cbf 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -1238,7 +1238,7 @@ const Conversations = () => {
{teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0
- ? 'Included weekly inference budget exhausted. Top up to continue.'
+ ? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.`
: `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
@@ -1448,7 +1448,7 @@ const Conversations = () => {
open={showLimitModal}
onClose={() => setShowLimitModal(false)}
isBudgetExhausted={isBudgetExhausted}
- resetTime={teamUsage?.fiveHourResetsAt}
+ resetTime={isBudgetExhausted ? teamUsage?.cycleEndsAt : teamUsage?.fiveHourResetsAt}
currentTier={currentTier}
/>
diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx
index 45f9f4f0a..c3a8e5938 100644
--- a/app/src/pages/Home.tsx
+++ b/app/src/pages/Home.tsx
@@ -9,13 +9,20 @@ import {
triggerLocalAiAssetBootstrap,
} from '../utils/localAiBootstrap';
import { formatBytes, formatEta, progressFromStatus } from '../utils/localAiHelpers';
-import { isTauri, type LocalAiStatus, openhumanLocalAiStatus } from '../utils/tauriCommands';
+import {
+ isTauri,
+ type LocalAiAssetsStatus,
+ type LocalAiStatus,
+ openhumanLocalAiAssetsStatus,
+ openhumanLocalAiStatus,
+} from '../utils/tauriCommands';
const Home = () => {
const { user } = useUser();
const navigate = useNavigate();
const userName = user?.firstName || 'User';
const [localAiStatus, setLocalAiStatus] = useState
(null);
+ const [localAiAssets, setLocalAiAssets] = useState(null);
const [downloadBusy, setDownloadBusy] = useState(false);
const [bootstrapMessage, setBootstrapMessage] = useState('');
const autoRetryDoneRef = useRef(false);
@@ -73,9 +80,16 @@ const Home = () => {
let mounted = true;
const load = async () => {
try {
- const status = await openhumanLocalAiStatus();
+ const [status, assets] = await Promise.all([
+ openhumanLocalAiStatus(),
+ openhumanLocalAiAssetsStatus().catch(err => {
+ console.warn('[Home] failed to load local AI assets status:', err);
+ return null;
+ }),
+ ]);
if (mounted) {
setLocalAiStatus(status.result);
+ setLocalAiAssets(assets?.result ?? null);
// Auto-retry bootstrap once if Ollama is degraded (install/server issue).
if (status.result?.state === 'degraded' && !autoRetryDoneRef.current) {
@@ -155,6 +169,29 @@ const Home = () => {
}, []);
const modelProgress = useMemo(() => progressFromStatus(localAiStatus), [localAiStatus]);
+ // Hide the Local Model Runtime card once every capability's model file is
+ // present on disk. We use `assets_status` (which inspects the filesystem)
+ // instead of the in-memory `LocalAiStatus` sub-states, because the latter
+ // stay at `idle` until a capability is first exercised — even when the
+ // underlying model has already been downloaded.
+ //
+ // A capability is considered "done" when its asset state is:
+ // - `ready` → model file exists on disk
+ // - `disabled` → not applicable for the selected preset
+ // - `ondemand` → vision preset intentionally defers download until first use
+ const allModelsDownloaded = useMemo(() => {
+ if (!localAiStatus || !localAiAssets) return false;
+ if (localAiStatus.state !== 'ready') return false;
+ const isDone = (state: string | undefined | null): boolean =>
+ state === 'ready' || state === 'disabled' || state === 'ondemand';
+ return (
+ isDone(localAiAssets.chat?.state) &&
+ isDone(localAiAssets.vision?.state) &&
+ isDone(localAiAssets.embedding?.state) &&
+ isDone(localAiAssets.stt?.state) &&
+ isDone(localAiAssets.tts?.state)
+ );
+ }, [localAiStatus, localAiAssets]);
const isInstalling = localAiStatus?.state === 'installing';
const indeterminateDownload =
isInstalling ||
@@ -233,8 +270,8 @@ const Home = () => {
- {/* Local AI card (desktop only) */}
- {isTauri() && (
+ {/* Local AI card (desktop only) — hidden once all models are fully downloaded */}
+ {isTauri() && !allModelsDownloaded && (
diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx
index 55de1c621..c4d6c80c2 100644
--- a/app/src/pages/Settings.tsx
+++ b/app/src/pages/Settings.tsx
@@ -27,22 +27,6 @@ import SettingsSectionPage from '../components/settings/SettingsSectionPage';
import { APP_VERSION } from '../utils/config';
const accountSettingsItems = [
- {
- id: 'billing',
- title: 'Billing & Usage',
- description: 'Manage your subscription, credits, and payment methods',
- route: 'billing',
- icon: (
-
-
-
- ),
- },
{
id: 'recovery-phrase',
title: 'Recovery Phrase',
@@ -260,7 +244,7 @@ const Settings = () => {
element={
}
diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx
index 8fe205491..649cce196 100644
--- a/app/src/pages/Skills.tsx
+++ b/app/src/pages/Skills.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ChannelSetupModal from '../components/channels/ChannelSetupModal';
@@ -13,7 +13,6 @@ import { installSkill } from '../lib/skills/skillsApi';
import { useAppSelector } from '../store/hooks';
import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels';
import { IS_DEV } from '../utils/config';
-import { openhumanGetRuntimeFlags, openhumanSetBrowserAllowAll } from '../utils/tauriCommands';
const CHANNEL_ICONS: Record
= {
telegram: '\u2708\uFE0F',
@@ -133,57 +132,6 @@ interface SkillItem {
skill?: SkillListEntry;
}
-// ─── Browser Access Toggle ─────────────────────────────────────────────────────
-
-function BrowserAccessToggle() {
- const [browserAllowAll, setBrowserAllowAll] = useState(false);
- const [browserBusy, setBrowserBusy] = useState(false);
-
- useEffect(() => {
- (async () => {
- try {
- const res = await openhumanGetRuntimeFlags();
- setBrowserAllowAll(res.result.browser_allow_all);
- } catch {
- // Silently ignore — toggle defaults to false
- }
- })();
- }, []);
-
- const handleToggle = async () => {
- const next = !browserAllowAll;
- setBrowserBusy(true);
- try {
- const res = await openhumanSetBrowserAllowAll(next);
- setBrowserAllowAll(res.result.browser_allow_all);
- } catch {
- // silently ignore
- } finally {
- setBrowserBusy(false);
- }
- };
-
- return (
-
-
-
Browser Access
-
Allow the browser tool to visit any public domain
-
-
-
-
-
- );
-}
-
// ─── Main Skills Page ──────────────────────────────────────────────────────────
export default function Skills() {
@@ -343,8 +291,6 @@ export default function Skills() {
-
-
({
vi.mock('../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
openhumanLocalAiStatus: vi.fn(),
+ openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }),
}));
describe('Home local AI bootstrap', () => {
diff --git a/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx b/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx
index 235d48553..5e24ba040 100644
--- a/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx
+++ b/app/src/pages/__tests__/HomeBootstrapButtons.test.tsx
@@ -19,6 +19,7 @@ vi.mock('../../utils/localAiBootstrap', () => ({
vi.mock('../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
openhumanLocalAiStatus: vi.fn(),
+ openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }),
}));
describe('Home bootstrap button states', () => {
diff --git a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx
index a2cf753a1..7063c75f7 100644
--- a/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx
+++ b/app/src/pages/__tests__/Skills.third-party-gmail-sync.test.tsx
@@ -8,17 +8,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Skills from '../Skills';
-vi.mock('../../utils/tauriCommands', async () => {
- const actual = await vi.importActual>('../../utils/tauriCommands');
- return {
- ...actual,
- openhumanGetRuntimeFlags: vi.fn().mockResolvedValue({ result: { browser_allow_all: false } }),
- openhumanSetBrowserAllowAll: vi
- .fn()
- .mockResolvedValue({ result: { browser_allow_all: false } }),
- };
-});
-
const gmailRegistryEntry = {
id: 'gmail',
name: 'Gmail',
diff --git a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx
index 43301ee09..a992c114c 100644
--- a/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx
+++ b/app/src/pages/__tests__/Skills.third-party-notion-debug-tools.test.tsx
@@ -9,17 +9,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Skills from '../Skills';
-vi.mock('../../utils/tauriCommands', async () => {
- const actual = await vi.importActual>('../../utils/tauriCommands');
- return {
- ...actual,
- openhumanGetRuntimeFlags: vi.fn().mockResolvedValue({ result: { browser_allow_all: false } }),
- openhumanSetBrowserAllowAll: vi
- .fn()
- .mockResolvedValue({ result: { browser_allow_all: false } }),
- };
-});
-
const notionRegistryEntry = {
id: 'notion',
name: 'Notion',
diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx
index cf3db13e3..38c58221d 100644
--- a/app/src/providers/CoreStateProvider.tsx
+++ b/app/src/providers/CoreStateProvider.tsx
@@ -272,24 +272,34 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
const setAnalyticsEnabled = useCallback(
async (enabled: boolean) => {
await openhumanUpdateAnalyticsSettings({ enabled });
+ // Optimistic local commit for instant UI feedback, then re-pull the
+ // authoritative snapshot so the frontend cache matches the core.
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, analyticsEnabled: enabled },
}));
syncAnalyticsConsent(enabled);
+ await refresh().catch(err => {
+ log('refresh failed after setAnalyticsEnabled: %O', sanitizeError(err));
+ });
},
- [commitState]
+ [commitState, refresh]
);
const setOnboardingCompletedFlag = useCallback(
async (value: boolean) => {
await setOnboardingCompleted(value);
+ // Optimistic local commit for instant UI feedback, then re-pull the
+ // authoritative snapshot so the frontend cache matches the core.
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, onboardingCompleted: value },
}));
+ await refresh().catch(err => {
+ log('refresh failed after setOnboardingCompletedFlag: %O', sanitizeError(err));
+ });
},
- [commitState]
+ [commitState, refresh]
);
const updateLocalState = useCallback(
@@ -318,7 +328,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
);
const clearSession = useCallback(async () => {
+ // Bump the snapshot request counter before doing anything else so that
+ // any snapshot poll already in flight when the user clicked logout is
+ // invalidated on return — otherwise its stale "authenticated" result
+ // would clobber our cleared state a few hundred ms after logout.
+ snapshotRequestIdRef.current += 1;
await tauriLogout();
+ // Optimistic local clear for instant UI response.
commitState(previous => ({
...previous,
teams: [],
@@ -333,7 +349,14 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
},
}));
memoryTokenRef.current = null;
- }, [commitState]);
+ // Re-pull the authoritative snapshot from the core so the frontend
+ // cache matches whatever the core now reports. This mirrors the pattern
+ // used by storeSessionToken and ensures any downstream consumer reading
+ // from the snapshot sees the post-logout state immediately.
+ await refresh().catch(err => {
+ log('refresh failed after clearSession: %O', sanitizeError(err));
+ });
+ }, [commitState, refresh]);
const value = useMemo(
() => ({
diff --git a/app/src/services/api/__tests__/billingApi.test.ts b/app/src/services/api/__tests__/billingApi.test.ts
index cdeb4a043..8d9b4274c 100644
--- a/app/src/services/api/__tests__/billingApi.test.ts
+++ b/app/src/services/api/__tests__/billingApi.test.ts
@@ -207,11 +207,11 @@ describe('creditsApi.getBalance', () => {
});
it('normalizes missing numeric fields so billing UI does not crash', async () => {
- mockCallCoreCommand.mockResolvedValue({ topUpBalanceUsd: 3 });
+ mockCallCoreCommand.mockResolvedValue({ teamTopupUsd: 3 });
const result = await creditsApi.getBalance();
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_balance');
- expect(result).toEqual({ balanceUsd: 0, topUpBalanceUsd: 3, topUpBaselineUsd: null });
+ expect(result).toEqual({ promotionBalanceUsd: 0, teamTopupUsd: 3 });
});
});
diff --git a/app/src/services/api/creditsApi.ts b/app/src/services/api/creditsApi.ts
index f176bd8b7..e0b1ac8c5 100644
--- a/app/src/services/api/creditsApi.ts
+++ b/app/src/services/api/creditsApi.ts
@@ -1,9 +1,24 @@
import { callCoreCommand } from '../coreCommandClient';
+/**
+ * Credit balance payload returned by `GET /payments/credits/balance`.
+ *
+ * Mirrors the backend shape defined in
+ * `backend-1/src/services/user/balanceService.ts` → `getCreditBalance(userId)`,
+ * which in turn derives from `IUser.usage.promotionBalanceUsd` on the user
+ * model and the team-level top-up ledger.
+ */
export interface CreditBalance {
- balanceUsd: number;
- topUpBalanceUsd: number;
- topUpBaselineUsd: number | null;
+ /**
+ * Promotional credit balance on the user document (signup bonus, coupons,
+ * referral rewards). Corresponds to `IUserUsage.promotionBalanceUsd`.
+ */
+ promotionBalanceUsd: number;
+ /**
+ * Team-level top-up balance (paid credits that cover overage once the
+ * included cycle budget is exhausted). Returned by `getTeamTopup(userId)`.
+ */
+ teamTopupUsd: number;
}
export interface TeamUsage {
@@ -185,18 +200,12 @@ function normalizeUsd(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
-function normalizeNullableUsd(value: unknown): number | null {
- if (value == null) return null;
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
-}
-
function normalizeCreditBalance(payload: unknown): CreditBalance {
- const raw = payload && typeof payload === 'object' ? (payload as Partial) : {};
+ const raw = (payload && typeof payload === 'object' ? payload : {}) as Record;
return {
- balanceUsd: normalizeUsd(raw.balanceUsd),
- topUpBalanceUsd: normalizeUsd(raw.topUpBalanceUsd),
- topUpBaselineUsd: normalizeNullableUsd(raw.topUpBaselineUsd),
+ promotionBalanceUsd: normalizeUsd(raw.promotionBalanceUsd),
+ teamTopupUsd: normalizeUsd(raw.teamTopupUsd),
};
}
diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts
index 4658bdf5f..666918c9a 100644
--- a/app/src/utils/desktopDeepLinkListener.ts
+++ b/app/src/utils/desktopDeepLinkListener.ts
@@ -189,8 +189,9 @@ const handleOAuthDeepLink = async (parsed: URL) => {
}
// 3. Notify the running skill of the OAuth credential, mark setup_complete,
- // and activate (list tools, sync to backend).
- // The Rust runtime automatically triggers an initial sync after auth succeeds.
+ // activate (list tools, sync tools to backend), and kick an initial sync.
+ // The initial sync is driven by SkillManager.notifyOAuthComplete — the
+ // Rust core no longer auto-syncs on oauth/complete (removed in 840b1d3c).
try {
await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, { clientKeyShare });
console.log(`[DeepLink] OAuth complete sent to skill '${skillId}'`);
diff --git a/src/api/rest.rs b/src/api/rest.rs
index 62539446e..d4726fbf5 100644
--- a/src/api/rest.rs
+++ b/src/api/rest.rs
@@ -691,21 +691,107 @@ pub fn decrypt_handoff_blob(b64_ciphertext: &str, key_str: &str) -> Result Result> {
- if key.len() == 44 {
- if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(key) {
- if decoded.len() == 32 {
- return Ok(decoded);
+ let trimmed = key.trim();
+
+ // Raw 32-byte ASCII key
+ if trimmed.len() == 32
+ && !trimmed.contains(|c: char| c == '+' || c == '/' || c == '-' || c == '_' || c == '=')
+ {
+ return Ok(trimmed.as_bytes().to_vec());
+ }
+
+ use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
+
+ // `base64::Engine` has generic methods and therefore isn't
+ // dyn-compatible, so we unroll the attempts instead of looping over
+ // a slice of trait objects.
+ macro_rules! try_decode {
+ ($engine:expr) => {
+ if let Ok(decoded) = $engine.decode(trimmed) {
+ if decoded.len() == 32 {
+ return Ok(decoded);
+ }
}
- }
+ };
+ }
+ try_decode!(URL_SAFE_NO_PAD);
+ try_decode!(URL_SAFE);
+ try_decode!(STANDARD);
+ try_decode!(STANDARD_NO_PAD);
+
+ anyhow::bail!(
+ "encryption key must decode to 32 raw bytes (raw, base64, or base64url accepted; got len={})",
+ trimmed.len()
+ );
+}
+
+#[cfg(test)]
+mod key_bytes_from_string_tests {
+ use super::key_bytes_from_string;
+ use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
+ use base64::Engine;
+
+ #[test]
+ fn decodes_base64url_no_pad() {
+ // A 32-byte key that, when base64url-encoded, contains both `-` and `_`.
+ let raw = [
+ 0xff_u8, 0xfb, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa,
+ 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
+ 0x0a, 0x0b, 0x0c, 0x0d,
+ ];
+ let url_key = URL_SAFE_NO_PAD.encode(raw);
+ assert!(url_key.contains('-') || url_key.contains('_'));
+ let decoded = key_bytes_from_string(&url_key).unwrap();
+ assert_eq!(decoded, raw);
+ }
+
+ #[test]
+ fn decodes_standard_base64() {
+ let raw = [0x41_u8; 32];
+ let std_key = STANDARD.encode(raw);
+ let decoded = key_bytes_from_string(&std_key).unwrap();
+ assert_eq!(decoded, raw);
+ }
+
+ #[test]
+ fn decodes_raw_32_byte_key() {
+ let raw = "abcdefghijklmnopqrstuvwxyz012345";
+ assert_eq!(raw.len(), 32);
+ let decoded = key_bytes_from_string(raw).unwrap();
+ assert_eq!(decoded, raw.as_bytes());
+ }
+
+ #[test]
+ fn trims_whitespace() {
+ let raw = [0x42_u8; 32];
+ let url_key = format!(" {}\n", URL_SAFE_NO_PAD.encode(raw));
+ let decoded = key_bytes_from_string(&url_key).unwrap();
+ assert_eq!(decoded, raw);
+ }
+
+ #[test]
+ fn rejects_wrong_length() {
+ let err = key_bytes_from_string("tooshort").unwrap_err();
+ assert!(err.to_string().contains("must decode to 32 raw bytes"));
}
- if key.len() == 32 {
- return Ok(key.as_bytes().to_vec());
- }
- if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(key) {
- if decoded.len() == 32 {
- return Ok(decoded);
- }
- }
- anyhow::bail!("encryption key must be 32 raw bytes or 44-char base64 (decoded to 32 bytes)");
}
diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs
index 6700f682e..07ffea209 100644
--- a/src/core/jsonrpc.rs
+++ b/src/core/jsonrpc.rs
@@ -28,29 +28,40 @@ use crate::core::types::{AppState, RpcError, RpcFailure, RpcRequest, RpcSuccess}
/// JSON-RPC 2.0 compliant success or failure response.
pub async fn rpc_handler(State(state): State, Json(req): Json) -> Response {
let id = req.id.clone();
- match invoke_method(state, req.method.as_str(), req.params).await {
- Ok(value) => (
- StatusCode::OK,
- Json(RpcSuccess {
- jsonrpc: "2.0",
- id,
- result: value,
- }),
- )
- .into_response(),
- Err(message) => (
- StatusCode::OK,
- Json(RpcFailure {
- jsonrpc: "2.0",
- id,
- error: RpcError {
- code: -32000,
- message,
- data: None,
- },
- }),
- )
- .into_response(),
+ let method = req.method.clone();
+ let started = std::time::Instant::now();
+ let result = invoke_method(state, method.as_str(), req.params).await;
+ let ms = started.elapsed().as_millis();
+
+ match result {
+ Ok(value) => {
+ tracing::info!("[rpc] {} -> ok ({}ms)", method, ms);
+ (
+ StatusCode::OK,
+ Json(RpcSuccess {
+ jsonrpc: "2.0",
+ id,
+ result: value,
+ }),
+ )
+ .into_response()
+ }
+ Err(message) => {
+ tracing::info!("[rpc] {} -> err ({}ms): {}", method, ms, message);
+ (
+ StatusCode::OK,
+ Json(RpcFailure {
+ jsonrpc: "2.0",
+ id,
+ error: RpcError {
+ code: -32000,
+ message,
+ data: None,
+ },
+ }),
+ )
+ .into_response()
+ }
}
}
@@ -374,6 +385,9 @@ pub fn build_core_http_router(socketio_enabled: bool) -> Router {
}
/// Middleware for logging incoming HTTP requests.
+///
+/// The `/rpc` path is logged inside [`rpc_handler`] instead (with the
+/// JSON-RPC method name), so we skip it here to avoid a redundant line.
async fn http_request_log_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
@@ -382,16 +396,18 @@ async fn http_request_log_middleware(req: Request, next: Next) -> Response {
let response = next.run(req).await;
- let status = response.status().as_u16();
- let ms = started.elapsed().as_millis();
- log::info!(
- "[http] {} {}{} -> {} ({}ms)",
- method,
- path,
- if query_len > 0 { "?…" } else { "" },
- status,
- ms
- );
+ if path != "/rpc" {
+ let status = response.status().as_u16();
+ let ms = started.elapsed().as_millis();
+ tracing::info!(
+ "[http] {} {}{} -> {} ({}ms)",
+ method,
+ path,
+ if query_len > 0 { "?…" } else { "" },
+ status,
+ ms
+ );
+ }
response
}
@@ -794,24 +810,23 @@ fn register_domain_subscribers() {
/// globally so RPC handlers (`openhuman.skills_*`, `openhuman.socket_*`) can
/// reach them.
pub async fn bootstrap_skill_runtime() {
+ use crate::openhuman::skills::paths::resolve_runtime_paths;
use crate::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine};
use crate::openhuman::socket::{set_global_socket_manager, SocketManager};
use std::sync::Arc;
- // Resolve the base directory (~/.openhuman or $OPENHUMAN_WORKSPACE).
- let base_dir = std::env::var("OPENHUMAN_WORKSPACE")
- .ok()
- .filter(|s| !s.is_empty())
- .map(std::path::PathBuf::from)
- .unwrap_or_else(|| {
- dirs::home_dir()
- .unwrap_or_else(|| std::path::PathBuf::from("."))
- .join(".openhuman")
- });
+ // Resolve per-user scoped paths via Config so `skills_data` lives under
+ // `~/.openhuman/users/{user_id}/skills_data` when an active user is set,
+ // matching how config, workspace, and auth are scoped.
+ let paths = resolve_runtime_paths().await;
+ let skills_data_dir = paths.skills_data_dir.clone();
+ let workspace_dir = paths.workspace_dir.clone();
- let skills_data_dir = base_dir.join("skills_data");
if let Err(e) = std::fs::create_dir_all(&skills_data_dir) {
- log::error!("[runtime] Failed to create skills data dir: {e}");
+ log::error!(
+ "[runtime] Failed to create skills data dir {}: {e}",
+ skills_data_dir.display()
+ );
return;
}
@@ -823,10 +838,10 @@ pub async fn bootstrap_skill_runtime() {
}
};
- // Point the engine at the workspace directory for user-installed skills.
- let workspace_dir = base_dir.join("workspace");
+ // Point the engine at the (also scoped) workspace directory for
+ // user-installed skills.
let _ = std::fs::create_dir_all(&workspace_dir);
- engine.set_workspace_dir(workspace_dir);
+ engine.set_workspace_dir(workspace_dir.clone());
// --- Event bus bootstrap ---
// Ensure the global event bus is initialized (no-op if already done by start_channels).
@@ -839,10 +854,10 @@ pub async fn bootstrap_skill_runtime() {
// --- Sub-agent definition registry bootstrap ---
// Loads built-in archetype definitions plus any custom TOML files
// under `/agents/*.toml`. Idempotent — safe to call from
- // both jsonrpc and repl paths.
- if let Err(err) = crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(
- &base_dir.join("workspace"),
- ) {
+ // both jsonrpc and repl paths. Uses the per-user scoped workspace_dir.
+ if let Err(err) =
+ crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&workspace_dir)
+ {
log::warn!(
"[runtime] AgentDefinitionRegistry::init_global failed: {err} — \
spawn_subagent will be unavailable until restart"
diff --git a/src/core/logging.rs b/src/core/logging.rs
index 4f1d15b27..d2e37ee1c 100644
--- a/src/core/logging.rs
+++ b/src/core/logging.rs
@@ -147,7 +147,22 @@ pub fn init_for_cli_run(verbose: bool, default_scope: CliLogDefault) {
}
});
- let use_color = io::stderr().is_terminal();
+ // Color resolution order (standard conventions):
+ // 1. `NO_COLOR` (any value) → force off.
+ // 2. `FORCE_COLOR` or `CLICOLOR_FORCE` → force on. Useful when the
+ // core runs as a child of the Tauri shell under `yarn tauri dev`,
+ // where the grandchild's stderr may not be detected as a TTY even
+ // though the ultimate terminal supports ANSI.
+ // 3. Fall back to TTY detection on stderr.
+ let use_color = if std::env::var_os("NO_COLOR").is_some() {
+ false
+ } else if std::env::var_os("FORCE_COLOR").is_some()
+ || std::env::var_os("CLICOLOR_FORCE").is_some()
+ {
+ true
+ } else {
+ io::stderr().is_terminal()
+ };
let file_constraints = parse_log_file_constraints();
let fmt_layer = tracing_subscriber::fmt::layer()
diff --git a/src/core/skills_cli.rs b/src/core/skills_cli.rs
index 26de13711..acf42de52 100644
--- a/src/core/skills_cli.rs
+++ b/src/core/skills_cli.rs
@@ -109,27 +109,25 @@ async fn bootstrap_skills_runtime(
log::info!("[skills-cli] SKILLS_LOCAL_DIR = {:?}", canonical);
}
- // Resolve the base directory (~/.openhuman or $OPENHUMAN_WORKSPACE).
- let base_dir = std::env::var("OPENHUMAN_WORKSPACE")
- .ok()
- .filter(|s| !s.is_empty())
- .map(PathBuf::from)
- .unwrap_or_else(|| {
- dirs::home_dir()
- .unwrap_or_else(|| PathBuf::from("."))
- .join(".openhuman")
- });
+ // Resolve per-user scoped paths via Config so `skills_data` honours
+ // `active_user.toml` and lives under `~/.openhuman/users/{user_id}/skills_data`
+ // for authenticated users. See `openhuman::skills::paths`.
+ let paths = crate::openhuman::skills::paths::resolve_runtime_paths().await;
+ let skills_data_dir = paths.skills_data_dir.clone();
+ let workspace_dir = paths.workspace_dir.clone();
- let skills_data_dir = base_dir.join("skills_data");
- std::fs::create_dir_all(&skills_data_dir)
- .map_err(|e| anyhow::anyhow!("failed to create skills data dir: {e}"))?;
+ std::fs::create_dir_all(&skills_data_dir).map_err(|e| {
+ anyhow::anyhow!(
+ "failed to create skills data dir {}: {e}",
+ skills_data_dir.display()
+ )
+ })?;
let engine = RuntimeEngine::new(skills_data_dir)
.map_err(|e| anyhow::anyhow!("failed to create RuntimeEngine: {e}"))?;
let engine = Arc::new(engine);
- // Point at workspace directory for user-installed skills.
- let workspace_dir = base_dir.join("workspace");
+ // Point at the (also scoped) workspace directory for user-installed skills.
let _ = std::fs::create_dir_all(&workspace_dir);
engine.set_workspace_dir(workspace_dir);
diff --git a/src/core/socketio.rs b/src/core/socketio.rs
index 73788d959..8790f557b 100644
--- a/src/core/socketio.rs
+++ b/src/core/socketio.rs
@@ -218,8 +218,10 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
log::debug!("[socketio] web_channel bridge stopped");
});
- // Clone for the transcription bridge spawned below.
+ // Clone for the transcription and overlay bridges spawned below; the
+ // dictation task takes ownership of `io` itself.
let io_transcription = io.clone();
+ let io_overlay = io.clone();
// Dictation hotkey events → broadcast to all connected clients.
tokio::spawn(async move {
@@ -246,6 +248,41 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
log::debug!("[socketio] dictation bridge stopped");
});
+ // Overlay attention events → broadcast to the overlay window.
+ //
+ // Any core-side caller (subconscious loop, heartbeat, screen intelligence, …)
+ // can publish an `OverlayAttentionEvent` via
+ // `openhuman::overlay::publish_attention(...)` and it will be forwarded
+ // to all Socket.IO clients here. The overlay window listens on a dedicated
+ // unauthenticated socket (see `OverlayApp.tsx`) and renders the bubble.
+ tokio::spawn(async move {
+ let mut rx = crate::openhuman::overlay::subscribe_attention_events();
+ loop {
+ let event = match rx.recv().await {
+ Ok(event) => event,
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
+ log::warn!(
+ "[socketio] dropped {} overlay attention events due to lag",
+ skipped
+ );
+ continue;
+ }
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
+ };
+
+ if let Ok(payload) = serde_json::to_value(&event) {
+ log::debug!(
+ "[socketio] broadcast overlay:attention source={:?} message_bytes={}",
+ event.source,
+ event.message.len()
+ );
+ let _ = io_overlay.emit("overlay:attention", &payload);
+ let _ = io_overlay.emit("overlay_attention", &payload);
+ }
+ }
+ log::debug!("[socketio] overlay attention bridge stopped");
+ });
+
// Transcription results → broadcast to all connected clients.
tokio::spawn(async move {
let mut rx = crate::openhuman::voice::dictation_listener::subscribe_transcription_results();
diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs
index bccd1a971..41987fc5b 100644
--- a/src/openhuman/about_app/catalog.rs
+++ b/src/openhuman/about_app/catalog.rs
@@ -238,15 +238,6 @@ const CAPABILITIES: &[Capability] = &[
how_to: "Settings > Connections",
status: CapabilityStatus::ComingSoon,
},
- Capability {
- id: "skills.browser_access_policy",
- name: "Configure Browser Access Policy",
- domain: "skills",
- category: CapabilityCategory::Skills,
- description: "Control whether browser-based tools can visit only the allowlist or any public domain.",
- how_to: "Settings > Developer Options > Skills > Browser Access",
- status: CapabilityStatus::Beta,
- },
Capability {
id: "local_ai.download_model",
name: "Download Local Models",
diff --git a/src/openhuman/autocomplete/schemas.rs b/src/openhuman/autocomplete/schemas.rs
index d558927c2..d512c0476 100644
--- a/src/openhuman/autocomplete/schemas.rs
+++ b/src/openhuman/autocomplete/schemas.rs
@@ -224,6 +224,12 @@ pub fn schemas(function: &str) -> ControllerSchema {
comment: "Whether tab key applies suggestion.",
required: false,
},
+ FieldSchema {
+ name: "overlay_ttl_ms",
+ ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
+ comment: "Overlay time-to-live in milliseconds (clamped to 300-10000).",
+ required: false,
+ },
],
outputs: vec![FieldSchema {
name: "result",
diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs
index 7656a4d35..73d66e513 100644
--- a/src/openhuman/config/schema/load.rs
+++ b/src/openhuman/config/schema/load.rs
@@ -450,7 +450,7 @@ impl Config {
migrate_legacy_autocomplete_disabled_apps(&mut config);
config.apply_env_overrides();
- tracing::info!(
+ tracing::debug!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
@@ -472,7 +472,7 @@ impl Config {
config.apply_env_overrides();
- tracing::info!(
+ tracing::debug!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
@@ -638,13 +638,14 @@ impl Config {
}
if let Ok(scope_raw) = std::env::var("OPENHUMAN_PROXY_SCOPE") {
- if let Some(scope) = parse_proxy_scope(&scope_raw) {
- self.proxy.scope = scope;
- } else {
- tracing::warn!(
- scope = %scope_raw,
- "Ignoring invalid OPENHUMAN_PROXY_SCOPE (valid: environment|openhuman|services)"
- );
+ let trimmed = scope_raw.trim();
+ if !trimmed.is_empty() {
+ match parse_proxy_scope(trimmed) {
+ Some(scope) => self.proxy.scope = scope,
+ None => {
+ tracing::warn!("Invalid OPENHUMAN_PROXY_SCOPE value {:?} ignored", trimmed);
+ }
+ }
}
}
diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs
index 21eb6ef87..1fca22b55 100644
--- a/src/openhuman/config/schemas.rs
+++ b/src/openhuman/config/schemas.rs
@@ -283,6 +283,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
},
optional_bool("vision_enabled", "Enable vision analysis."),
optional_bool("autocomplete_enabled", "Enable autocomplete integration."),
+ optional_bool(
+ "use_vision_model",
+ "Use a vision LLM for screenshot analysis (false = OCR + text LLM).",
+ ),
optional_bool("keep_screenshots", "Keep screenshots on disk after vision processing."),
FieldSchema {
name: "allowlist",
diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs
index 4469be6d9..620f458c1 100644
--- a/src/openhuman/mod.rs
+++ b/src/openhuman/mod.rs
@@ -37,6 +37,7 @@ pub mod learning;
pub mod local_ai;
pub mod memory;
pub mod migration;
+pub mod overlay;
pub mod providers;
pub mod referral;
pub mod screen_intelligence;
diff --git a/src/openhuman/overlay/bus.rs b/src/openhuman/overlay/bus.rs
new file mode 100644
index 000000000..4e26ef71d
--- /dev/null
+++ b/src/openhuman/overlay/bus.rs
@@ -0,0 +1,75 @@
+//! Broadcast bus for overlay attention events.
+//!
+//! Mirrors the pattern used by `voice::dictation_listener`: a single
+//! `tokio::sync::broadcast` channel wrapped in a `Lazy` static so any
+//! module in the core can publish without threading a sender around.
+//! The Socket.IO bridge in `core::socketio::spawn_web_channel_bridge`
+//! subscribes here and forwards every event to the overlay window as
+//! an `overlay:attention` Socket.IO message.
+
+use once_cell::sync::Lazy;
+use tokio::sync::broadcast;
+
+use super::types::OverlayAttentionEvent;
+
+const LOG_PREFIX: &str = "[overlay]";
+
+static ATTENTION_BUS: Lazy> = Lazy::new(|| {
+ let (tx, _rx) = broadcast::channel(64);
+ tx
+});
+
+/// Subscribe to overlay attention events. Used by the Socket.IO bridge.
+pub fn subscribe_attention_events() -> broadcast::Receiver {
+ ATTENTION_BUS.subscribe()
+}
+
+/// Publish an attention event toward the overlay window.
+///
+/// Fire-and-forget: if nobody is currently subscribed (e.g. the bridge
+/// hasn't started yet, or the overlay socket is disconnected) the event
+/// is dropped. Returns the number of active subscribers that received
+/// the event for diagnostics.
+pub fn publish_attention(event: OverlayAttentionEvent) -> usize {
+ log::debug!(
+ "{LOG_PREFIX} publish attention source={:?} tone={:?} message_bytes={} ttl_ms={:?}",
+ event.source,
+ event.tone,
+ event.message.len(),
+ event.ttl_ms
+ );
+ match ATTENTION_BUS.send(event) {
+ Ok(n) => n,
+ Err(_) => {
+ log::debug!("{LOG_PREFIX} no overlay subscribers — attention event dropped");
+ 0
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::openhuman::overlay::types::OverlayAttentionTone;
+
+ #[tokio::test]
+ async fn publish_is_received_by_subscriber() {
+ let mut rx = subscribe_attention_events();
+ let delivered = publish_attention(
+ OverlayAttentionEvent::new("hello overlay")
+ .with_tone(OverlayAttentionTone::Accent)
+ .with_source("test"),
+ );
+ assert!(delivered >= 1);
+ let event = rx.recv().await.expect("event delivered");
+ assert_eq!(event.message, "hello overlay");
+ assert_eq!(event.tone, OverlayAttentionTone::Accent);
+ assert_eq!(event.source.as_deref(), Some("test"));
+ }
+
+ #[test]
+ fn publish_with_no_subscribers_is_safe() {
+ // Drop any existing subscribers by not holding one.
+ let _ = publish_attention(OverlayAttentionEvent::new("dropped"));
+ }
+}
diff --git a/src/openhuman/overlay/mod.rs b/src/openhuman/overlay/mod.rs
new file mode 100644
index 000000000..092ffa4d3
--- /dev/null
+++ b/src/openhuman/overlay/mod.rs
@@ -0,0 +1,26 @@
+//! Overlay domain — signals pushed to the desktop overlay window.
+//!
+//! The Tauri desktop shell hosts a separate `overlay` window (see
+//! `app/src-tauri/tauri.conf.json`) that renders `OverlayApp.tsx`. Because
+//! the overlay runs in its own WebView with its own JS runtime, it cannot
+//! share Redux state with the main window. Instead it subscribes to a
+//! dedicated Socket.IO connection against the core process (same pattern
+//! `useDictationHotkey` uses) and reacts to events emitted here.
+//!
+//! Currently the overlay activates in two cases:
+//! 1. **STT / dictation** — driven by the existing `dictation:toggle`
+//! and `dictation:transcription` events (see `voice::dictation_listener`).
+//! 2. **Attention** — a short, user-visible message the core wants to
+//! surface without stealing focus. Any core-side caller (subconscious
+//! loop, heartbeat, screen intelligence, …) can publish an
+//! `OverlayAttentionEvent` via [`publish_attention`] and it will be
+//! broadcast to the overlay window as `overlay:attention`.
+//!
+//! Keep this module light: it is export-focused and owns one broadcast
+//! bus. The Socket.IO bridge lives in `src/core/socketio.rs`.
+
+pub mod bus;
+pub mod types;
+
+pub use bus::{publish_attention, subscribe_attention_events};
+pub use types::{OverlayAttentionEvent, OverlayAttentionTone};
diff --git a/src/openhuman/overlay/types.rs b/src/openhuman/overlay/types.rs
new file mode 100644
index 000000000..3d6a70c99
--- /dev/null
+++ b/src/openhuman/overlay/types.rs
@@ -0,0 +1,73 @@
+//! Types for the overlay attention bus.
+
+use serde::{Deserialize, Serialize};
+
+/// Visual tone hint for the overlay bubble. The frontend maps these to
+/// bubble colours (see `OverlayApp.tsx`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "lowercase")]
+pub enum OverlayAttentionTone {
+ /// Informational / neutral (slate bubble).
+ #[default]
+ Neutral,
+ /// Important / assistant-initiated (blue bubble).
+ Accent,
+ /// Positive confirmation (green bubble).
+ Success,
+}
+
+/// A single attention message emitted toward the overlay window.
+///
+/// Only `message` is required. All other fields have sensible defaults
+/// so callers can do `OverlayAttentionEvent::new("Hey …")` and go.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OverlayAttentionEvent {
+ /// Stable id for this message; if `None`, the frontend generates one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub id: Option,
+ /// The text to display. The overlay types it out character by
+ /// character, so keep it short (a sentence or two).
+ pub message: String,
+ /// Visual tone for the bubble.
+ #[serde(default)]
+ pub tone: OverlayAttentionTone,
+ /// How long the overlay should stay visible, in milliseconds, before
+ /// auto-dismissing back to idle. `None` → frontend default.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub ttl_ms: Option,
+ /// Free-form source label for logging / debugging ("subconscious",
+ /// "heartbeat", "screen_intelligence", …). Optional.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+}
+
+impl OverlayAttentionEvent {
+ /// Convenience constructor with neutral tone and default ttl.
+ pub fn new(message: impl Into) -> Self {
+ Self {
+ id: None,
+ message: message.into(),
+ tone: OverlayAttentionTone::default(),
+ ttl_ms: None,
+ source: None,
+ }
+ }
+
+ /// Builder-style source setter for diagnostics.
+ pub fn with_source(mut self, source: impl Into) -> Self {
+ self.source = Some(source.into());
+ self
+ }
+
+ /// Builder-style tone setter.
+ pub fn with_tone(mut self, tone: OverlayAttentionTone) -> Self {
+ self.tone = tone;
+ self
+ }
+
+ /// Builder-style ttl setter.
+ pub fn with_ttl_ms(mut self, ttl_ms: u32) -> Self {
+ self.ttl_ms = Some(ttl_ms);
+ self
+ }
+}
diff --git a/src/openhuman/screen_intelligence/schemas.rs b/src/openhuman/screen_intelligence/schemas.rs
index 48fba25ed..60973fcb4 100644
--- a/src/openhuman/screen_intelligence/schemas.rs
+++ b/src/openhuman/screen_intelligence/schemas.rs
@@ -142,15 +142,21 @@ pub fn schemas(function: &str) -> ControllerSchema {
description: "Start screen intelligence session.",
inputs: vec![
FieldSchema {
- name: "sample_interval_ms",
+ name: "consent",
+ ty: TypeSchema::Bool,
+ comment: "Explicit user consent to start the accessibility session.",
+ required: true,
+ },
+ FieldSchema {
+ name: "ttl_secs",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
- comment: "Capture interval in milliseconds.",
+ comment: "Session time-to-live in seconds.",
required: false,
},
FieldSchema {
- name: "capture_policy",
- ty: TypeSchema::Option(Box::new(TypeSchema::String)),
- comment: "Capture policy mode.",
+ name: "screen_monitoring",
+ ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
+ comment: "Whether screen recording capture should be enabled for this session.",
required: false,
},
],
diff --git a/src/openhuman/skills/mod.rs b/src/openhuman/skills/mod.rs
index 1af803e6b..c7eb16009 100644
--- a/src/openhuman/skills/mod.rs
+++ b/src/openhuman/skills/mod.rs
@@ -11,6 +11,7 @@ pub mod bus;
pub mod cron_scheduler;
pub mod manifest;
pub mod ops;
+pub mod paths;
pub mod ping_scheduler;
pub mod preferences;
pub mod qjs_engine;
diff --git a/src/openhuman/skills/paths.rs b/src/openhuman/skills/paths.rs
new file mode 100644
index 000000000..b2c767a39
--- /dev/null
+++ b/src/openhuman/skills/paths.rs
@@ -0,0 +1,173 @@
+//! Path resolution for the skills runtime.
+//!
+//! Skill runtime state (OAuth tokens, webhook routes, ops snapshots, per-skill
+//! data dirs) must live **under the active user's scoped directory** —
+//! `~/.openhuman/users/{user_id}/skills_data` — so that switching users on the
+//! same machine cleanly isolates OAuth credentials, sync cursors, and cached
+//! results.
+//!
+//! Before this helper was introduced, both `core::jsonrpc::bootstrap_skill_runtime`
+//! and `core::skills_cli::bootstrap_skills_runtime` reconstructed the base path
+//! manually from `$OPENHUMAN_WORKSPACE` or `~/.openhuman`, bypassing
+//! `Config::load_or_init` and therefore ignoring `active_user.toml`. That left
+//! every skill's data pooled at `~/.openhuman/skills_data` regardless of which
+//! user was logged in — the single unscoped slice of state in an otherwise
+//! user-scoped layout.
+//!
+//! This module centralises resolution so every bootstrap site produces the
+//! same scoped paths.
+
+use std::path::PathBuf;
+
+/// Paths required to initialise the QuickJS skills runtime.
+#[derive(Debug, Clone)]
+pub struct SkillsRuntimePaths {
+ /// Where per-skill persistent state lives (OAuth tokens, ops snapshots,
+ /// `skill-preferences.json`, `webhook_routes.json`, per-skill subdirs).
+ pub skills_data_dir: PathBuf,
+ /// The workspace directory the engine uses for user-installed skills
+ /// from the registry and for agent definitions (`agents/*.toml`).
+ pub workspace_dir: PathBuf,
+}
+
+/// Resolve skills runtime paths via `Config::load_or_init` so they honour
+/// `active_user.toml` and the full per-user scoping pipeline.
+///
+/// If config loading fails (e.g. the process is booting before any config
+/// file has been written) this falls back to the legacy unscoped layout so
+/// the runtime can still come up — but emits a warning so the degraded mode
+/// is visible in logs.
+pub async fn resolve_runtime_paths() -> SkillsRuntimePaths {
+ match crate::openhuman::config::Config::load_or_init().await {
+ Ok(config) => {
+ // `config_path` is `{openhuman_dir}/config.toml`. Its parent is the
+ // openhuman_dir itself — which, when an active user is set, is
+ // `{root}/users/{user_id}` (see `resolve_runtime_config_dirs` in
+ // `config/schema/load.rs`). Sibling-of-config matches the existing
+ // on-disk convention for skills_data (it used to sit next to
+ // config.toml at `~/.openhuman/skills_data`).
+ let openhuman_dir = config
+ .config_path
+ .parent()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| config.workspace_dir.clone());
+
+ let skills_data_dir = openhuman_dir.join("skills_data");
+ let workspace_dir = config.workspace_dir.clone();
+
+ log::debug!(
+ "[skills:paths] resolved via Config openhuman_dir={} workspace_dir={} skills_data_dir={}",
+ openhuman_dir.display(),
+ workspace_dir.display(),
+ skills_data_dir.display(),
+ );
+
+ warn_if_legacy_unscoped_data_exists(&skills_data_dir);
+
+ SkillsRuntimePaths {
+ skills_data_dir,
+ workspace_dir,
+ }
+ }
+ Err(err) => {
+ log::warn!(
+ "[skills:paths] Config::load_or_init failed ({err}) — falling back to legacy unscoped layout"
+ );
+ legacy_unscoped_paths()
+ }
+ }
+}
+
+/// Warn (once per bootstrap) if the legacy, unscoped
+/// `~/.openhuman/skills_data` directory still exists while we are now
+/// resolving to a different (user-scoped) location. This tells the user why
+/// their skills appear to have "forgotten" their OAuth credentials after
+/// upgrading and where to find the stale data.
+///
+/// We deliberately **do not** auto-migrate: the legacy dir was shared
+/// across every user of the machine, so silently moving it into whichever
+/// user happened to log in first could leak another user's tokens.
+fn warn_if_legacy_unscoped_data_exists(resolved_skills_data_dir: &std::path::Path) {
+ let Some(home) = dirs::home_dir() else {
+ return;
+ };
+ let legacy = home.join(".openhuman").join("skills_data");
+ if !legacy.exists() {
+ return;
+ }
+ if legacy == resolved_skills_data_dir {
+ // We resolved to the legacy path itself (unauthenticated layout) —
+ // nothing to warn about.
+ return;
+ }
+ log::warn!(
+ "[skills:paths] legacy unscoped skills_data still present at {} but runtime now uses {}. \
+ Skills will start fresh. Manually move the subdirectories you want to preserve \
+ (e.g. per-skill OAuth tokens, webhook_routes.json) into the new path.",
+ legacy.display(),
+ resolved_skills_data_dir.display(),
+ );
+}
+
+/// Legacy fallback — the original resolution that was inlined in both
+/// bootstrap sites. Used only when `Config::load_or_init` fails during
+/// runtime bootstrap.
+fn legacy_unscoped_paths() -> SkillsRuntimePaths {
+ let base_dir = std::env::var("OPENHUMAN_WORKSPACE")
+ .ok()
+ .filter(|s| !s.is_empty())
+ .map(PathBuf::from)
+ .unwrap_or_else(|| {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".openhuman")
+ });
+ SkillsRuntimePaths {
+ skills_data_dir: base_dir.join("skills_data"),
+ workspace_dir: base_dir.join("workspace"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// RAII guard that restores `OPENHUMAN_WORKSPACE` on drop so tests
+ /// remain panic-safe and don't pollute sibling tests that read the
+ /// same env var.
+ struct EnvGuard {
+ key: &'static str,
+ previous: Option,
+ }
+
+ impl EnvGuard {
+ fn set(key: &'static str, value: &str) -> Self {
+ let previous = std::env::var(key).ok();
+ std::env::set_var(key, value);
+ Self { key, previous }
+ }
+ }
+
+ impl Drop for EnvGuard {
+ fn drop(&mut self) {
+ match self.previous.take() {
+ Some(value) => std::env::set_var(self.key, value),
+ None => std::env::remove_var(self.key),
+ }
+ }
+ }
+
+ #[test]
+ fn legacy_fallback_uses_env_workspace_when_set() {
+ let _guard = EnvGuard::set("OPENHUMAN_WORKSPACE", "/tmp/openhuman-test-paths");
+ let paths = legacy_unscoped_paths();
+ assert_eq!(
+ paths.skills_data_dir,
+ PathBuf::from("/tmp/openhuman-test-paths/skills_data")
+ );
+ assert_eq!(
+ paths.workspace_dir,
+ PathBuf::from("/tmp/openhuman-test-paths/workspace")
+ );
+ }
+}