From d26c5f5921fa9259b2abaf80d160a2ee6d2a1656 Mon Sep 17 00:00:00 2001 From: rschumann Date: Tue, 11 Aug 2026 15:31:27 +0200 Subject: [PATCH 1/3] feat: add Cortex agent lifecycle parity --- README.md | 11 ++- cli/src/main.rs | 127 ++++++++++++++++++++--------- cli/src/pod_selector.rs | 1 + docs/WIZARDS.md | 4 +- docs/guides/INDEX.md | 2 +- docs/guides/getting-started.md | 2 +- docs/guides/plans-and-agents.md | 29 +++++-- docs/guides/public-beta-install.md | 4 +- docs/guides/tytus-ecosystem.md | 7 +- tray/src/main.rs | 31 +++++-- tray/src/web_server.rs | 79 ++++++++++++++---- 11 files changed, 219 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 36af4d1..03f82d3 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ brew install traylinx/tap/tytus - **Stable gateway:** `http://10.42.42.1:18080/v1` plus a per-user key from `tytus env --export`. Paste once into Cursor, Claude Desktop, OpenCode, Codex, Aider, Vibe, or any OpenAI-compatible SDK. - **TytusOS:** the local browser desktop served by the tray. It is the primary UI for pods, files, channels, settings, terminal, Atomek, and app workflows. Legacy Tower is hidden rollback only via `TYTUS_ENABLE_LEGACY_TOWER=1`. -- **Private pods:** OpenClaw and Hermes agent runtimes inside isolated pod slots, reachable through the Tytus forwarder. +- **Private pods:** OpenClaw, Hermes, and the gated Cortex knowledge-engine preview run inside isolated pod slots, reachable through the Tytus forwarder. - **Tytus Home:** `~/Tytus` with `Downloads`, `Inbox`, `Logs`, `Outbox`, `Pods`, `Projects`, and `Shared`, used by Files and Terminal. - **Atomek:** the TytusOS workbench for local files, chat, artifacts, embedded docs/skills, AIL routing, and Resource Fabric control. - **MCP + skills:** `tytus-mcp`, `tytus llm-docs`, and `tytus os-docs` give AI agents the exact product contract. @@ -107,14 +107,17 @@ allocated: | Agent | Cost | Gateway port | Description | |---|---|---|---| | `openclaw` | 1 unit | 3000 | OpenClaw runtime | -| `hermes` | 2 units | 8642 | Nous Research Hermes gateway | +| `hermes` | 1 unit | 8642 | Nous Research Hermes gateway | +| `cortex` | 2 units | 8080 | API-first cited knowledge engine; Creator or higher; no browser UI; production activation gated | You can mix and match within your budget. For example, an Operator user -can run 4 OpenClaw pods, or 2 Hermes pods, or 2 OpenClaw pods + 1 Hermes pod. +can run four 1-unit OpenClaw/Hermes pods, two Cortex pods, or one Cortex pod +plus two 1-unit agents. ```bash tytus connect --agent openclaw # default — 1 unit -tytus connect --agent hermes # 2 units +tytus connect --agent hermes # 1 unit +tytus connect --agent cortex # 2 units; gated preview ``` ## Models on the pod gateway diff --git a/cli/src/main.rs b/cli/src/main.rs index 1902694..7245ff4 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -370,7 +370,7 @@ Identity & integrations: link Wire Tytus into a project (drops CLAUDE.md / .mcp.json) mcp Print MCP server config for your AI CLI channels Add bot tokens / API secrets for chat channels - configure Configure your agent (OpenClaw / Hermes) + configure Configure your agent (OpenClaw / Hermes / Cortex) Settings: autostart Auto-start the tunnel at login @@ -404,6 +404,7 @@ enum AgentType { #[value(alias = "nemoclaw")] Openclaw, Hermes, + Cortex, } #[derive(Clone, ValueEnum, Debug)] @@ -421,6 +422,7 @@ impl AgentType { match self { AgentType::Openclaw => "openclaw", AgentType::Hermes => "hermes", + AgentType::Cortex => "cortex", } } } @@ -640,7 +642,7 @@ enum Commands { #[command(subcommand)] action: Option, }, - /// Configure your agent (OpenClaw / Hermes) interactively + /// Configure your agent (OpenClaw / Hermes / Cortex) interactively Configure, /// Login to Traylinx (opens browser for device auth) Login, @@ -3538,6 +3540,7 @@ fn display_agent_name(agent: &str) -> &str { "none" => "default", "nemoclaw" | "openclaw" => "OpenClaw", "hermes" => "Hermes", + "cortex" => "Cortex", other => other, } } @@ -3719,9 +3722,16 @@ async fn configure_agent_for_zero_auth( ) -> Result<(), String> { match agent_type { "hermes" => configure_hermes_for_zero_auth(client, pod_id, json).await, - // OpenClaw is the default; other values fall through to the - // openclaw-style path since they're all likely OpenClaw-derived. - _ => configure_nemoclaw_for_zero_auth(client, pod_id, json).await, + "nemoclaw" | "openclaw" => configure_nemoclaw_for_zero_auth(client, pod_id, json).await, + "cortex" => { + if !json { + println!( + " Configuration: Cortex provider and storage overlays are managed by the pod blueprint." + ); + } + Ok(()) + } + _ => Ok(()), } } @@ -6332,12 +6342,17 @@ async fn cmd_setup(http: &atomek_core::HttpClient, json: bool) { wizard::print_step(2, total_steps, "Choose your AI agent"); println!(); wizard::print_info("OpenClaw — lightweight assistant (1 unit, good for most tasks)"); - wizard::print_info("Hermes — advanced reasoning agent (2 units, better for complex tasks)"); + wizard::print_info("Hermes — advanced reasoning agent (1 unit, better for complex tasks)"); + wizard::print_info("Cortex — cited knowledge engine (2 units, Creator plan or higher)"); println!(); let agent = if state.pods.is_empty() { - match wizard::select("Which agent?", &["OpenClaw (recommended)", "Hermes"]) { + match wizard::select( + "Which agent?", + &["OpenClaw (recommended)", "Hermes", "Cortex"], + ) { Ok(s) if s.to_ascii_lowercase().starts_with("hermes") => "hermes", + Ok(s) if s.to_ascii_lowercase().starts_with("cortex") => "cortex", _ => "nemoclaw", // backend identifier for the public OpenClaw brand } } else { @@ -6348,6 +6363,7 @@ async fn cmd_setup(http: &atomek_core::HttpClient, json: bool) { let display = match first_agent.as_str() { "nemoclaw" => "OpenClaw", "hermes" => "Hermes", + "cortex" => "Cortex", "none" => "Default (AIL only)", other => other, }; @@ -6835,12 +6851,13 @@ async fn cmd_configure(http: &atomek_core::HttpClient, json: bool) { std::process::exit(1); } }; + let agent_type = pod.agent_type.as_deref().unwrap_or("none"); wizard::print_header("Configure your agent"); wizard::print_info(&format!( "Pod: {} — Agent: {}", pod.pod_id, - pod.agent_type.as_deref().unwrap_or("?") + display_agent_name(agent_type) )); println!(); @@ -6860,30 +6877,45 @@ async fn cmd_configure(http: &atomek_core::HttpClient, json: bool) { match atomek_pods::exec_in_agent( &client, &pod.pod_id, - "openclaw --version 2>&1 || echo 'not installed'", + agent_test_command(agent_type), 10, ) .await { - Ok(result) => { + Ok(result) if result.exit_code == 0 => { let out = result.stdout.unwrap_or_default(); wizard::finish_ok(&pb, "Agent responded"); println!(); wizard::print_info(out.trim()); } + Ok(result) => { + let error = result + .stderr + .or(result.stdout) + .unwrap_or_else(|| "health probe failed".to_string()); + wizard::finish_fail( + &pb, + &format!("Agent probe exited {}: {}", result.exit_code, error.trim()), + ); + } Err(e) => { wizard::finish_fail(&pb, &format!("Failed: {}", e)); } } } Ok("View agent logs") => { - wizard::print_info("Use: tytus exec 'tail -50 /var/log/openclaw.log'"); + wizard::print_info(&format!("Use: tytus logs --pod {} --lines 50", pod.pod_id)); } Ok("Restart agent") => { if wizard::confirm("Restart the agent container?", true).unwrap_or(false) { - wizard::print_info( - "Restart via DAM — use `tytus exec` for custom commands or contact support.", - ); + let pb = wizard::spinner("Restarting agent..."); + let (sk, auid) = get_credentials(&mut state, http).await; + let client = atomek_pods::TytusClient::new(http, &sk, &auid); + let target = atomek_pods::AgentTarget::new(&pod.pod_id, pod.route_id.as_deref()); + match atomek_pods::restart_agent_target(&client, target).await { + Ok(_) => wizard::finish_ok(&pb, "Agent restarted"), + Err(e) => wizard::finish_fail(&pb, &format!("Restart failed: {}", e)), + } } } Ok("Advanced: run custom command") => { @@ -6914,6 +6946,17 @@ async fn cmd_configure(http: &atomek_core::HttpClient, json: bool) { } } +fn agent_test_command(agent_type: &str) -> &'static str { + match agent_type { + "cortex" => { + "python -c \"import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5).read().decode())\"" + } + "hermes" => "hermes --version", + "nemoclaw" | "openclaw" => "openclaw --version", + _ => "printf 'agent runtime available\\n'", + } +} + // ── Autostart (macOS LaunchAgent + Linux systemd --user) ──── #[cfg(unix)] @@ -8028,15 +8071,17 @@ async fn cmd_ui( // through the sidecar's socat forwarder on 10.42.42.1:18080 — there // is no `/` web UI to show. Silently forwarding would land the user // on a blank page with no explanation. - if pod.agent_type.as_deref() == Some("none") { + if matches!(pod.agent_type.as_deref(), Some("none" | "cortex")) { + let reason = if pod.agent_type.as_deref() == Some("cortex") { + "Cortex is API-first and has no browser UI." + } else { + "This is the default AIL-only pod and has no agent UI." + }; eprintln!( - "Pod {} is the default pod (AIL-only, no agent installed).\n\ - There's no agent UI to open. Either install an agent:\n \ - tytus agent install openclaw --pod {}\n\ - or use the stable AIL endpoint directly:\n \ - http://10.42.42.1:18080/v1", + "Pod {} has no browser UI. {}\n\ + Use `tytus configure`, `tytus exec`, or the agent API instead.", target.label(), - target_key, + reason, ); std::process::exit(1); } @@ -8490,7 +8535,7 @@ async fn ensure_controlui_overlay( .find(|p| p.pod_id == pod_id) .and_then(|p| p.agent_type.clone()) .unwrap_or_default(); - if agent_type == "hermes" || agent_type == "none" { + if !matches!(agent_type.as_str(), "nemoclaw" | "openclaw") { return Ok(()); } let secret = st.secret_key.as_deref().ok_or("no secret_key in state")?; @@ -8606,14 +8651,16 @@ async fn fetch_gateway_token_via_provider( .and_then(|p| p.agent_type.clone()) .unwrap_or_else(|| "nemoclaw".into()); let client = atomek_pods::TytusClient::new(http, secret, user_id); - let script = if agent_type == "hermes" { - "cat /app/workspace/.hermes/api_server_key 2>/dev/null" - } else { - "cat /app/workspace/.openclaw/config.json 2>/dev/null | \ + let script = match agent_type.as_str() { + "hermes" => "cat /app/workspace/.hermes/api_server_key 2>/dev/null", + "nemoclaw" | "openclaw" => { + "cat /app/workspace/.openclaw/config.json 2>/dev/null | \ node -e \"let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{\ try{const c=JSON.parse(d);\ process.stdout.write((c.gateway&&c.gateway.auth&&c.gateway.auth.token)||'');}\ catch(e){process.exit(0);}})\"" + } + _ => return None, }; let res = atomek_pods::exec_in_agent(&client, pod_id, script, 10) .await @@ -9343,6 +9390,7 @@ fn find_crlf2(buf: &[u8]) -> Option { fn agent_ui_port(agent_type: &str) -> u16 { match agent_type { "hermes" => 9119, + "cortex" => 8080, _ => 3000, } } @@ -9350,6 +9398,7 @@ fn agent_ui_port(agent_type: &str) -> u16 { fn agent_api_port(agent_type: &str) -> u16 { match agent_type { "hermes" => 8642, + "cortex" => 8080, _ => 3000, } } @@ -9987,7 +10036,8 @@ the CLI. - **Pod** = one user's isolated slice: a WireGuard sidecar + an agent container - **Agents** (containerised AIs running INSIDE a pod): - `openclaw` = OpenClaw runtime (1 unit, port 3000) - - `hermes` = Nous Research Hermes gateway (2 units, port 8642) + - `hermes` = Nous Research Hermes gateway (1 unit, port 8642) + - `cortex` = cited knowledge engine (2 units, API port 8080, no browser UI) - **Plan tiers**: Explorer (1 unit), Creator (2 units), Operator (4 units). Unit budget is enforced atomically by Scalesys; you cannot overspend. - **SwitchAILocal**: the OpenAI-compatible LLM gateway on every droplet. @@ -10014,7 +10064,7 @@ tytus doctor # full diagnostic (auth, tunnel, gateway, MCP) # Pods tytus setup # interactive wizard: auth → pick → tunnel → test -tytus connect [--agent openclaw|hermes] [--pod NN] +tytus connect [--agent openclaw|hermes|cortex] [--pod NN] tytus disconnect [--pod NN] # tear down tunnel daemon, leave allocation tytus revoke # free units (does NOT need disconnect first) tytus restart [--pod NN] # restart agent container (re-runs entry script) @@ -10051,7 +10101,9 @@ The agent is a containerised AI with its own filesystem and config. ```bash tytus connect --agent openclaw # OpenClaw # OR -tytus connect --agent hermes # Nous Research Hermes (2 units) +tytus connect --agent hermes # Nous Research Hermes (1 unit) +# OR +tytus connect --agent cortex # Cited knowledge engine (2 units; gated preview) # Customise the agent without rebuilding the image: tytus exec --pod 02 "cat /app/workspace/config.user.json.example" @@ -10113,9 +10165,10 @@ written to `.tytus/os-manual.md` in linked projects. ### What is Tytus - **Pod** = one user's isolated slice (WireGuard sidecar + agent container) -- **Two agent types** runnable inside a pod: +- **Agent types** runnable inside a pod: - `openclaw` (1 unit, port 3000) — OpenClaw runtime - - `hermes` (2 units, port 8642) — Nous Research Hermes + - `hermes` (1 unit, port 8642) — Nous Research Hermes + - `cortex` (2 units, port 8080) — cited knowledge engine, no browser UI, gated preview - **Plan tiers**: Explorer=1u, Creator=2u, Operator=4u - **Models** on the gateway: live AIL aliases returned by `/v1/models`; do not hardcode provider ids @@ -10130,7 +10183,7 @@ eval "$(tytus env --export)" ```bash tytus status [--json] # account + pods + tunnel tytus doctor # full health diagnostic -tytus connect [--agent openclaw|hermes] # allocate + tunnel up +tytus connect [--agent openclaw|hermes|cortex] # allocate + tunnel up tytus disconnect [--pod NN] # tear down tunnel tytus revoke # free units tytus restart [--pod NN] # restart the agent container @@ -10190,8 +10243,8 @@ Then dispatch on `$ARGUMENTS`: tunnel state. If `--json` is needed for parsing, use `tytus status --json`. Always run `tytus doctor` if anything looks off. -- **connect**: `tytus connect [--agent openclaw|hermes]`. Default agent is - OpenClaw (1 unit). Hermes costs 2 units. Confirm with the user before +- **connect**: `tytus connect [--agent openclaw|hermes|cortex]`. OpenClaw and + Hermes cost 1 unit. Cortex costs 2 units and has no browser UI. Confirm before spending units. - **test**: `tytus test` — full E2E health check (auth → pod → tunnel → @@ -10208,7 +10261,7 @@ Then dispatch on `$ARGUMENTS`: OPENAI_API_KEY pair. Use `--raw` for the legacy per-pod values. - **deploy AGENT** or **--agent AGENT**: shorthand for `tytus connect - --agent `. Verify the user understands the unit cost. + --agent `. Verify the user understands the unit cost. - **disconnect**: `tytus disconnect` — tears down the tunnel daemon, leaves the allocation alive. Cheap to reconnect. @@ -10248,7 +10301,7 @@ tytus os-docs # Tytus OS user manual — desktop, windows, apps, settings `llm-docs` is the authoritative documentation for the `tytus` tool: every subcommand, the stable URL/key model, agent types (OpenClaw=1u, -hermes=2u), plan tiers (Explorer=1u, Creator=2u, Operator=4u), models +Hermes=1u, Cortex=2u gated preview), plan tiers (Explorer=1u, Creator=2u, Operator=4u), models (ail-compound, ail-image, ail-embed), and a troubleshooting catalog. `os-docs` is the authoritative documentation for the desktop OS — read @@ -10261,7 +10314,7 @@ Common flow: ```bash tytus status # what does the user have? -tytus connect [--agent openclaw|hermes] # if no pod yet +tytus connect [--agent openclaw|hermes|cortex] # if no pod yet tytus test # E2E health eval "$(tytus env --export)" # load OPENAI_* envs tytus chat # REPL, OR diff --git a/cli/src/pod_selector.rs b/cli/src/pod_selector.rs index dcd9f77..ceadbb6 100644 --- a/cli/src/pod_selector.rs +++ b/cli/src/pod_selector.rs @@ -23,6 +23,7 @@ impl ResolvedPodTarget { match self.agent_type.as_deref() { Some("nemoclaw") => "OpenClaw", Some("hermes") => "Hermes", + Some("cortex") => "Cortex", Some("none") => "AIL", _ => "unknown", } diff --git a/docs/WIZARDS.md b/docs/WIZARDS.md index b36d38c..1582570 100644 --- a/docs/WIZARDS.md +++ b/docs/WIZARDS.md @@ -29,7 +29,7 @@ Goal: anyone can install, set up, and use Tytus without reading docs or touching | Command | What it does | Wizard? | |---------|-------------|---------| -| `tytus configure` | Configure agent (OpenClaw/Hermes) | ✓ Interactive wizard | +| `tytus configure` | Configure agent (OpenClaw/Hermes/Cortex) | ✓ Interactive wizard | | `tytus exec ` | Run command in pod container | No (power tool) | | `tytus logs` | Stream agent logs | ✓ Live tail with colors | | `tytus restart` | Restart agent container | ✓ Confirmation + progress | @@ -86,7 +86,7 @@ Goal: anyone can install, set up, and use Tytus without reading docs or touching 3. [onboarding wizard runs] - Welcome screen with logo - "Do you have an account?" → opens browser for login - - "Which agent do you want?" → OpenClaw (simple) or Hermes (advanced) + - "Which agent do you want?" → OpenClaw (simple), Hermes (advanced), or Cortex (2-unit gated knowledge-engine preview) - "Connecting to your pod..." → animated progress - "Configuring OpenClaw..." → runs exec commands - "Let's test it!" → runs a sample chat completion diff --git a/docs/guides/INDEX.md b/docs/guides/INDEX.md index d22abb6..709faa8 100644 --- a/docs/guides/INDEX.md +++ b/docs/guides/INDEX.md @@ -10,7 +10,7 @@ Welcome to Tytus — your private AI pod, driven from any terminal. | [Getting Started](getting-started.md) | Install, setup, first connection — 2 minutes to a working pod | | [Use with AI Tools](use-with-ai-tools.md) | Claude Code, Cursor, OpenCode, Gemini, Aider, Vibe — one pod, every tool | | [Atomek Workbench](atomek-workbench.md) | TytusOS editor, chat, artifacts, Resource Fabric cockpit, local app skills | -| [Plans, Agents, and Models](plans-and-agents.md) | Subscription tiers, OpenClaw vs Hermes, available models | +| [Plans, Agents, and Models](plans-and-agents.md) | Subscription tiers, OpenClaw/Hermes/Cortex, available models | | [Auto-Start and Daemon](autostart-and-daemon.md) | Survive reboots, background token refresh, tray icon | | [Common Use Cases](common-use-cases.md) | Copy-paste recipes for real-world scenarios | | [Tytus Ecosystem](tytus-ecosystem.md) | CLI, tray, TytusOS, Files, pods, session state, and install matrix | diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 48da072..bdc4ab3 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -82,7 +82,7 @@ The setup wizard walks you through everything: 1. **Sign in** — Opens your browser for secure login (no passwords typed in the terminal) 2. **Plan check** — Shows your subscription tier and available units -3. **Agent pick** — Choose OpenClaw (default, 1 unit) or Hermes (2 units) +3. **Agent pick** — Choose OpenClaw (default, 1 unit), Hermes (1 unit), or the gated Cortex preview (2 units, Creator or higher) 4. **Connect** — Allocates your pod and opens the WireGuard tunnel 5. **Test** — Sends a sample chat to verify everything works diff --git a/docs/guides/plans-and-agents.md b/docs/guides/plans-and-agents.md index 97382f8..3e11c6a 100644 --- a/docs/guides/plans-and-agents.md +++ b/docs/guides/plans-and-agents.md @@ -8,8 +8,8 @@ Every Tytus plan comes with a **unit budget** — a fixed number of units you ca | Plan | Price | Units | What you can run | |---|---|---|---| -| Explorer | $39/mo | 1 unit | 1 OpenClaw pod | -| Creator | $79/mo | 2 units | 2 OpenClaw pods, or 1 Hermes pod | +| Explorer | $39/mo | 1 unit | 1 OpenClaw or Hermes pod | +| Creator | $79/mo | 2 units | 2 one-unit pods, or 1 Cortex pod after activation | | Operator | $149/mo | 4 units | Any mix up to 4 units | Check your current plan and usage: @@ -34,7 +34,7 @@ OpenClaw runtime. Lightweight, fast startup. Best for: - Quick tasks and one-off queries - When you want maximum pods per plan -### Hermes (2 units) +### Hermes (1 unit) ```bash tytus connect --agent hermes @@ -45,6 +45,21 @@ Nous Research Hermes agent. More capable, heavier runtime. Best for: - Agentic workflows - When quality matters more than quantity +### Cortex (2 units, gated preview) + +```bash +tytus connect --agent cortex +``` + +Cortex is an API-first cited knowledge engine. It uses the pod-local +switchAILocal aliases `ail-compound` and `ail-embed`, has no browser UI, and +requires Creator or higher. The CLI/provider/infra path can be shipped before +customer production activation; until that separate gate opens, allocation can +still be refused by the service. + +Use `tytus configure` for health and restart actions. Use the Cortex API or +`tytus exec` for operator work; `tytus ui` deliberately refuses Cortex. + ### Switching Agents You can't change the agent on a running pod. To switch: @@ -118,10 +133,10 @@ tytus exec "ls /workspace" | You have | You can run | |---|---| -| 1 unit (Explorer) | 1 OpenClaw pod | -| 2 units (Creator) | 2 OpenClaw pods, OR 1 Hermes pod | -| 3 units | 3 OpenClaw pods, OR 1 Hermes pod + 1 OpenClaw pod | -| 4 units (Operator) | 4 OpenClaw pods, OR 2 Hermes pods, OR 2 OpenClaw pods + 1 Hermes pod | +| 1 unit (Explorer) | 1 OpenClaw or Hermes pod; Cortex is refused | +| 2 units (Creator) | 2 one-unit pods, OR 1 Cortex pod after activation | +| 3 units | 3 one-unit pods, OR 1 Cortex pod + 1 one-unit pod | +| 4 units (Operator) | 4 one-unit pods, OR 2 Cortex pods, OR 1 Cortex pod + 2 one-unit pods | If you try to allocate more than your budget allows: ``` diff --git a/docs/guides/public-beta-install.md b/docs/guides/public-beta-install.md index baaf3ab..ec3f52c 100644 --- a/docs/guides/public-beta-install.md +++ b/docs/guides/public-beta-install.md @@ -10,7 +10,7 @@ Open: https://get.traylinx.com/ ``` -Download one file for your computer, open it, then open **Tytus** and follow the setup wizard. The wizard signs you in, lets you pick OpenClaw or Hermes, starts the private tunnel, and runs the test. +Download one file for your computer, open it, then open **Tytus** and follow the setup wizard. The wizard signs you in, lets you pick OpenClaw, Hermes, or the gated Cortex preview when enabled, starts the private tunnel, and runs the test. ## macOS @@ -87,7 +87,7 @@ The setup wizard does this: 1. Sign in through the browser. 2. Check your plan. -3. Pick an assistant: OpenClaw or Hermes. +3. Pick an assistant: OpenClaw, Hermes, or the gated Cortex preview when enabled. 4. Start the private encrypted tunnel. 5. Run a health test. diff --git a/docs/guides/tytus-ecosystem.md b/docs/guides/tytus-ecosystem.md index 1c16b2f..574bc83 100644 --- a/docs/guides/tytus-ecosystem.md +++ b/docs/guides/tytus-ecosystem.md @@ -103,7 +103,7 @@ Core apps: ## Agent team workflow -Tytus is not just one chat app. It is a resource fabric that lets OpenClaw, Hermes, local agents, shared folders, channels, and local apps work from the same mission context. +Tytus is not just one chat app. It is a resource fabric that lets OpenClaw, Hermes, the gated Cortex knowledge-engine preview, local agents, shared folders, channels, and local apps work from the same mission context. Recommended loop: @@ -115,7 +115,7 @@ Recommended loop: 6. Keep transcripts in `runs/`, artifacts in `outputs/`, edits in `proposals/`, and final handoff in `OUTBOX.md`. 7. Apply edits only after preview/approval. -Use **OpenClaw** and **Hermes** in user-facing docs and UI. Old internal agent labels are not product names. +Use **OpenClaw**, **Hermes**, and **Cortex** in user-facing docs and UI. Old internal agent labels are not product names. ## Atomek workbench @@ -207,7 +207,7 @@ Rules: ## Agent defaults and autonomy -A fresh OpenClaw or Hermes pod should have enough environment to work without manual bootstrap: +A fresh OpenClaw, Hermes, or Cortex pod should have enough environment to work without manual bootstrap. Cortex is API-first, has no browser UI, and remains production-gated until separately activated: - `AIL_URL` / `AIL_INFERENCE_URL` / `OPENAI_BASE_URL` - `AIL_API_KEY` / `OPENAI_API_KEY` @@ -233,6 +233,7 @@ tytus env --export # stable SDK env tytus agent catalog # available agents tytus agent install openclaw # allocate/install OpenClaw tytus agent install hermes # allocate/install Hermes +tytus agent install cortex # 2-unit gated knowledge-engine preview tytus ls /app/workspace --pod 01 tytus push ./file.pdf --pod 01 tytus pull /app/workspace/out/result.md --pod 01 diff --git a/tray/src/main.rs b/tray/src/main.rs index 22407a9..69b7006 100644 --- a/tray/src/main.rs +++ b/tray/src/main.rs @@ -381,7 +381,7 @@ pub struct TrayState { /// Concrete per-pod info (id + which agent is running). Drives the /// "Pods & Agents" submenu. Empty when the user has no allocations. pub pods: Vec, - /// Units currently consumed by allocated pods (OpenClaw=1, Hermes=2). + /// Units currently consumed by allocated pods (OpenClaw=1, Hermes=1, Cortex=2). /// Derived from `pods` but cached so menu building doesn't recompute. pub units_used: u32, /// Hard cap from the user's plan. Explorer=1, Creator=2, Operator=4. @@ -485,13 +485,13 @@ impl PodInfo { Some(format!("{}/?token={}", base, token)) } - /// Unit cost — mirrors Scalesys: OpenClaw=1, Hermes=2, included AIL pod=0. + /// Unit cost — mirrors Scalesys: OpenClaw=1, Hermes=1, Cortex=2, AIL=0. pub fn units(&self) -> u32 { if let Some(units) = self.agent_units { return units; } match self.agent_type.as_str() { - "hermes" => 2, + "cortex" => 2, "none" => 0, // agent-less default pod (SPRINT §4.1) _ => 1, } @@ -506,6 +506,7 @@ impl PodInfo { return match self.agent_type.as_str() { "nemoclaw" => format!("{} · OpenClaw", name), "hermes" => format!("{} · Hermes", name), + "cortex" => format!("{} · Cortex", name), "none" => name.to_string(), _ => name.to_string(), }; @@ -518,6 +519,7 @@ impl PodInfo { match self.agent_type.as_str() { "nemoclaw" => "OpenClaw".into(), "hermes" => "Hermes".into(), + "cortex" => "Cortex".into(), "none" => "Default (AIL only)".into(), other if !other.is_empty() => other.to_string(), _ => "Unknown".into(), @@ -1666,7 +1668,8 @@ fn build_menu(state: &TrayState) -> Menu { let add_sub = Submenu::new("Install Agent (terminal)", true); let remaining = state.units_limit.saturating_sub(state.units_used); let nemo_ok = state.units_limit == 0 || remaining >= 1; - let hermes_ok = state.units_limit == 0 || remaining >= 2; + let hermes_ok = state.units_limit == 0 || remaining >= 1; + let cortex_ok = state.units_limit == 0 || remaining >= 2; let _ = add_sub.append(&MenuItem::with_id( "install_agent_nemoclaw", format!( @@ -1683,7 +1686,7 @@ fn build_menu(state: &TrayState) -> Menu { let _ = add_sub.append(&MenuItem::with_id( "install_agent_hermes", format!( - "Hermes (2 units){}", + "Hermes (1 unit){}", if hermes_ok { "" } else { @@ -1693,6 +1696,19 @@ fn build_menu(state: &TrayState) -> Menu { hermes_ok, None, )); + let _ = add_sub.append(&MenuItem::with_id( + "install_agent_cortex", + format!( + "Cortex (2 units){}", + if cortex_ok { + "" + } else { + " — not enough units" + } + ), + cortex_ok, + None, + )); let _ = pods_sub.append(&add_sub); // Budget line at the bottom for context. @@ -2502,6 +2518,11 @@ fn handle_menu_event(id: &str, state: &Arc>) { "tytus agent install hermes; echo; echo 'Press Enter to close…'; read _", ); } + "install_agent_cortex" => { + open_in_terminal_simple( + "tytus agent install cortex; echo; echo 'Press Enter to close…'; read _", + ); + } other if other.starts_with("launch_") => { let binary = &other["launch_".len()..]; let clis = launcher::detect_installed_clis(); diff --git a/tray/src/web_server.rs b/tray/src/web_server.rs index 276ab22..f50670e 100644 --- a/tray/src/web_server.rs +++ b/tray/src/web_server.rs @@ -476,7 +476,7 @@ fn probe_agent_status( if api_url.is_empty() { return AgentStatus::Stopped; } - let probe_url = format!("{}/models", api_url.trim_end_matches('/')); + let probe_url = agent_probe_url(agent_type, api_url); let bearer = probe_bearer_token(agent_type, user_key, gateway_token); if bearer.is_empty() { return AgentStatus::Starting; @@ -507,6 +507,18 @@ fn probe_agent_status( } } +fn agent_probe_url(agent_type: &str, api_url: &str) -> String { + if agent_type == "cortex" { + let base = api_url + .trim_end_matches('/') + .strip_suffix("/v1") + .unwrap_or_else(|| api_url.trim_end_matches('/')); + format!("{}/health", base) + } else { + format!("{}/models", api_url.trim_end_matches('/')) + } +} + fn agent_has_browser_ui(agent_type: &str) -> bool { matches!(agent_type, "nemoclaw" | "openclaw" | "hermes") } @@ -1417,6 +1429,7 @@ fn resource_agent_display_name(agent_type: &str) -> String { // Legacy internal id. Never leak it to product surfaces. "nemoclaw" | "openclaw" => "OpenClaw".to_string(), "hermes" => "Hermes".to_string(), + "cortex" => "Cortex".to_string(), "none" => "AIL Gateway".to_string(), other if !other.trim().is_empty() => other.to_string(), _ => "Tytus Agent".to_string(), @@ -1427,6 +1440,7 @@ fn resource_agent_family(agent_type: &str) -> &'static str { match agent_type { "nemoclaw" | "openclaw" => "openclaw", "hermes" => "hermes", + "cortex" => "cortex", "none" => "ail", _ => "agent", } @@ -7084,7 +7098,7 @@ struct IncludedSlot { /// Per-plan unit budgets — must match Scalesys `AGENT_UNITS` + the Rails /// plan tiering. Keep aligned with `services/wannolot-provider/src/...` -/// where OpenClaw=1, Hermes=2, included AIL=0 and Explorer=1 / Creator=2 / +/// where OpenClaw=1, Hermes=1, Cortex=2, included AIL=0 and Explorer=1 / Creator=2 / /// Operator=4. Unknown agent types default to 1 unit (conservative so /// we never under-count the user's spend). /// Compute the per-pod gateway auth token. The edge plugin accepts @@ -7114,7 +7128,7 @@ fn plan_limit_for(tier: &str) -> u32 { } fn agent_units_for(agent_type: &str) -> u32 { match agent_type { - "hermes" => 2, + "cortex" => 2, "none" => 0, _ => 1, // OpenClaw-family agents } @@ -8978,9 +8992,13 @@ fn compute_state_snapshot() -> StateSnapshot { .map(|base| format!("{}/p/{}", base.trim_end_matches('/'), pod_id)) }); let api_url = public_url.as_ref().map(|u| format!("{}/v1", u)); - let ui_url = match (public_url.as_ref(), gateway_token.as_ref()) { - (Some(u), Some(t)) => Some(format!("{}/?token={}", u, t)), - _ => None, + let ui_url = if agent_has_browser_ui(&agent_type) { + match (public_url.as_ref(), gateway_token.as_ref()) { + (Some(u), Some(t)) => Some(format!("{}/?token={}", u, t)), + _ => None, + } + } else { + None }; if agent_type == "none" { @@ -9422,6 +9440,18 @@ fn handle_pod_open(request: Request, query: &str) { ); return; }; + if !agent_has_browser_ui(&agent.agent_type) { + respond_json( + request, + 409, + &serde_json::json!({ + "error": "agent has no browser UI", + "agent_type": agent.agent_type, + "api_url": agent.api_url, + }), + ); + return; + } // 1) Prefer the cached tokenized UI URL (public edge + gateway_token) // when the daemon already has one — fastest path, no Provider hop. if let Some(u) = agent.ui_url.clone() { @@ -10335,11 +10365,7 @@ fn handle_sharing_defaults_save(mut request: Request) { let body: Body = match serde_json::from_str(&buf) { Ok(b) => b, Err(e) => { - respond_json( - request, - 400, - &serde_json::json!({"error":format!("bad json: {}", e)}), - ); + respond_json(request, 400, &serde_json::json!({"error":format!("bad json: {}", e)})); return; } }; @@ -11810,7 +11836,11 @@ fn handle_progressive_toggle(mut request: Request) { let body: Body = match serde_json::from_str(&buf) { Ok(b) => b, Err(e) => { - respond_json(request, 400, &serde_json::json!({"error":format!("bad json: {}", e)})); + respond_json( + request, + 400, + &serde_json::json!({"error":format!("bad json: {}", e)}), + ); return; } }; @@ -14876,7 +14906,8 @@ fn handle_shared_folders_provision_pod(mut request: Request, registry: &Registry /// Probe whether a just-installed pod is actually reachable. The CLI's /// `agent install` returns as soon as Scalesys allocates the pod row + /// fires the DAM deploy — the container is typically still starting at -/// that moment (15-60 s for OpenClaw, 30-90 s for Hermes). The wizard +/// that moment (15-60 s for OpenClaw, 30-90 s for Hermes, up to 240 s for +/// Cortex's multi-container blueprint). The wizard /// polls this endpoint post-install so the user doesn't see a fake /// "done" screen with a broken "Chat now" button. /// @@ -15004,7 +15035,10 @@ fn handle_pod_ready(request: Request, query: &str) { return; } }; - let probe_url = format!("{}/models", api.trim_end_matches('/')); + let probe_url = agent_probe_url( + agent.as_ref().map(|a| a.agent_type.as_str()).unwrap_or(""), + &api, + ); let Some(agent) = agent else { respond_json( request, @@ -15222,7 +15256,7 @@ fn handle_pod_uninstall(request: Request, query: &str) { return; } }; - // Only agent pods (OpenClaw, Hermes) can be uninstalled. AIL-included + // Only agent pods (OpenClaw, Hermes, Cortex) can be uninstalled. AIL-included // pods have no agent to remove — `tytus agent uninstall ` on a // default pod is a no-op + confusing error. let snap = compute_state_snapshot(); @@ -18670,12 +18704,25 @@ mod tests { } #[test] - fn hermes_is_a_browser_ui_agent() { + fn browser_ui_support_is_explicit() { assert!(agent_has_browser_ui("hermes")); assert!(agent_has_browser_ui("nemoclaw")); + assert!(!agent_has_browser_ui("cortex")); assert!(!agent_has_browser_ui("none")); } + #[test] + fn cortex_uses_health_readiness_and_two_units() { + assert_eq!( + agent_probe_url("cortex", "https://pod.example/v1"), + "https://pod.example/health" + ); + assert_eq!(agent_units_for("cortex"), 2); + assert_eq!(agent_units_for("hermes"), 1); + assert_eq!(resource_agent_display_name("cortex"), "Cortex"); + assert_eq!(resource_agent_family("cortex"), "cortex"); + } + #[test] fn resource_agent_id_keeps_same_pod_families_distinct() { assert_eq!(resource_agent_id("openclaw", "01"), "pod-agent.openclaw.01"); From 125f0a622c6d90f67cd9ecced39d7a6d72c7a487 Mon Sep 17 00:00:00 2001 From: rschumann Date: Tue, 11 Aug 2026 15:45:23 +0200 Subject: [PATCH 2/3] test(progressive-sync): make wall-cap probe direct --- progressive-sync/src/s3.rs | 58 +++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/progressive-sync/src/s3.rs b/progressive-sync/src/s3.rs index 3565d6b..ea35c8f 100644 --- a/progressive-sync/src/s3.rs +++ b/progressive-sync/src/s3.rs @@ -33,7 +33,9 @@ fn wall_cap(idle_timeout_secs: u32) -> Duration { /// drained on threads so a chatty child can never deadlock against a full /// pipe while we wait. On deadline: kill + reap + Transient error. fn run_wall_capped(mut cmd: Command, cap: Duration) -> Result { - cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); let mut child = cmd .spawn() .map_err(|e| S3Error::Transient(format!("spawn rclone: {e}")))?; @@ -83,7 +85,11 @@ fn run_wall_capped(mut cmd: Command, cap: Duration) -> Result, max: usize) - -> Result, S3Error>; + fn list_after( + &self, + prefix: &str, + start_after: Option<&str>, + max: usize, + ) -> Result, S3Error>; /// Fetch a small object (event JSON) with the 8 KiB guard. fn cat_small(&self, key: &str) -> Result, S3Error>; /// Download one object to a local temp path (same-filesystem apply). @@ -161,9 +171,12 @@ impl RcloneS3 { } impl S3Ops for RcloneS3 { - fn list_after(&self, prefix: &str, start_after: Option<&str>, max: usize) - -> Result, S3Error> - { + fn list_after( + &self, + prefix: &str, + start_after: Option<&str>, + max: usize, + ) -> Result, S3Error> { let mut cmd = self.base(LSJSON_TIMEOUT_SECS); cmd.arg("lsjson") .arg("--files-only") @@ -185,7 +198,10 @@ impl S3Ops for RcloneS3 { .filter_map(|v| { let name = v.get("Path").and_then(|p| p.as_str())?; let size = v.get("Size").and_then(|s| s.as_u64()).unwrap_or(0); - Some(RemoteObject { key: format!("{prefix}{name}"), size }) + Some(RemoteObject { + key: format!("{prefix}{name}"), + size, + }) }) .collect(); rows.sort_by(|a, b| a.key.cmp(&b.key)); // lexicographic == sequence order @@ -212,7 +228,9 @@ impl S3Ops for RcloneS3 { return Err(S3Error::Transient(stderr.chars().take(300).collect())); } if output.stdout.len() > MAX_EVENT_OBJECT_BYTES { - return Err(S3Error::Permanent(format!("event object over {MAX_EVENT_OBJECT_BYTES} bytes"))); + return Err(S3Error::Permanent(format!( + "event object over {MAX_EVENT_OBJECT_BYTES} bytes" + ))); } Ok(output.stdout) } @@ -229,7 +247,9 @@ impl S3Ops for RcloneS3 { return Err(S3Error::Transient(stderr.chars().take(300).collect())); } if !target.exists() { - return Err(S3Error::Transient("copyto reported success but target missing".into())); + return Err(S3Error::Transient( + "copyto reported success but target missing".into(), + )); } Ok(()) } @@ -250,8 +270,15 @@ mod tests { #[test] fn wall_cap_kills_a_hung_child_and_reports_transient() { let started = Instant::now(); - let err = run_wall_capped(sh("sleep 30"), Duration::from_millis(300)).unwrap_err(); - assert!(started.elapsed() < Duration::from_secs(5), "child was not killed promptly"); + // Replace the shell with the hung process. Without `exec`, Linux /bin/sh + // leaves `sleep` as a grandchild holding the captured pipes open after + // the shell is killed, so the test measures shell process-tree behavior + // rather than the direct rclone child used in production. + let err = run_wall_capped(sh("exec sleep 30"), Duration::from_millis(300)).unwrap_err(); + assert!( + started.elapsed() < Duration::from_secs(5), + "child was not killed promptly" + ); match err { S3Error::Transient(msg) => assert!(msg.contains("wall-clock cap"), "{msg}"), other => panic!("expected Transient, got {other}"), @@ -261,8 +288,11 @@ mod tests { #[cfg(unix)] #[test] fn wall_cap_passes_through_a_fast_child_with_output() { - let out = run_wall_capped(sh("printf hello; printf world >&2; exit 3"), - Duration::from_secs(10)).unwrap(); + let out = run_wall_capped( + sh("printf hello; printf world >&2; exit 3"), + Duration::from_secs(10), + ) + .unwrap(); assert_eq!(out.stdout, b"hello"); assert_eq!(out.stderr, b"world"); assert_eq!(out.status.code(), Some(3)); From 1bc21dbf1b3224d6a716d4eb172a38e9c7c9f432 Mon Sep 17 00:00:00 2001 From: rschumann Date: Tue, 11 Aug 2026 15:46:01 +0200 Subject: [PATCH 3/3] chore: minimize wall-cap test diff --- progressive-sync/src/s3.rs | 58 ++++++++++---------------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/progressive-sync/src/s3.rs b/progressive-sync/src/s3.rs index ea35c8f..d51bb6e 100644 --- a/progressive-sync/src/s3.rs +++ b/progressive-sync/src/s3.rs @@ -33,9 +33,7 @@ fn wall_cap(idle_timeout_secs: u32) -> Duration { /// drained on threads so a chatty child can never deadlock against a full /// pipe while we wait. On deadline: kill + reap + Transient error. fn run_wall_capped(mut cmd: Command, cap: Duration) -> Result { - cmd.stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd .spawn() .map_err(|e| S3Error::Transient(format!("spawn rclone: {e}")))?; @@ -85,11 +83,7 @@ fn run_wall_capped(mut cmd: Command, cap: Duration) -> Result, - max: usize, - ) -> Result, S3Error>; + fn list_after(&self, prefix: &str, start_after: Option<&str>, max: usize) + -> Result, S3Error>; /// Fetch a small object (event JSON) with the 8 KiB guard. fn cat_small(&self, key: &str) -> Result, S3Error>; /// Download one object to a local temp path (same-filesystem apply). @@ -171,12 +161,9 @@ impl RcloneS3 { } impl S3Ops for RcloneS3 { - fn list_after( - &self, - prefix: &str, - start_after: Option<&str>, - max: usize, - ) -> Result, S3Error> { + fn list_after(&self, prefix: &str, start_after: Option<&str>, max: usize) + -> Result, S3Error> + { let mut cmd = self.base(LSJSON_TIMEOUT_SECS); cmd.arg("lsjson") .arg("--files-only") @@ -198,10 +185,7 @@ impl S3Ops for RcloneS3 { .filter_map(|v| { let name = v.get("Path").and_then(|p| p.as_str())?; let size = v.get("Size").and_then(|s| s.as_u64()).unwrap_or(0); - Some(RemoteObject { - key: format!("{prefix}{name}"), - size, - }) + Some(RemoteObject { key: format!("{prefix}{name}"), size }) }) .collect(); rows.sort_by(|a, b| a.key.cmp(&b.key)); // lexicographic == sequence order @@ -228,9 +212,7 @@ impl S3Ops for RcloneS3 { return Err(S3Error::Transient(stderr.chars().take(300).collect())); } if output.stdout.len() > MAX_EVENT_OBJECT_BYTES { - return Err(S3Error::Permanent(format!( - "event object over {MAX_EVENT_OBJECT_BYTES} bytes" - ))); + return Err(S3Error::Permanent(format!("event object over {MAX_EVENT_OBJECT_BYTES} bytes"))); } Ok(output.stdout) } @@ -247,9 +229,7 @@ impl S3Ops for RcloneS3 { return Err(S3Error::Transient(stderr.chars().take(300).collect())); } if !target.exists() { - return Err(S3Error::Transient( - "copyto reported success but target missing".into(), - )); + return Err(S3Error::Transient("copyto reported success but target missing".into())); } Ok(()) } @@ -270,15 +250,10 @@ mod tests { #[test] fn wall_cap_kills_a_hung_child_and_reports_transient() { let started = Instant::now(); - // Replace the shell with the hung process. Without `exec`, Linux /bin/sh - // leaves `sleep` as a grandchild holding the captured pipes open after - // the shell is killed, so the test measures shell process-tree behavior - // rather than the direct rclone child used in production. + // Use a direct hung child. Without `exec`, Linux /bin/sh leaves sleep as a + // grandchild holding the captured pipes open after the shell is killed. let err = run_wall_capped(sh("exec sleep 30"), Duration::from_millis(300)).unwrap_err(); - assert!( - started.elapsed() < Duration::from_secs(5), - "child was not killed promptly" - ); + assert!(started.elapsed() < Duration::from_secs(5), "child was not killed promptly"); match err { S3Error::Transient(msg) => assert!(msg.contains("wall-clock cap"), "{msg}"), other => panic!("expected Transient, got {other}"), @@ -288,11 +263,8 @@ mod tests { #[cfg(unix)] #[test] fn wall_cap_passes_through_a_fast_child_with_output() { - let out = run_wall_capped( - sh("printf hello; printf world >&2; exit 3"), - Duration::from_secs(10), - ) - .unwrap(); + let out = run_wall_capped(sh("printf hello; printf world >&2; exit 3"), + Duration::from_secs(10)).unwrap(); assert_eq!(out.stdout, b"hello"); assert_eq!(out.stderr, b"world"); assert_eq!(out.status.code(), Some(3));