diff --git a/.prettierignore b/.prettierignore
index 13cc3e2a98..a4d17617db 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,6 +1,7 @@
node_modules
dist
coverage
+app
src-tauri
rust-core
skills
diff --git a/Cargo.lock b/Cargo.lock
index b487bf3daf..abc1d34098 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1169,6 +1169,15 @@ dependencies = [
"strsim",
]
+[[package]]
+name = "clap_complete"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"
+dependencies = [
+ "clap",
+]
+
[[package]]
name = "clap_derive"
version = "4.6.0"
@@ -5441,6 +5450,7 @@ dependencies = [
"chrono",
"chrono-tz",
"clap",
+ "clap_complete",
"console",
"cron",
"dialoguer",
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000000..a547bf36d8
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 0000000000..102e366893
--- /dev/null
+++ b/app/README.md
@@ -0,0 +1,7 @@
+# Tauri + React + Typescript
+
+This template should help get you started developing with Tauri, React and Typescript in Vite.
+
+## Recommended IDE Setup
+
+- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
diff --git a/app/index.html b/app/index.html
new file mode 100644
index 0000000000..ff93803bbc
--- /dev/null
+++ b/app/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Tauri + React + Typescript
+
+
+
+
+
+
+
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 0000000000..2af9184d2e
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "openhuman",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "tauri": "tauri"
+ },
+ "dependencies": {
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "@tauri-apps/api": "^2",
+ "@tauri-apps/plugin-opener": "^2"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "^4.6.0",
+ "typescript": "~5.8.3",
+ "vite": "^7.0.4",
+ "@tauri-apps/cli": "^2"
+ }
+}
diff --git a/app/public/tauri.svg b/app/public/tauri.svg
new file mode 100644
index 0000000000..31b62c9280
--- /dev/null
+++ b/app/public/tauri.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/public/vite.svg b/app/public/vite.svg
new file mode 100644
index 0000000000..e7b8dfb1b2
--- /dev/null
+++ b/app/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/src-tauri/.gitignore b/app/src-tauri/.gitignore
new file mode 100644
index 0000000000..b21bd681d9
--- /dev/null
+++ b/app/src-tauri/.gitignore
@@ -0,0 +1,7 @@
+# Generated by Cargo
+# will have compiled files and executables
+/target/
+
+# Generated by Tauri
+# will have schema files for capabilities auto-completion
+/gen/schemas
diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml
new file mode 100644
index 0000000000..0eca656f7b
--- /dev/null
+++ b/app/src-tauri/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "openhuman"
+version = "0.1.0"
+description = "A Tauri App"
+authors = ["you"]
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[lib]
+# The `_lib` suffix may seem redundant but it is necessary
+# to make the lib name unique and wouldn't conflict with the bin name.
+# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
+name = "openhuman_lib"
+crate-type = ["staticlib", "cdylib", "rlib"]
+
+[build-dependencies]
+tauri-build = { version = "2", features = [] }
+
+[dependencies]
+tauri = { version = "2", features = [] }
+tauri-plugin-opener = "2"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs
new file mode 100644
index 0000000000..d860e1e6a7
--- /dev/null
+++ b/app/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json
new file mode 100644
index 0000000000..4cdbf49a76
--- /dev/null
+++ b/app/src-tauri/capabilities/default.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "../gen/schemas/desktop-schema.json",
+ "identifier": "default",
+ "description": "Capability for the main window",
+ "windows": ["main"],
+ "permissions": [
+ "core:default",
+ "opener:default"
+ ]
+}
diff --git a/app/src-tauri/icons/128x128.png b/app/src-tauri/icons/128x128.png
new file mode 100644
index 0000000000..6be5e50e9b
Binary files /dev/null and b/app/src-tauri/icons/128x128.png differ
diff --git a/app/src-tauri/icons/128x128@2x.png b/app/src-tauri/icons/128x128@2x.png
new file mode 100644
index 0000000000..e81becee57
Binary files /dev/null and b/app/src-tauri/icons/128x128@2x.png differ
diff --git a/app/src-tauri/icons/32x32.png b/app/src-tauri/icons/32x32.png
new file mode 100644
index 0000000000..a437dd5174
Binary files /dev/null and b/app/src-tauri/icons/32x32.png differ
diff --git a/app/src-tauri/icons/Square107x107Logo.png b/app/src-tauri/icons/Square107x107Logo.png
new file mode 100644
index 0000000000..0ca4f27198
Binary files /dev/null and b/app/src-tauri/icons/Square107x107Logo.png differ
diff --git a/app/src-tauri/icons/Square142x142Logo.png b/app/src-tauri/icons/Square142x142Logo.png
new file mode 100644
index 0000000000..b81f820394
Binary files /dev/null and b/app/src-tauri/icons/Square142x142Logo.png differ
diff --git a/app/src-tauri/icons/Square150x150Logo.png b/app/src-tauri/icons/Square150x150Logo.png
new file mode 100644
index 0000000000..624c7bfba0
Binary files /dev/null and b/app/src-tauri/icons/Square150x150Logo.png differ
diff --git a/app/src-tauri/icons/Square284x284Logo.png b/app/src-tauri/icons/Square284x284Logo.png
new file mode 100644
index 0000000000..c021d2ba76
Binary files /dev/null and b/app/src-tauri/icons/Square284x284Logo.png differ
diff --git a/app/src-tauri/icons/Square30x30Logo.png b/app/src-tauri/icons/Square30x30Logo.png
new file mode 100644
index 0000000000..6219700230
Binary files /dev/null and b/app/src-tauri/icons/Square30x30Logo.png differ
diff --git a/app/src-tauri/icons/Square310x310Logo.png b/app/src-tauri/icons/Square310x310Logo.png
new file mode 100644
index 0000000000..f9bc048394
Binary files /dev/null and b/app/src-tauri/icons/Square310x310Logo.png differ
diff --git a/app/src-tauri/icons/Square44x44Logo.png b/app/src-tauri/icons/Square44x44Logo.png
new file mode 100644
index 0000000000..d5fbfb2ab4
Binary files /dev/null and b/app/src-tauri/icons/Square44x44Logo.png differ
diff --git a/app/src-tauri/icons/Square71x71Logo.png b/app/src-tauri/icons/Square71x71Logo.png
new file mode 100644
index 0000000000..63440d7984
Binary files /dev/null and b/app/src-tauri/icons/Square71x71Logo.png differ
diff --git a/app/src-tauri/icons/Square89x89Logo.png b/app/src-tauri/icons/Square89x89Logo.png
new file mode 100644
index 0000000000..f3f705af2f
Binary files /dev/null and b/app/src-tauri/icons/Square89x89Logo.png differ
diff --git a/app/src-tauri/icons/StoreLogo.png b/app/src-tauri/icons/StoreLogo.png
new file mode 100644
index 0000000000..4556388261
Binary files /dev/null and b/app/src-tauri/icons/StoreLogo.png differ
diff --git a/app/src-tauri/icons/icon.icns b/app/src-tauri/icons/icon.icns
new file mode 100644
index 0000000000..12a5bcee26
Binary files /dev/null and b/app/src-tauri/icons/icon.icns differ
diff --git a/app/src-tauri/icons/icon.ico b/app/src-tauri/icons/icon.ico
new file mode 100644
index 0000000000..b3636e4b22
Binary files /dev/null and b/app/src-tauri/icons/icon.ico differ
diff --git a/app/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png
new file mode 100644
index 0000000000..e1cd2619e0
Binary files /dev/null and b/app/src-tauri/icons/icon.png differ
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
new file mode 100644
index 0000000000..4a277ef350
--- /dev/null
+++ b/app/src-tauri/src/lib.rs
@@ -0,0 +1,14 @@
+// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
+#[tauri::command]
+fn greet(name: &str) -> String {
+ format!("Hello, {}! You've been greeted from Rust!", name)
+}
+
+#[cfg_attr(mobile, tauri::mobile_entry_point)]
+pub fn run() {
+ tauri::Builder::default()
+ .plugin(tauri_plugin_opener::init())
+ .invoke_handler(tauri::generate_handler![greet])
+ .run(tauri::generate_context!())
+ .expect("error while running tauri application");
+}
diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs
new file mode 100644
index 0000000000..212d0a3762
--- /dev/null
+++ b/app/src-tauri/src/main.rs
@@ -0,0 +1,6 @@
+// Prevents additional console window on Windows in release, DO NOT REMOVE!!
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+fn main() {
+ openhuman_lib::run()
+}
diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json
new file mode 100644
index 0000000000..3d6a5d24d7
--- /dev/null
+++ b/app/src-tauri/tauri.conf.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "https://schema.tauri.app/config/2",
+ "productName": "openhuman",
+ "version": "0.1.0",
+ "identifier": "com.tinyhumansai.openhuman",
+ "build": {
+ "beforeDevCommand": "yarn dev",
+ "devUrl": "http://localhost:1420",
+ "beforeBuildCommand": "yarn build",
+ "frontendDist": "../dist"
+ },
+ "app": {
+ "windows": [
+ {
+ "title": "openhuman",
+ "width": 800,
+ "height": 600
+ }
+ ],
+ "security": {
+ "csp": null
+ }
+ },
+ "bundle": {
+ "active": true,
+ "targets": "all",
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.icns",
+ "icons/icon.ico"
+ ]
+ }
+}
diff --git a/app/src/App.css b/app/src/App.css
new file mode 100644
index 0000000000..85f7a4a1c8
--- /dev/null
+++ b/app/src/App.css
@@ -0,0 +1,116 @@
+.logo.vite:hover {
+ filter: drop-shadow(0 0 2em #747bff);
+}
+
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafb);
+}
+:root {
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 400;
+
+ color: #0f0f0f;
+ background-color: #f6f6f6;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-text-size-adjust: 100%;
+}
+
+.container {
+ margin: 0;
+ padding-top: 10vh;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: 0.75s;
+}
+
+.logo.tauri:hover {
+ filter: drop-shadow(0 0 2em #24c8db);
+}
+
+.row {
+ display: flex;
+ justify-content: center;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+
+a:hover {
+ color: #535bf2;
+}
+
+h1 {
+ text-align: center;
+}
+
+input,
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ color: #0f0f0f;
+ background-color: #ffffff;
+ transition: border-color 0.25s;
+ box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
+}
+
+button {
+ cursor: pointer;
+}
+
+button:hover {
+ border-color: #396cd8;
+}
+button:active {
+ border-color: #396cd8;
+ background-color: #e8e8e8;
+}
+
+input,
+button {
+ outline: none;
+}
+
+#greet-input {
+ margin-right: 5px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ color: #f6f6f6;
+ background-color: #2f2f2f;
+ }
+
+ a:hover {
+ color: #24c8db;
+ }
+
+ input,
+ button {
+ color: #ffffff;
+ background-color: #0f0f0f98;
+ }
+ button:active {
+ background-color: #0f0f0f69;
+ }
+}
diff --git a/app/src/App.tsx b/app/src/App.tsx
new file mode 100644
index 0000000000..8286a76ec4
--- /dev/null
+++ b/app/src/App.tsx
@@ -0,0 +1,51 @@
+import { useState } from "react";
+import reactLogo from "./assets/react.svg";
+import { invoke } from "@tauri-apps/api/core";
+import "./App.css";
+
+function App() {
+ const [greetMsg, setGreetMsg] = useState("");
+ const [name, setName] = useState("");
+
+ async function greet() {
+ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
+ setGreetMsg(await invoke("greet", { name }));
+ }
+
+ return (
+
+ Welcome to Tauri + React
+
+
+ Click on the Tauri, Vite, and React logos to learn more.
+
+
+ {greetMsg}
+
+ );
+}
+
+export default App;
diff --git a/app/src/assets/react.svg b/app/src/assets/react.svg
new file mode 100644
index 0000000000..6c87de9bb3
--- /dev/null
+++ b/app/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/src/main.tsx b/app/src/main.tsx
new file mode 100644
index 0000000000..2be325ed25
--- /dev/null
+++ b/app/src/main.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+ ,
+);
diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/app/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/app/tsconfig.json b/app/tsconfig.json
new file mode 100644
index 0000000000..a7fc6fbf23
--- /dev/null
+++ b/app/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json
new file mode 100644
index 0000000000..42872c59f5
--- /dev/null
+++ b/app/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/app/vite.config.ts b/app/vite.config.ts
new file mode 100644
index 0000000000..ddad22a3e2
--- /dev/null
+++ b/app/vite.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// @ts-expect-error process is a nodejs global
+const host = process.env.TAURI_DEV_HOST;
+
+// https://vite.dev/config/
+export default defineConfig(async () => ({
+ plugins: [react()],
+
+ // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
+ //
+ // 1. prevent Vite from obscuring rust errors
+ clearScreen: false,
+ // 2. tauri expects a fixed port, fail if that port is not available
+ server: {
+ port: 1420,
+ strictPort: true,
+ host: host || false,
+ hmr: host
+ ? {
+ protocol: "ws",
+ host,
+ port: 1421,
+ }
+ : undefined,
+ watch: {
+ // 3. tell Vite to ignore watching `src-tauri`
+ ignored: ["**/src-tauri/**"],
+ },
+ },
+}));
diff --git a/eslint.config.js b/eslint.config.js
index 5d8e51a76f..aa37bca0e0 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -26,6 +26,7 @@ export default [
'**/target/**',
'dist/**',
'coverage/**',
+ 'app/**',
'src-tauri/**',
'rust-core/**',
'skills/**',
diff --git a/rust-core/Cargo.toml b/rust-core/Cargo.toml
index d71feadff5..26c848b06c 100644
--- a/rust-core/Cargo.toml
+++ b/rust-core/Cargo.toml
@@ -62,6 +62,7 @@ rustls-pki-types = "1.14.0"
tokio-rustls = "0.26.4"
webpki-roots = "1.0.6"
clap = { version = "4.5", features = ["derive"] }
+clap_complete = "4.5"
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
mail-parser = "0.11.2"
async-imap = { version = "0.11", features = ["runtime-tokio"], default-features = false }
diff --git a/rust-core/src/bin/openhuman.rs b/rust-core/src/bin/openhuman.rs
index 32bd30b970..06ceda722f 100644
--- a/rust-core/src/bin/openhuman.rs
+++ b/rust-core/src/bin/openhuman.rs
@@ -1,334 +1,7 @@
-use clap::{Args, Parser, Subcommand};
-use serde_json::json;
-
-#[derive(Debug, Parser)]
-#[command(name = "openhuman")]
-#[command(about = "OpenHuman core CLI")]
-struct Cli {
- #[command(subcommand)]
- command: Command,
-}
-
-#[derive(Debug, Subcommand)]
-enum Command {
- /// Run JSON-RPC server
- Serve {
- #[arg(long)]
- port: Option,
- },
- /// Check core health
- Ping,
- /// Print core version
- Version,
- /// Get health snapshot
- Health,
- /// Get runtime flags
- RuntimeFlags,
- /// Get security policy info
- SecurityPolicy,
- /// Generic JSON-RPC style method call
- Call {
- #[arg(long)]
- method: String,
- #[arg(long, default_value = "{}")]
- params: String,
- },
- /// Config operations
- Config {
- #[command(subcommand)]
- command: ConfigCommand,
- },
- /// Service operations
- Service {
- #[command(subcommand)]
- command: ServiceCommand,
- },
- /// Doctor operations
- Doctor {
- #[command(subcommand)]
- command: DoctorCommand,
- },
- /// Integrations operations
- Integrations {
- #[command(subcommand)]
- command: IntegrationsCommand,
- },
- /// Send one-shot agent message
- AgentChat(AgentChatArgs),
- /// Hardware operations
- Hardware {
- #[command(subcommand)]
- command: HardwareCommand,
- },
- /// Encrypt a secret
- Encrypt { plaintext: String },
- /// Decrypt a secret
- Decrypt { ciphertext: String },
- /// Toggle browser allow-all runtime flag
- BrowserAllowAll {
- #[arg(long)]
- enabled: bool,
- },
- /// Refresh model catalog
- ModelsRefresh {
- #[arg(long)]
- provider: Option,
- #[arg(long, default_value_t = false)]
- force: bool,
- },
- /// Migrate OpenClaw memory
- MigrateOpenclaw {
- #[arg(long)]
- source_workspace: Option,
- #[arg(long, default_value_t = true)]
- dry_run: bool,
- },
-}
-
-#[derive(Debug, Subcommand)]
-enum ConfigCommand {
- /// Get full config snapshot
- Get,
- /// Update model settings with a JSON object
- UpdateModel {
- #[arg(long)]
- json: String,
- },
- /// Update memory settings with a JSON object
- UpdateMemory {
- #[arg(long)]
- json: String,
- },
- /// Update gateway settings with a JSON object
- UpdateGateway {
- #[arg(long)]
- json: String,
- },
- /// Update runtime settings with a JSON object
- UpdateRuntime {
- #[arg(long)]
- json: String,
- },
- /// Update browser settings with a JSON object
- UpdateBrowser {
- #[arg(long)]
- json: String,
- },
- /// Replace tunnel settings with a JSON object
- UpdateTunnel {
- #[arg(long)]
- json: String,
- },
-}
-
-#[derive(Debug, Subcommand)]
-enum ServiceCommand {
- Install,
- Start,
- Stop,
- Status,
- Reinstall,
- Uninstall,
-}
-
-#[derive(Debug, Subcommand)]
-enum DoctorCommand {
- /// Run doctor checks
- Report,
- /// Probe model catalog
- Models {
- #[arg(long)]
- provider: Option,
- #[arg(long, default_value_t = true)]
- use_cache: bool,
- },
-}
-
-#[derive(Debug, Subcommand)]
-enum IntegrationsCommand {
- /// List integrations
- List,
- /// Get one integration info
- Info {
- #[arg(long)]
- name: String,
- },
-}
-
-#[derive(Debug, Subcommand)]
-enum HardwareCommand {
- /// Discover connected hardware
- Discover,
- /// Introspect one device path
- Introspect {
- #[arg(long)]
- path: String,
- },
-}
-
-#[derive(Debug, Args)]
-struct AgentChatArgs {
- message: String,
- #[arg(long)]
- provider: Option,
- #[arg(long)]
- model: Option,
- #[arg(long)]
- temperature: Option,
-}
-
-fn parse_json_arg(raw: &str) -> Result {
- serde_json::from_str(raw).map_err(|e| format!("invalid JSON for --json/--params: {e}"))
-}
-
-async fn call_local(method: &str, params: serde_json::Value) -> Result {
- openhuman_core::core_server::call_method(method, params).await
-}
-
-async fn execute(cli: Cli) -> Result {
- match cli.command {
- Command::Serve { port } => openhuman_core::core_server::run_server(port)
- .await
- .map(|_| serde_json::Value::Null)
- .map_err(|e| format!("serve failed: {e}")),
- Command::Ping => call_local("core.ping", json!({})).await,
- Command::Version => call_local("core.version", json!({})).await,
- Command::Health => call_local("openhuman.health_snapshot", json!({})).await,
- Command::RuntimeFlags => call_local("openhuman.get_runtime_flags", json!({})).await,
- Command::SecurityPolicy => call_local("openhuman.security_policy_info", json!({})).await,
- Command::Call { method, params } => call_local(&method, parse_json_arg(¶ms)?).await,
- Command::Config { command } => match command {
- ConfigCommand::Get => call_local("openhuman.get_config", json!({})).await,
- ConfigCommand::UpdateModel { json } => {
- call_local("openhuman.update_model_settings", parse_json_arg(&json)?).await
- }
- ConfigCommand::UpdateMemory { json } => {
- call_local("openhuman.update_memory_settings", parse_json_arg(&json)?).await
- }
- ConfigCommand::UpdateGateway { json } => {
- call_local("openhuman.update_gateway_settings", parse_json_arg(&json)?).await
- }
- ConfigCommand::UpdateRuntime { json } => {
- call_local("openhuman.update_runtime_settings", parse_json_arg(&json)?).await
- }
- ConfigCommand::UpdateBrowser { json } => {
- call_local("openhuman.update_browser_settings", parse_json_arg(&json)?).await
- }
- ConfigCommand::UpdateTunnel { json } => {
- call_local("openhuman.update_tunnel_settings", parse_json_arg(&json)?).await
- }
- },
- Command::Service { command } => match command {
- ServiceCommand::Install => call_local("openhuman.service_install", json!({})).await,
- ServiceCommand::Start => call_local("openhuman.service_start", json!({})).await,
- ServiceCommand::Stop => call_local("openhuman.service_stop", json!({})).await,
- ServiceCommand::Status => call_local("openhuman.service_status", json!({})).await,
- ServiceCommand::Reinstall => {
- call_local("openhuman.service_uninstall", json!({})).await?;
- call_local("openhuman.service_install", json!({})).await
- }
- ServiceCommand::Uninstall => call_local("openhuman.service_uninstall", json!({})).await,
- },
- Command::Doctor { command } => match command {
- DoctorCommand::Report => call_local("openhuman.doctor_report", json!({})).await,
- DoctorCommand::Models {
- provider,
- use_cache,
- } => {
- call_local(
- "openhuman.doctor_models",
- json!({
- "provider_override": provider,
- "use_cache": use_cache,
- }),
- )
- .await
- }
- },
- Command::Integrations { command } => match command {
- IntegrationsCommand::List => call_local("openhuman.list_integrations", json!({})).await,
- IntegrationsCommand::Info { name } => {
- call_local("openhuman.get_integration_info", json!({ "name": name })).await
- }
- },
- Command::AgentChat(args) => {
- call_local(
- "openhuman.agent_chat",
- json!({
- "message": args.message,
- "provider_override": args.provider,
- "model_override": args.model,
- "temperature": args.temperature,
- }),
- )
- .await
- }
- Command::Hardware { command } => match command {
- HardwareCommand::Discover => call_local("openhuman.hardware_discover", json!({})).await,
- HardwareCommand::Introspect { path } => {
- call_local("openhuman.hardware_introspect", json!({ "path": path })).await
- }
- },
- Command::Encrypt { plaintext } => {
- call_local(
- "openhuman.encrypt_secret",
- json!({ "plaintext": plaintext }),
- )
- .await
- }
- Command::Decrypt { ciphertext } => {
- call_local(
- "openhuman.decrypt_secret",
- json!({ "ciphertext": ciphertext }),
- )
- .await
- }
- Command::BrowserAllowAll { enabled } => {
- call_local(
- "openhuman.set_browser_allow_all",
- json!({ "enabled": enabled }),
- )
- .await
- }
- Command::ModelsRefresh { provider, force } => {
- call_local(
- "openhuman.models_refresh",
- json!({
- "provider_override": provider,
- "force": force,
- }),
- )
- .await
- }
- Command::MigrateOpenclaw {
- source_workspace,
- dry_run,
- } => {
- call_local(
- "openhuman.migrate_openclaw",
- json!({
- "source_workspace": source_workspace,
- "dry_run": dry_run,
- }),
- )
- .await
- }
- }
-}
-
-#[tokio::main]
-async fn main() {
- let cli = Cli::parse();
- match execute(cli).await {
- Ok(value) => {
- println!(
- "{}",
- serde_json::to_string_pretty(&value).unwrap_or_else(|_| "null".to_string())
- );
- }
- Err(err) => {
- eprintln!("{err}");
- std::process::exit(1);
- }
+fn main() {
+ let args: Vec = std::env::args().skip(1).collect();
+ if let Err(err) = openhuman_core::run_core_from_args(&args) {
+ eprintln!("{err}");
+ std::process::exit(1);
}
}
diff --git a/rust-core/src/core_server.rs b/rust-core/src/core_server.rs
index 3fd4291a57..b813c9da79 100644
--- a/rust-core/src/core_server.rs
+++ b/rust-core/src/core_server.rs
@@ -4,9 +4,15 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
+use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
+use clap::{Args, CommandFactory, Parser, Subcommand};
+use clap_complete::{generate, Shell};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
use crate::openhuman::config::Config;
use crate::openhuman::cron;
@@ -16,6 +22,7 @@ use crate::openhuman::local_ai::{
Suggestion,
};
use crate::openhuman::security::{SecretStore, SecurityPolicy};
+use crate::openhuman::tools::{ScreenshotTool, Tool};
use crate::openhuman::{
accessibility, doctor, hardware, integrations, migration, onboard, service,
};
@@ -23,9 +30,10 @@ use chrono::Utc;
pub use crate::openhuman::accessibility::{
AccessibilityStatus, AutocompleteCommitParams, AutocompleteCommitResult,
- AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureNowResult, InputActionParams,
- InputActionResult, PermissionRequestParams, PermissionStatus, SessionStatus,
- StartSessionParams, StopSessionParams, VisionFlushResult, VisionRecentResult,
+ AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureImageRefResult, CaptureNowResult,
+ InputActionParams, InputActionResult, PermissionRequestParams, PermissionState,
+ PermissionStatus, SessionStatus, StartSessionParams, StopSessionParams, VisionFlushResult,
+ VisionRecentResult,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1067,6 +1075,15 @@ async fn dispatch(
))
}
+ "openhuman.accessibility_capture_image_ref" => {
+ let result: CaptureImageRefResult =
+ accessibility::global_engine().capture_image_ref_test().await;
+ to_json_value(command_response(
+ result,
+ vec!["accessibility direct image_ref capture requested".to_string()],
+ ))
+ }
+
"openhuman.accessibility_input_action" => {
let payload: InputActionParams = parse_params(params)?;
let result = accessibility::global_engine().input_action(payload).await?;
@@ -1211,29 +1228,905 @@ pub async fn run_server(port: Option) -> Result<()> {
Ok(())
}
-pub fn run_from_cli_args(args: &[String]) -> Result<()> {
- let mut port: Option = None;
-
- let mut idx = 0;
- while idx < args.len() {
- match args[idx].as_str() {
- "serve" => {}
- "--port" => {
- let value = args
- .get(idx + 1)
- .ok_or_else(|| anyhow::anyhow!("missing value for --port"))?;
- port = Some(value.parse::()?);
- idx += 1;
- }
- _ => {}
+#[derive(Debug, Parser)]
+#[command(name = "openhuman-core")]
+#[command(about = "OpenHuman core CLI")]
+#[command(arg_required_else_help = true)]
+struct CoreCli {
+ #[command(subcommand)]
+ command: CoreCommand,
+}
+
+#[derive(Debug, Subcommand)]
+enum CoreCommand {
+ /// Run JSON-RPC server
+ #[command(alias = "serve")]
+ Run {
+ #[arg(long)]
+ port: Option,
+ },
+ /// Check core health
+ Ping,
+ /// Print core version
+ Version,
+ /// Get health snapshot
+ Health,
+ /// Get runtime flags
+ RuntimeFlags,
+ /// Get security policy info
+ SecurityPolicy,
+ /// Generic JSON-RPC style method call
+ Call {
+ #[arg(long)]
+ method: String,
+ #[arg(long, default_value = "{}")]
+ params: String,
+ },
+ /// Generate shell completion scripts
+ Completions {
+ #[arg(value_enum)]
+ shell: Shell,
+ },
+ /// Settings style commands mirroring app settings sections
+ Settings {
+ #[command(subcommand)]
+ command: SettingsCommand,
+ },
+ /// Accessibility automation commands
+ Accessibility {
+ #[command(subcommand)]
+ command: AccessibilityCommand,
+ },
+ /// Tool wrappers for local CLI testing
+ Tools {
+ #[command(subcommand)]
+ command: ToolsCommand,
+ },
+ /// Legacy config operations
+ Config {
+ #[command(subcommand)]
+ command: ConfigCommand,
+ },
+}
+
+#[derive(Debug, Subcommand)]
+enum SettingsCommand {
+ Model {
+ #[command(subcommand)]
+ command: ModelSettingsCommand,
+ },
+ Memory {
+ #[command(subcommand)]
+ command: MemorySettingsCommand,
+ },
+ Gateway {
+ #[command(subcommand)]
+ command: GatewaySettingsCommand,
+ },
+ Tunnel {
+ #[command(subcommand)]
+ command: TunnelSettingsCommand,
+ },
+ Runtime {
+ #[command(subcommand)]
+ command: RuntimeSettingsCommand,
+ },
+ Browser {
+ #[command(subcommand)]
+ command: BrowserSettingsCommand,
+ },
+}
+
+#[derive(Debug, Subcommand)]
+enum ModelSettingsCommand {
+ Get,
+ Set(ModelSetArgs),
+}
+
+#[derive(Debug, Args)]
+struct ModelSetArgs {
+ #[arg(long)]
+ api_key: Option,
+ #[arg(long)]
+ api_url: Option,
+ #[arg(long)]
+ provider: Option,
+ #[arg(long)]
+ model: Option,
+ #[arg(long)]
+ temperature: Option,
+}
+
+#[derive(Debug, Subcommand)]
+enum MemorySettingsCommand {
+ Get,
+ Set(MemorySetArgs),
+}
+
+#[derive(Debug, Args)]
+struct MemorySetArgs {
+ #[arg(long)]
+ backend: Option,
+ #[arg(long)]
+ auto_save: Option,
+ #[arg(long)]
+ embedding_provider: Option,
+ #[arg(long)]
+ embedding_model: Option,
+ #[arg(long)]
+ embedding_dimensions: Option,
+}
+
+#[derive(Debug, Subcommand)]
+enum GatewaySettingsCommand {
+ Get,
+ Set(GatewaySetArgs),
+}
+
+#[derive(Debug, Args)]
+struct GatewaySetArgs {
+ #[arg(long)]
+ host: Option,
+ #[arg(long)]
+ port: Option,
+ #[arg(long)]
+ require_pairing: Option,
+ #[arg(long)]
+ allow_public_bind: Option,
+}
+
+#[derive(Debug, Subcommand)]
+enum TunnelSettingsCommand {
+ Get,
+ /// Replace tunnel settings with full JSON payload
+ Set(TunnelSetArgs),
+}
+
+#[derive(Debug, Args)]
+struct TunnelSetArgs {
+ #[arg(long)]
+ json: String,
+}
+
+#[derive(Debug, Subcommand)]
+enum RuntimeSettingsCommand {
+ Get,
+ Set(RuntimeSetArgs),
+}
+
+#[derive(Debug, Args)]
+struct RuntimeSetArgs {
+ #[arg(long)]
+ kind: Option,
+ #[arg(long)]
+ reasoning_enabled: Option,
+}
+
+#[derive(Debug, Subcommand)]
+enum BrowserSettingsCommand {
+ Get,
+ Set(BrowserSetArgs),
+}
+
+#[derive(Debug, Subcommand)]
+enum AccessibilityCommand {
+ /// Read current accessibility automation status
+ Status,
+ /// Diagnose accessibility permission readiness with actionable fixes
+ Doctor,
+ /// Request all accessibility-related permissions
+ RequestPermissions,
+ /// Request a specific permission kind
+ RequestPermission(RequestPermissionArgs),
+ /// Start a bounded accessibility session
+ StartSession(StartSessionCliArgs),
+ /// Stop the active accessibility session
+ StopSession(StopSessionCliArgs),
+ /// Force an immediate capture sample
+ CaptureNow,
+ /// Directly trigger capture_screen_image_ref (no active session required)
+ CaptureImageRef,
+ /// Fetch recent vision summaries
+ VisionRecent(VisionRecentCliArgs),
+ /// Flush immediate vision summary from latest frame
+ VisionFlush,
+}
+
+#[derive(Debug, Args)]
+struct RequestPermissionArgs {
+ /// One of: screen_recording, accessibility, input_monitoring
+ #[arg(long)]
+ permission: String,
+}
+
+#[derive(Debug, Args)]
+struct StartSessionCliArgs {
+ /// Explicit consent required to start
+ #[arg(long, default_value_t = false)]
+ consent: bool,
+ /// Optional session TTL in seconds (bounded server-side)
+ #[arg(long)]
+ ttl_secs: Option,
+ /// Optional override for screen monitoring
+ #[arg(long)]
+ screen_monitoring: Option,
+ /// Optional override for device control
+ #[arg(long)]
+ device_control: Option,
+ /// Optional override for predictive input
+ #[arg(long)]
+ predictive_input: Option,
+}
+
+#[derive(Debug, Args)]
+struct StopSessionCliArgs {
+ #[arg(long)]
+ reason: Option,
+}
+
+#[derive(Debug, Args)]
+struct VisionRecentCliArgs {
+ #[arg(long)]
+ limit: Option,
+}
+
+#[derive(Debug, Args)]
+struct BrowserSetArgs {
+ #[arg(long)]
+ enabled: Option,
+}
+
+#[derive(Debug, Subcommand)]
+enum ConfigCommand {
+ /// Get full config snapshot
+ Get,
+ /// Update model settings with a JSON object
+ UpdateModel {
+ #[arg(long)]
+ json: String,
+ },
+ /// Update memory settings with a JSON object
+ UpdateMemory {
+ #[arg(long)]
+ json: String,
+ },
+ /// Update gateway settings with a JSON object
+ UpdateGateway {
+ #[arg(long)]
+ json: String,
+ },
+ /// Update runtime settings with a JSON object
+ UpdateRuntime {
+ #[arg(long)]
+ json: String,
+ },
+ /// Update browser settings with a JSON object
+ UpdateBrowser {
+ #[arg(long)]
+ json: String,
+ },
+ /// Replace tunnel settings with a JSON object
+ UpdateTunnel {
+ #[arg(long)]
+ json: String,
+ },
+}
+
+#[derive(Debug, Subcommand)]
+enum ToolsCommand {
+ /// List tool wrappers exposed by this CLI
+ List,
+ /// Capture a screenshot using the screenshot tool
+ Screenshot(ToolsScreenshotArgs),
+ /// Capture image ref directly from accessibility engine
+ ScreenshotRef(ToolsScreenshotRefArgs),
+ /// Generic wrapper for available tool commands
+ Run(ToolsRunArgs),
+}
+
+#[derive(Debug, Args)]
+struct ToolsScreenshotArgs {
+ /// Optional filename saved under workspace
+ #[arg(long)]
+ filename: Option,
+ /// Optional region for macOS: selection | window
+ #[arg(long)]
+ region: Option,
+ /// Optional output file path (copies or writes PNG to this path)
+ #[arg(long)]
+ output: Option,
+ /// Include full data URL in JSON output
+ #[arg(long, default_value_t = false)]
+ print_data_url: bool,
+}
+
+#[derive(Debug, Args)]
+struct ToolsScreenshotRefArgs {
+ /// Optional output file path (writes PNG to this path)
+ #[arg(long)]
+ output: Option,
+ /// Include full data URL in JSON output
+ #[arg(long, default_value_t = false)]
+ print_data_url: bool,
+}
+
+#[derive(Debug, Args)]
+struct ToolsRunArgs {
+ /// Tool wrapper name: screenshot | screenshot-ref
+ #[arg(long)]
+ name: String,
+ /// JSON arguments payload for selected wrapper
+ #[arg(long, default_value = "{}")]
+ args: String,
+}
+
+fn parse_json_arg(raw: &str) -> Result {
+ serde_json::from_str(raw).map_err(|e| format!("invalid JSON for --json/--params: {e}"))
+}
+
+fn ensure_non_empty_payload(payload: &serde_json::Map) -> Result<()> {
+ if payload.is_empty() {
+ return Err(anyhow::anyhow!("no fields provided for set operation"));
+ }
+ Ok(())
+}
+
+fn extract_data_url(raw: &str) -> Option {
+ raw.lines()
+ .find_map(|line| {
+ let trimmed = line.trim();
+ trimmed
+ .starts_with("data:image/")
+ .then(|| trimmed.to_string())
+ })
+}
+
+fn extract_saved_path(raw: &str) -> Option {
+ const PREFIX: &str = "Screenshot saved to: ";
+ raw.lines()
+ .find_map(|line| line.strip_prefix(PREFIX).map(PathBuf::from))
+}
+
+fn decode_data_url_bytes(data_url: &str) -> Result, String> {
+ let (meta, payload) = data_url
+ .split_once(',')
+ .ok_or_else(|| "invalid data URL: missing comma separator".to_string())?;
+ if !meta.starts_with("data:image/") || !meta.ends_with(";base64") {
+ return Err("invalid data URL: expected data:image/*;base64,...".to_string());
+ }
+ BASE64_STANDARD
+ .decode(payload)
+ .map_err(|e| format!("failed to decode base64 image payload: {e}"))
+}
+
+fn write_bytes_to_path(path: &Path, bytes: &[u8]) -> Result<(), String> {
+ if let Some(parent) = path.parent() {
+ if !parent.as_os_str().is_empty() {
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("failed to create output directory: {e}"))?;
+ }
+ }
+ std::fs::write(path, bytes).map_err(|e| format!("failed to write output file: {e}"))
+}
+
+async fn execute_tools_screenshot(args: ToolsScreenshotArgs) -> Result {
+ let config = load_openhuman_config().await?;
+ let security = Arc::new(SecurityPolicy::from_config(
+ &config.autonomy,
+ &config.workspace_dir,
+ ));
+ let tool = ScreenshotTool::new(security);
+
+ let mut payload = serde_json::Map::new();
+ if let Some(filename) = args.filename {
+ payload.insert("filename".to_string(), json!(filename));
+ }
+ if let Some(region) = args.region {
+ payload.insert("region".to_string(), json!(region));
+ }
+
+ let tool_result = tool
+ .execute(serde_json::Value::Object(payload))
+ .await
+ .map_err(|e| format!("screenshot tool failed to execute: {e}"))?;
+
+ let mut logs = vec!["tools.screenshot executed".to_string()];
+
+ if let Some(output_path) = args.output.as_ref() {
+ if let Some(saved_path) = extract_saved_path(&tool_result.output) {
+ std::fs::copy(&saved_path, output_path).map_err(|e| {
+ format!(
+ "failed to copy screenshot from {} to {}: {e}",
+ saved_path.display(),
+ output_path.display()
+ )
+ })?;
+ logs.push(format!("copied screenshot to {}", output_path.display()));
+ } else if let Some(data_url) = extract_data_url(&tool_result.output) {
+ let bytes = decode_data_url_bytes(&data_url)?;
+ write_bytes_to_path(output_path, &bytes)?;
+ logs.push(format!(
+ "decoded data URL and wrote {} bytes to {}",
+ bytes.len(),
+ output_path.display()
+ ));
+ } else {
+ return Err(
+ "screenshot tool response did not contain a saved path or image data URL"
+ .to_string(),
+ );
+ }
+ }
+
+ let data_url = extract_data_url(&tool_result.output);
+ let response = json!({
+ "result": {
+ "success": tool_result.success,
+ "error": tool_result.error,
+ "output_path": args.output.as_ref().map(|p| p.display().to_string()),
+ "tool_output": tool_result.output,
+ "data_url": if args.print_data_url { data_url } else { None:: },
+ },
+ "logs": logs
+ });
+
+ Ok(response)
+}
+
+async fn execute_tools_screenshot_ref(
+ args: ToolsScreenshotRefArgs,
+) -> Result {
+ let raw = call_method("openhuman.accessibility_capture_image_ref", json!({})).await?;
+ let payload: CommandResponse = serde_json::from_value(raw)
+ .map_err(|e| format!("failed to decode accessibility capture_image_ref response: {e}"))?;
+
+ let mut logs = payload.logs;
+ logs.push("tools.screenshot-ref executed".to_string());
+
+ if let Some(output_path) = args.output.as_ref() {
+ if let Some(data_url) = payload.result.image_ref.as_deref() {
+ let bytes = decode_data_url_bytes(data_url)?;
+ write_bytes_to_path(output_path, &bytes)?;
+ logs.push(format!(
+ "decoded image_ref and wrote {} bytes to {}",
+ bytes.len(),
+ output_path.display()
+ ));
+ } else {
+ return Err("accessibility capture_image_ref did not return image_ref".to_string());
}
- idx += 1;
}
+ Ok(json!({
+ "result": {
+ "ok": payload.result.ok,
+ "mime_type": payload.result.mime_type,
+ "bytes_estimate": payload.result.bytes_estimate,
+ "message": payload.result.message,
+ "output_path": args.output.as_ref().map(|p| p.display().to_string()),
+ "image_ref": if args.print_data_url { payload.result.image_ref } else { None:: },
+ },
+ "logs": logs
+ }))
+}
+
+async fn get_config_snapshot() -> Result, String> {
+ let value = call_method("openhuman.get_config", json!({})).await?;
+ serde_json::from_value::>(value)
+ .map_err(|e| format!("failed to decode config snapshot: {e}"))
+}
+
+fn settings_view_response(
+ section: &'static str,
+ snapshot: CommandResponse,
+) -> CommandResponse {
+ let cfg = &snapshot.result.config;
+ let settings = match section {
+ "model" => json!({
+ "api_key": cfg.get("api_key"),
+ "api_url": cfg.get("api_url"),
+ "default_provider": cfg.get("default_provider"),
+ "default_model": cfg.get("default_model"),
+ "default_temperature": cfg.get("default_temperature"),
+ }),
+ "memory" => cfg.get("memory").cloned().unwrap_or(serde_json::Value::Null),
+ "gateway" => cfg.get("gateway").cloned().unwrap_or(serde_json::Value::Null),
+ "tunnel" => cfg.get("tunnel").cloned().unwrap_or(serde_json::Value::Null),
+ "runtime" => cfg.get("runtime").cloned().unwrap_or(serde_json::Value::Null),
+ "browser" => cfg.get("browser").cloned().unwrap_or(serde_json::Value::Null),
+ _ => serde_json::Value::Null,
+ };
+
+ command_response(
+ json!({
+ "section": section,
+ "settings": settings,
+ "workspace_dir": snapshot.result.workspace_dir,
+ "config_path": snapshot.result.config_path,
+ }),
+ snapshot.logs,
+ )
+}
+
+async fn execute_core_cli(cli: CoreCli) -> Result {
+ match cli.command {
+ CoreCommand::Run { port } => run_server(port)
+ .await
+ .map(|_| serde_json::Value::Null)
+ .map_err(|e| format!("run failed: {e}")),
+ CoreCommand::Ping => call_method("core.ping", json!({})).await,
+ CoreCommand::Version => call_method("core.version", json!({})).await,
+ CoreCommand::Health => call_method("openhuman.health_snapshot", json!({})).await,
+ CoreCommand::RuntimeFlags => call_method("openhuman.get_runtime_flags", json!({})).await,
+ CoreCommand::SecurityPolicy => {
+ call_method("openhuman.security_policy_info", json!({})).await
+ }
+ CoreCommand::Call { method, params } => call_method(&method, parse_json_arg(¶ms)?).await,
+ CoreCommand::Completions { shell } => {
+ let mut cmd = CoreCli::command();
+ let bin_name = cmd.get_name().to_string();
+ generate(shell, &mut cmd, bin_name, &mut io::stdout());
+ Ok(serde_json::Value::Null)
+ }
+ CoreCommand::Settings { command } => match command {
+ SettingsCommand::Model { command } => match command {
+ ModelSettingsCommand::Get => {
+ let snapshot = get_config_snapshot().await?;
+ serde_json::to_value(settings_view_response("model", snapshot))
+ .map_err(|e| e.to_string())
+ }
+ ModelSettingsCommand::Set(args) => {
+ let mut payload = serde_json::Map::new();
+ if let Some(v) = args.api_key {
+ payload.insert("api_key".to_string(), json!(v));
+ }
+ if let Some(v) = args.api_url {
+ payload.insert("api_url".to_string(), json!(v));
+ }
+ if let Some(v) = args.provider {
+ payload.insert("default_provider".to_string(), json!(v));
+ }
+ if let Some(v) = args.model {
+ payload.insert("default_model".to_string(), json!(v));
+ }
+ if let Some(v) = args.temperature {
+ payload.insert("default_temperature".to_string(), json!(v));
+ }
+ ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
+ call_method("openhuman.update_model_settings", serde_json::Value::Object(payload))
+ .await
+ }
+ },
+ SettingsCommand::Memory { command } => match command {
+ MemorySettingsCommand::Get => {
+ let snapshot = get_config_snapshot().await?;
+ serde_json::to_value(settings_view_response("memory", snapshot))
+ .map_err(|e| e.to_string())
+ }
+ MemorySettingsCommand::Set(args) => {
+ let mut payload = serde_json::Map::new();
+ if let Some(v) = args.backend {
+ payload.insert("backend".to_string(), json!(v));
+ }
+ if let Some(v) = args.auto_save {
+ payload.insert("auto_save".to_string(), json!(v));
+ }
+ if let Some(v) = args.embedding_provider {
+ payload.insert("embedding_provider".to_string(), json!(v));
+ }
+ if let Some(v) = args.embedding_model {
+ payload.insert("embedding_model".to_string(), json!(v));
+ }
+ if let Some(v) = args.embedding_dimensions {
+ payload.insert("embedding_dimensions".to_string(), json!(v));
+ }
+ ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
+ call_method(
+ "openhuman.update_memory_settings",
+ serde_json::Value::Object(payload),
+ )
+ .await
+ }
+ },
+ SettingsCommand::Gateway { command } => match command {
+ GatewaySettingsCommand::Get => {
+ let snapshot = get_config_snapshot().await?;
+ serde_json::to_value(settings_view_response("gateway", snapshot))
+ .map_err(|e| e.to_string())
+ }
+ GatewaySettingsCommand::Set(args) => {
+ let mut payload = serde_json::Map::new();
+ if let Some(v) = args.host {
+ payload.insert("host".to_string(), json!(v));
+ }
+ if let Some(v) = args.port {
+ payload.insert("port".to_string(), json!(v));
+ }
+ if let Some(v) = args.require_pairing {
+ payload.insert("require_pairing".to_string(), json!(v));
+ }
+ if let Some(v) = args.allow_public_bind {
+ payload.insert("allow_public_bind".to_string(), json!(v));
+ }
+ ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
+ call_method(
+ "openhuman.update_gateway_settings",
+ serde_json::Value::Object(payload),
+ )
+ .await
+ }
+ },
+ SettingsCommand::Tunnel { command } => match command {
+ TunnelSettingsCommand::Get => {
+ let snapshot = get_config_snapshot().await?;
+ serde_json::to_value(settings_view_response("tunnel", snapshot))
+ .map_err(|e| e.to_string())
+ }
+ TunnelSettingsCommand::Set(args) => {
+ call_method("openhuman.update_tunnel_settings", parse_json_arg(&args.json)?).await
+ }
+ },
+ SettingsCommand::Runtime { command } => match command {
+ RuntimeSettingsCommand::Get => {
+ let snapshot = get_config_snapshot().await?;
+ serde_json::to_value(settings_view_response("runtime", snapshot))
+ .map_err(|e| e.to_string())
+ }
+ RuntimeSettingsCommand::Set(args) => {
+ let mut payload = serde_json::Map::new();
+ if let Some(v) = args.kind {
+ payload.insert("kind".to_string(), json!(v));
+ }
+ if let Some(v) = args.reasoning_enabled {
+ payload.insert("reasoning_enabled".to_string(), json!(v));
+ }
+ ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
+ call_method(
+ "openhuman.update_runtime_settings",
+ serde_json::Value::Object(payload),
+ )
+ .await
+ }
+ },
+ SettingsCommand::Browser { command } => match command {
+ BrowserSettingsCommand::Get => {
+ let snapshot = get_config_snapshot().await?;
+ serde_json::to_value(settings_view_response("browser", snapshot))
+ .map_err(|e| e.to_string())
+ }
+ BrowserSettingsCommand::Set(args) => {
+ let mut payload = serde_json::Map::new();
+ if let Some(v) = args.enabled {
+ payload.insert("enabled".to_string(), json!(v));
+ }
+ ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
+ call_method(
+ "openhuman.update_browser_settings",
+ serde_json::Value::Object(payload),
+ )
+ .await
+ }
+ },
+ },
+ CoreCommand::Accessibility { command } => match command {
+ AccessibilityCommand::Status => {
+ call_method("openhuman.accessibility_status", json!({})).await
+ }
+ AccessibilityCommand::Doctor => {
+ let raw = call_method("openhuman.accessibility_status", json!({})).await?;
+ let payload: CommandResponse = serde_json::from_value(raw)
+ .map_err(|e| format!("failed to decode accessibility status: {e}"))?;
+ let permissions = &payload.result.permissions;
+
+ let screen_ready = permissions.screen_recording == PermissionState::Granted;
+ let control_ready = permissions.accessibility == PermissionState::Granted;
+ let monitoring_ready = permissions.input_monitoring == PermissionState::Granted;
+ let overall_ready = payload.result.platform_supported && screen_ready && control_ready;
+
+ let mut recommendations: Vec = Vec::new();
+ if !payload.result.platform_supported {
+ recommendations.push(
+ "Accessibility automation is macOS-only in this build/runtime."
+ .to_string(),
+ );
+ }
+ if permissions.screen_recording != PermissionState::Granted {
+ recommendations.push(
+ "Grant Screen Recording in System Settings -> Privacy & Security -> Screen Recording."
+ .to_string(),
+ );
+ }
+ if permissions.accessibility != PermissionState::Granted {
+ recommendations.push(
+ "Grant Accessibility in System Settings -> Privacy & Security -> Accessibility."
+ .to_string(),
+ );
+ }
+ if permissions.input_monitoring != PermissionState::Granted {
+ recommendations.push(
+ "Grant Input Monitoring in System Settings -> Privacy & Security -> Input Monitoring (optional but recommended)."
+ .to_string(),
+ );
+ }
+ if recommendations.is_empty() {
+ recommendations.push("No action required. Accessibility automation is ready.".to_string());
+ }
+
+ Ok(json!({
+ "result": {
+ "summary": {
+ "overall_ready": overall_ready,
+ "platform_supported": payload.result.platform_supported,
+ "session_active": payload.result.session.active,
+ "screen_capture_ready": screen_ready,
+ "device_control_ready": control_ready,
+ "input_monitoring_ready": monitoring_ready
+ },
+ "permissions": permissions,
+ "features": payload.result.features,
+ "recommendations": recommendations
+ },
+ "logs": payload.logs
+ }))
+ }
+ AccessibilityCommand::RequestPermissions => {
+ call_method("openhuman.accessibility_request_permissions", json!({})).await
+ }
+ AccessibilityCommand::RequestPermission(args) => {
+ call_method(
+ "openhuman.accessibility_request_permission",
+ json!({ "permission": args.permission }),
+ )
+ .await
+ }
+ AccessibilityCommand::StartSession(args) => {
+ call_method(
+ "openhuman.accessibility_start_session",
+ json!({
+ "consent": args.consent,
+ "ttl_secs": args.ttl_secs,
+ "screen_monitoring": args.screen_monitoring,
+ "device_control": args.device_control,
+ "predictive_input": args.predictive_input,
+ }),
+ )
+ .await
+ }
+ AccessibilityCommand::StopSession(args) => {
+ call_method(
+ "openhuman.accessibility_stop_session",
+ json!({ "reason": args.reason }),
+ )
+ .await
+ }
+ AccessibilityCommand::CaptureNow => {
+ call_method("openhuman.accessibility_capture_now", json!({})).await
+ }
+ AccessibilityCommand::CaptureImageRef => {
+ call_method("openhuman.accessibility_capture_image_ref", json!({})).await
+ }
+ AccessibilityCommand::VisionRecent(args) => {
+ call_method(
+ "openhuman.accessibility_vision_recent",
+ json!({ "limit": args.limit }),
+ )
+ .await
+ }
+ AccessibilityCommand::VisionFlush => {
+ call_method("openhuman.accessibility_vision_flush", json!({})).await
+ }
+ },
+ CoreCommand::Tools { command } => match command {
+ ToolsCommand::List => Ok(json!({
+ "result": {
+ "wrappers": [
+ {
+ "name": "screenshot",
+ "description": "Capture a screenshot with screenshot tool wrapper."
+ },
+ {
+ "name": "screenshot-ref",
+ "description": "Capture data URL from accessibility capture_image_ref."
+ }
+ ]
+ },
+ "logs": ["tools wrappers listed"]
+ })),
+ ToolsCommand::Screenshot(args) => execute_tools_screenshot(args).await,
+ ToolsCommand::ScreenshotRef(args) => execute_tools_screenshot_ref(args).await,
+ ToolsCommand::Run(args) => {
+ let parsed = parse_json_arg(&args.args)?;
+ match args.name.as_str() {
+ "screenshot" => {
+ let payload = parsed.as_object().cloned().unwrap_or_default();
+ let wrapped = ToolsScreenshotArgs {
+ filename: payload
+ .get("filename")
+ .and_then(serde_json::Value::as_str)
+ .map(str::to_string),
+ region: payload
+ .get("region")
+ .and_then(serde_json::Value::as_str)
+ .map(str::to_string),
+ output: payload
+ .get("output")
+ .and_then(serde_json::Value::as_str)
+ .map(PathBuf::from),
+ print_data_url: payload
+ .get("print_data_url")
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false),
+ };
+ execute_tools_screenshot(wrapped).await
+ }
+ "screenshot-ref" | "screenshot_ref" => {
+ let payload = parsed.as_object().cloned().unwrap_or_default();
+ let wrapped = ToolsScreenshotRefArgs {
+ output: payload
+ .get("output")
+ .and_then(serde_json::Value::as_str)
+ .map(PathBuf::from),
+ print_data_url: payload
+ .get("print_data_url")
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false),
+ };
+ execute_tools_screenshot_ref(wrapped).await
+ }
+ other => Err(format!(
+ "unsupported tool wrapper '{other}'. available: screenshot, screenshot-ref"
+ )),
+ }
+ }
+ },
+ CoreCommand::Config { command } => match command {
+ ConfigCommand::Get => call_method("openhuman.get_config", json!({})).await,
+ ConfigCommand::UpdateModel { json } => {
+ call_method("openhuman.update_model_settings", parse_json_arg(&json)?).await
+ }
+ ConfigCommand::UpdateMemory { json } => {
+ call_method("openhuman.update_memory_settings", parse_json_arg(&json)?).await
+ }
+ ConfigCommand::UpdateGateway { json } => {
+ call_method("openhuman.update_gateway_settings", parse_json_arg(&json)?).await
+ }
+ ConfigCommand::UpdateRuntime { json } => {
+ call_method("openhuman.update_runtime_settings", parse_json_arg(&json)?).await
+ }
+ ConfigCommand::UpdateBrowser { json } => {
+ call_method("openhuman.update_browser_settings", parse_json_arg(&json)?).await
+ }
+ ConfigCommand::UpdateTunnel { json } => {
+ call_method("openhuman.update_tunnel_settings", parse_json_arg(&json)?).await
+ }
+ },
+ }
+}
+
+pub fn run_from_cli_args(args: &[String]) -> Result<()> {
+ let mut argv = Vec::with_capacity(args.len() + 1);
+ argv.push("openhuman-core".to_string());
+ argv.extend(args.iter().cloned());
+ let cli =
+ CoreCli::try_parse_from(argv).map_err(|e| anyhow::anyhow!(e.render().to_string()))?;
+
+ let thread_stack_size = std::env::var("OPENHUMAN_CORE_THREAD_STACK_SIZE")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .unwrap_or(8 * 1024 * 1024);
+
let runtime = tokio::runtime::Builder::new_multi_thread()
+ .thread_stack_size(thread_stack_size)
.enable_all()
.build()?;
- runtime.block_on(run_server(port))
+ let output = runtime.block_on(execute_core_cli(cli)).map_err(anyhow::Error::msg)?;
+ if !output.is_null() {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&output).unwrap_or_else(|_| "null".to_string())
+ );
+ }
+ Ok(())
}
#[cfg(test)]
diff --git a/rust-core/src/openhuman/accessibility/mod.rs b/rust-core/src/openhuman/accessibility/mod.rs
index 001c51997a..32fe03cc33 100644
--- a/rust-core/src/openhuman/accessibility/mod.rs
+++ b/rust-core/src/openhuman/accessibility/mod.rs
@@ -122,6 +122,15 @@ pub struct CaptureNowResult {
pub frame: Option,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CaptureImageRefResult {
+ pub ok: bool,
+ pub image_ref: Option,
+ pub mime_type: String,
+ pub bytes_estimate: Option,
+ pub message: String,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisionSummary {
pub id: String,
@@ -535,6 +544,30 @@ impl AccessibilityEngine {
})
}
+ pub async fn capture_image_ref_test(&self) -> CaptureImageRefResult {
+ match capture_screen_image_ref() {
+ Ok(image_ref) => {
+ let bytes_estimate = image_ref
+ .strip_prefix("data:image/png;base64,")
+ .map(|payload| payload.len() * 3 / 4);
+ CaptureImageRefResult {
+ ok: true,
+ image_ref: Some(image_ref),
+ mime_type: "image/png".to_string(),
+ bytes_estimate,
+ message: "screen capture completed".to_string(),
+ }
+ }
+ Err(err) => CaptureImageRefResult {
+ ok: false,
+ image_ref: None,
+ mime_type: "image/png".to_string(),
+ bytes_estimate: None,
+ message: err,
+ },
+ }
+ }
+
pub async fn input_action(
&self,
action: InputActionParams,
diff --git a/rust-core/src/openhuman/config/schema/local_ai.rs b/rust-core/src/openhuman/config/schema/local_ai.rs
index 0fcb850626..b24116ef1c 100644
--- a/rust-core/src/openhuman/config/schema/local_ai.rs
+++ b/rust-core/src/openhuman/config/schema/local_ai.rs
@@ -60,15 +60,15 @@ fn default_provider() -> String {
}
fn default_model_id() -> String {
- "qwen2.5:1.5b".to_string()
+ "gemma3:4b-it-qat".to_string()
}
fn default_chat_model_id() -> String {
- "qwen2.5:1.5b".to_string()
+ "gemma3:4b-it-qat".to_string()
}
fn default_vision_model_id() -> String {
- "qwen3-vl:2b".to_string()
+ "gemma3:4b-it-qat".to_string()
}
fn default_embedding_model_id() -> String {
diff --git a/rust-core/src/openhuman/service/mod.rs b/rust-core/src/openhuman/service/mod.rs
index 9beecefd37..c7e48fde68 100644
--- a/rust-core/src/openhuman/service/mod.rs
+++ b/rust-core/src/openhuman/service/mod.rs
@@ -7,19 +7,79 @@ use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
-const SERVICE_LABEL: &str = "com.openhuman.daemon";
-const LEGACY_SERVICE_LABEL: &str = "com.openhuman.app";
-const WINDOWS_TASK_NAME: &str = "OpenHuman Daemon";
+const SERVICE_LABEL: &str = "com.openhuman.core";
+const LEGACY_SERVICE_LABEL: &str = "com.openhuman.daemon";
+const LEGACY_APP_LABEL: &str = "com.openhuman.app";
+const WINDOWS_TASK_NAME: &str = "OpenHuman Core";
fn windows_task_name() -> &'static str {
WINDOWS_TASK_NAME
}
+fn resolve_daemon_executable() -> Result {
+ if let Ok(path) = std::env::var("OPENHUMAN_CORE_BIN") {
+ let candidate = PathBuf::from(path);
+ if candidate.exists() {
+ return Ok(candidate);
+ }
+ }
+
+ let exe = std::env::current_exe().context("Failed to resolve current executable")?;
+ let exe_dir = exe
+ .parent()
+ .map(PathBuf::from)
+ .ok_or_else(|| anyhow::anyhow!("Failed to resolve executable directory"))?;
+
+ #[cfg(target_os = "macos")]
+ let mut search_dirs = vec![
+ exe_dir.clone(),
+ exe_dir
+ .parent()
+ .map(|p| p.join("Resources"))
+ .unwrap_or_else(|| exe_dir.clone()),
+ ];
+ #[cfg(not(target_os = "macos"))]
+ let search_dirs = vec![exe_dir.clone()];
+
+ for dir in search_dirs.drain(..) {
+ let Ok(entries) = fs::read_dir(&dir) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if !path.is_file() || is_current_executable(&path) {
+ continue;
+ }
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
+ continue;
+ };
+
+ #[cfg(windows)]
+ let matches = name.starts_with("openhuman-")
+ || name.starts_with("openhuman-core-")
+ || name.eq_ignore_ascii_case("openhuman.exe")
+ || name.eq_ignore_ascii_case("openhuman-core.exe");
+ #[cfg(not(windows))]
+ let matches = name.starts_with("openhuman-")
+ || name.starts_with("openhuman-core-")
+ || name == "openhuman"
+ || name == "openhuman-core";
+
+ if matches {
+ return Ok(path);
+ }
+ }
+ }
+
+ Ok(exe)
+}
+
fn daemon_program_args(exe: &std::path::Path) -> Vec {
let raw_file_name = exe.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let file_name = raw_file_name.to_ascii_lowercase();
let standalone_core_binary = !is_current_executable(exe)
&& (file_name.contains("openhuman-core")
+ || file_name.starts_with("openhuman-")
|| file_name.starts_with("openhuman-core-")
|| raw_file_name == "openhuman"
|| raw_file_name == "openhuman.exe");
@@ -95,9 +155,25 @@ pub fn start(config: &Config) -> Result {
let domain = macos_gui_domain()?;
let primary_target = macos_target(SERVICE_LABEL)?;
+ if !plist.exists() {
+ log::info!(
+ "[service] LaunchAgent plist missing, installing it before start: {}",
+ plist.display()
+ );
+ install_macos(config)?;
+ }
+
+ validate_macos_plist(&plist)?;
+
// Prefer modern launchctl lifecycle commands on macOS.
if !is_service_loaded_macos()? {
log::info!("[service] Loading macOS LaunchAgent service");
+ run_best_effort(
+ Command::new("launchctl")
+ .arg("bootout")
+ .arg(&domain)
+ .arg(&primary_target),
+ );
let bootstrap_ok = run_checked(
Command::new("launchctl")
.arg("bootstrap")
@@ -205,6 +281,8 @@ pub fn stop(config: &Config) -> Result {
let legacy_plist = macos_service_file_for(LEGACY_SERVICE_LABEL)?;
let legacy_target = macos_target(LEGACY_SERVICE_LABEL)?;
+ let legacy_app_plist = macos_service_file_for(LEGACY_APP_LABEL)?;
+ let legacy_app_target = macos_target(LEGACY_APP_LABEL)?;
// Modern lifecycle path first.
run_best_effort(
@@ -231,6 +309,18 @@ pub fn stop(config: &Config) -> Result {
.arg(&domain)
.arg(&legacy_plist),
);
+ run_best_effort(
+ Command::new("launchctl")
+ .arg("bootout")
+ .arg(&domain)
+ .arg(&legacy_app_target),
+ );
+ run_best_effort(
+ Command::new("launchctl")
+ .arg("bootout")
+ .arg(&domain)
+ .arg(&legacy_app_plist),
+ );
// Compatibility fallback.
run_best_effort(Command::new("launchctl").arg("stop").arg(SERVICE_LABEL));
@@ -239,6 +329,7 @@ pub fn stop(config: &Config) -> Result {
.arg("stop")
.arg(LEGACY_SERVICE_LABEL),
);
+ run_best_effort(Command::new("launchctl").arg("stop").arg(LEGACY_APP_LABEL));
return status(config);
}
@@ -261,9 +352,11 @@ pub fn stop(config: &Config) -> Result {
pub fn status(config: &Config) -> Result {
if std::env::consts::OS == "macos" {
let out = run_capture(Command::new("launchctl").arg("list"))?;
- let running = out
- .lines()
- .any(|line| line.contains(SERVICE_LABEL) || line.contains(LEGACY_SERVICE_LABEL));
+ let running = out.lines().any(|line| {
+ line.contains(SERVICE_LABEL)
+ || line.contains(LEGACY_SERVICE_LABEL)
+ || line.contains(LEGACY_APP_LABEL)
+ });
return Ok(ServiceStatus {
state: if running {
ServiceState::Running
@@ -342,6 +435,10 @@ pub fn uninstall(config: &Config) -> Result {
if legacy_file.exists() {
let _ = fs::remove_file(&legacy_file);
}
+ let legacy_app_file = macos_service_file_for(LEGACY_APP_LABEL)?;
+ if legacy_app_file.exists() {
+ let _ = fs::remove_file(&legacy_app_file);
+ }
return Ok(ServiceStatus {
state: ServiceState::NotInstalled,
unit_path: Some(file),
@@ -395,7 +492,7 @@ fn install_macos(config: &Config) -> Result<()> {
fs::create_dir_all(parent)?;
}
- let exe = std::env::current_exe().context("Failed to resolve current executable")?;
+ let exe = resolve_daemon_executable()?;
let logs_dir = config
.config_path
.parent()
@@ -412,9 +509,9 @@ fn install_macos(config: &Config) -> Result<()> {
.collect::();
let plist = format!(
- r#"
-
-
+ r#"
+
+
Label
{label}
@@ -465,7 +562,7 @@ fn install_linux(config: &Config) -> Result<()> {
fs::create_dir_all(parent)?;
}
- let exe = std::env::current_exe().context("Failed to resolve current executable")?;
+ let exe = resolve_daemon_executable()?;
let logs_dir = config
.config_path
.parent()
@@ -490,7 +587,7 @@ fn install_linux(config: &Config) -> Result<()> {
}
fn install_windows(config: &Config) -> Result<()> {
- let exe = std::env::current_exe().context("Failed to resolve current executable")?;
+ let exe = resolve_daemon_executable()?;
let logs_dir = config
.config_path
.parent()
@@ -555,6 +652,11 @@ fn macos_target(label: &str) -> Result {
Ok(format!("{}/{}", macos_gui_domain()?, label))
}
+fn validate_macos_plist(path: &std::path::Path) -> Result<()> {
+ run_checked(Command::new("plutil").arg("-lint").arg(path))
+ .with_context(|| format!("Invalid launch agent plist: {}", path.display()))
+}
+
fn linux_service_file(config: &Config) -> Result {
let config_dir = config
.config_path
@@ -598,24 +700,35 @@ fn run_best_effort(cmd: &mut Command) {
}
}
+fn run_check_silent(cmd: &mut Command) -> bool {
+ cmd.stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .map(|status| status.success())
+ .unwrap_or(false)
+}
+
/// Check if the macOS LaunchAgent service is loaded (regardless of running state)
fn is_service_loaded_macos() -> Result {
- if run_checked(
+ if run_check_silent(
Command::new("launchctl")
.arg("print")
.arg(macos_target(SERVICE_LABEL)?),
- )
- .is_ok()
- {
+ ) {
return Ok(true);
}
- if run_checked(
+ if run_check_silent(
Command::new("launchctl")
.arg("print")
.arg(macos_target(LEGACY_SERVICE_LABEL)?),
- )
- .is_ok()
- {
+ ) {
+ return Ok(true);
+ }
+ if run_check_silent(
+ Command::new("launchctl")
+ .arg("print")
+ .arg(macos_target(LEGACY_APP_LABEL)?),
+ ) {
return Ok(true);
}
Ok(false)
diff --git a/src-tauri/src/commands/openhuman.rs b/src-tauri/src/commands/openhuman.rs
index 9cf5f40d20..19a3a41cea 100644
--- a/src-tauri/src/commands/openhuman.rs
+++ b/src-tauri/src/commands/openhuman.rs
@@ -76,6 +76,32 @@ async fn call_core(
crate::core_rpc::call(method, params).await
}
+async fn ensure_service_managed_core_running() -> Result<(), String> {
+ let config = load_config_local().await?;
+ let _ = service::install(&config);
+ let _ = service::start(&config);
+
+ for _ in 0..40 {
+ if crate::core_rpc::ping().await {
+ return Ok(());
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(250)).await;
+ }
+
+ Err(
+ "OpenHuman Core daemon did not become ready. Confirm the background service is running."
+ .to_string(),
+ )
+}
+
+async fn call_core_service_managed(
+ method: &str,
+ params: serde_json::Value,
+) -> Result {
+ ensure_service_managed_core_running().await?;
+ crate::core_rpc::call(method, params).await
+}
+
async fn load_config_local() -> Result {
let timeout_duration = std::time::Duration::from_secs(30);
match tokio::time::timeout(
@@ -96,6 +122,12 @@ pub struct AgentServerStatus {
pub url: String,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DaemonHostConfigPayload {
+ pub show_tray: bool,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalAiStatus {
pub state: String,
@@ -438,7 +470,8 @@ pub async fn openhuman_agent_chat(
pub async fn openhuman_accessibility_status(
app: tauri::AppHandle,
) -> Result, String> {
- call_core(&app, "openhuman.accessibility_status", params_none()).await
+ let _ = app;
+ call_core_service_managed("openhuman.accessibility_status", params_none()).await
}
/// Request accessibility-related permissions on macOS.
@@ -446,12 +479,9 @@ pub async fn openhuman_accessibility_status(
pub async fn openhuman_accessibility_request_permissions(
app: tauri::AppHandle,
) -> Result, String> {
- let response: CommandResponse = call_core(
- &app,
- "openhuman.accessibility_request_permissions",
- params_none(),
- )
- .await?;
+ let response: CommandResponse =
+ call_core_service_managed("openhuman.accessibility_request_permissions", params_none())
+ .await?;
emit_accessibility_event(
&app,
"permissions_requested",
@@ -466,8 +496,7 @@ pub async fn openhuman_accessibility_request_permission(
app: tauri::AppHandle,
params: PermissionRequestParams,
) -> Result, String> {
- let response: CommandResponse = call_core(
- &app,
+ let response: CommandResponse = call_core_service_managed(
"openhuman.accessibility_request_permission",
serde_json::json!(params),
)
@@ -486,8 +515,7 @@ pub async fn openhuman_accessibility_start_session(
app: tauri::AppHandle,
params: StartSessionParams,
) -> Result, String> {
- let response: CommandResponse = call_core(
- &app,
+ let response: CommandResponse = call_core_service_managed(
"openhuman.accessibility_start_session",
serde_json::json!(params),
)
@@ -502,8 +530,7 @@ pub async fn openhuman_accessibility_stop_session(
app: tauri::AppHandle,
params: Option,
) -> Result, String> {
- let response: CommandResponse = call_core(
- &app,
+ let response: CommandResponse = call_core_service_managed(
"openhuman.accessibility_stop_session",
serde_json::json!(params.unwrap_or(StopSessionParams { reason: None })),
)
@@ -517,7 +544,8 @@ pub async fn openhuman_accessibility_stop_session(
pub async fn openhuman_accessibility_capture_now(
app: tauri::AppHandle,
) -> Result, String> {
- call_core(&app, "openhuman.accessibility_capture_now", params_none()).await
+ let _ = app;
+ call_core_service_managed("openhuman.accessibility_capture_now", params_none()).await
}
/// Execute a validated input action in an active accessibility session.
@@ -526,8 +554,7 @@ pub async fn openhuman_accessibility_input_action(
app: tauri::AppHandle,
params: InputActionParams,
) -> Result, String> {
- let response: CommandResponse = call_core(
- &app,
+ let response: CommandResponse = call_core_service_managed(
"openhuman.accessibility_input_action",
serde_json::json!(params),
)
@@ -544,8 +571,8 @@ pub async fn openhuman_accessibility_autocomplete_suggest(
app: tauri::AppHandle,
params: Option,
) -> Result, String> {
- call_core(
- &app,
+ let _ = app;
+ call_core_service_managed(
"openhuman.accessibility_autocomplete_suggest",
serde_json::json!(params.unwrap_or(AutocompleteSuggestParams {
context: None,
@@ -561,8 +588,8 @@ pub async fn openhuman_accessibility_autocomplete_commit(
app: tauri::AppHandle,
params: AutocompleteCommitParams,
) -> Result, String> {
- call_core(
- &app,
+ let _ = app;
+ call_core_service_managed(
"openhuman.accessibility_autocomplete_commit",
serde_json::json!(params),
)
@@ -574,8 +601,8 @@ pub async fn openhuman_accessibility_vision_recent(
app: tauri::AppHandle,
limit: Option,
) -> Result, String> {
- call_core(
- &app,
+ let _ = app;
+ call_core_service_managed(
"openhuman.accessibility_vision_recent",
serde_json::json!({ "limit": limit }),
)
@@ -586,7 +613,8 @@ pub async fn openhuman_accessibility_vision_recent(
pub async fn openhuman_accessibility_vision_flush(
app: tauri::AppHandle,
) -> Result, String> {
- call_core(&app, "openhuman.accessibility_vision_flush", params_none()).await
+ let _ = app;
+ call_core_service_managed("openhuman.accessibility_vision_flush", params_none()).await
}
#[tauri::command]
@@ -922,6 +950,42 @@ pub async fn openhuman_agent_server_status() -> Result Result, String> {
+ let cfg = crate::daemon_host_config::load(&app).await;
+ Ok(CommandResponse {
+ result: DaemonHostConfigPayload {
+ show_tray: cfg.show_tray,
+ },
+ logs: vec!["daemon host config loaded".to_string()],
+ })
+}
+
+/// Update local daemon-host settings (not synced, machine-local).
+#[tauri::command]
+pub async fn openhuman_set_daemon_host_config(
+ app: tauri::AppHandle,
+ show_tray: Option,
+) -> Result, String> {
+ let mut cfg = crate::daemon_host_config::load(&app).await;
+ if let Some(value) = show_tray {
+ cfg.show_tray = value;
+ }
+ crate::daemon_host_config::save(&app, &cfg).await?;
+ Ok(CommandResponse {
+ result: DaemonHostConfigPayload {
+ show_tray: cfg.show_tray,
+ },
+ logs: vec![
+ "daemon host config saved".to_string(),
+ "restart daemon host process to apply tray visibility changes".to_string(),
+ ],
+ })
+}
+
/// Install the OpenHuman daemon service.
#[tauri::command]
pub async fn openhuman_service_install(
diff --git a/src-tauri/src/core_process.rs b/src-tauri/src/core_process.rs
index 8dd659b6f7..4885c1da58 100644
--- a/src-tauri/src/core_process.rs
+++ b/src-tauri/src/core_process.rs
@@ -71,9 +71,9 @@ impl CoreProcessHandle {
// explicit subcommand path so we don't accidentally relaunch clients.
cmd.arg("core");
}
- cmd.arg("serve").arg("--port").arg(self.port.to_string());
+ cmd.arg("run").arg("--port").arg(self.port.to_string());
log::info!(
- "[core] spawning dedicated core binary: {:?} serve --port {}",
+ "[core] spawning dedicated core binary: {:?} run --port {}",
cmd.as_std().get_program(),
self.port
);
@@ -83,7 +83,7 @@ impl CoreProcessHandle {
.map_err(|e| format!("failed to resolve current executable: {e}"))?;
let mut cmd = Command::new(exe);
cmd.arg("core")
- .arg("serve")
+ .arg("run")
.arg("--port")
.arg(self.port.to_string());
log::warn!(
@@ -218,7 +218,7 @@ pub fn default_core_bin() -> Option {
}
// Dev ergonomics: in debug builds, prefer spawning this same executable with
- // `core serve` so Cargo recompiles core logic changes as part of tauri dev.
+ // `core run` so Cargo recompiles core logic changes as part of tauri dev.
// Sidecar discovery remains enabled for packaged/release builds.
if cfg!(debug_assertions) {
return None;
diff --git a/src-tauri/src/daemon_host_config.rs b/src-tauri/src/daemon_host_config.rs
new file mode 100644
index 0000000000..9b270cdc6b
--- /dev/null
+++ b/src-tauri/src/daemon_host_config.rs
@@ -0,0 +1,50 @@
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use tauri::{AppHandle, Manager};
+
+const DAEMON_HOST_CONFIG_FILE: &str = "daemon_host_config.json";
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DaemonHostConfig {
+ pub show_tray: bool,
+}
+
+impl Default for DaemonHostConfig {
+ fn default() -> Self {
+ Self { show_tray: true }
+ }
+}
+
+fn config_path(app: &AppHandle) -> PathBuf {
+ app.path()
+ .app_data_dir()
+ .unwrap_or_else(|_| {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".openhuman")
+ })
+ .join(DAEMON_HOST_CONFIG_FILE)
+}
+
+pub async fn load(app: &AppHandle) -> DaemonHostConfig {
+ let path = config_path(app);
+ let Ok(contents) = tokio::fs::read_to_string(path).await else {
+ return DaemonHostConfig::default();
+ };
+ serde_json::from_str::(&contents).unwrap_or_default()
+}
+
+pub async fn save(app: &AppHandle, config: &DaemonHostConfig) -> Result<(), String> {
+ let path = config_path(app);
+ if let Some(parent) = path.parent() {
+ tokio::fs::create_dir_all(parent)
+ .await
+ .map_err(|e| format!("failed to create daemon host config directory: {e}"))?;
+ }
+ let bytes = serde_json::to_vec_pretty(config)
+ .map_err(|e| format!("failed to serialize daemon host config: {e}"))?;
+ tokio::fs::write(path, bytes)
+ .await
+ .map_err(|e| format!("failed to write daemon host config: {e}"))
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 42e0ebbe52..b85fd960a6 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -14,6 +14,7 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp
mod commands;
mod core_process;
mod core_rpc;
+mod daemon_host_config;
pub mod memory;
mod models;
mod openhuman_daemon;
@@ -727,7 +728,15 @@ pub fn run() {
#[cfg(desktop)]
{
if daemon_mode {
- setup_tray(app.handle())?;
+ let daemon_host_cfg =
+ tauri::async_runtime::block_on(daemon_host_config::load(app.handle()));
+ if daemon_host_cfg.show_tray {
+ setup_tray(app.handle())?;
+ } else {
+ log::info!(
+ "[app] Daemon host tray disabled by local config (show_tray=false)"
+ );
+ }
}
}
@@ -1117,6 +1126,8 @@ pub fn run() {
openhuman_service_status,
openhuman_service_uninstall,
openhuman_agent_server_status,
+ openhuman_get_daemon_host_config,
+ openhuman_set_daemon_host_config,
// Unified skill registry commands
unified_list_skills,
unified_execute_skill,
@@ -1168,13 +1179,6 @@ pub fn run() {
log::info!("[openhuman] Daemon shutdown signalled");
}
- if let Some(core) = app_handle.try_state::() {
- let core_handle: core_process::CoreProcessHandle = (*core).clone();
- tauri::async_runtime::spawn(async move {
- core_handle.shutdown().await;
- });
- }
-
let _ = app_handle;
}
diff --git a/src/components/daemon/DaemonHealthPanel.tsx b/src/components/daemon/DaemonHealthPanel.tsx
index b85278f5e9..47cc8caa4f 100644
--- a/src/components/daemon/DaemonHealthPanel.tsx
+++ b/src/components/daemon/DaemonHealthPanel.tsx
@@ -13,11 +13,16 @@ import {
XCircleIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { formatRelativeTime, useDaemonHealth } from '../../hooks/useDaemonHealth';
import type { ComponentHealth, DaemonStatus } from '../../store/daemonSlice';
import { IS_DEV } from '../../utils/config';
+import {
+ isTauri,
+ openhumanGetDaemonHostConfig,
+ openhumanSetDaemonHostConfig,
+} from '../../utils/tauriCommands';
interface Props {
userId?: string;
@@ -28,6 +33,8 @@ interface Props {
const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => {
const daemonHealth = useDaemonHealth(userId);
const [operationLoading, setOperationLoading] = useState(null);
+ const [showTray, setShowTray] = useState(true);
+ const [trayLoaded, setTrayLoaded] = useState(false);
// Handle agent operations with loading states
const handleOperation = async (operation: () => Promise, operationName: string) => {
@@ -83,6 +90,39 @@ const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => {
const statusStyling = getStatusStyling(daemonHealth.status);
const StatusIcon = statusStyling.icon;
+ useEffect(() => {
+ if (!isTauri()) {
+ return;
+ }
+ const loadTray = async () => {
+ try {
+ const result = await openhumanGetDaemonHostConfig();
+ setShowTray(result.result.show_tray);
+ setTrayLoaded(true);
+ } catch (error) {
+ console.error('[AgentHealthPanel] Failed to load daemon host config:', error);
+ }
+ };
+ void loadTray();
+ }, []);
+
+ const updateTraySetting = async (enabled: boolean) => {
+ if (!isTauri()) {
+ return;
+ }
+ const previous = showTray;
+ setShowTray(enabled);
+ setOperationLoading('tray');
+ try {
+ await openhumanSetDaemonHostConfig(enabled);
+ } catch (error) {
+ setShowTray(previous);
+ console.error('[AgentHealthPanel] Failed to save daemon host config:', error);
+ } finally {
+ setOperationLoading(null);
+ }
+ };
+
return (
{/* Header */}
@@ -186,6 +226,26 @@ const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => {
+
+
+
Show Daemon Tray
+
Display tray icon in daemon host mode
+
Requires daemon host restart to apply
+
+
+
+
{/* Control Actions */}
+ {/* Tray Toggle */}
+
+
+
Show Daemon Tray
+
+ Keep OpenHuman Core tray icon visible in daemon host mode
+
+
+ Requires daemon host restart to fully apply
+
+
+
+
+
{/* Connection Info */}
{daemonHealth.connectionAttempts > 0 && (
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index d50868f3b0..3314e68ac6 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -142,7 +142,9 @@ const Home = () => {
-
{localAiStatus?.model_id ?? 'qwen3-1.7b'}
+
+ {localAiStatus?.model_id ?? 'gemma3:4b-it-qat'}
+
{localAiStatus?.state ?? 'starting'}
diff --git a/src/utils/tauriCommands.ts b/src/utils/tauriCommands.ts
index e096b9ff1b..cc2181f682 100644
--- a/src/utils/tauriCommands.ts
+++ b/src/utils/tauriCommands.ts
@@ -427,6 +427,10 @@ export interface AgentServerStatus {
url: string;
}
+export interface DaemonHostConfig {
+ show_tray: boolean;
+}
+
export type AccessibilityPermissionState = 'granted' | 'denied' | 'unknown' | 'unsupported';
export type AccessibilityPermissionKind = 'screen_recording' | 'accessibility' | 'input_monitoring';
@@ -1187,6 +1191,22 @@ export async function openhumanAgentServerStatus(): Promise
> {
+ if (!isTauri()) {
+ throw new Error('Not running in Tauri');
+ }
+ return await invoke('openhuman_get_daemon_host_config');
+}
+
+export async function openhumanSetDaemonHostConfig(
+ showTray?: boolean
+): Promise> {
+ if (!isTauri()) {
+ throw new Error('Not running in Tauri');
+ }
+ return await invoke('openhuman_set_daemon_host_config', { showTray });
+}
+
export async function openhumanAccessibilityStatus(): Promise<
CommandResponse
> {