diff --git a/.env.example b/.env.example
index 55c3adb5..8fd44c5a 100644
--- a/.env.example
+++ b/.env.example
@@ -78,7 +78,7 @@ NEARAI_AUTH_URL=https://private.near.ai
# === MiniMax ===
# LLM_BACKEND=minimax
# MINIMAX_API_KEY=...
-# MINIMAX_MODEL=MiniMax-M2.5
+# MINIMAX_MODEL=MiniMax-M2.7
# MINIMAX_BASE_URL=https://api.minimax.io/v1 # default (global); use https://api.minimaxi.com/v1 for China
# === Anthropic Direct ===
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index e7371677..2f885b16 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -174,7 +174,7 @@ jobs:
- name: Run E2E tests
run: |
- pytest tests/e2e/ -v -x --timeout=120
+ pytest tests/e2e/ -v --timeout=120
env:
RUST_LOG: ironclaw=info
RUST_BACKTRACE: "1"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36c4d103..6aad4993 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17
+
+### Added
+
+- verify telegram owner during hot activation ([#1157](https://github.com/nearai/ironclaw/pull/1157))
+- *(config)* unify config resolution with Settings fallback (Phase 2, #1119) ([#1203](https://github.com/nearai/ironclaw/pull/1203))
+- *(sandbox)* add retry logic for transient container failures ([#1232](https://github.com/nearai/ironclaw/pull/1232))
+- *(heartbeat)* fire_at time-of-day scheduling with IANA timezone ([#1029](https://github.com/nearai/ironclaw/pull/1029))
+- Reuse Codex CLI OAuth tokens for ChatGPT backend LLM calls ([#693](https://github.com/nearai/ironclaw/pull/693))
+- add pre-push git hook with delta lint mode ([#833](https://github.com/nearai/ironclaw/pull/833))
+- *(cli)* add `logs` command for gateway log access ([#1105](https://github.com/nearai/ironclaw/pull/1105))
+- add Feishu/Lark WASM channel plugin ([#1110](https://github.com/nearai/ironclaw/pull/1110))
+- add Criterion benchmarks for safety layer hot paths ([#836](https://github.com/nearai/ironclaw/pull/836))
+- *(routines)* human-readable cron schedule summaries in web UI ([#1154](https://github.com/nearai/ironclaw/pull/1154))
+- *(web)* add follow-up suggestion chips and ghost text ([#1156](https://github.com/nearai/ironclaw/pull/1156))
+- *(ci)* include commit history in staging promotion PRs ([#952](https://github.com/nearai/ironclaw/pull/952))
+- *(tools)* add reusable sensitive JSON redaction helper ([#457](https://github.com/nearai/ironclaw/pull/457))
+- configurable hybrid search fusion strategy ([#234](https://github.com/nearai/ironclaw/pull/234))
+- *(cli)* add cron subcommand for managing scheduled routines ([#1017](https://github.com/nearai/ironclaw/pull/1017))
+- adds context-llm tool support ([#616](https://github.com/nearai/ironclaw/pull/616))
+- *(web-chat)* add hover copy button for user/assistant messages ([#948](https://github.com/nearai/ironclaw/pull/948))
+- add Slack approval buttons for tool execution in DMs ([#796](https://github.com/nearai/ironclaw/pull/796))
+- enhance HTTP tool parameter parsing ([#911](https://github.com/nearai/ironclaw/pull/911))
+- *(routines)* enable tool access in lightweight routine execution ([#257](https://github.com/nearai/ironclaw/pull/257)) ([#730](https://github.com/nearai/ironclaw/pull/730))
+- add MiniMax as a built-in LLM provider ([#940](https://github.com/nearai/ironclaw/pull/940))
+- *(cli)* add `ironclaw channels list` subcommand ([#933](https://github.com/nearai/ironclaw/pull/933))
+- *(cli)* add `ironclaw skills list/search/info` subcommands ([#918](https://github.com/nearai/ironclaw/pull/918))
+- add cargo-deny for supply chain safety ([#834](https://github.com/nearai/ironclaw/pull/834))
+- *(setup)* display ASCII art banner during onboarding ([#851](https://github.com/nearai/ironclaw/pull/851))
+- *(extensions)* unify auth and configure into single entrypoint ([#677](https://github.com/nearai/ironclaw/pull/677))
+- *(i18n)* Add internationalization support with Chinese and English translations ([#929](https://github.com/nearai/ironclaw/pull/929))
+- Import OpenClaw memory, history and settings ([#903](https://github.com/nearai/ironclaw/pull/903))
+
+### Fixed
+
+- jobs limit ([#1274](https://github.com/nearai/ironclaw/pull/1274))
+- misleading UI message ([#1265](https://github.com/nearai/ironclaw/pull/1265))
+- bump channel registry versions for promotion ([#1264](https://github.com/nearai/ironclaw/pull/1264))
+- cover staging CI all-features and routine batch regressions ([#1256](https://github.com/nearai/ironclaw/pull/1256))
+- resolve merge conflict fallout and missing config fields
+- web/CLI routine mutations do not refresh live event trigger cache ([#1255](https://github.com/nearai/ironclaw/pull/1255))
+- *(jobs)* make completed->completed transition idempotent to prevent race errors ([#1068](https://github.com/nearai/ironclaw/pull/1068))
+- *(llm)* persist refreshed Anthropic OAuth token after Keychain re-read ([#1213](https://github.com/nearai/ironclaw/pull/1213))
+- *(worker)* prevent orphaned tool_results and fix parallel merging ([#1069](https://github.com/nearai/ironclaw/pull/1069))
+- Telegram bot token validation fails intermittently (HTTP 404) ([#1166](https://github.com/nearai/ironclaw/pull/1166))
+- *(security)* prevent metadata spoofing of internal job monitor flag ([#1195](https://github.com/nearai/ironclaw/pull/1195))
+- *(security)* default webhook server to loopback when tunnel is configured ([#1194](https://github.com/nearai/ironclaw/pull/1194))
+- *(auth)* avoid false success and block chat during pending auth ([#1111](https://github.com/nearai/ironclaw/pull/1111))
+- *(config)* unify ChannelsConfig resolution to env > settings > default ([#1124](https://github.com/nearai/ironclaw/pull/1124))
+- *(web-chat)* normalize chat copy to plain text ([#1114](https://github.com/nearai/ironclaw/pull/1114))
+- *(skill)* treat empty url param as absent when installing skills ([#1128](https://github.com/nearai/ironclaw/pull/1128))
+- preserve AuthError type in oauth_http_client cache ([#1152](https://github.com/nearai/ironclaw/pull/1152))
+- *(web)* prevent Safari IME composition Enter from sending message ([#1140](https://github.com/nearai/ironclaw/pull/1140))
+- *(mcp)* handle 400 auth errors, clear auth mode after OAuth, trim tokens ([#1158](https://github.com/nearai/ironclaw/pull/1158))
+- eliminate panic paths in production code ([#1184](https://github.com/nearai/ironclaw/pull/1184))
+- N+1 query pattern in event trigger loop (routine_engine) ([#1163](https://github.com/nearai/ironclaw/pull/1163))
+- *(llm)* add stop_sequences parity for tool completions ([#1170](https://github.com/nearai/ironclaw/pull/1170))
+- *(channels)* use live owner binding during wasm hot activation ([#1171](https://github.com/nearai/ironclaw/pull/1171))
+- Non-transactional multi-step context updates between metadata/to… ([#1161](https://github.com/nearai/ironclaw/pull/1161))
+- *(webhook)* avoid lock-held awaits in server lifecycle paths ([#1168](https://github.com/nearai/ironclaw/pull/1168))
+- Google Sheets returns 403 PERMISSION_DENIED after completing OAuth ([#1164](https://github.com/nearai/ironclaw/pull/1164))
+- HTTP webhook secret transmitted in request body rather than via header, docs inconsistency and security concern ([#1162](https://github.com/nearai/ironclaw/pull/1162))
+- *(ci)* exclude ironclaw_safety from release automation ([#1146](https://github.com/nearai/ironclaw/pull/1146))
+- *(registry)* bump versions for github, web-search, and discord extensions ([#1106](https://github.com/nearai/ironclaw/pull/1106))
+- *(mcp)* address 14 audit findings across MCP module ([#1094](https://github.com/nearai/ironclaw/pull/1094))
+- *(http)* replace .expect() with match in webhook handler ([#1133](https://github.com/nearai/ironclaw/pull/1133))
+- *(time)* treat empty timezone string as absent ([#1127](https://github.com/nearai/ironclaw/pull/1127))
+- 5 critical/high-priority bugs (auth bypass, relay failures, unbounded recursion, context growth) ([#1083](https://github.com/nearai/ironclaw/pull/1083))
+- *(ci)* checkout promotion PR head for metadata refresh ([#1097](https://github.com/nearai/ironclaw/pull/1097))
+- *(ci)* add missing attachments field and crates/ dir to Dockerfiles ([#1100](https://github.com/nearai/ironclaw/pull/1100))
+- *(registry)* bump telegram channel version for capabilities change ([#1064](https://github.com/nearai/ironclaw/pull/1064))
+- *(ci)* repair staging promotion workflow behavior ([#1091](https://github.com/nearai/ironclaw/pull/1091))
+- *(wasm)* address #1086 review followups -- description hint and coercion safety ([#1092](https://github.com/nearai/ironclaw/pull/1092))
+- *(ci)* repair staging-ci workflow parsing ([#1090](https://github.com/nearai/ironclaw/pull/1090))
+- *(extensions)* fix lifecycle bugs + comprehensive E2E tests ([#1070](https://github.com/nearai/ironclaw/pull/1070))
+- add tool_info schema discovery for WASM tools ([#1086](https://github.com/nearai/ironclaw/pull/1086))
+- resolve bug_bash UX/logging issues (#1054 #1055 #1058) ([#1072](https://github.com/nearai/ironclaw/pull/1072))
+- *(http)* fail closed when webhook secret is missing at runtime ([#1075](https://github.com/nearai/ironclaw/pull/1075))
+- *(service)* set CLI_ENABLED=false in macOS launchd plist ([#1079](https://github.com/nearai/ironclaw/pull/1079))
+- relax approval requirements for low-risk tools ([#922](https://github.com/nearai/ironclaw/pull/922))
+- *(web)* make approval requests appear without page reload ([#996](https://github.com/nearai/ironclaw/pull/996)) ([#1073](https://github.com/nearai/ironclaw/pull/1073))
+- *(routines)* run cron checks immediately on ticker startup ([#1066](https://github.com/nearai/ironclaw/pull/1066))
+- *(web)* recompute cron next_fire_at when re-enabling routines ([#1080](https://github.com/nearai/ironclaw/pull/1080))
+- *(memory)* reject absolute filesystem paths with corrective routing ([#934](https://github.com/nearai/ironclaw/pull/934))
+- remove all inline event handlers for CSP script-src compliance ([#1063](https://github.com/nearai/ironclaw/pull/1063))
+- *(mcp)* include OAuth state parameter in authorization URLs ([#1049](https://github.com/nearai/ironclaw/pull/1049))
+- *(mcp)* open MCP OAuth in same browser as gateway ([#951](https://github.com/nearai/ironclaw/pull/951))
+- *(deploy)* harden production container and bootstrap security ([#1014](https://github.com/nearai/ironclaw/pull/1014))
+- release lock guards before awaiting channel send ([#869](https://github.com/nearai/ironclaw/pull/869)) ([#1003](https://github.com/nearai/ironclaw/pull/1003))
+- *(registry)* use versioned artifact URLs and checksums for all WASM manifests ([#1007](https://github.com/nearai/ironclaw/pull/1007))
+- *(setup)* preserve model selection on provider re-run ([#679](https://github.com/nearai/ironclaw/pull/679)) ([#987](https://github.com/nearai/ironclaw/pull/987))
+- *(mcp)* attach session manager for non-OAuth HTTP clients ([#793](https://github.com/nearai/ironclaw/pull/793)) ([#986](https://github.com/nearai/ironclaw/pull/986))
+- *(security)* migrate webhook auth to HMAC-SHA256 signature header ([#970](https://github.com/nearai/ironclaw/pull/970))
+- *(security)* make unsafe env::set_var calls safe with explicit invariants ([#968](https://github.com/nearai/ironclaw/pull/968))
+- *(security)* require explicit SANDBOX_ALLOW_FULL_ACCESS to enable FullAccess policy ([#967](https://github.com/nearai/ironclaw/pull/967))
+- *(security)* add Content-Security-Policy header to web gateway ([#966](https://github.com/nearai/ironclaw/pull/966))
+- *(test)* stabilize openai compat oversized-body regression ([#839](https://github.com/nearai/ironclaw/pull/839))
+- *(ci)* disambiguate WASM bundle filenames to prevent tool/channel collision ([#964](https://github.com/nearai/ironclaw/pull/964))
+- *(setup)* validate channel credentials during setup ([#684](https://github.com/nearai/ironclaw/pull/684))
+- drain tunnel pipes to prevent zombie process ([#735](https://github.com/nearai/ironclaw/pull/735))
+- *(mcp)* header safety validation and Authorization conflict bug from #704 ([#752](https://github.com/nearai/ironclaw/pull/752))
+- *(agent)* block thread_id-based context pollution across users ([#760](https://github.com/nearai/ironclaw/pull/760))
+- *(mcp)* stdio/unix transports skip initialize handshake ([#890](https://github.com/nearai/ironclaw/pull/890)) ([#935](https://github.com/nearai/ironclaw/pull/935))
+- *(setup)* drain residual events and filter key kind in onboard prompts ([#937](https://github.com/nearai/ironclaw/pull/937)) ([#949](https://github.com/nearai/ironclaw/pull/949))
+- *(security)* load WASM tool description and schema from capabilities.json ([#520](https://github.com/nearai/ironclaw/pull/520))
+- *(security)* resolve DNS once and reuse for SSRF validation to prevent rebinding ([#518](https://github.com/nearai/ironclaw/pull/518))
+- *(security)* replace regex HTML sanitizer with DOMPurify to prevent XSS ([#510](https://github.com/nearai/ironclaw/pull/510))
+- *(ci)* improve Claude Code review reliability ([#955](https://github.com/nearai/ironclaw/pull/955))
+- *(ci)* run gated test jobs during staging CI ([#956](https://github.com/nearai/ironclaw/pull/956))
+- *(ci)* prevent staging-ci tag failure and chained PR auto-close ([#900](https://github.com/nearai/ironclaw/pull/900))
+- *(ci)* WASM WIT compat sqlite3 duplicate symbol conflict ([#953](https://github.com/nearai/ironclaw/pull/953))
+- resolve deferred review items from PRs #883, #848, #788 ([#915](https://github.com/nearai/ironclaw/pull/915))
+- *(web)* improve UX readability and accessibility in chat UI ([#910](https://github.com/nearai/ironclaw/pull/910))
+
+### Other
+
+- Fix Telegram auto-verify flow and routing ([#1273](https://github.com/nearai/ironclaw/pull/1273))
+- *(e2e)* fix approval waiting regression coverage ([#1270](https://github.com/nearai/ironclaw/pull/1270))
+- isolate heavy integration tests ([#1266](https://github.com/nearai/ironclaw/pull/1266))
+- Merge branch 'main' into fix/resolve-conflicts
+- Refactor owner scope across channels and fix default routing fallback ([#1151](https://github.com/nearai/ironclaw/pull/1151))
+- *(extensions)* document relay manager init order ([#928](https://github.com/nearai/ironclaw/pull/928))
+- *(setup)* extract init logic from wizard into owning modules ([#1210](https://github.com/nearai/ironclaw/pull/1210))
+- mention MiniMax as built-in provider in all READMEs ([#1209](https://github.com/nearai/ironclaw/pull/1209))
+- Fix schema-guided tool parameter coercion ([#1143](https://github.com/nearai/ironclaw/pull/1143))
+- Make no-panics CI check test-aware ([#1160](https://github.com/nearai/ironclaw/pull/1160))
+- *(mcp)* avoid reallocating SSE buffer on each chunk ([#1153](https://github.com/nearai/ironclaw/pull/1153))
+- *(routines)* avoid full message history clone each tool iteration ([#1172](https://github.com/nearai/ironclaw/pull/1172))
+- *(registry)* align manifest versions with published artifacts ([#1169](https://github.com/nearai/ironclaw/pull/1169))
+- remove __pycache__ from repo and add to .gitignore ([#1177](https://github.com/nearai/ironclaw/pull/1177))
+- *(registry)* move MCP servers from code to JSON manifests ([#1144](https://github.com/nearai/ironclaw/pull/1144))
+- improve routine schema guidance ([#1089](https://github.com/nearai/ironclaw/pull/1089))
+- add event-trigger routine e2e coverage ([#1088](https://github.com/nearai/ironclaw/pull/1088))
+- enforce no .unwrap(), .expect(), or assert!() in production code ([#1087](https://github.com/nearai/ironclaw/pull/1087))
+- periodic sync main into staging (resolved conflicts) ([#1098](https://github.com/nearai/ironclaw/pull/1098))
+- fix formatting in cli/mod.rs and mcp/auth.rs ([#1071](https://github.com/nearai/ironclaw/pull/1071))
+- Expose the shared agent session manager via AppComponents ([#532](https://github.com/nearai/ironclaw/pull/532))
+- *(agent)* remove unnecessary Worker re-export ([#923](https://github.com/nearai/ironclaw/pull/923))
+- Fix UTF-8 unsafe truncation in WASM emit_message ([#1015](https://github.com/nearai/ironclaw/pull/1015))
+- extract safety module into ironclaw_safety crate ([#1024](https://github.com/nearai/ironclaw/pull/1024))
+- Add Z.AI provider support for GLM-5 ([#938](https://github.com/nearai/ironclaw/pull/938))
+- *(html_to_markdown)* refresh golden files after renderer bump ([#1016](https://github.com/nearai/ironclaw/pull/1016))
+- Migrate GitHub webhook normalization into github tool ([#758](https://github.com/nearai/ironclaw/pull/758))
+- Fix systemctl unit ([#472](https://github.com/nearai/ironclaw/pull/472))
+- add Russian localization (README.ru.md) ([#850](https://github.com/nearai/ironclaw/pull/850))
+- Add generic host-verified /webhook/tools/{tool} ingress ([#757](https://github.com/nearai/ironclaw/pull/757))
+
## [0.18.0](https://github.com/nearai/ironclaw/compare/v0.17.0...v0.18.0) - 2026-03-11
### Other
diff --git a/Cargo.lock b/Cargo.lock
index 854d103a..2c5547e0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3436,7 +3436,7 @@ dependencies = [
[[package]]
name = "ironclaw"
-version = "0.18.0"
+version = "0.19.0"
dependencies = [
"aes-gcm",
"aho-corasick",
diff --git a/Cargo.toml b/Cargo.toml
index b396b18d..5b452651 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,7 +20,7 @@ exclude = [
[package]
name = "ironclaw"
-version = "0.18.0"
+version = "0.19.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
diff --git a/README.ja.md b/README.ja.md
new file mode 100644
index 00000000..887cf67e
--- /dev/null
+++ b/README.ja.md
@@ -0,0 +1,330 @@
+
+
+
+
+IronClaw
+
+
+ あなたの味方になる、安全なパーソナルAIアシスタント
+
+
+
+
+
+
+
+
+
+ English |
+ 简体中文 |
+ Русский |
+ 日本語
+
+
+
+ フィロソフィー •
+ 機能 •
+ インストール •
+ 設定 •
+ セキュリティ •
+ アーキテクチャ
+
+
+---
+
+## フィロソフィー
+
+IronClawはシンプルな原則に基づいて構築されています:**あなたのAIアシスタントは、あなたのために働くべきであり、あなたに不利益をもたらすべきではありません。**
+
+AIシステムがデータの取り扱いについて不透明になり、企業の利益に沿って調整されることが増えている世界で、IronClawは異なるアプローチを取ります:
+
+- **あなたのデータはあなたのもの** - すべての情報はローカルに保存・暗号化され、あなたの管理下から離れることはありません
+- **設計段階からの透明性** - オープンソース、監査可能、隠れたテレメトリやデータ収集なし
+- **自己拡張する能力** - ベンダーのアップデートを待たずに、新しいツールをその場で構築
+- **多層防御** - 複数のセキュリティレイヤーがプロンプトインジェクションやデータ流出から保護
+
+IronClawは、個人生活にも仕事にも本当に信頼できるAIアシスタントです。
+
+## 機能
+
+### セキュリティファースト
+
+- **WASMサンドボックス** - 信頼されていないツールは、機能ベースの権限を持つ隔離されたWebAssemblyコンテナで実行
+- **認証情報の保護** - シークレットはツールに公開されず、リーク検出付きでホスト境界で注入
+- **プロンプトインジェクション防御** - パターン検出、コンテンツサニタイズ、ポリシー適用
+- **エンドポイントの許可リスト** - HTTPリクエストは明示的に許可されたホストとパスのみに制限
+
+### 常時利用可能
+
+- **マルチチャネル** - REPL、HTTPウェブフック、WASMチャネル(Telegram、Slack)、Webゲートウェイ
+- **Dockerサンドボックス** - ジョブごとのトークンとオーケストレーター/ワーカーパターンによる隔離されたコンテナ実行
+- **Webゲートウェイ** - リアルタイムSSE/WebSocketストリーミング対応のブラウザUI
+- **ルーティン** - cronスケジュール、イベントトリガー、ウェブフックハンドラーによるバックグラウンド自動化
+- **ハートビートシステム** - 監視・保守タスクのためのプロアクティブなバックグラウンド実行
+- **並列ジョブ** - 隔離されたコンテキストで複数のリクエストを同時に処理
+- **自己修復** - スタックした操作の自動検出と復旧
+
+### 自己拡張
+
+- **動的ツール構築** - 必要なものを説明すると、IronClawがWASMツールとして構築
+- **MCPプロトコル** - Model Context Protocolサーバーに接続して追加機能を利用
+- **プラグインアーキテクチャ** - 再起動なしで新しいWASMツールやチャネルを追加
+
+### 永続メモリ
+
+- **ハイブリッド検索** - Reciprocal Rank Fusionを使用した全文検索+ベクトル検索
+- **ワークスペースファイルシステム** - メモ、ログ、コンテキストのための柔軟なパスベースストレージ
+- **アイデンティティファイル** - セッション間で一貫した人格と設定を維持
+
+## インストール
+
+### 前提条件
+
+- Rust 1.85+
+- PostgreSQL 15+ ([pgvector](https://github.com/pgvector/pgvector)拡張機能を含む)
+- NEAR AIアカウント(セットアップウィザードで認証を処理)
+
+## ダウンロードまたはビルド
+
+最新のアップデートは[リリースページ](https://github.com/nearai/ironclaw/releases/)をご覧ください。
+
+
+ Windowsインストーラーでインストール(Windows)
+
+[Windowsインストーラー](https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-x86_64-pc-windows-msvc.msi)をダウンロードして実行してください。
+
+
+
+
+ PowerShellスクリプトでインストール(Windows)
+
+```sh
+irm https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.ps1 | iex
+```
+
+
+
+
+ シェルスクリプトでインストール(macOS、Linux、Windows/WSL)
+
+```sh
+curl --proto '=https' --tlsv1.2 -LsSf https://github.com/nearai/ironclaw/releases/latest/download/ironclaw-installer.sh | sh
+```
+
+
+
+ Homebrewでインストール(macOS/Linux)
+
+```sh
+brew install ironclaw
+```
+
+
+
+
+ ソースコードからコンパイル(Windows、Linux、macOSでCargo)
+
+`cargo`でインストールします。コンピューターに[Rust](https://rustup.rs)がインストールされていることを確認してください。
+
+```bash
+# リポジトリをクローン
+git clone https://github.com/nearai/ironclaw.git
+cd ironclaw
+
+# ビルド
+cargo build --release
+
+# テストを実行
+cargo test
+```
+
+**フルリリース**(チャネルソースを変更した後)の場合、まず`./scripts/build-all.sh`を実行してチャネルを再ビルドしてください。
+
+
+
+### データベースのセットアップ
+
+```bash
+# データベースを作成
+createdb ironclaw
+
+# pgvectorを有効化
+psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
+```
+
+## 設定
+
+セットアップウィザードを実行してIronClawを設定します:
+
+```bash
+ironclaw onboard
+```
+
+ウィザードは、データベース接続、NEAR AI認証(ブラウザOAuth経由)、シークレットの暗号化(システムキーチェーンを使用)を処理します。設定は接続されたデータベースに永続化されます。ブートストラップ変数(例:`DATABASE_URL`、`LLM_BACKEND`)は、データベース接続前に利用できるよう`~/.ironclaw/.env`に書き込まれます。
+
+### 代替LLMプロバイダー
+
+IronClawはデフォルトでNEAR AIを使用しますが、多くのLLMプロバイダーをすぐに利用できます。組み込みプロバイダーには**Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral**、**Ollama**(ローカル)が含まれます。**OpenRouter**(300以上のモデル)、**Together AI**、**Fireworks AI**、セルフホストサーバー(**vLLM**、**LiteLLM**)などのOpenAI互換サービスもサポートされています。
+
+ウィザードでプロバイダーを選択するか、環境変数を直接設定してください:
+
+```env
+# 例:MiniMax(組み込み、204Kコンテキスト)
+LLM_BACKEND=minimax
+MINIMAX_API_KEY=...
+
+# 例:OpenAI互換エンドポイント
+LLM_BACKEND=openai_compatible
+LLM_BASE_URL=https://openrouter.ai/api/v1
+LLM_API_KEY=sk-or-...
+LLM_MODEL=anthropic/claude-sonnet-4
+```
+
+完全なプロバイダーガイドは[docs/LLM_PROVIDERS.md](docs/LLM_PROVIDERS.md)をご覧ください。
+
+## セキュリティ
+
+IronClawは、データを保護し悪用を防ぐために多層防御を実装しています。
+
+### WASMサンドボックス
+
+すべての信頼されていないツールは、隔離されたWebAssemblyコンテナで実行されます:
+
+- **機能ベースの権限** - HTTP、シークレット、ツール呼び出しの明示的なオプトイン
+- **エンドポイントの許可リスト** - 許可されたホスト/パスへのHTTPリクエストのみ
+- **認証情報の注入** - シークレットはホスト境界で注入され、WASMコードに公開されない
+- **リーク検出** - リクエストとレスポンスのシークレット流出試行をスキャン
+- **レート制限** - 悪用防止のためのツールごとのリクエスト制限
+- **リソース制限** - メモリ、CPU、実行時間の制約
+
+```
+WASM ──► 許可リスト ──► リーク ──► 認証情報 ──► リクエスト ──► リーク ──► WASM
+ バリデーター スキャン 注入 実行 スキャン
+ (リクエスト) (レスポンス)
+```
+
+### プロンプトインジェクション防御
+
+外部コンテンツは複数のセキュリティレイヤーを通過します:
+
+- パターンベースのインジェクション試行検出
+- コンテンツのサニタイズとエスケープ
+- 重要度レベル付きポリシールール(ブロック/警告/レビュー/サニタイズ)
+- 安全なLLMコンテキスト注入のためのツール出力ラッピング
+
+### データ保護
+
+- すべてのデータはローカルのPostgreSQLデータベースに保存
+- AES-256-GCMでシークレットを暗号化
+- テレメトリ、分析、データ共有なし
+- すべてのツール実行の完全な監査ログ
+
+## アーキテクチャ
+
+```
+┌────────────────────────────────────────────────────────────────┐
+│ チャネル │
+│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ REPL │ │ HTTP │ │WASMチャネル │ │ Web │ │
+│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ ゲートウェイ│ │
+│ │ │ │ │(SSE + WS) │ │
+│ │ │ │ └──────┬──────┘ │
+│ └─────────┴──────────────┴────────────────┘ │
+│ │ │
+│ ┌─────────▼─────────┐ │
+│ │ エージェントループ │ インテントルーティング│
+│ └────┬──────────┬───┘ │
+│ │ │ │
+│ ┌──────────▼────┐ ┌──▼───────────────┐ │
+│ │ スケジューラー │ │ ルーティン │ │
+│ │ (並列ジョブ) │ │ エンジン │ │
+│ └──────┬────────┘ │(cron,event,wh) │ │
+│ │ └────────┬─────────┘ │
+│ ┌─────────────┼────────────────────┘ │
+│ │ │ │
+│ ┌───▼─────┐ ┌────▼────────────────┐ │
+│ │ ローカル │ │ オーケストレーター │ │
+│ │ ワーカー │ │ ┌───────────────┐ │ │
+│ │(プロセス │ │ │ Docker │ │ │
+│ │ 内) │ │ │ サンドボックス│ │ │
+│ └───┬─────┘ │ │ コンテナ │ │ │
+│ │ │ │ ┌───────────┐ │ │ │
+│ │ │ │ │Worker / CC│ │ │ │
+│ │ │ │ └───────────┘ │ │ │
+│ │ │ └───────────────┘ │ │
+│ │ └─────────┬───────────┘ │
+│ └──────────────────┤ │
+│ │ │
+│ ┌───────────▼──────────┐ │
+│ │ ツールレジストリ │ │
+│ │ 組み込み, MCP, WASM │ │
+│ └──────────────────────┘ │
+└────────────────────────────────────────────────────────────────┘
+```
+
+### コアコンポーネント
+
+| コンポーネント | 目的 |
+|---------------|------|
+| **エージェントループ** | メインのメッセージ処理とジョブの調整 |
+| **ルーター** | ユーザーの意図を分類(コマンド、クエリ、タスク) |
+| **スケジューラー** | 優先度付きの並列ジョブ実行を管理 |
+| **ワーカー** | LLM推論とツール呼び出しでジョブを実行 |
+| **オーケストレーター** | コンテナのライフサイクル、LLMプロキシ、ジョブごとの認証 |
+| **Webゲートウェイ** | チャット、メモリ、ジョブ、ログ、拡張機能、ルーティンのブラウザUI |
+| **ルーティンエンジン** | スケジュール(cron)とリアクティブ(イベント、ウェブフック)のバックグラウンドタスク |
+| **ワークスペース** | ハイブリッド検索付き永続メモリ |
+| **セーフティレイヤー** | プロンプトインジェクション防御とコンテンツサニタイズ |
+
+## 使い方
+
+```bash
+# 初回セットアップ(データベース、認証などを設定)
+ironclaw onboard
+
+# インタラクティブREPLを起動
+cargo run
+
+# デバッグログ付き
+RUST_LOG=ironclaw=debug cargo run
+```
+
+## 開発
+
+```bash
+# コードフォーマット
+cargo fmt
+
+# リント
+cargo clippy --all --benches --tests --examples --all-features
+
+# テスト実行
+createdb ironclaw_test
+cargo test
+
+# 特定のテストを実行
+cargo test test_name
+```
+
+- **Telegramチャネル**: セットアップとDMペアリングについては[docs/TELEGRAM_SETUP.md](docs/TELEGRAM_SETUP.md)を参照してください。
+- **チャネルソースの変更**: `cargo build`の前に`./channels-src/telegram/build.sh`を実行して、更新されたWASMをバンドルしてください。
+
+## OpenClawの系譜
+
+IronClawは[OpenClaw](https://github.com/openclaw/openclaw)にインスパイアされたRust再実装です。完全な対応表は[FEATURE_PARITY.md](FEATURE_PARITY.md)をご覧ください。
+
+主な違い:
+
+- **Rust vs TypeScript** - ネイティブパフォーマンス、メモリ安全性、シングルバイナリ
+- **WASMサンドボックス vs Docker** - 軽量、機能ベースのセキュリティ
+- **PostgreSQL vs SQLite** - 本番環境対応の永続化
+- **セキュリティファースト設計** - 複数の防御レイヤー、認証情報の保護
+
+## ライセンス
+
+以下のいずれかのライセンスの下で提供されています:
+
+- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
+- MIT License ([LICENSE-MIT](LICENSE-MIT))
+
+お好みに応じて選択してください。
diff --git a/README.md b/README.md
index 9684ee4d..fa73dc45 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,8 @@
English |
简体中文 |
- Русский
+ Русский |
+ 日本語
diff --git a/README.ru.md b/README.ru.md
index c64770a9..0546e7f4 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -17,7 +17,8 @@
English |
简体中文 |
- Русский
+ Русский |
+ 日本語
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 34023822..a337d713 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -17,7 +17,8 @@
English |
简体中文 |
- Русский
+ Русский |
+ 日本語
diff --git a/codecov.yml b/codecov.yml
index 3e31b00a..723c1175 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -2,9 +2,13 @@ coverage:
status:
project:
default:
- target: auto
- threshold: 1%
+ target: 80%
+ threshold: 2%
patch:
default:
- target: 80%
- threshold: 5%
\ No newline at end of file
+ target: 90%
+
+comment:
+ layout: "reach,diff,flags"
+ behavior: default
+ require_changes: true
diff --git a/docs/LLM_PROVIDERS.md b/docs/LLM_PROVIDERS.md
index a581a56b..0623ce25 100644
--- a/docs/LLM_PROVIDERS.md
+++ b/docs/LLM_PROVIDERS.md
@@ -15,7 +15,7 @@ configurations.
| io.net | `ionet` | `IONET_API_KEY` | Intelligence API |
| Mistral | `mistral` | `MISTRAL_API_KEY` | Mistral models |
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
-| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.5 models |
+| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
| Ollama | `ollama` | No | Local inference |
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
@@ -84,7 +84,7 @@ LLM_BACKEND=minimax
MINIMAX_API_KEY=...
```
-Available models: `MiniMax-M2.5` (default), `MiniMax-M2.5-highspeed`
+Available models: `MiniMax-M2.7` (default), `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`
To use the China mainland endpoint, set:
diff --git a/providers.json b/providers.json
index 12723a6f..550edd64 100644
--- a/providers.json
+++ b/providers.json
@@ -393,8 +393,8 @@
"api_key_required": true,
"base_url_env": "MINIMAX_BASE_URL",
"model_env": "MINIMAX_MODEL",
- "default_model": "MiniMax-M2.5",
- "description": "MiniMax API (MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
+ "default_model": "MiniMax-M2.7",
+ "description": "MiniMax API (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5 and MiniMax-M2.5-highspeed models)",
"setup": {
"kind": "api_key",
"secret_name": "llm_minimax_api_key",
diff --git a/registry/channels/discord.json b/registry/channels/discord.json
index 50ef85ee..dc545d75 100644
--- a/registry/channels/discord.json
+++ b/registry/channels/discord.json
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/discord-0.2.0-wasm32-wasip2.tar.gz",
- "sha256": "efa1b9019fa33e243f8db1e1fcc732731d45836336bdd26ca19b6fe227ca8b69"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-discord-0.2.1-wasm32-wasip2.tar.gz",
+ "sha256": "6159cb54aa44a9d8219e29bf0aea9404213b20ff567506fe75f23d4698d6ec18"
}
},
"auth_summary": {
diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json
index 0446a442..66cecf1d 100644
--- a/registry/channels/feishu.json
+++ b/registry/channels/feishu.json
@@ -17,7 +17,12 @@
"capabilities": "feishu.capabilities.json",
"crate_name": "feishu-channel"
},
- "artifacts": {},
+ "artifacts": {
+ "wasm32-wasip2": {
+ "sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
+ }
+ },
"auth_summary": {
"method": "manual",
"provider": "Feishu / Lark",
diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json
index e44061e5..bd07208f 100644
--- a/registry/channels/telegram.json
+++ b/registry/channels/telegram.json
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.3-wasm32-wasip2.tar.gz",
- "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz",
+ "sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8"
}
},
"auth_summary": {
diff --git a/registry/tools/github.json b/registry/tools/github.json
index e775ac82..e760c4df 100644
--- a/registry/tools/github.json
+++ b/registry/tools/github.json
@@ -19,8 +19,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/github-0.2.0-wasm32-wasip2.tar.gz",
- "sha256": "da9fac56b6f20197a415489bbaec9fefb085a5cf6324cab79ea48a47eb19c13b"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz",
+ "sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758"
}
},
"auth_summary": {
diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json
index a647a153..e4e9808c 100644
--- a/registry/tools/llm-context.json
+++ b/registry/tools/llm-context.json
@@ -21,8 +21,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/latest/download/llm-context-wasm32-wasip2.tar.gz",
- "sha256": "581cc5867ef3b75116b7ddc8161e63dd92befe2b53e6ad8213c007639aa243c3"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz",
+ "sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e"
}
},
"auth_summary": {
diff --git a/registry/tools/slack.json b/registry/tools/slack.json
index 11bd7fff..8e1df989 100644
--- a/registry/tools/slack.json
+++ b/registry/tools/slack.json
@@ -17,8 +17,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/slack-0.2.1-wasm32-wasip2.tar.gz",
- "sha256": "d4667e35126986509d862bc3a0088777305d8f41c75de83c1e223b42312ede48"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz",
+ "sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb"
}
},
"auth_summary": {
diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json
index 680d6fdb..12e58c68 100644
--- a/registry/tools/telegram.json
+++ b/registry/tools/telegram.json
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/telegram-0.2.2-wasm32-wasip2.tar.gz",
- "sha256": "b9a83d5a2d1285ce0ec116b354336a1f245f893291ccb01dffbcaccf89d72aed"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz",
+ "sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4"
}
},
"auth_summary": {
diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json
index 1722c391..5c1dedef 100644
--- a/registry/tools/web-search.json
+++ b/registry/tools/web-search.json
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
- "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/web-search-0.2.0-wasm32-wasip2.tar.gz",
- "sha256": "56834573c54ea2a33cea1eb0f04bbdf59f1ef8d8702995cf431b0921302eeccc"
+ "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz",
+ "sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218"
}
},
"auth_summary": {
diff --git a/release-plz.toml b/release-plz.toml
index ee7037df..b003952d 100644
--- a/release-plz.toml
+++ b/release-plz.toml
@@ -3,4 +3,5 @@ git_release_enable = false
[[package]]
name = "ironclaw_safety"
+publish = false
release = false
diff --git a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md
index 8afa857d..5e64a2b2 100644
--- a/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md
+++ b/skills/ironclaw-workflow-orchestrator/references/workflow-routines.md
@@ -8,15 +8,21 @@ Replace `{{...}}` placeholders before use.
{
"name": "wf-issue-plan",
"description": "Create implementation plan when a new issue arrives",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "issue.opened",
- "event_filters": {
- "repository_name": "{{repository}}"
- },
- "action_type": "full_job",
"prompt": "For issue #{{issue_number}} in {{repository}}, produce a concrete implementation plan with milestones, edge cases, and tests. Post/update an issue comment with the plan.",
- "cooldown_secs": 30
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "issue.opened",
+ "filters": {
+ "repository_name": "{{repository}}"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 30
+ }
}
```
@@ -28,16 +34,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-maintainer-comment-gate-{{maintainer}}",
"description": "React to maintainer guidance comments on issues/PRs",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "pr.comment.created",
- "event_filters": {
- "repository_name": "{{repository}}",
- "comment_author": "{{maintainer}}"
- },
- "action_type": "full_job",
"prompt": "Read the maintainer comment and decide: update plan or start/continue implementation. If plan changes are requested, edit the plan artifact first. If implementation is requested, continue on the feature branch and update PR status/comment.",
- "cooldown_secs": 20
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "pr.comment.created",
+ "filters": {
+ "repository_name": "{{repository}}",
+ "comment_author": "{{maintainer}}"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 20
+ }
}
```
@@ -47,15 +59,21 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-pr-monitor-loop",
"description": "Keep PR healthy: address review comments and refresh branch",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "pr.synchronize",
- "event_filters": {
- "repository_name": "{{repository}}"
- },
- "action_type": "full_job",
"prompt": "For PR #{{pr_number}}, collect open review comments and unresolved threads, apply fixes, push branch updates, and summarize remaining blockers. If conflict with {{main_branch}}, rebase/merge from origin/{{main_branch}} and resolve safely.",
- "cooldown_secs": 20
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "pr.synchronize",
+ "filters": {
+ "repository_name": "{{repository}}"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 20
+ }
}
```
@@ -65,16 +83,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-ci-fix-loop",
"description": "Fix failing CI checks on active PRs",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "ci.check_run.completed",
- "event_filters": {
- "repository_name": "{{repository}}",
- "ci_conclusion": "failure"
- },
- "action_type": "full_job",
"prompt": "Find failing check details for PR #{{pr_number}}, implement minimal safe fixes, rerun or await CI, and post concise status updates. Prioritize deterministic and test-backed fixes.",
- "cooldown_secs": 20
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "ci.check_run.completed",
+ "filters": {
+ "repository_name": "{{repository}}",
+ "ci_conclusion": "failure"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 20
+ }
}
```
@@ -84,11 +108,17 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-staging-batch-review",
"description": "Batch correctness review through staging, then merge to main",
- "trigger_type": "cron",
- "schedule": "0 0 */{{batch_interval_hours}} * * *",
- "action_type": "full_job",
"prompt": "Every cycle: list ready PRs, merge ready ones into {{staging_branch}}, run deep correctness analysis in batch, fix discovered issues on affected branches, ensure CI green, then merge {{staging_branch}} into {{main_branch}} if clean.",
- "cooldown_secs": 120
+ "request": {
+ "kind": "cron",
+ "schedule": "0 0 */{{batch_interval_hours}} * * *"
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 120
+ }
}
```
@@ -98,16 +128,22 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
{
"name": "wf-learning-memory",
"description": "Capture merge learnings into shared memory",
- "trigger_type": "system_event",
- "event_source": "github",
- "event_type": "pr.closed",
- "event_filters": {
- "repository_name": "{{repository}}",
- "pr_merged": "true"
- },
- "action_type": "full_job",
"prompt": "From merged PR #{{pr_number}}, extract preventable mistakes, reviewer themes, CI failure causes, and successful patterns. Write/update a shared memory doc with actionable rules to reduce cycle time and regressions.",
- "cooldown_secs": 30
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "pr.closed",
+ "filters": {
+ "repository_name": "{{repository}}",
+ "pr_merged": "true"
+ }
+ },
+ "execution": {
+ "mode": "full_job"
+ },
+ "advanced": {
+ "cooldown_secs": 30
+ }
}
```
@@ -115,7 +151,7 @@ Trigger per-maintainer by creating one routine per handle, or maintain a shared
```json
{
- "source": "github",
+ "event_source": "github",
"event_type": "issue.opened",
"payload": {
"repository_name": "{{repository}}",
diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs
index 83d971ef..132ba4a1 100644
--- a/src/agent/agent_loop.rs
+++ b/src/agent/agent_loop.rs
@@ -161,9 +161,10 @@ pub struct Agent {
pub(super) heartbeat_config: Option,
pub(super) hygiene_config: Option,
pub(super) routine_config: Option,
- /// Optional slot to expose the routine engine to the gateway for manual triggering.
+ /// Shared routine-engine slot used for internal event matching and for exposing
+ /// the engine to gateway/manual trigger entry points.
pub(super) routine_engine_slot:
- Option>>>>,
+ Arc>>>,
}
impl Agent {
@@ -228,16 +229,21 @@ impl Agent {
heartbeat_config,
hygiene_config,
routine_config,
- routine_engine_slot: None,
+ routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)),
}
}
- /// Set the routine engine slot for exposing the engine to the gateway.
+ /// Replace the routine-engine slot with a shared one so the gateway and
+ /// agent reference the same engine.
pub fn set_routine_engine_slot(
&mut self,
slot: Arc>>>,
) {
- self.routine_engine_slot = Some(slot);
+ self.routine_engine_slot = slot;
+ }
+
+ async fn routine_engine(&self) -> Option> {
+ self.routine_engine_slot.read().await.clone()
}
// Convenience accessors
@@ -633,9 +639,7 @@ impl Agent {
// via a local to use in the message loop below.
// Expose engine to gateway for manual triggering
- if let Some(ref slot) = self.routine_engine_slot {
- *slot.write().await = Some(Arc::clone(&engine));
- }
+ *self.routine_engine_slot.write().await = Some(Arc::clone(&engine));
tracing::debug!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
@@ -655,9 +659,6 @@ impl Agent {
None
};
- // Extract engine ref for use in message loop
- let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
-
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
@@ -693,29 +694,6 @@ impl Agent {
// Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await;
- // Event-triggered routines consume plain user input before it enters
- // the normal chat/tool pipeline. This avoids a duplicate turn where
- // the main agent responds and the routine also fires on the same
- // inbound message.
- if !message.is_internal
- && matches!(
- SubmissionParser::parse(&message.content),
- Submission::UserInput { .. }
- )
- && let Some(ref engine) = routine_engine_for_loop
- {
- let fired = engine.check_event_triggers(&message).await;
- if fired > 0 {
- tracing::debug!(
- channel = %message.channel,
- user = %message.user_id,
- fired,
- "Consumed inbound user message with matching event-triggered routine(s)"
- );
- continue;
- }
- }
-
match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
@@ -1032,6 +1010,24 @@ impl Agent {
message.content.len()
);
+ if !message.is_internal
+ && let Submission::UserInput { ref content } = submission
+ && let Some(engine) = self.routine_engine().await
+ {
+ let fired = engine
+ .check_event_triggers(&message.user_id, &message.channel, content)
+ .await;
+ if fired > 0 {
+ tracing::debug!(
+ channel = %message.channel,
+ user = %message.user_id,
+ fired,
+ "Consumed inbound user message with matching event-triggered routine(s)"
+ );
+ return Ok(Some(String::new()));
+ }
+ }
+
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs
index 519f16c2..2487ac05 100644
--- a/src/agent/routine_engine.rs
+++ b/src/agent/routine_engine.rs
@@ -10,6 +10,7 @@
//! Lightweight routines execute inline (single LLM call, no scheduler slot).
//! Full-job routines are delegated to the existing `Scheduler`.
+use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
@@ -23,19 +24,19 @@ use crate::agent::Scheduler;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
};
-use crate::channels::{IncomingMessage, OutgoingResponse};
+use crate::channels::OutgoingResponse;
use crate::config::RoutineConfig;
-use crate::context::JobContext;
+use crate::context::{JobContext, JobState};
use crate::db::Database;
use crate::error::RoutineError;
use crate::llm::{
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
};
-use crate::safety::SafetyLayer;
use crate::tools::{
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
};
use crate::workspace::Workspace;
+use ironclaw_safety::SafetyLayer;
enum EventMatcher {
Message { routine: Routine, regex: Regex },
@@ -60,6 +61,10 @@ pub struct RoutineEngine {
tools: Arc,
/// Safety layer for tool output sanitization.
safety: Arc,
+ /// Timestamp when this engine instance was created. Used by
+ /// `sync_dispatched_runs` to distinguish orphaned runs (from a previous
+ /// process) from actively-watched runs (from this process).
+ boot_time: chrono::DateTime,
}
impl RoutineEngine {
@@ -85,9 +90,16 @@ impl RoutineEngine {
scheduler,
tools,
safety,
+ boot_time: Utc::now(),
}
}
+ /// Expose the running count for integration tests.
+ #[doc(hidden)]
+ pub fn running_count_for_test(&self) -> &Arc {
+ &self.running_count
+ }
+
/// Refresh the in-memory event trigger cache from DB.
pub async fn refresh_event_cache(&self) {
match self.store.list_event_routines().await {
@@ -135,10 +147,19 @@ impl RoutineEngine {
/// Check incoming message against event triggers. Returns number of routines fired.
///
- /// Called synchronously from the main loop after handle_message(). The actual
- /// execution is spawned async so this returns quickly.
- pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
+ /// Accepts only the three fields needed for matching (user scope, channel,
+ /// message content) so callers never need to clone a full `IncomingMessage`.
+ pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
let cache = self.event_cache.read().await;
+
+ // Early return if there are no message matchers at all.
+ if !cache
+ .iter()
+ .any(|m| matches!(m, EventMatcher::Message { .. }))
+ {
+ return 0;
+ }
+
let mut fired = 0;
// Collect routine IDs for batch query
@@ -155,16 +176,9 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
- let concurrent_counts = match self
- .store
- .count_running_routine_runs_batch(&routine_ids)
- .await
- {
- Ok(counts) => counts,
- Err(e) => {
- tracing::error!("Failed to batch-load concurrent counts: {}", e);
- return 0;
- }
+ let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
+ Some(counts) => counts,
+ None => return 0,
};
for matcher in cache.iter() {
@@ -173,7 +187,7 @@ impl RoutineEngine {
EventMatcher::System { .. } => continue,
};
- if routine.user_id != message.user_id {
+ if routine.user_id != user_id {
continue;
}
@@ -181,13 +195,13 @@ impl RoutineEngine {
if let Trigger::Event {
channel: Some(ch), ..
} = &routine.trigger
- && ch != &message.channel
+ && ch != channel
{
continue;
}
// Regex match
- if !re.is_match(&message.content) {
+ if !re.is_match(content) {
continue;
}
@@ -210,7 +224,7 @@ impl RoutineEngine {
continue;
}
- let detail = truncate(&message.content, 200);
+ let detail = truncate(content, 200);
self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1;
}
@@ -229,6 +243,15 @@ impl RoutineEngine {
user_id: Option<&str>,
) -> usize {
let cache = self.event_cache.read().await;
+
+ // Early return if there are no system-event matchers at all.
+ if !cache
+ .iter()
+ .any(|m| matches!(m, EventMatcher::System { .. }))
+ {
+ return 0;
+ }
+
let mut fired = 0;
// Collect routine IDs for batch query
@@ -245,19 +268,9 @@ impl RoutineEngine {
}
// Single batch query instead of N queries
- let concurrent_counts = match self
- .store
- .count_running_routine_runs_batch(&routine_ids)
- .await
- {
- Ok(counts) => counts,
- Err(e) => {
- tracing::error!(
- "Failed to batch-load concurrent counts for system events: {}",
- e
- );
- return 0;
- }
+ let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
+ Some(counts) => counts,
+ None => return 0,
};
for matcher in cache.iter() {
@@ -331,6 +344,23 @@ impl RoutineEngine {
fired
}
+ /// Batch-load concurrent run counts for a set of routine IDs.
+ ///
+ /// Returns `None` on database error (already logged).
+ async fn batch_concurrent_counts(&self, routine_ids: &[Uuid]) -> Option> {
+ match self
+ .store
+ .count_running_routine_runs_batch(routine_ids)
+ .await
+ {
+ Ok(counts) => Some(counts),
+ Err(e) => {
+ tracing::error!("Failed to batch-load concurrent counts: {}", e);
+ None
+ }
+ }
+ }
+
/// Check all due cron routines and fire them. Called by the cron ticker.
pub async fn check_cron_triggers(&self) {
let routines = match self.store.list_due_cron_routines().await {
@@ -365,6 +395,230 @@ impl RoutineEngine {
}
}
+ /// Reconcile orphaned full_job routine runs with their linked job outcomes.
+ ///
+ /// Called on each cron tick. Finds routine runs that are still `running`
+ /// with a linked `job_id`, checks the job state, and finalizes the run
+ /// when the job reaches a completed or terminal state.
+ ///
+ /// Only processes runs started **before** this engine's boot time, so it
+ /// never races with `FullJobWatcher` instances from the current process.
+ /// This makes it safe to call on every tick as a crash-recovery mechanism.
+ pub async fn sync_dispatched_runs(&self) {
+ let runs = match self.store.list_dispatched_routine_runs().await {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::error!("Failed to list dispatched routine runs: {}", e);
+ return;
+ }
+ };
+
+ // Only process runs from a previous process instance. Runs started
+ // after boot_time are actively watched by a FullJobWatcher in this
+ // process and should not be finalized here.
+ let orphaned: Vec<_> = runs
+ .into_iter()
+ .filter(|r| r.started_at < self.boot_time)
+ .collect();
+
+ if orphaned.is_empty() {
+ return;
+ }
+
+ tracing::info!(
+ "Recovering {} orphaned dispatched routine runs",
+ orphaned.len()
+ );
+
+ for run in orphaned {
+ let job_id = match run.job_id {
+ Some(id) => id,
+ None => continue, // Should not happen (query filters), but guard anyway
+ };
+
+ // Fetch the linked job
+ let job = match self.store.get_job(job_id).await {
+ Ok(Some(j)) => j,
+ Ok(None) => {
+ // Orphaned: job record was deleted or never persisted
+ tracing::warn!(
+ run_id = %run.id,
+ job_id = %job_id,
+ "Linked job not found, marking routine run as failed"
+ );
+ self.complete_dispatched_run(
+ &run,
+ RunStatus::Failed,
+ &format!("Linked job {job_id} not found (orphaned)"),
+ )
+ .await;
+ continue;
+ }
+ Err(e) => {
+ tracing::error!(
+ run_id = %run.id,
+ job_id = %job_id,
+ "Failed to fetch linked job: {}", e
+ );
+ continue;
+ }
+ };
+
+ // Map job state to final run status
+ let final_status = match job.state {
+ JobState::Completed | JobState::Submitted | JobState::Accepted => {
+ Some(RunStatus::Ok)
+ }
+ JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
+ // Pending, InProgress, Stuck — still running
+ _ => None,
+ };
+
+ let status = match final_status {
+ Some(s) => s,
+ None => continue, // Job still active, check again next tick
+ };
+
+ // Build summary
+ let summary = if status == RunStatus::Failed {
+ match self.store.get_agent_job_failure_reason(job_id).await {
+ Ok(Some(reason)) => format!("Job {job_id} failed: {reason}"),
+ _ => format!("Job {job_id} {}", job.state),
+ }
+ } else {
+ format!("Job {job_id} completed successfully")
+ };
+
+ self.complete_dispatched_run(&run, status, &summary).await;
+ }
+ }
+
+ /// Finalize a dispatched routine run: update DB, update routine runtime,
+ /// persist to conversation thread, and send notification.
+ async fn complete_dispatched_run(&self, run: &RoutineRun, status: RunStatus, summary: &str) {
+ // Complete the run record in DB
+ if let Err(e) = self
+ .store
+ .complete_routine_run(run.id, status, Some(summary), None)
+ .await
+ {
+ tracing::error!(
+ run_id = %run.id,
+ "Failed to complete dispatched routine run: {}", e
+ );
+ return;
+ }
+
+ tracing::info!(
+ run_id = %run.id,
+ status = %status,
+ "Finalized dispatched routine run"
+ );
+
+ // Load the routine to update consecutive_failures and send notification
+ let routine = match self.store.get_routine(run.routine_id).await {
+ Ok(Some(r)) => r,
+ Ok(None) => {
+ tracing::warn!(
+ run_id = %run.id,
+ routine_id = %run.routine_id,
+ "Routine not found for dispatched run finalization"
+ );
+ return;
+ }
+ Err(e) => {
+ tracing::error!(
+ run_id = %run.id,
+ "Failed to load routine for dispatched run: {}", e
+ );
+ return;
+ }
+ };
+
+ // Update runtime fields. In crash recovery, execute_routine() never
+ // reached its normal runtime update, so we must advance all fields here.
+ let new_failures = if status == RunStatus::Failed {
+ routine.consecutive_failures + 1
+ } else {
+ 0
+ };
+
+ let now = Utc::now();
+ let next_fire = if let Trigger::Cron {
+ ref schedule,
+ ref timezone,
+ } = routine.trigger
+ {
+ next_cron_fire(schedule, timezone.as_deref()).unwrap_or(None)
+ } else {
+ None
+ };
+
+ if let Err(e) = self
+ .store
+ .update_routine_runtime(
+ routine.id,
+ now,
+ next_fire,
+ routine.run_count + 1,
+ new_failures,
+ &routine.state,
+ )
+ .await
+ {
+ tracing::error!(
+ routine = %routine.name,
+ "Failed to update routine runtime after dispatched run: {}", e
+ );
+ }
+
+ // Persist result to the routine's conversation thread
+ let thread_id = match self
+ .store
+ .get_or_create_routine_conversation(routine.id, &routine.name, &routine.user_id)
+ .await
+ {
+ Ok(conv_id) => {
+ let msg = format!("[dispatched] {}: {}", status, summary);
+ if let Err(e) = self
+ .store
+ .add_conversation_message(conv_id, "assistant", &msg)
+ .await
+ {
+ tracing::error!(
+ routine = %routine.name,
+ "Failed to persist dispatched run message: {}", e
+ );
+ }
+ Some(conv_id.to_string())
+ }
+ Err(e) => {
+ tracing::error!(
+ routine = %routine.name,
+ "Failed to get routine conversation: {}", e
+ );
+ None
+ }
+ };
+
+ // Send notification
+ send_notification(
+ &self.notify_tx,
+ &routine.notify,
+ &routine.user_id,
+ &routine.name,
+ status,
+ Some(summary),
+ thread_id.as_deref(),
+ )
+ .await;
+
+ // Note: we do NOT decrement running_count here. In normal flow,
+ // execute_routine() handles that after FullJobWatcher returns.
+ // This sync path only runs for crash recovery (process restarted),
+ // where running_count was already reset to 0.
+ }
+
/// Fire a routine manually (from tool call or CLI).
///
/// Bypasses cooldown checks (those only apply to cron/event triggers).
@@ -508,6 +762,92 @@ impl RoutineEngine {
}
}
+/// Watches a dispatched full_job until the linked scheduler job completes.
+///
+/// Polls `store.get_job(job_id)` at a fixed interval until the job leaves
+/// an active state (Pending/InProgress/Stuck). Maps the final `JobState` to
+/// a `RunStatus` for the routine run.
+struct FullJobWatcher {
+ store: Arc,
+ job_id: Uuid,
+ routine_name: String,
+}
+
+impl FullJobWatcher {
+ /// Poll interval between DB checks.
+ const POLL_INTERVAL: Duration = Duration::from_secs(5);
+ /// Safety ceiling: 24 hours, derived from POLL_INTERVAL.
+ const MAX_POLLS: u32 = (24 * 60 * 60) / Self::POLL_INTERVAL.as_secs() as u32;
+
+ fn new(store: Arc, job_id: Uuid, routine_name: String) -> Self {
+ Self {
+ store,
+ job_id,
+ routine_name,
+ }
+ }
+
+ /// Block until the linked job finishes and return the mapped status + summary.
+ async fn wait_for_completion(&self) -> (RunStatus, Option) {
+ let mut polls = 0u32;
+
+ let final_status = loop {
+ // Check job state before sleeping so we finalize promptly
+ // if the job is already done (e.g. fast-failing jobs).
+ match self.store.get_job(self.job_id).await {
+ Ok(Some(job_ctx)) => {
+ // Use is_parallel_blocking (Pending/InProgress/Stuck) instead
+ // of is_active (!is_terminal) because routine jobs typically
+ // stop at Completed — which is NOT terminal but IS finished
+ // from an execution standpoint.
+ if !job_ctx.state.is_parallel_blocking() {
+ break Self::map_job_state(&job_ctx.state);
+ }
+ }
+ Ok(None) => {
+ tracing::warn!(
+ routine = %self.routine_name,
+ job_id = %self.job_id,
+ "full_job disappeared from DB while polling"
+ );
+ break RunStatus::Failed;
+ }
+ Err(e) => {
+ tracing::error!(
+ routine = %self.routine_name,
+ job_id = %self.job_id,
+ "Error polling full_job state: {}", e
+ );
+ break RunStatus::Failed;
+ }
+ }
+
+ polls += 1;
+ if polls >= Self::MAX_POLLS {
+ tracing::error!(
+ routine = %self.routine_name,
+ job_id = %self.job_id,
+ "full_job timed out after 24 hours, treating as failed"
+ );
+ break RunStatus::Failed;
+ }
+
+ tokio::time::sleep(Self::POLL_INTERVAL).await;
+ };
+
+ let summary = format!("Job {} finished ({})", self.job_id, final_status);
+ (final_status, Some(summary))
+ }
+
+ fn map_job_state(state: &crate::context::JobState) -> RunStatus {
+ use crate::context::JobState;
+ match state {
+ JobState::Failed | JobState::Cancelled => RunStatus::Failed,
+ _ => RunStatus::Ok, // Completed / Submitted / Accepted
+ }
+ }
+}
+
/// Shared context passed to the execution function.
struct EngineContext {
config: RoutineConfig,
@@ -682,8 +1022,10 @@ fn sanitize_routine_name(name: &str) -> String {
///
/// Fire-and-forget: creates a job via `Scheduler::dispatch_job` (which handles
/// creation, metadata, persistence, and scheduling), links the routine run to
-/// the job, and returns immediately. The job runs independently via the
-/// existing Worker/Scheduler with full tool access.
+/// the job, then watches it via `FullJobWatcher` until it reaches a
+/// non-active state (not Pending/InProgress/Stuck). Returns the final
+/// `RunStatus` mapped from the job outcome. This keeps the routine run
+/// active for the full job lifetime so concurrency guardrails apply.
async fn execute_full_job(
ctx: &EngineContext,
routine: &Routine,
@@ -726,25 +1068,30 @@ async fn execute_full_job(
reason: format!("failed to dispatch job: {e}"),
})?;
- // Link the routine run to the dispatched job
- if let Err(e) = ctx.store.link_routine_run_to_job(run.id, job_id).await {
- tracing::error!(
- routine = %routine.name,
- "Failed to link run to job: {}", e
- );
- }
+ // Link the routine run to the dispatched job.
+ // This MUST succeed — if it fails, sync_dispatched_runs() will never find
+ // this run (it filters on job_id IS NOT NULL), leaving it stuck as 'running'
+ // with running_count permanently elevated.
+ ctx.store
+ .link_routine_run_to_job(run.id, job_id)
+ .await
+ .map_err(|e| RoutineError::Database {
+ reason: format!("failed to link run to job: {e}"),
+ })?;
tracing::info!(
routine = %routine.name,
job_id = %job_id,
max_iterations = max_iterations,
- "Dispatched full job for routine"
+ "Dispatched full job for routine, watching for completion"
);
- let summary = format!(
- "Dispatched job {job_id} for full execution with tool access (max_iterations: {max_iterations})"
- );
- Ok((RunStatus::Ok, Some(summary), None))
+ // Watch the job until it finishes — keeps the routine run active
+ // so concurrency guardrails (running_count, routine_runs status)
+ // remain enforced for the full job lifetime.
+ let watcher = FullJobWatcher::new(ctx.store.clone(), job_id, routine.name.clone());
+ let (status, summary) = watcher.wait_for_completion().await;
+ Ok((status, summary, None))
}
/// Execute a lightweight routine with optional tool support.
@@ -784,23 +1131,12 @@ async fn execute_lightweight(
Err(_) => None,
};
- // Build the user-facing prompt
- let mut full_prompt = String::new();
- full_prompt.push_str(prompt);
-
- if !context_parts.is_empty() {
- full_prompt.push_str("\n\n---\n\n# Context\n\n");
- full_prompt.push_str(&context_parts.join("\n\n"));
- }
-
- if let Some(state) = &state_content {
- full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
- full_prompt.push_str(state);
- }
-
- full_prompt.push_str(
- "\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
- If something needs attention, provide a concise summary.",
+ let full_prompt = build_lightweight_prompt(
+ prompt,
+ &context_parts,
+ state_content.as_deref(),
+ &routine.notify,
+ use_tools,
);
// Get system prompt
@@ -844,6 +1180,65 @@ async fn execute_lightweight(
}
}
+fn build_lightweight_prompt(
+ prompt: &str,
+ context_parts: &[String],
+ state_content: Option<&str>,
+ notify: &NotifyConfig,
+ use_tools: bool,
+) -> String {
+ let mut full_prompt = String::new();
+ full_prompt.push_str(prompt);
+
+ if notify.on_attention {
+ full_prompt.push_str("\n\n---\n\n# Delivery\n\n");
+ full_prompt.push_str(
+ "If you reply with anything other than ROUTINE_OK, the host will deliver your \
+ reply as the routine notification. Return the message exactly as it should be sent.\n",
+ );
+
+ if let Some(channel) = notify.channel.as_deref() {
+ full_prompt.push_str(&format!(
+ "The configured delivery channel for this routine is `{channel}`.\n"
+ ));
+ }
+
+ if let Some(user) = notify.user.as_deref() {
+ full_prompt.push_str(&format!(
+ "The configured delivery target for this routine is `{user}`.\n"
+ ));
+ }
+
+ full_prompt.push_str(
+ "Do not claim you lack messaging integrations or ask the user to set one up when \
+ a plain reply is sufficient.\n",
+ );
+ }
+
+ if !use_tools {
+ full_prompt.push_str(
+ "\nTools are disabled for this routine run. Do not ask to call tools or describe tool limitations unless they prevent a necessary external action.\n",
+ );
+ }
+
+ if !context_parts.is_empty() {
+ full_prompt.push_str("\n\n---\n\n# Context\n\n");
+ full_prompt.push_str(&context_parts.join("\n\n"));
+ }
+
+ if let Some(state) = state_content {
+ full_prompt.push_str("\n\n---\n\n# Previous State\n\n");
+ full_prompt.push_str(state);
+ }
+
+ full_prompt.push_str(
+ "\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\
+ If something needs attention, provide a concise summary.",
+ );
+
+ full_prompt
+}
+
/// Execute a lightweight routine without tool support (original single-call behavior).
async fn execute_lightweight_no_tools(
ctx: &EngineContext,
@@ -901,8 +1296,8 @@ fn handle_text_response(
};
}
- // Check for the "nothing to do" sentinel
- if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
+ // Check for the "nothing to do" sentinel (exact match on trimmed content).
+ if content == "ROUTINE_OK" {
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
return Ok((RunStatus::Ok, None, total_tokens));
}
@@ -1268,14 +1663,22 @@ pub fn spawn_cron_ticker(
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
- // Run one check immediately so routines due at startup don't wait
- // an extra full polling interval.
+ // Recover orphaned runs from a previous process crash before
+ // dispatching any new work, so we don't confuse fresh dispatches
+ // with crash orphans.
+ engine.sync_dispatched_runs().await;
+
+ // Run one cron check immediately so routines due at startup don't
+ // wait an extra full polling interval.
engine.check_cron_triggers().await;
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
+ // Sync first: only processes runs from before boot_time, so it
+ // never races with FullJobWatcher instances from this process.
+ engine.sync_dispatched_runs().await;
engine.check_cron_triggers().await;
}
})
@@ -1385,22 +1788,78 @@ mod tests {
}
}
+ #[test]
+ fn test_build_lightweight_prompt_explains_delivery_and_disabled_tools() {
+ let notify = NotifyConfig {
+ channel: Some("telegram".to_string()),
+ user: Some("default".to_string()),
+ on_attention: true,
+ on_failure: true,
+ on_success: false,
+ };
+
+ let prompt = super::build_lightweight_prompt(
+ "Send a Telegram reminder message to the user.",
+ &[],
+ None,
+ ¬ify,
+ false,
+ );
+
+ assert!(
+ prompt.contains("the host will deliver your reply as the routine notification"),
+ "delivery guidance should explain host delivery: {prompt}",
+ );
+ assert!(
+ prompt.contains("configured delivery channel for this routine is `telegram`"),
+ "delivery guidance should mention telegram channel: {prompt}",
+ );
+ assert!(
+ prompt.contains("Do not claim you lack messaging integrations"),
+ "delivery guidance should suppress fake setup chatter: {prompt}",
+ );
+ assert!(
+ prompt.contains("Tools are disabled for this routine run"),
+ "prompt should explain that tools are disabled: {prompt}",
+ );
+ }
+
+ #[test]
+ fn test_build_lightweight_prompt_skips_delivery_block_when_attention_notifications_disabled() {
+ let notify = NotifyConfig {
+ on_attention: false,
+ ..NotifyConfig::default()
+ };
+
+ let prompt = super::build_lightweight_prompt("Check inbox.", &[], None, ¬ify, true);
+
+ assert!(
+ !prompt.contains("# Delivery"),
+ "prompt should not include delivery guidance when attention notifications are off: {prompt}",
+ );
+ assert!(
+ !prompt.contains("Tools are disabled for this routine run"),
+ "prompt should not claim tools are disabled when they are enabled: {prompt}",
+ );
+ }
+
#[test]
fn test_routine_sentinel_detection_exact_match() {
- // The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
- // After trim(), whitespace is removed
+ // Sentinel detection uses exact match on trimmed content to avoid
+ // false positives from substrings like "NOT_ROUTINE_OK".
let test_cases = vec![
("ROUTINE_OK", true),
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
- ("something ROUTINE_OK something", true),
- ("ROUTINE_OK is done", true),
- ("done ROUTINE_OK", true),
+ ("something ROUTINE_OK something", false), // substring no longer matches
+ ("ROUTINE_OK is done", false), // substring no longer matches
+ ("done ROUTINE_OK", false), // substring no longer matches
+ ("NOT_ROUTINE_OK", false), // exact match prevents this
("no sentinel here", false),
];
for (content, should_match) in test_cases {
let trimmed = content.trim();
- let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
+ let matches = trimmed == "ROUTINE_OK";
assert_eq!(
matches, should_match,
"Content '{}' sentinel detection should be {}, got {}",
@@ -1514,4 +1973,86 @@ mod tests {
assert_eq!(snapshot[1].content, "a"); // safety: test-only no-panics CI false positive
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
}
+
+ /// Regression test for #1317: FullJobWatcher maps terminal job states correctly.
+ #[test]
+ fn test_full_job_watcher_state_mapping() {
+ use crate::context::JobState;
+
+ // Failed/Cancelled → RunStatus::Failed
+ assert_eq!(
+ super::FullJobWatcher::map_job_state(&JobState::Failed),
+ RunStatus::Failed
+ );
+ assert_eq!(
+ super::FullJobWatcher::map_job_state(&JobState::Cancelled),
+ RunStatus::Failed
+ );
+
+ // All other non-active states → RunStatus::Ok
+ assert_eq!(
+ super::FullJobWatcher::map_job_state(&JobState::Completed),
+ RunStatus::Ok
+ );
+ assert_eq!(
+ super::FullJobWatcher::map_job_state(&JobState::Accepted),
+ RunStatus::Ok
+ );
+ }
+
+ /// Verify that job state to run status mapping covers all expected cases.
+ #[test]
+ fn test_job_state_to_run_status_mapping() {
+ use crate::context::JobState;
+
+ // Success states
+ for state in [JobState::Completed, JobState::Submitted, JobState::Accepted] {
+ let status = match state {
+ JobState::Completed | JobState::Submitted | JobState::Accepted => {
+ Some(RunStatus::Ok)
+ }
+ JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
+ _ => None,
+ };
+ assert_eq!(
+ status,
+ Some(RunStatus::Ok),
+ "{:?} should map to RunStatus::Ok",
+ state
+ );
+ }
+
+ // Failure states
+ for state in [JobState::Failed, JobState::Cancelled] {
+ let status = match state {
+ JobState::Completed | JobState::Submitted | JobState::Accepted => {
+ Some(RunStatus::Ok)
+ }
+ JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
+ _ => None,
+ };
+ assert_eq!(
+ status,
+ Some(RunStatus::Failed),
+ "{:?} should map to RunStatus::Failed",
+ state
+ );
+ }
+
+ // Active states (should not finalize)
+ for state in [JobState::Pending, JobState::InProgress, JobState::Stuck] {
+ let status = match state {
+ JobState::Completed | JobState::Submitted | JobState::Accepted => {
+ Some(RunStatus::Ok)
+ }
+ JobState::Failed | JobState::Cancelled => Some(RunStatus::Failed),
+ _ => None,
+ };
+ assert_eq!(
+ status, None,
+ "{:?} should not finalize the routine run",
+ state
+ );
+ }
+ }
}
diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs
index 6ca79831..65f978ac 100644
--- a/src/channels/wasm/wrapper.rs
+++ b/src/channels/wasm/wrapper.rs
@@ -492,8 +492,16 @@ impl near::agent::channel_host::Host for ChannelStoreData {
tracing::debug!(body = %truncated, "Response body");
}
- // Leak detection on response body (best-effort)
- if let Ok(body_str) = std::str::from_utf8(&body) {
+ // Leak detection on response body (best-effort).
+ //
+ // Telegram `getUpdates` is special: it is inbound polling data, so
+ // user-pasted secrets can legitimately appear in the response body.
+ // Those messages are still checked later by the inbound message
+ // safety layer before they reach the LLM, so we allow the polling
+ // response to continue here to avoid poisoning the offset state.
+ if let Ok(body_str) = std::str::from_utf8(&body)
+ && !should_skip_response_leak_scan(&url)
+ {
leak_detector
.scan_and_clean(body_str)
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
@@ -3122,6 +3130,19 @@ fn extract_host_from_url(url: &str) -> Option {
})
}
+fn should_skip_response_leak_scan(url: &str) -> bool {
+ url::Url::parse(url).is_ok_and(|parsed| {
+ matches!(parsed.scheme(), "http" | "https")
+ && parsed
+ .host_str()
+ .is_some_and(|host| host.eq_ignore_ascii_case("api.telegram.org"))
+ && parsed
+ .path_segments()
+ .and_then(|segments| segments.rev().find(|segment| !segment.is_empty()))
+ .is_some_and(|segment| segment == "getUpdates")
+ })
+}
+
/// Pre-resolve host credentials for all HTTP capability mappings.
///
/// Called once per callback (in async context, before spawn_blocking) so the
@@ -4386,6 +4407,22 @@ mod tests {
assert_eq!(store.redact_credentials(input), input);
}
+ #[test]
+ fn test_should_skip_response_leak_scan_only_for_telegram_getupdates() {
+ use super::should_skip_response_leak_scan;
+
+ assert!(should_skip_response_leak_scan(
+ "https://api.telegram.org/bot123/getUpdates?offset=1"
+ ));
+ assert!(!should_skip_response_leak_scan(
+ "https://api.telegram.org/bot123/sendMessage"
+ ));
+ assert!(!should_skip_response_leak_scan(
+ "https://api.example.com/getUpdates"
+ ));
+ assert!(!should_skip_response_leak_scan("not a url"));
+ }
+
/// Verify that WASM HTTP host functions work using a dedicated
/// current-thread runtime inside spawn_blocking.
#[tokio::test]
diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs
index 0d970569..a96f7c7b 100644
--- a/src/channels/web/mod.rs
+++ b/src/channels/web/mod.rs
@@ -102,6 +102,7 @@ impl GatewayChannel {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
+ active_config: server::ActiveConfigSnapshot::default(),
});
Self {
@@ -139,6 +140,7 @@ impl GatewayChannel {
cost_guard: self.state.cost_guard.clone(),
routine_engine: Arc::clone(&self.state.routine_engine),
startup_time: self.state.startup_time,
+ active_config: self.state.active_config.clone(),
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -250,6 +252,12 @@ impl GatewayChannel {
self
}
+ /// Inject the active (resolved) configuration snapshot for the status endpoint.
+ pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
+ self.rebuild_state(|s| s.active_config = config);
+ self
+ }
+
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index 74cfb240..f0bd4c9a 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -126,6 +126,14 @@ impl RateLimiter {
}
}
+/// Snapshot of the active (resolved) configuration exposed to the frontend.
+#[derive(Debug, Clone, Default, serde::Serialize)]
+pub struct ActiveConfigSnapshot {
+ pub llm_backend: String,
+ pub llm_model: String,
+ pub enabled_channels: Vec,
+}
+
/// Shared state for all gateway handlers.
pub struct GatewayState {
/// Channel to send messages to the agent loop.
@@ -177,6 +185,8 @@ pub struct GatewayState {
pub routine_engine: RoutineEngineSlot,
/// Server startup time for uptime calculation.
pub startup_time: std::time::Instant,
+ /// Snapshot of active (resolved) configuration for the frontend.
+ pub active_config: ActiveConfigSnapshot,
}
/// Start the gateway HTTP server.
@@ -2932,6 +2942,9 @@ async fn gateway_status_handler(
daily_cost,
actions_this_hour,
model_usage,
+ llm_backend: state.active_config.llm_backend.clone(),
+ llm_model: state.active_config.llm_model.clone(),
+ enabled_channels: state.active_config.enabled_channels.clone(),
})
}
@@ -2957,6 +2970,9 @@ struct GatewayStatusResponse {
actions_this_hour: Option,
#[serde(skip_serializing_if = "Option::is_none")]
model_usage: Option>,
+ llm_backend: String,
+ llm_model: String,
+ enabled_channels: Vec,
}
#[cfg(test)]
@@ -3153,6 +3169,7 @@ mod tests {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
+ active_config: ActiveConfigSnapshot::default(),
})
}
diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js
index 3d47810b..e5c43296 100644
--- a/src/channels/web/static/app.js
+++ b/src/channels/web/static/app.js
@@ -21,6 +21,7 @@ const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
let stagedImages = [];
let authFlowPending = false;
let _ghostSuggestion = '';
+let currentSettingsSubtab = 'inference';
// --- Slash Commands ---
@@ -135,6 +136,7 @@ function apiFetch(path, options) {
throw new Error(body || (res.status + ' ' + res.statusText));
});
}
+ if (res.status === 204) return null;
return res.json();
});
}
@@ -364,8 +366,8 @@ function connectSSE() {
debouncedLoadThreads();
}
- // Extension setup flows can surface approvals while user is on Extensions tab.
- if (currentTab === 'extensions') loadExtensions();
+ // Extension setup flows can surface approvals from any settings subtab.
+ if (currentTab === 'settings') refreshCurrentSettingsTab();
});
eventSource.addEventListener('auth_required', (e) => {
@@ -373,11 +375,12 @@ function connectSSE() {
});
eventSource.addEventListener('auth_completed', (e) => {
- handleAuthCompleted(JSON.parse(e.data));
+ const data = JSON.parse(e.data);
+ handleAuthCompleted(data);
});
eventSource.addEventListener('extension_status', (e) => {
- if (currentTab === 'extensions') loadExtensions();
+ if (currentTab === 'settings') refreshCurrentSettingsTab();
});
eventSource.addEventListener('image_generated', (e) => {
@@ -1232,7 +1235,7 @@ function handleAuthCompleted(data) {
if (shouldShowChannelConnectedMessage(data.extension_name, data.success)) {
addMessage('system', 'Telegram is now connected. You can message me there and I can send you notifications.');
}
- if (currentTab === 'extensions') loadExtensions();
+ if (currentTab === 'settings') refreshCurrentSettingsTab();
enableChatInput();
}
@@ -1877,14 +1880,12 @@ function switchTab(tab) {
if (tab === 'jobs') loadJobs();
if (tab === 'routines') loadRoutines();
if (tab === 'logs') applyLogFilters();
- if (tab === 'extensions') {
- loadExtensions();
- startPairingPoll();
+ if (tab === 'config') loadConfig();
+ if (tab === 'settings') {
+ loadSettingsSubtab(currentSettingsSubtab);
} else {
stopPairingPoll();
}
- if (tab === 'skills') loadSkills();
- if (tab === 'config') loadConfig();
}
// --- Memory (filesystem tree) ---
@@ -2271,61 +2272,42 @@ var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server':
function loadExtensions() {
const extList = document.getElementById('extensions-list');
const wasmList = document.getElementById('available-wasm-list');
- const mcpList = document.getElementById('mcp-servers-list');
- const toolsTbody = document.getElementById('tools-tbody');
- const toolsEmpty = document.getElementById('tools-empty');
+ extList.innerHTML = renderCardsSkeleton(3);
- // Fetch all three in parallel
+ // Fetch extensions and registry in parallel
Promise.all([
apiFetch('/api/extensions').catch(() => ({ extensions: [] })),
- apiFetch('/api/extensions/tools').catch(() => ({ tools: [] })),
apiFetch('/api/extensions/registry').catch(function(err) { console.warn('registry fetch failed:', err); return { entries: [] }; }),
- ]).then(([extData, toolData, registryData]) => {
- // Render installed extensions
- if (extData.extensions.length === 0) {
+ ]).then(([extData, registryData]) => {
+ // Render installed extensions (exclude wasm_channel and mcp_server — shown in their own tabs)
+ var nonChannelExts = extData.extensions.filter(function(e) {
+ return e.kind !== 'wasm_channel' && e.kind !== 'mcp_server';
+ });
+ if (nonChannelExts.length === 0) {
extList.innerHTML = '' + I18n.t('extensions.noInstalled') + '
';
} else {
extList.innerHTML = '';
- for (const ext of extData.extensions) {
+ for (const ext of nonChannelExts) {
extList.appendChild(renderExtensionCard(ext));
}
}
- // Split registry entries by kind
- var wasmEntries = registryData.entries.filter(function(e) { return e.kind !== 'mcp_server' && !e.installed; });
- var mcpEntries = registryData.entries.filter(function(e) { return e.kind === 'mcp_server'; });
+ // Available extensions (exclude MCP servers and channels — they have their own tabs)
+ var wasmEntries = registryData.entries.filter(function(e) {
+ return e.kind !== 'mcp_server' && e.kind !== 'wasm_channel' && e.kind !== 'channel' && !e.installed;
+ });
- // Available WASM extensions
+ var wasmSection = document.getElementById('available-wasm-section');
if (wasmEntries.length === 0) {
- wasmList.innerHTML = '' + I18n.t('extensions.noAvailable') + '
';
+ if (wasmSection) wasmSection.style.display = 'none';
} else {
+ if (wasmSection) wasmSection.style.display = '';
wasmList.innerHTML = '';
for (const entry of wasmEntries) {
wasmList.appendChild(renderAvailableExtensionCard(entry));
}
}
- // MCP servers (show both installed and uninstalled)
- if (mcpEntries.length === 0) {
- mcpList.innerHTML = '' + I18n.t('mcp.noServers') + '
';
- } else {
- mcpList.innerHTML = '';
- for (const entry of mcpEntries) {
- var installedExt = extData.extensions.find(function(e) { return e.name === entry.name; });
- mcpList.appendChild(renderMcpServerCard(entry, installedExt));
- }
- }
-
- // Render tools
- if (toolData.tools.length === 0) {
- toolsTbody.innerHTML = '';
- toolsEmpty.style.display = 'block';
- } else {
- toolsEmpty.style.display = 'none';
- toolsTbody.innerHTML = toolData.tools.map((t) =>
- '| ' + escapeHtml(t.name) + ' | ' + escapeHtml(t.description) + ' |
'
- ).join('');
- }
});
}
@@ -2391,18 +2373,18 @@ function renderAvailableExtensionCard(entry) {
showToast('Opening authentication for ' + entry.display_name, 'info');
openOAuthUrl(res.auth_url);
}
- loadExtensions();
+ refreshCurrentSettingsTab();
// Auto-open configure for WASM channels
if (entry.kind === 'wasm_channel') {
showConfigureModal(entry.name);
}
} else {
showToast('Install: ' + (res.message || 'unknown error'), 'error');
- loadExtensions();
+ refreshCurrentSettingsTab();
}
}).catch(function(err) {
showToast('Install failed: ' + err.message, 'error');
- loadExtensions();
+ refreshCurrentSettingsTab();
});
});
actions.appendChild(installBtn);
@@ -2458,6 +2440,13 @@ function renderMcpServerCard(entry, installedExt) {
activeLabel.textContent = I18n.t('ext.active');
actions.appendChild(activeLabel);
}
+ if (installedExt.needs_setup || (installedExt.has_auth && installedExt.authenticated)) {
+ var configBtn = document.createElement('button');
+ configBtn.className = 'btn-ext configure';
+ configBtn.textContent = installedExt.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
+ configBtn.addEventListener('click', function() { showConfigureModal(installedExt.name); });
+ actions.appendChild(configBtn);
+ }
var removeBtn = document.createElement('button');
removeBtn.className = 'btn-ext remove';
removeBtn.textContent = I18n.t('ext.remove');
@@ -2479,10 +2468,10 @@ function renderMcpServerCard(entry, installedExt) {
} else {
showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error');
}
- loadExtensions();
+ loadMcpServers();
}).catch(function(err) {
showToast(I18n.t('ext.installFailed', { message: err.message }), 'error');
- loadExtensions();
+ loadMcpServers();
});
});
actions.appendChild(installBtn);
@@ -2502,7 +2491,16 @@ function createReconfigureButton(extName) {
function renderExtensionCard(ext) {
const card = document.createElement('div');
- card.className = 'ext-card';
+ var stateClass = 'state-inactive';
+ if (ext.kind === 'wasm_channel') {
+ var s = ext.activation_status || 'installed';
+ if (s === 'active') stateClass = 'state-active';
+ else if (s === 'failed') stateClass = 'state-error';
+ else if (s === 'pairing') stateClass = 'state-pairing';
+ } else if (ext.active) {
+ stateClass = 'state-active';
+ }
+ card.className = 'ext-card ' + stateClass;
const header = document.createElement('div');
header.className = 'ext-header';
@@ -2647,6 +2645,12 @@ function renderExtensionCard(ext) {
return card;
}
+function refreshCurrentSettingsTab() {
+ if (currentSettingsSubtab === 'extensions') loadExtensions();
+ if (currentSettingsSubtab === 'channels') loadChannelsStatus();
+ if (currentSettingsSubtab === 'mcp') loadMcpServers();
+}
+
function activateExtension(name) {
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
.then((res) => {
@@ -2660,7 +2664,7 @@ function activateExtension(name) {
showToast('Opening authentication for ' + name, 'info');
openOAuthUrl(res.auth_url);
}
- loadExtensions();
+ refreshCurrentSettingsTab();
return;
}
@@ -2676,23 +2680,24 @@ function activateExtension(name) {
} else {
showToast('Activate failed: ' + res.message, 'error');
}
- loadExtensions();
+ refreshCurrentSettingsTab();
})
.catch((err) => showToast('Activate failed: ' + err.message, 'error'));
}
function removeExtension(name) {
- if (!confirm(I18n.t('ext.confirmRemove', { name: name }))) return;
- apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
- .then((res) => {
- if (!res.success) {
- showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
- } else {
- showToast(I18n.t('ext.removed', { name: name }), 'success');
- }
- loadExtensions();
- })
- .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
+ showConfirmModal(I18n.t('ext.confirmRemove', { name: name }), '', function() {
+ apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
+ .then((res) => {
+ if (!res.success) {
+ showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
+ } else {
+ showToast(I18n.t('ext.removed', { name: name }), 'success');
+ }
+ refreshCurrentSettingsTab();
+ })
+ .catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
+ }, I18n.t('common.remove'), 'btn-danger');
}
function showConfigureModal(name) {
@@ -2970,7 +2975,7 @@ function submitConfigureModal(name, fields, options) {
});
showToast('Opening OAuth authorization for ' + name, 'info');
openOAuthUrl(res.auth_url);
- loadExtensions();
+ refreshCurrentSettingsTab();
}
// For non-OAuth success: the server always broadcasts auth_completed SSE,
// which will show the toast and refresh extensions — no need to do it here too.
@@ -3079,7 +3084,7 @@ function approvePairing(channel, code, container) {
}).then(res => {
if (res.success) {
showToast('Pairing approved', 'success');
- loadExtensions();
+ refreshCurrentSettingsTab();
} else {
showToast(res.message || 'Approve failed', 'error');
}
@@ -4185,7 +4190,7 @@ function addMcpServer() {
showToast('Added MCP server ' + name, 'success');
document.getElementById('mcp-install-name').value = '';
document.getElementById('mcp-install-url').value = '';
- loadExtensions();
+ loadMcpServers();
} else {
showToast('Failed to add MCP server: ' + (res.message || 'unknown error'), 'error');
}
@@ -4198,6 +4203,7 @@ function addMcpServer() {
function loadSkills() {
var skillsList = document.getElementById('skills-list');
+ skillsList.innerHTML = renderCardsSkeleton(3);
apiFetch('/api/skills').then(function(data) {
if (!data.skills || data.skills.length === 0) {
skillsList.innerHTML = '' + I18n.t('skills.noInstalled') + '
';
@@ -4214,7 +4220,7 @@ function loadSkills() {
function renderSkillCard(skill) {
var card = document.createElement('div');
- card.className = 'ext-card';
+ card.className = 'ext-card state-active';
var header = document.createElement('div');
header.className = 'ext-header';
@@ -4481,20 +4487,21 @@ function installSkill(nameOrSlug, url, btn) {
}
function removeSkill(name) {
- if (!confirm(I18n.t('skills.confirmRemove', { name: name }))) return;
- apiFetch('/api/skills/' + encodeURIComponent(name), {
- method: 'DELETE',
- headers: { 'X-Confirm-Action': 'true' },
- }).then(function(res) {
- if (res.success) {
- showToast(I18n.t('skills.removed', { name: name }), 'success');
- } else {
- showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
- }
- loadSkills();
- }).catch(function(err) {
- showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
- });
+ showConfirmModal(I18n.t('skills.confirmRemove', { name: name }), '', function() {
+ apiFetch('/api/skills/' + encodeURIComponent(name), {
+ method: 'DELETE',
+ headers: { 'X-Confirm-Action': 'true' },
+ }).then(function(res) {
+ if (res.success) {
+ showToast(I18n.t('skills.removed', { name: name }), 'success');
+ } else {
+ showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
+ }
+ loadSkills();
+ }).catch(function(err) {
+ showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
+ });
+ }, I18n.t('common.remove'), 'btn-danger');
}
function installSkillFromForm() {
@@ -4523,10 +4530,10 @@ document.addEventListener('keydown', (e) => {
const tag = (e.target.tagName || '').toLowerCase();
const inInput = tag === 'input' || tag === 'textarea';
- // Mod+1-6: switch tabs
- if (mod && e.key >= '1' && e.key <= '6') {
+ // Mod+1-5: switch tabs
+ if (mod && e.key >= '1' && e.key <= '5') {
e.preventDefault();
- const tabs = ['chat', 'memory', 'jobs', 'routines', 'extensions', 'skills'];
+ const tabs = ['chat', 'memory', 'jobs', 'routines', 'settings'];
const idx = parseInt(e.key) - 1;
if (tabs[idx]) switchTab(tabs[idx]);
return;
@@ -4566,6 +4573,684 @@ document.addEventListener('keydown', (e) => {
}
});
+// --- Settings Tab ---
+
+document.querySelectorAll('.settings-subtab').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ switchSettingsSubtab(btn.getAttribute('data-settings-subtab'));
+ });
+});
+
+function switchSettingsSubtab(subtab) {
+ currentSettingsSubtab = subtab;
+ document.querySelectorAll('.settings-subtab').forEach(function(b) {
+ b.classList.toggle('active', b.getAttribute('data-settings-subtab') === subtab);
+ });
+ document.querySelectorAll('.settings-subpanel').forEach(function(p) {
+ p.classList.toggle('active', p.id === 'settings-' + subtab);
+ });
+ // Clear search when switching subtabs so stale filters don't apply
+ var searchInput = document.getElementById('settings-search-input');
+ if (searchInput && searchInput.value) {
+ searchInput.value = '';
+ searchInput.dispatchEvent(new Event('input'));
+ }
+ loadSettingsSubtab(subtab);
+}
+
+function loadSettingsSubtab(subtab) {
+ if (subtab === 'inference') loadInferenceSettings();
+ else if (subtab === 'agent') loadAgentSettings();
+ else if (subtab === 'channels') { loadChannelsStatus(); startPairingPoll(); }
+ else if (subtab === 'networking') loadNetworkingSettings();
+ else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
+ else if (subtab === 'mcp') loadMcpServers();
+ else if (subtab === 'skills') loadSkills();
+ if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
+}
+
+// --- Structured Settings Definitions ---
+
+var INFERENCE_SETTINGS = [
+ {
+ group: 'cfg.group.llm',
+ settings: [
+ { key: 'llm_backend', label: 'cfg.llm_backend.label', description: 'cfg.llm_backend.desc',
+ type: 'select', options: ['nearai', 'anthropic', 'openai', 'ollama', 'openai_compatible', 'tinfoil', 'bedrock'] },
+ { key: 'selected_model', label: 'cfg.selected_model.label', description: 'cfg.selected_model.desc', type: 'text' },
+ { key: 'ollama_base_url', label: 'cfg.ollama_base_url.label', description: 'cfg.ollama_base_url.desc', type: 'text',
+ showWhen: { key: 'llm_backend', value: 'ollama' } },
+ { key: 'openai_compatible_base_url', label: 'cfg.openai_compatible_base_url.label', description: 'cfg.openai_compatible_base_url.desc', type: 'text',
+ showWhen: { key: 'llm_backend', value: 'openai_compatible' } },
+ { key: 'bedrock_region', label: 'cfg.bedrock_region.label', description: 'cfg.bedrock_region.desc', type: 'text',
+ showWhen: { key: 'llm_backend', value: 'bedrock' } },
+ { key: 'bedrock_cross_region', label: 'cfg.bedrock_cross_region.label', description: 'cfg.bedrock_cross_region.desc',
+ type: 'select', options: ['us', 'eu', 'apac', 'global'],
+ showWhen: { key: 'llm_backend', value: 'bedrock' } },
+ { key: 'bedrock_profile', label: 'cfg.bedrock_profile.label', description: 'cfg.bedrock_profile.desc', type: 'text',
+ showWhen: { key: 'llm_backend', value: 'bedrock' } },
+ ]
+ },
+ {
+ group: 'cfg.group.embeddings',
+ settings: [
+ { key: 'embeddings.enabled', label: 'cfg.embeddings_enabled.label', description: 'cfg.embeddings_enabled.desc', type: 'boolean' },
+ { key: 'embeddings.provider', label: 'cfg.embeddings_provider.label', description: 'cfg.embeddings_provider.desc',
+ type: 'select', options: ['openai', 'nearai'] },
+ { key: 'embeddings.model', label: 'cfg.embeddings_model.label', description: 'cfg.embeddings_model.desc', type: 'text' },
+ ]
+ },
+];
+
+var AGENT_SETTINGS = [
+ {
+ group: 'cfg.group.agent',
+ settings: [
+ { key: 'agent.name', label: 'cfg.agent_name.label', description: 'cfg.agent_name.desc', type: 'text' },
+ { key: 'agent.max_parallel_jobs', label: 'cfg.agent_max_parallel_jobs.label', description: 'cfg.agent_max_parallel_jobs.desc', type: 'number' },
+ { key: 'agent.job_timeout_secs', label: 'cfg.agent_job_timeout.label', description: 'cfg.agent_job_timeout.desc', type: 'number' },
+ { key: 'agent.max_tool_iterations', label: 'cfg.agent_max_tool_iterations.label', description: 'cfg.agent_max_tool_iterations.desc', type: 'number' },
+ { key: 'agent.use_planning', label: 'cfg.agent_use_planning.label', description: 'cfg.agent_use_planning.desc', type: 'boolean' },
+ { key: 'agent.auto_approve_tools', label: 'cfg.agent_auto_approve.label', description: 'cfg.agent_auto_approve.desc', type: 'boolean' },
+ { key: 'agent.default_timezone', label: 'cfg.agent_timezone.label', description: 'cfg.agent_timezone.desc', type: 'text' },
+ { key: 'agent.session_idle_timeout_secs', label: 'cfg.agent_session_idle.label', description: 'cfg.agent_session_idle.desc', type: 'number' },
+ { key: 'agent.stuck_threshold_secs', label: 'cfg.agent_stuck_threshold.label', description: 'cfg.agent_stuck_threshold.desc', type: 'number' },
+ { key: 'agent.max_repair_attempts', label: 'cfg.agent_max_repair.label', description: 'cfg.agent_max_repair.desc', type: 'number' },
+ { key: 'agent.max_cost_per_day_cents', label: 'cfg.agent_max_cost.label', description: 'cfg.agent_max_cost.desc', type: 'number', min: 0 },
+ { key: 'agent.max_actions_per_hour', label: 'cfg.agent_max_actions.label', description: 'cfg.agent_max_actions.desc', type: 'number', min: 0 },
+ { key: 'agent.allow_local_tools', label: 'cfg.agent_allow_local.label', description: 'cfg.agent_allow_local.desc', type: 'boolean' },
+ ]
+ },
+ {
+ group: 'cfg.group.heartbeat',
+ settings: [
+ { key: 'heartbeat.enabled', label: 'cfg.heartbeat_enabled.label', description: 'cfg.heartbeat_enabled.desc', type: 'boolean' },
+ { key: 'heartbeat.interval_secs', label: 'cfg.heartbeat_interval.label', description: 'cfg.heartbeat_interval.desc', type: 'number' },
+ { key: 'heartbeat.notify_channel', label: 'cfg.heartbeat_notify_channel.label', description: 'cfg.heartbeat_notify_channel.desc', type: 'text' },
+ { key: 'heartbeat.notify_user', label: 'cfg.heartbeat_notify_user.label', description: 'cfg.heartbeat_notify_user.desc', type: 'text' },
+ { key: 'heartbeat.quiet_hours_start', label: 'cfg.heartbeat_quiet_start.label', description: 'cfg.heartbeat_quiet_start.desc', type: 'number', min: 0, max: 23 },
+ { key: 'heartbeat.quiet_hours_end', label: 'cfg.heartbeat_quiet_end.label', description: 'cfg.heartbeat_quiet_end.desc', type: 'number', min: 0, max: 23 },
+ { key: 'heartbeat.timezone', label: 'cfg.heartbeat_timezone.label', description: 'cfg.heartbeat_timezone.desc', type: 'text' },
+ ]
+ },
+ {
+ group: 'cfg.group.sandbox',
+ settings: [
+ { key: 'sandbox.enabled', label: 'cfg.sandbox_enabled.label', description: 'cfg.sandbox_enabled.desc', type: 'boolean' },
+ { key: 'sandbox.policy', label: 'cfg.sandbox_policy.label', description: 'cfg.sandbox_policy.desc',
+ type: 'select', options: ['readonly', 'workspace_write', 'full_access'] },
+ { key: 'sandbox.timeout_secs', label: 'cfg.sandbox_timeout.label', description: 'cfg.sandbox_timeout.desc', type: 'number', min: 0 },
+ { key: 'sandbox.memory_limit_mb', label: 'cfg.sandbox_memory.label', description: 'cfg.sandbox_memory.desc', type: 'number', min: 0 },
+ { key: 'sandbox.image', label: 'cfg.sandbox_image.label', description: 'cfg.sandbox_image.desc', type: 'text' },
+ ]
+ },
+ {
+ group: 'cfg.group.routines',
+ settings: [
+ { key: 'routines.max_concurrent', label: 'cfg.routines_max_concurrent.label', description: 'cfg.routines_max_concurrent.desc', type: 'number', min: 0 },
+ { key: 'routines.default_cooldown_secs', label: 'cfg.routines_cooldown.label', description: 'cfg.routines_cooldown.desc', type: 'number', min: 0 },
+ ]
+ },
+ {
+ group: 'cfg.group.safety',
+ settings: [
+ { key: 'safety.max_output_length', label: 'cfg.safety_max_output.label', description: 'cfg.safety_max_output.desc', type: 'number', min: 0 },
+ { key: 'safety.injection_check_enabled', label: 'cfg.safety_injection_check.label', description: 'cfg.safety_injection_check.desc', type: 'boolean' },
+ ]
+ },
+ {
+ group: 'cfg.group.skills',
+ settings: [
+ { key: 'skills.max_active', label: 'cfg.skills_max_active.label', description: 'cfg.skills_max_active.desc', type: 'number', min: 0 },
+ { key: 'skills.max_context_tokens', label: 'cfg.skills_max_tokens.label', description: 'cfg.skills_max_tokens.desc', type: 'number', min: 0 },
+ ]
+ },
+ {
+ group: 'cfg.group.search',
+ settings: [
+ { key: 'search.fusion_strategy', label: 'cfg.search_fusion.label', description: 'cfg.search_fusion.desc',
+ type: 'select', options: ['rrf', 'weighted'] },
+ ]
+ },
+];
+
+function renderSettingsSkeleton(rows) {
+ var html = '';
+ for (var i = 0; i < (rows || 5); i++) {
+ var w1 = 100 + Math.floor(Math.random() * 60);
+ var w2 = 140 + Math.floor(Math.random() * 60);
+ html += '
';
+ }
+ html += '
';
+ return html;
+}
+
+function renderCardsSkeleton(count) {
+ var html = '';
+ for (var i = 0; i < (count || 3); i++) {
+ html += '';
+ }
+ return html;
+}
+
+function loadInferenceSettings() {
+ var container = document.getElementById('settings-inference-content');
+ container.innerHTML = renderSettingsSkeleton(6);
+
+ Promise.all([
+ apiFetch('/api/settings/export'),
+ apiFetch('/api/gateway/status').catch(function() { return {}; }),
+ apiFetch('/v1/models').catch(function() { return { data: [] }; })
+ ]).then(function(results) {
+ var settings = results[0].settings || {};
+ var status = results[1];
+ var modelsData = results[2];
+ var activeValues = {
+ 'llm_backend': status.llm_backend,
+ 'selected_model': status.llm_model
+ };
+ // Inject available model IDs as suggestions for the selected_model field
+ var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean);
+ var llmGroup = INFERENCE_SETTINGS[0];
+ for (var i = 0; i < llmGroup.settings.length; i++) {
+ if (llmGroup.settings[i].key === 'selected_model') {
+ llmGroup.settings[i].suggestions = modelIds;
+ break;
+ }
+ }
+ container.innerHTML = '';
+ renderStructuredSettingsInto(container, INFERENCE_SETTINGS, settings, activeValues);
+ }).catch(function(err) {
+ container.innerHTML = '' + I18n.t('common.loadFailed') + ': '
+ + escapeHtml(err.message) + '
';
+ });
+}
+
+function loadAgentSettings() {
+ loadStructuredSettings('settings-agent-content', AGENT_SETTINGS);
+}
+
+function loadStructuredSettings(containerId, settingsDefs) {
+ var container = document.getElementById(containerId);
+ container.innerHTML = renderSettingsSkeleton(8);
+
+ apiFetch('/api/settings/export').then(function(data) {
+ var settings = data.settings || {};
+ container.innerHTML = '';
+ renderStructuredSettingsInto(container, settingsDefs, settings, {});
+ }).catch(function(err) {
+ container.innerHTML = '' + I18n.t('common.loadFailed') + ': '
+ + escapeHtml(err.message) + '
';
+ });
+}
+
+function renderStructuredSettingsInto(container, settingsDefs, settings, activeValues) {
+ for (var gi = 0; gi < settingsDefs.length; gi++) {
+ var groupDef = settingsDefs[gi];
+ var group = document.createElement('div');
+ group.className = 'settings-group';
+
+ var title = document.createElement('div');
+ title.className = 'settings-group-title';
+ title.textContent = I18n.t(groupDef.group);
+ group.appendChild(title);
+
+ var rows = [];
+ for (var si = 0; si < groupDef.settings.length; si++) {
+ var def = groupDef.settings[si];
+ var activeVal = activeValues ? activeValues[def.key] : undefined;
+ var row = renderStructuredSettingsRow(def, settings[def.key], activeVal);
+ if (def.showWhen) {
+ row.setAttribute('data-show-when-key', def.showWhen.key);
+ row.setAttribute('data-show-when-value', def.showWhen.value);
+ var currentVal = settings[def.showWhen.key];
+ if (currentVal === def.showWhen.value) {
+ row.classList.remove('hidden');
+ } else {
+ row.classList.add('hidden');
+ }
+ }
+ rows.push(row);
+ group.appendChild(row);
+ }
+
+ container.appendChild(group);
+
+ // Wire up showWhen reactivity for select fields in this group
+ (function(groupRows, allSettings) {
+ for (var ri = 0; ri < groupRows.length; ri++) {
+ var sel = groupRows[ri].querySelector('.settings-select');
+ if (sel) {
+ sel.addEventListener('change', function() {
+ var changedKey = this.getAttribute('data-setting-key');
+ var changedVal = this.value;
+ for (var rj = 0; rj < groupRows.length; rj++) {
+ var whenKey = groupRows[rj].getAttribute('data-show-when-key');
+ var whenVal = groupRows[rj].getAttribute('data-show-when-value');
+ if (whenKey === changedKey) {
+ if (changedVal === whenVal) {
+ groupRows[rj].classList.remove('hidden');
+ } else {
+ groupRows[rj].classList.add('hidden');
+ }
+ }
+ }
+ });
+ }
+ }
+ })(rows, settings);
+ }
+
+ if (container.children.length === 0) {
+ container.innerHTML = '' + I18n.t('settings.noSettings') + '
';
+ }
+}
+
+function renderStructuredSettingsRow(def, value, activeValue) {
+ var row = document.createElement('div');
+ row.className = 'settings-row';
+
+ var labelWrap = document.createElement('div');
+ labelWrap.className = 'settings-label-wrap';
+
+ var label = document.createElement('div');
+ label.className = 'settings-label';
+ label.textContent = I18n.t(def.label);
+ labelWrap.appendChild(label);
+
+ if (def.description) {
+ var desc = document.createElement('div');
+ desc.className = 'settings-description';
+ desc.textContent = I18n.t(def.description);
+ labelWrap.appendChild(desc);
+ }
+
+ row.appendChild(labelWrap);
+
+ var inputWrap = document.createElement('div');
+ inputWrap.style.display = 'flex';
+ inputWrap.style.alignItems = 'center';
+ inputWrap.style.gap = '8px';
+
+ var ariaLabel = I18n.t(def.label) + (def.description ? '. ' + I18n.t(def.description) : '');
+ var placeholderText = activeValue ? I18n.t('settings.envValue', { value: activeValue }) : (def.placeholder || I18n.t('settings.envDefault'));
+
+ if (def.type === 'boolean') {
+ var boolSel = document.createElement('select');
+ boolSel.className = 'settings-select';
+ boolSel.setAttribute('data-setting-key', def.key);
+ boolSel.setAttribute('aria-label', ariaLabel);
+ var boolDefault = document.createElement('option');
+ boolDefault.value = '';
+ boolDefault.textContent = activeValue !== undefined && activeValue !== null
+ ? '\u2014 ' + I18n.t('settings.envValue', { value: String(activeValue) }) + ' \u2014'
+ : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014';
+ if (value === null || value === undefined) boolDefault.selected = true;
+ boolSel.appendChild(boolDefault);
+ var boolOn = document.createElement('option');
+ boolOn.value = 'true';
+ boolOn.textContent = I18n.t('settings.on');
+ if (value === true) boolOn.selected = true;
+ boolSel.appendChild(boolOn);
+ var boolOff = document.createElement('option');
+ boolOff.value = 'false';
+ boolOff.textContent = I18n.t('settings.off');
+ if (value === false) boolOff.selected = true;
+ boolSel.appendChild(boolOff);
+ boolSel.addEventListener('change', (function(k, el) {
+ return function() {
+ if (el.value === '') saveSetting(k, null);
+ else saveSetting(k, el.value === 'true');
+ };
+ })(def.key, boolSel));
+ inputWrap.appendChild(boolSel);
+ } else if (def.type === 'select' && def.options) {
+ var sel = document.createElement('select');
+ sel.className = 'settings-select';
+ sel.setAttribute('data-setting-key', def.key);
+ sel.setAttribute('aria-label', ariaLabel);
+ var emptyOpt = document.createElement('option');
+ emptyOpt.value = '';
+ emptyOpt.textContent = activeValue ? '\u2014 ' + I18n.t('settings.envValue', { value: activeValue }) + ' \u2014' : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014';
+ if (!value && value !== false && value !== 0) emptyOpt.selected = true;
+ sel.appendChild(emptyOpt);
+ for (var oi = 0; oi < def.options.length; oi++) {
+ var opt = document.createElement('option');
+ opt.value = def.options[oi];
+ opt.textContent = def.options[oi];
+ if (String(value) === def.options[oi]) opt.selected = true;
+ sel.appendChild(opt);
+ }
+ sel.addEventListener('change', (function(k, el) {
+ return function() { saveSetting(k, el.value === '' ? null : el.value); };
+ })(def.key, sel));
+ inputWrap.appendChild(sel);
+ } else if (def.type === 'number') {
+ var numInp = document.createElement('input');
+ numInp.type = 'number';
+ numInp.step = '1';
+ numInp.className = 'settings-input';
+ numInp.setAttribute('aria-label', ariaLabel);
+ numInp.value = (value === null || value === undefined) ? '' : value;
+ if (!value && value !== 0) numInp.placeholder = placeholderText;
+ if (def.min !== undefined) numInp.min = def.min;
+ if (def.max !== undefined) numInp.max = def.max;
+ numInp.addEventListener('change', (function(k, el) {
+ return function() {
+ if (el.value === '') return saveSetting(k, null);
+ var parsed = parseInt(el.value, 10);
+ if (isNaN(parsed)) return;
+ el.value = parsed;
+ saveSetting(k, parsed);
+ };
+ })(def.key, numInp));
+ inputWrap.appendChild(numInp);
+ } else {
+ var textInp = document.createElement('input');
+ textInp.type = 'text';
+ textInp.className = 'settings-input';
+ textInp.setAttribute('aria-label', ariaLabel);
+ textInp.value = (value === null || value === undefined) ? '' : String(value);
+ if (!value) textInp.placeholder = placeholderText;
+ // Attach datalist for autocomplete suggestions (e.g., model list)
+ if (def.suggestions && def.suggestions.length > 0) {
+ var dlId = 'dl-' + def.key.replace(/\./g, '-');
+ var dl = document.createElement('datalist');
+ dl.id = dlId;
+ for (var di = 0; di < def.suggestions.length; di++) {
+ var dlOpt = document.createElement('option');
+ dlOpt.value = def.suggestions[di];
+ dl.appendChild(dlOpt);
+ }
+ textInp.setAttribute('list', dlId);
+ inputWrap.appendChild(dl);
+ }
+ textInp.addEventListener('change', (function(k, el) {
+ return function() { saveSetting(k, el.value === '' ? null : el.value); };
+ })(def.key, textInp));
+ inputWrap.appendChild(textInp);
+ }
+
+ var saved = document.createElement('span');
+ saved.className = 'settings-saved-indicator';
+ saved.textContent = '\u2713 ' + I18n.t('settings.saved');
+ saved.setAttribute('data-key', def.key);
+ saved.setAttribute('role', 'status');
+ saved.setAttribute('aria-live', 'polite');
+ inputWrap.appendChild(saved);
+
+ row.appendChild(inputWrap);
+ return row;
+}
+
+var RESTART_REQUIRED_KEYS = ['llm_backend', 'selected_model', 'ollama_base_url', 'openai_compatible_base_url',
+ 'bedrock_region', 'bedrock_cross_region', 'bedrock_profile', 'embeddings.enabled', 'embeddings.provider', 'embeddings.model',
+ 'agent.auto_approve_tools', 'tunnel.provider', 'tunnel.public_url', 'gateway.rate_limit', 'gateway.max_connections'];
+
+var _settingsSavedTimers = {};
+
+function saveSetting(key, value) {
+ var method = (value === null || value === undefined) ? 'DELETE' : 'PUT';
+ var opts = { method: method };
+ if (method === 'PUT') opts.body = { value: value };
+ apiFetch('/api/settings/' + encodeURIComponent(key), opts).then(function() {
+ var indicator = document.querySelector('.settings-saved-indicator[data-key="' + key + '"]');
+ if (indicator) {
+ if (_settingsSavedTimers[key]) clearTimeout(_settingsSavedTimers[key]);
+ indicator.classList.add('visible');
+ _settingsSavedTimers[key] = setTimeout(function() { indicator.classList.remove('visible'); }, 2000);
+ }
+ // Show restart banner for inference settings
+ if (RESTART_REQUIRED_KEYS.indexOf(key) !== -1) {
+ showRestartBanner();
+ }
+ }).catch(function(err) {
+ showToast('Failed to save ' + key + ': ' + err.message, 'error');
+ });
+}
+
+function showRestartBanner() {
+ var container = document.querySelector('.settings-content');
+ if (!container || container.querySelector('.restart-banner')) return;
+ var banner = document.createElement('div');
+ banner.className = 'restart-banner';
+ banner.setAttribute('role', 'alert');
+ var textSpan = document.createElement('span');
+ textSpan.className = 'restart-banner-text';
+ textSpan.textContent = '\u26A0\uFE0F ' + I18n.t('settings.restartRequired');
+ banner.appendChild(textSpan);
+ var restartBtn = document.createElement('button');
+ restartBtn.className = 'restart-banner-btn';
+ restartBtn.textContent = I18n.t('settings.restartNow');
+ restartBtn.addEventListener('click', function() { triggerRestart(); });
+ banner.appendChild(restartBtn);
+ container.insertBefore(banner, container.firstChild);
+}
+
+function loadMcpServers() {
+ var mcpList = document.getElementById('mcp-servers-list');
+ mcpList.innerHTML = renderCardsSkeleton(2);
+
+ Promise.all([
+ apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }),
+ apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }),
+ ]).then(function(results) {
+ var extData = results[0];
+ var registryData = results[1];
+ var mcpEntries = (registryData.entries || []).filter(function(e) { return e.kind === 'mcp_server'; });
+ var installedMcp = (extData.extensions || []).filter(function(e) { return e.kind === 'mcp_server'; });
+
+ mcpList.innerHTML = '';
+ var renderedNames = {};
+
+ // Registry entries (cross-referenced with installed)
+ for (var i = 0; i < mcpEntries.length; i++) {
+ renderedNames[mcpEntries[i].name] = true;
+ var installedExt = installedMcp.find(function(e) { return e.name === mcpEntries[i].name; });
+ mcpList.appendChild(renderMcpServerCard(mcpEntries[i], installedExt));
+ }
+
+ // Custom installed MCP servers not in registry
+ for (var j = 0; j < installedMcp.length; j++) {
+ if (!renderedNames[installedMcp[j].name]) {
+ mcpList.appendChild(renderExtensionCard(installedMcp[j]));
+ }
+ }
+
+ if (mcpList.children.length === 0) {
+ mcpList.innerHTML = '' + I18n.t('mcp.noServers') + '
';
+ }
+ }).catch(function(err) {
+ mcpList.innerHTML = '' + I18n.t('common.loadFailed') + ': '
+ + escapeHtml(err.message) + '
';
+ });
+}
+
+function loadChannelsStatus() {
+ var container = document.getElementById('settings-channels-content');
+ container.innerHTML = renderCardsSkeleton(4);
+
+ Promise.all([
+ apiFetch('/api/gateway/status').catch(function() { return {}; }),
+ apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }),
+ apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }),
+ ]).then(function(results) {
+ var status = results[0];
+ var extensions = results[1].extensions || [];
+ var registry = results[2].entries || [];
+
+ container.innerHTML = '';
+
+ // Built-in Channels section
+ var builtinSection = document.createElement('div');
+ builtinSection.className = 'extensions-section';
+ var builtinTitle = document.createElement('h3');
+ builtinTitle.textContent = I18n.t('channels.builtin');
+ builtinSection.appendChild(builtinTitle);
+ var builtinList = document.createElement('div');
+ builtinList.className = 'extensions-list';
+
+ builtinList.appendChild(renderBuiltinChannelCard(
+ I18n.t('channels.webGateway'),
+ I18n.t('channels.webGatewayDesc'),
+ true,
+ 'SSE: ' + (status.sse_connections || 0) + ' \u00B7 WS: ' + (status.ws_connections || 0)
+ ));
+
+ var enabledChannels = status.enabled_channels || [];
+
+ builtinList.appendChild(renderBuiltinChannelCard(
+ I18n.t('channels.httpWebhook'),
+ I18n.t('channels.httpWebhookDesc'),
+ enabledChannels.indexOf('http') !== -1,
+ I18n.t('channels.configureVia', { env: 'ENABLE_HTTP=true' })
+ ));
+
+ builtinList.appendChild(renderBuiltinChannelCard(
+ I18n.t('channels.cli'),
+ I18n.t('channels.cliDesc'),
+ enabledChannels.indexOf('cli') !== -1,
+ I18n.t('channels.runWith', { cmd: 'ironclaw run --cli' })
+ ));
+
+ builtinList.appendChild(renderBuiltinChannelCard(
+ I18n.t('channels.repl'),
+ I18n.t('channels.replDesc'),
+ enabledChannels.indexOf('repl') !== -1,
+ I18n.t('channels.runWith', { cmd: 'ironclaw run --repl' })
+ ));
+
+ builtinSection.appendChild(builtinList);
+ container.appendChild(builtinSection);
+
+ // Messaging Channels section — use extension cards with full stepper/pairing UI
+ var channelEntries = registry.filter(function(e) {
+ return e.kind === 'wasm_channel' || e.kind === 'channel';
+ });
+ var installedChannels = extensions.filter(function(e) {
+ return e.kind === 'wasm_channel';
+ });
+
+ if (channelEntries.length > 0 || installedChannels.length > 0) {
+ var messagingSection = document.createElement('div');
+ messagingSection.className = 'extensions-section';
+ var messagingTitle = document.createElement('h3');
+ messagingTitle.textContent = I18n.t('channels.messaging');
+ messagingSection.appendChild(messagingTitle);
+ var messagingList = document.createElement('div');
+ messagingList.className = 'extensions-list';
+
+ var renderedNames = {};
+
+ // Registry entries: show full ext card if installed, available card if not
+ for (var i = 0; i < channelEntries.length; i++) {
+ var entry = channelEntries[i];
+ renderedNames[entry.name] = true;
+ var installed = null;
+ for (var k = 0; k < installedChannels.length; k++) {
+ if (installedChannels[k].name === entry.name) { installed = installedChannels[k]; break; }
+ }
+ if (installed) {
+ messagingList.appendChild(renderExtensionCard(installed));
+ } else {
+ messagingList.appendChild(renderAvailableExtensionCard(entry));
+ }
+ }
+
+ // Installed channels not in registry (custom installs)
+ for (var j = 0; j < installedChannels.length; j++) {
+ if (!renderedNames[installedChannels[j].name]) {
+ messagingList.appendChild(renderExtensionCard(installedChannels[j]));
+ }
+ }
+
+ messagingSection.appendChild(messagingList);
+ container.appendChild(messagingSection);
+ }
+ });
+}
+
+function renderBuiltinChannelCard(name, description, active, detail) {
+ var card = document.createElement('div');
+ card.className = 'ext-card ' + (active ? 'state-active' : 'state-inactive');
+
+ var header = document.createElement('div');
+ header.className = 'ext-header';
+
+ var nameEl = document.createElement('span');
+ nameEl.className = 'ext-name';
+ nameEl.textContent = name;
+ header.appendChild(nameEl);
+
+ var kindEl = document.createElement('span');
+ kindEl.className = 'ext-kind kind-builtin';
+ kindEl.textContent = I18n.t('ext.builtin');
+ header.appendChild(kindEl);
+
+ var statusDot = document.createElement('span');
+ statusDot.className = 'ext-auth-dot ' + (active ? 'authed' : 'unauthed');
+ statusDot.title = active ? I18n.t('ext.active') : I18n.t('ext.inactive');
+ header.appendChild(statusDot);
+
+ card.appendChild(header);
+
+ var desc = document.createElement('div');
+ desc.className = 'ext-desc';
+ desc.textContent = description;
+ card.appendChild(desc);
+
+ if (detail) {
+ var detailEl = document.createElement('div');
+ detailEl.className = 'ext-url';
+ detailEl.textContent = detail;
+ card.appendChild(detailEl);
+ }
+
+ var actions = document.createElement('div');
+ actions.className = 'ext-actions';
+ var label = document.createElement('span');
+ label.className = 'ext-active-label';
+ label.textContent = active ? I18n.t('ext.active') : I18n.t('ext.inactive');
+ actions.appendChild(label);
+ card.appendChild(actions);
+
+ return card;
+}
+
+// --- Networking Settings ---
+
+var NETWORKING_SETTINGS = [
+ {
+ group: 'cfg.group.tunnel',
+ settings: [
+ { key: 'tunnel.provider', label: 'cfg.tunnel_provider.label', description: 'cfg.tunnel_provider.desc',
+ type: 'select', options: ['none', 'cloudflare', 'ngrok', 'tailscale', 'custom'] },
+ { key: 'tunnel.public_url', label: 'cfg.tunnel_public_url.label', description: 'cfg.tunnel_public_url.desc', type: 'text' },
+ ]
+ },
+ {
+ group: 'cfg.group.gateway',
+ settings: [
+ { key: 'gateway.rate_limit', label: 'cfg.gateway_rate_limit.label', description: 'cfg.gateway_rate_limit.desc', type: 'number', min: 0 },
+ { key: 'gateway.max_connections', label: 'cfg.gateway_max_connections.label', description: 'cfg.gateway_max_connections.desc', type: 'number', min: 0 },
+ ]
+ },
+];
+
+function loadNetworkingSettings() {
+ var container = document.getElementById('settings-networking-content');
+ container.innerHTML = renderSettingsSkeleton(4);
+
+ apiFetch('/api/settings/export').then(function(data) {
+ var settings = data.settings || {};
+ container.innerHTML = '';
+ renderStructuredSettingsInto(container, NETWORKING_SETTINGS, settings, {});
+ }).catch(function(err) {
+ container.innerHTML = '' + I18n.t('common.loadFailed') + ': '
+ + escapeHtml(err.message) + '
';
+ });
+}
+
// --- Toasts ---
function showToast(message, type) {
@@ -4618,6 +5303,8 @@ document.getElementById('wasm-install-btn').addEventListener('click', () => inst
document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer());
document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub());
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
+document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings());
+document.getElementById('settings-import-btn').addEventListener('click', () => importSettings());
// --- Delegated Event Handlers (for dynamically generated HTML) ---
@@ -4699,6 +5386,129 @@ document.getElementById('language-btn').addEventListener('click', function() {
if (typeof toggleLanguageMenu === 'function') toggleLanguageMenu();
});
+// --- Confirmation Modal ---
+
+var _confirmModalCallback = null;
+
+function showConfirmModal(title, message, onConfirm, confirmLabel, confirmClass) {
+ var modal = document.getElementById('confirm-modal');
+ document.getElementById('confirm-modal-title').textContent = title;
+ document.getElementById('confirm-modal-message').textContent = message || '';
+ document.getElementById('confirm-modal-message').style.display = message ? '' : 'none';
+ var btn = document.getElementById('confirm-modal-btn');
+ btn.textContent = confirmLabel || I18n.t('btn.confirm');
+ btn.className = confirmClass || 'btn-danger';
+ _confirmModalCallback = onConfirm;
+ modal.style.display = 'flex';
+ btn.focus();
+}
+
+function closeConfirmModal() {
+ document.getElementById('confirm-modal').style.display = 'none';
+ _confirmModalCallback = null;
+}
+
+document.getElementById('confirm-modal-btn').addEventListener('click', function() {
+ if (_confirmModalCallback) _confirmModalCallback();
+ closeConfirmModal();
+});
+document.getElementById('confirm-modal-cancel-btn').addEventListener('click', closeConfirmModal);
+document.getElementById('confirm-modal').addEventListener('click', function(e) {
+ if (e.target === this) closeConfirmModal();
+});
+document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape' && document.getElementById('confirm-modal').style.display === 'flex') {
+ closeConfirmModal();
+ }
+});
+
+// --- Settings Import/Export ---
+
+function exportSettings() {
+ apiFetch('/api/settings/export').then(function(data) {
+ var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement('a');
+ a.href = url;
+ a.download = 'ironclaw-settings.json';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ showToast(I18n.t('settings.exportSuccess'), 'success');
+ }).catch(function(err) {
+ showToast(I18n.t('settings.exportFailed', { message: err.message }), 'error');
+ });
+}
+
+function importSettings() {
+ var input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json,application/json';
+ input.addEventListener('change', function() {
+ if (!input.files || !input.files[0]) return;
+ var reader = new FileReader();
+ reader.onload = function() {
+ try {
+ var data = JSON.parse(reader.result);
+ apiFetch('/api/settings/import', {
+ method: 'POST',
+ body: data,
+ }).then(function() {
+ showToast(I18n.t('settings.importSuccess'), 'success');
+ loadSettingsSubtab(currentSettingsSubtab);
+ }).catch(function(err) {
+ showToast(I18n.t('settings.importFailed', { message: err.message }), 'error');
+ });
+ } catch (e) {
+ showToast(I18n.t('settings.importFailed', { message: e.message }), 'error');
+ }
+ };
+ reader.readAsText(input.files[0]);
+ });
+ input.click();
+}
+
+// --- Settings Search ---
+
+document.getElementById('settings-search-input').addEventListener('input', function() {
+ var query = this.value.toLowerCase();
+ var activePanel = document.querySelector('.settings-subpanel.active');
+ if (!activePanel) return;
+ var rows = activePanel.querySelectorAll('.settings-row');
+ if (rows.length === 0) return;
+ var visibleCount = 0;
+ rows.forEach(function(row) {
+ var text = row.textContent.toLowerCase();
+ if (query === '' || text.indexOf(query) !== -1) {
+ row.classList.remove('search-hidden');
+ if (!row.classList.contains('hidden')) visibleCount++;
+ } else {
+ row.classList.add('search-hidden');
+ }
+ });
+ // Show/hide group titles based on visible children
+ var groups = activePanel.querySelectorAll('.settings-group');
+ groups.forEach(function(group) {
+ var visibleRows = group.querySelectorAll('.settings-row:not(.search-hidden):not(.hidden)');
+ if (visibleRows.length === 0 && query !== '') {
+ group.style.display = 'none';
+ } else {
+ group.style.display = '';
+ }
+ });
+ // Show/hide empty state
+ var existingEmpty = activePanel.querySelector('.settings-search-empty');
+ if (existingEmpty) existingEmpty.remove();
+ if (query !== '' && visibleCount === 0) {
+ var empty = document.createElement('div');
+ empty.className = 'settings-search-empty';
+ empty.textContent = I18n.t('settings.noMatchingSettings', { query: this.value });
+ activePanel.appendChild(empty);
+ }
+});
+
+
// --- Config Tab ---
// Like apiFetch but for endpoints that return 204 No Content
diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js
index 25163396..fe3b8b5d 100644
--- a/src/channels/web/static/i18n/en.js
+++ b/src/channels/web/static/i18n/en.js
@@ -29,10 +29,16 @@ I18n.register('en', {
'tab.memory': 'Memory',
'tab.jobs': 'Jobs',
'tab.routines': 'Routines',
+ 'tab.settings': 'Settings',
'tab.extensions': 'Extensions',
'tab.skills': 'Skills',
'tab.config': 'Config',
'tab.logs': 'Logs',
+ 'settings.inference': 'Inference',
+ 'settings.agent': 'Agent',
+ 'settings.channels': 'Channels',
+ 'settings.networking': 'Networking',
+ 'settings.mcp': 'MCP',
// Status
'status.connected': 'Connected',
@@ -132,10 +138,10 @@ I18n.register('en', {
// Extensions Tab
'extensions.installed': 'Installed Extensions',
- 'extensions.available': 'Available WASM Extensions',
- 'extensions.installWasm': 'Install WASM Extension',
+ 'extensions.available': 'Available Extensions',
+ 'extensions.installWasm': 'Install Extension',
'extensions.noInstalled': 'No extensions installed',
- 'extensions.noAvailable': 'No additional WASM extensions available',
+ 'extensions.noAvailable': 'No additional extensions available',
'extensions.loading': 'Loading...',
'extensions.install': 'Install',
'extensions.installing': 'Installing...',
@@ -157,13 +163,8 @@ I18n.register('en', {
'mcp.addCustom': 'Add Custom MCP Server',
'mcp.add': 'Add',
'mcp.addedSuccess': 'Added MCP server {name}',
-
- // Registered Tools
- 'tools.registered': 'Registered Tools',
- 'tools.name': 'Name',
- 'tools.description': 'Description',
- 'tools.empty': 'No tools registered',
-
+
+
// Skills Tab
'skills.installed': 'Installed Skills',
'skills.noInstalled': 'No skills installed',
@@ -303,6 +304,7 @@ I18n.register('en', {
// Common
'common.loading': 'Loading...',
+ 'common.loadFailed': 'Failed to load',
'common.noData': 'No data',
'common.search': 'Search',
'common.add': 'Add',
@@ -329,6 +331,8 @@ I18n.register('en', {
// Extensions
'ext.active': 'Active',
+ 'ext.inactive': 'Inactive',
+ 'ext.builtin': 'Built-in',
'ext.remove': 'Remove',
'ext.install': 'Install',
'ext.installing': 'Installing...',
@@ -395,4 +399,160 @@ I18n.register('en', {
'config.autoGenerate': 'Auto-generated if empty',
'config.save': 'Save',
'config.cancel': 'Cancel',
+
+ // Settings toolbar
+ 'settings.export': 'Export',
+ 'settings.import': 'Import',
+ 'settings.searchPlaceholder': 'Search settings...',
+ 'settings.exportSuccess': 'Settings exported',
+ 'settings.exportFailed': 'Export failed: {message}',
+ 'settings.importSuccess': 'Settings imported successfully',
+ 'settings.importFailed': 'Import failed: {message}',
+ 'settings.restartRequired': 'Restart required for changes to take effect.',
+ 'settings.restartNow': 'Restart Now',
+ 'settings.noMatchingSettings': 'No settings matching "{query}"',
+ 'settings.noSettings': 'No settings found',
+ 'settings.saved': 'Saved',
+ 'settings.on': 'On',
+ 'settings.off': 'Off',
+ 'settings.envValue': 'env: {value}',
+ 'settings.envDefault': 'env default',
+ 'settings.useEnvDefault': 'use env default',
+
+ // Settings groups
+ 'cfg.group.llm': 'LLM Provider',
+ 'cfg.group.embeddings': 'Embeddings',
+ 'cfg.group.agent': 'Agent',
+ 'cfg.group.heartbeat': 'Heartbeat',
+ 'cfg.group.sandbox': 'Sandbox',
+ 'cfg.group.routines': 'Routines',
+ 'cfg.group.safety': 'Safety',
+ 'cfg.group.skills': 'Skills',
+ 'cfg.group.search': 'Search',
+ 'cfg.group.tunnel': 'Tunnel',
+ 'cfg.group.gateway': 'Gateway',
+
+ // Inference settings
+ 'cfg.llm_backend.label': 'Backend',
+ 'cfg.llm_backend.desc': 'LLM inference provider',
+ 'cfg.selected_model.label': 'Model',
+ 'cfg.selected_model.desc': 'Model name or ID for the selected backend',
+ 'cfg.ollama_base_url.label': 'Ollama URL',
+ 'cfg.ollama_base_url.desc': 'Base URL for Ollama API',
+ 'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL',
+ 'cfg.openai_compatible_base_url.desc': 'Base URL for OpenAI-compatible API',
+ 'cfg.bedrock_region.label': 'Bedrock Region',
+ 'cfg.bedrock_region.desc': 'AWS region for Bedrock',
+ 'cfg.bedrock_cross_region.label': 'Cross-Region',
+ 'cfg.bedrock_cross_region.desc': 'Enable cross-region inference',
+ 'cfg.bedrock_profile.label': 'AWS Profile',
+ 'cfg.bedrock_profile.desc': 'AWS profile for Bedrock auth',
+ 'cfg.embeddings_enabled.label': 'Enabled',
+ 'cfg.embeddings_enabled.desc': 'Enable vector embeddings for memory search',
+ 'cfg.embeddings_provider.label': 'Provider',
+ 'cfg.embeddings_provider.desc': 'Embeddings API provider',
+ 'cfg.embeddings_model.label': 'Model',
+ 'cfg.embeddings_model.desc': 'Embedding model name',
+
+ // Agent settings
+ 'cfg.agent_name.label': 'Name',
+ 'cfg.agent_name.desc': 'Agent display name',
+ 'cfg.agent_max_parallel_jobs.label': 'Max Parallel Jobs',
+ 'cfg.agent_max_parallel_jobs.desc': 'Maximum concurrent background jobs',
+ 'cfg.agent_job_timeout.label': 'Job Timeout',
+ 'cfg.agent_job_timeout.desc': 'Max duration per job in seconds',
+ 'cfg.agent_max_tool_iterations.label': 'Max Tool Iterations',
+ 'cfg.agent_max_tool_iterations.desc': 'Max tool calls per turn',
+ 'cfg.agent_use_planning.label': 'Planning',
+ 'cfg.agent_use_planning.desc': 'Enable multi-step planning before execution',
+ 'cfg.agent_auto_approve.label': 'Auto-approve Tools',
+ 'cfg.agent_auto_approve.desc': 'Skip manual approval for tool calls',
+ 'cfg.agent_timezone.label': 'Timezone',
+ 'cfg.agent_timezone.desc': 'Default timezone (IANA)',
+ 'cfg.agent_session_idle.label': 'Session Idle Timeout',
+ 'cfg.agent_session_idle.desc': 'Seconds before idle session expires',
+ 'cfg.agent_stuck_threshold.label': 'Stuck Threshold',
+ 'cfg.agent_stuck_threshold.desc': 'Seconds before a job is considered stuck',
+ 'cfg.agent_max_repair.label': 'Max Repair Attempts',
+ 'cfg.agent_max_repair.desc': 'Auto-recovery attempts for stuck jobs',
+ 'cfg.agent_max_cost.label': 'Max Daily Cost',
+ 'cfg.agent_max_cost.desc': 'Daily LLM spend cap in cents (0 = unlimited)',
+ 'cfg.agent_max_actions.label': 'Max Actions/Hour',
+ 'cfg.agent_max_actions.desc': 'Hourly tool call rate limit (0 = unlimited)',
+ 'cfg.agent_allow_local.label': 'Allow Local Tools',
+ 'cfg.agent_allow_local.desc': 'Enable local filesystem tool execution',
+
+ // Heartbeat settings
+ 'cfg.heartbeat_enabled.label': 'Enabled',
+ 'cfg.heartbeat_enabled.desc': 'Run periodic background checks',
+ 'cfg.heartbeat_interval.label': 'Interval',
+ 'cfg.heartbeat_interval.desc': 'Seconds between heartbeats (default: 1800)',
+ 'cfg.heartbeat_notify_channel.label': 'Notify Channel',
+ 'cfg.heartbeat_notify_channel.desc': 'Channel to send heartbeat findings to',
+ 'cfg.heartbeat_notify_user.label': 'Notify User',
+ 'cfg.heartbeat_notify_user.desc': 'User ID to notify',
+ 'cfg.heartbeat_quiet_start.label': 'Quiet Hours Start',
+ 'cfg.heartbeat_quiet_start.desc': 'Hour (0-23) to stop heartbeats',
+ 'cfg.heartbeat_quiet_end.label': 'Quiet Hours End',
+ 'cfg.heartbeat_quiet_end.desc': 'Hour (0-23) to resume heartbeats',
+ 'cfg.heartbeat_timezone.label': 'Timezone',
+ 'cfg.heartbeat_timezone.desc': 'Timezone for quiet hours (IANA)',
+
+ // Sandbox settings
+ 'cfg.sandbox_enabled.label': 'Enabled',
+ 'cfg.sandbox_enabled.desc': 'Enable Docker sandbox for background jobs',
+ 'cfg.sandbox_policy.label': 'Policy',
+ 'cfg.sandbox_policy.desc': 'Sandbox security policy',
+ 'cfg.sandbox_timeout.label': 'Timeout',
+ 'cfg.sandbox_timeout.desc': 'Max job duration in seconds',
+ 'cfg.sandbox_memory.label': 'Memory Limit',
+ 'cfg.sandbox_memory.desc': 'Container memory limit (MB)',
+ 'cfg.sandbox_image.label': 'Docker Image',
+ 'cfg.sandbox_image.desc': 'Container image for sandbox jobs',
+
+ // Routines settings
+ 'cfg.routines_max_concurrent.label': 'Max Concurrent',
+ 'cfg.routines_max_concurrent.desc': 'Maximum routines running simultaneously',
+ 'cfg.routines_cooldown.label': 'Default Cooldown',
+ 'cfg.routines_cooldown.desc': 'Minimum seconds between routine fires',
+
+ // Safety settings
+ 'cfg.safety_max_output.label': 'Max Output Length',
+ 'cfg.safety_max_output.desc': 'Maximum output tokens per response',
+ 'cfg.safety_injection_check.label': 'Injection Check',
+ 'cfg.safety_injection_check.desc': 'Enable prompt injection detection',
+
+ // Skills settings
+ 'cfg.skills_max_active.label': 'Max Active Skills',
+ 'cfg.skills_max_active.desc': 'Maximum skills active simultaneously',
+ 'cfg.skills_max_tokens.label': 'Max Context Tokens',
+ 'cfg.skills_max_tokens.desc': 'Token budget for skill prompts',
+
+ // Search settings
+ 'cfg.search_fusion.label': 'Fusion Strategy',
+ 'cfg.search_fusion.desc': 'Hybrid search ranking method',
+
+ // Networking settings
+ 'cfg.tunnel_provider.label': 'Provider',
+ 'cfg.tunnel_provider.desc': 'Public URL tunnel provider',
+ 'cfg.tunnel_public_url.label': 'Public URL',
+ 'cfg.tunnel_public_url.desc': 'Static public URL (if not using tunnel provider)',
+ 'cfg.gateway_rate_limit.label': 'Rate Limit',
+ 'cfg.gateway_rate_limit.desc': 'Max chat messages per minute',
+ 'cfg.gateway_max_connections.label': 'Max Connections',
+ 'cfg.gateway_max_connections.desc': 'Max simultaneous SSE/WS connections',
+
+ // Channels subtab
+ 'channels.builtin': 'Built-in Channels',
+ 'channels.messaging': 'Messaging Channels',
+ 'channels.webGateway': 'Web Gateway',
+ 'channels.webGatewayDesc': 'Browser-based chat interface',
+ 'channels.httpWebhook': 'HTTP Webhook',
+ 'channels.httpWebhookDesc': 'Incoming webhook endpoint for external integrations',
+ 'channels.cli': 'CLI',
+ 'channels.cliDesc': 'Terminal UI with Ratatui',
+ 'channels.repl': 'REPL',
+ 'channels.replDesc': 'Simple read-eval-print loop for testing',
+ 'channels.configureVia': 'Configure via {env}',
+ 'channels.runWith': 'Run with: {cmd}',
});
diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js
index 0d814b2c..cb31fc36 100644
--- a/src/channels/web/static/i18n/zh-CN.js
+++ b/src/channels/web/static/i18n/zh-CN.js
@@ -29,10 +29,16 @@ I18n.register('zh-CN', {
'tab.memory': '记忆',
'tab.jobs': '任务',
'tab.routines': '定时任务',
+ 'tab.settings': '设置',
'tab.extensions': '扩展',
'tab.skills': '技能',
'tab.config': '配置',
'tab.logs': '日志',
+ 'settings.inference': '推理',
+ 'settings.agent': '代理',
+ 'settings.channels': '频道',
+ 'settings.networking': '网络',
+ 'settings.mcp': 'MCP',
// 状态
'status.connected': '已连接',
@@ -132,10 +138,10 @@ I18n.register('zh-CN', {
// 扩展标签页
'extensions.installed': '已安装扩展',
- 'extensions.available': '可用 WASM 扩展',
- 'extensions.installWasm': '安装 WASM 扩展',
+ 'extensions.available': '可用扩展',
+ 'extensions.installWasm': '安装扩展',
'extensions.noInstalled': '没有安装扩展',
- 'extensions.noAvailable': '没有其他可用的 WASM 扩展',
+ 'extensions.noAvailable': '没有其他可用扩展',
'extensions.loading': '加载中...',
'extensions.install': '安装',
'extensions.installing': '安装中...',
@@ -157,13 +163,8 @@ I18n.register('zh-CN', {
'mcp.addCustom': '添加自定义 MCP 服务器',
'mcp.add': '添加',
'mcp.addedSuccess': '已添加 MCP 服务器 {name}',
-
- // 注册工具
- 'tools.registered': '注册工具',
- 'tools.name': '名称',
- 'tools.description': '描述',
- 'tools.empty': '没有注册工具',
-
+
+
// 技能标签页
'skills.installed': '已安装技能',
'skills.noInstalled': '没有安装技能',
@@ -303,6 +304,7 @@ I18n.register('zh-CN', {
// 通用
'common.loading': '加载中...',
+ 'common.loadFailed': '加载失败',
'common.noData': '暂无数据',
'common.search': '搜索',
'common.add': '添加',
@@ -329,6 +331,8 @@ I18n.register('zh-CN', {
// 扩展
'ext.active': '已激活',
+ 'ext.inactive': '未激活',
+ 'ext.builtin': '内置',
'ext.remove': '移除',
'ext.install': '安装',
'ext.installing': '安装中...',
@@ -394,4 +398,160 @@ I18n.register('zh-CN', {
'config.autoGenerate': '如果为空则自动生成',
'config.save': '保存',
'config.cancel': '取消',
+
+ // 设置工具栏
+ 'settings.export': '导出',
+ 'settings.import': '导入',
+ 'settings.searchPlaceholder': '搜索设置...',
+ 'settings.exportSuccess': '设置已导出',
+ 'settings.exportFailed': '导出失败: {message}',
+ 'settings.importSuccess': '设置导入成功',
+ 'settings.importFailed': '导入失败: {message}',
+ 'settings.restartRequired': '需要重启才能使更改生效。',
+ 'settings.restartNow': '立即重启',
+ 'settings.noMatchingSettings': '没有匹配 "{query}" 的设置',
+ 'settings.noSettings': '未找到设置',
+ 'settings.saved': '已保存',
+ 'settings.on': '开启',
+ 'settings.off': '关闭',
+ 'settings.envValue': '环境变量: {value}',
+ 'settings.envDefault': '使用环境变量默认值',
+ 'settings.useEnvDefault': '使用环境变量默认值',
+
+ // 设置分组
+ 'cfg.group.llm': 'LLM 提供商',
+ 'cfg.group.embeddings': '嵌入向量',
+ 'cfg.group.agent': '代理',
+ 'cfg.group.heartbeat': '心跳',
+ 'cfg.group.sandbox': '沙箱',
+ 'cfg.group.routines': '定时任务',
+ 'cfg.group.safety': '安全',
+ 'cfg.group.skills': '技能',
+ 'cfg.group.search': '搜索',
+ 'cfg.group.tunnel': '隧道',
+ 'cfg.group.gateway': '网关',
+
+ // 推理设置
+ 'cfg.llm_backend.label': '后端',
+ 'cfg.llm_backend.desc': 'LLM 推理提供商',
+ 'cfg.selected_model.label': '模型',
+ 'cfg.selected_model.desc': '所选后端的模型名称或 ID',
+ 'cfg.ollama_base_url.label': 'Ollama URL',
+ 'cfg.ollama_base_url.desc': 'Ollama API 基础 URL',
+ 'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL',
+ 'cfg.openai_compatible_base_url.desc': 'OpenAI 兼容 API 基础 URL',
+ 'cfg.bedrock_region.label': 'Bedrock 区域',
+ 'cfg.bedrock_region.desc': 'Bedrock 的 AWS 区域',
+ 'cfg.bedrock_cross_region.label': '跨区域',
+ 'cfg.bedrock_cross_region.desc': '启用跨区域推理',
+ 'cfg.bedrock_profile.label': 'AWS 配置文件',
+ 'cfg.bedrock_profile.desc': 'Bedrock 认证的 AWS 配置文件',
+ 'cfg.embeddings_enabled.label': '启用',
+ 'cfg.embeddings_enabled.desc': '启用向量嵌入以支持记忆搜索',
+ 'cfg.embeddings_provider.label': '提供商',
+ 'cfg.embeddings_provider.desc': '嵌入向量 API 提供商',
+ 'cfg.embeddings_model.label': '模型',
+ 'cfg.embeddings_model.desc': '嵌入向量模型名称',
+
+ // 代理设置
+ 'cfg.agent_name.label': '名称',
+ 'cfg.agent_name.desc': '代理显示名称',
+ 'cfg.agent_max_parallel_jobs.label': '最大并行任务数',
+ 'cfg.agent_max_parallel_jobs.desc': '最大并发后台任务数',
+ 'cfg.agent_job_timeout.label': '任务超时',
+ 'cfg.agent_job_timeout.desc': '每个任务的最大持续时间(秒)',
+ 'cfg.agent_max_tool_iterations.label': '最大工具迭代次数',
+ 'cfg.agent_max_tool_iterations.desc': '每轮最大工具调用次数',
+ 'cfg.agent_use_planning.label': '规划',
+ 'cfg.agent_use_planning.desc': '执行前启用多步规划',
+ 'cfg.agent_auto_approve.label': '自动批准工具',
+ 'cfg.agent_auto_approve.desc': '跳过工具调用的手动审批',
+ 'cfg.agent_timezone.label': '时区',
+ 'cfg.agent_timezone.desc': '默认时区(IANA)',
+ 'cfg.agent_session_idle.label': '会话空闲超时',
+ 'cfg.agent_session_idle.desc': '空闲会话过期前的秒数',
+ 'cfg.agent_stuck_threshold.label': '卡住阈值',
+ 'cfg.agent_stuck_threshold.desc': '任务被认为卡住前的秒数',
+ 'cfg.agent_max_repair.label': '最大修复尝试次数',
+ 'cfg.agent_max_repair.desc': '卡住任务的自动恢复尝试次数',
+ 'cfg.agent_max_cost.label': '每日最大费用',
+ 'cfg.agent_max_cost.desc': '每日 LLM 支出上限(美分,0 = 无限制)',
+ 'cfg.agent_max_actions.label': '每小时最大操作数',
+ 'cfg.agent_max_actions.desc': '每小时工具调用速率限制(0 = 无限制)',
+ 'cfg.agent_allow_local.label': '允许本地工具',
+ 'cfg.agent_allow_local.desc': '启用本地文件系统工具执行',
+
+ // 心跳设置
+ 'cfg.heartbeat_enabled.label': '启用',
+ 'cfg.heartbeat_enabled.desc': '运行定期后台检查',
+ 'cfg.heartbeat_interval.label': '间隔',
+ 'cfg.heartbeat_interval.desc': '心跳间隔秒数(默认:1800)',
+ 'cfg.heartbeat_notify_channel.label': '通知频道',
+ 'cfg.heartbeat_notify_channel.desc': '发送心跳发现的频道',
+ 'cfg.heartbeat_notify_user.label': '通知用户',
+ 'cfg.heartbeat_notify_user.desc': '要通知的用户 ID',
+ 'cfg.heartbeat_quiet_start.label': '静默时段开始',
+ 'cfg.heartbeat_quiet_start.desc': '停止心跳的小时(0-23)',
+ 'cfg.heartbeat_quiet_end.label': '静默时段结束',
+ 'cfg.heartbeat_quiet_end.desc': '恢复心跳的小时(0-23)',
+ 'cfg.heartbeat_timezone.label': '时区',
+ 'cfg.heartbeat_timezone.desc': '静默时段的时区(IANA)',
+
+ // 沙箱设置
+ 'cfg.sandbox_enabled.label': '启用',
+ 'cfg.sandbox_enabled.desc': '启用 Docker 沙箱以运行后台任务',
+ 'cfg.sandbox_policy.label': '策略',
+ 'cfg.sandbox_policy.desc': '沙箱安全策略',
+ 'cfg.sandbox_timeout.label': '超时',
+ 'cfg.sandbox_timeout.desc': '最大任务持续时间(秒)',
+ 'cfg.sandbox_memory.label': '内存限制',
+ 'cfg.sandbox_memory.desc': '容器内存限制(MB)',
+ 'cfg.sandbox_image.label': 'Docker 镜像',
+ 'cfg.sandbox_image.desc': '沙箱任务的容器镜像',
+
+ // 定时任务设置
+ 'cfg.routines_max_concurrent.label': '最大并发数',
+ 'cfg.routines_max_concurrent.desc': '同时运行的最大定时任务数',
+ 'cfg.routines_cooldown.label': '默认冷却时间',
+ 'cfg.routines_cooldown.desc': '定时任务触发间的最小秒数',
+
+ // 安全设置
+ 'cfg.safety_max_output.label': '最大输出长度',
+ 'cfg.safety_max_output.desc': '每次响应的最大输出令牌数',
+ 'cfg.safety_injection_check.label': '注入检查',
+ 'cfg.safety_injection_check.desc': '启用提示注入检测',
+
+ // 技能设置
+ 'cfg.skills_max_active.label': '最大活跃技能数',
+ 'cfg.skills_max_active.desc': '同时活跃的最大技能数',
+ 'cfg.skills_max_tokens.label': '最大上下文令牌数',
+ 'cfg.skills_max_tokens.desc': '技能提示的令牌预算',
+
+ // 搜索设置
+ 'cfg.search_fusion.label': '融合策略',
+ 'cfg.search_fusion.desc': '混合搜索排名方法',
+
+ // 网络设置
+ 'cfg.tunnel_provider.label': '提供商',
+ 'cfg.tunnel_provider.desc': '公网 URL 隧道提供商',
+ 'cfg.tunnel_public_url.label': '公网 URL',
+ 'cfg.tunnel_public_url.desc': '静态公网 URL(不使用隧道提供商时)',
+ 'cfg.gateway_rate_limit.label': '速率限制',
+ 'cfg.gateway_rate_limit.desc': '每分钟最大聊天消息数',
+ 'cfg.gateway_max_connections.label': '最大连接数',
+ 'cfg.gateway_max_connections.desc': '最大同时 SSE/WS 连接数',
+
+ // 频道子标签
+ 'channels.builtin': '内置频道',
+ 'channels.messaging': '消息频道',
+ 'channels.webGateway': 'Web 网关',
+ 'channels.webGatewayDesc': '基于浏览器的聊天界面',
+ 'channels.httpWebhook': 'HTTP Webhook',
+ 'channels.httpWebhookDesc': '用于外部集成的传入 webhook 端点',
+ 'channels.cli': 'CLI',
+ 'channels.cliDesc': '使用 Ratatui 的终端 UI',
+ 'channels.repl': 'REPL',
+ 'channels.replDesc': '用于测试的简单读取-求值-打印循环',
+ 'channels.configureVia': '通过 {env} 配置',
+ 'channels.runWith': '运行命令: {cmd}',
});
diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html
index c354dd41..250cb515 100644
--- a/src/channels/web/static/index.html
+++ b/src/channels/web/static/index.html
@@ -149,9 +149,8 @@
-
-
+
@@ -326,100 +325,148 @@
-
-
-
-
-
Installed Extensions
-
+
+
+
+
-
-
Available WASM Extensions
-
-
Loading...
+
-
-
Install WASM Extension
-
-
-
MCP Servers
-
-
Loading...
+
-
Add Custom MCP Server
-
-
-
Registered Tools
-
-
No tools registered
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
Search ClawHub
-
-
-
-
-
+
+
+
+
+
-
-
Installed Skills
-
+
+ ⚠
+ Changes take effect after restart.
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css
index 31861421..07aacede 100644
--- a/src/channels/web/static/style.css
+++ b/src/channels/web/static/style.css
@@ -18,6 +18,12 @@
--radius-lg: 12px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
+ --text-muted: #71717a;
+ --bg-hover: rgba(255, 255, 255, 0.03);
+ --danger-soft: rgba(230, 76, 76, 0.15);
+ --warning-soft: rgba(245, 166, 35, 0.15);
+ --transition-fast: 150ms ease;
+ --transition-base: 0.2s ease;
}
* {
@@ -332,10 +338,10 @@ body {
.restart-loader-content {
position: relative;
z-index: 10000;
- background-color: #1a1a1a;
- border: 1px solid #333;
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border);
border-radius: 0.75rem;
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -352,7 +358,7 @@ body {
}
.restart-title {
- color: #e0e0e0;
+ color: var(--text);
font-size: 0.85rem;
margin-bottom: 1rem;
margin-top: 0;
@@ -388,10 +394,10 @@ body {
.restart-modal-content {
position: relative;
z-index: 10000;
- background-color: #1a1a1a;
- border: 1px solid #333;
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border);
border-radius: 0.75rem;
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
width: 100%;
max-width: 28rem;
margin: 0 1rem;
@@ -403,11 +409,11 @@ body {
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
- border-bottom: 1px solid #2a2a2a;
+ border-bottom: 1px solid var(--border);
}
.restart-modal-header h2 {
- color: #e0e0e0;
+ color: var(--text);
font-size: 0.95rem;
margin: 0;
}
@@ -426,8 +432,8 @@ body {
}
.restart-modal-close:hover {
- color: #ccc;
- background-color: #2a2a2a;
+ color: var(--text-secondary);
+ background-color: var(--bg-tertiary);
}
.restart-modal-body {
@@ -435,21 +441,21 @@ body {
}
.restart-modal-description {
- color: #aaa;
+ color: var(--text-secondary);
font-size: 0.85rem;
margin: 0;
}
.restart-modal-warning {
margin-top: 1rem;
- background-color: #1e1400;
- border: 1px solid #3a2a00;
+ background-color: var(--warning-soft);
+ border: 1px solid rgba(245, 166, 35, 0.25);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.restart-modal-warning p {
- color: #facc15;
+ color: var(--warning);
font-size: 0.8rem;
margin: 0;
}
@@ -460,7 +466,7 @@ body {
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
- border-top: 1px solid #2a2a2a;
+ border-top: 1px solid var(--border);
}
.restart-modal-btn {
@@ -473,28 +479,28 @@ body {
}
.restart-modal-btn.cancel {
- color: #ccc;
+ color: var(--text-secondary);
background-color: transparent;
}
.restart-modal-btn.cancel:hover {
- background-color: #2a2a2a;
+ background-color: var(--bg-tertiary);
}
.restart-modal-btn.confirm {
- background-color: #00D894;
- color: #111;
+ background-color: var(--accent);
+ color: #09090b;
}
.restart-modal-btn.confirm:hover {
- background-color: #00be82;
+ background-color: var(--accent-hover);
}
/* Progress Bar for Restart */
.restart-progress-bar {
width: 100%;
height: 0.375rem;
- background-color: #2a2a2a;
+ background-color: var(--bg-tertiary);
border-radius: 9999px;
overflow: hidden;
}
@@ -502,7 +508,7 @@ body {
.restart-progress-fill {
height: 100%;
border-radius: 9999px;
- background-color: #00D894;
+ background-color: var(--accent);
width: 40%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@@ -523,14 +529,14 @@ body {
}
.restart-modal-info {
- color: #666;
+ color: var(--text-secondary);
font-size: 0.8rem;
margin-top: 1.25rem;
margin-bottom: 0;
}
.restart-modal-info a {
- color: #00D894;
+ color: var(--accent);
text-decoration: none;
}
@@ -2522,17 +2528,21 @@ body {
}
.extensions-section h3 {
- font-size: 15px;
+ font-size: 11px;
font-weight: 600;
margin-bottom: 12px;
- color: var(--text);
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
.extensions-section h4 {
- font-size: 13px;
+ font-size: 11px;
font-weight: 600;
margin: 16px 0 8px;
- color: var(--text-secondary);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
}
.extensions-list {
@@ -2544,12 +2554,29 @@ body {
.ext-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
+ border-left: 3px solid transparent;
border-radius: var(--radius-lg);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
- transition: border-color 0.2s, transform 0.2s;
+ transition: border-color var(--transition-base), box-shadow var(--transition-base), transform 0.2s;
+}
+
+.ext-card.state-active {
+ border-left-color: var(--success);
+}
+
+.ext-card.state-inactive {
+ border-left-color: var(--text-muted);
+}
+
+.ext-card.state-error {
+ border-left-color: var(--danger);
+}
+
+.ext-card.state-pairing {
+ border-left-color: var(--warning);
}
.ext-card:hover {
@@ -2592,6 +2619,11 @@ body {
color: var(--warning);
}
+.ext-kind.kind-builtin {
+ background: rgba(161, 161, 170, 0.15);
+ color: var(--text-secondary);
+}
+
.ext-version {
font-size: 11px;
color: var(--text-muted);
@@ -2767,13 +2799,20 @@ body {
border-radius: var(--radius);
cursor: pointer;
font-size: 12px;
+ font-weight: 500;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text);
+ transition: all var(--transition-fast);
}
.btn-ext:hover {
background: var(--border);
+ transform: translateY(-1px);
+}
+
+.btn-ext:active {
+ transform: scale(0.97);
}
.btn-ext.activate {
@@ -2873,6 +2912,7 @@ body {
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
@@ -2893,7 +2933,7 @@ body {
.configure-modal h3 {
margin: 0 0 16px 0;
font-size: 16px;
- color: var(--text-primary);
+ color: var(--text);
}
.configure-hint {
@@ -3036,31 +3076,6 @@ body {
justify-content: flex-end;
}
-.tools-table {
- width: 100%;
- border-collapse: collapse;
-}
-
-.tools-table th,
-.tools-table td {
- padding: 8px 12px;
- text-align: left;
- border-bottom: 1px solid var(--border);
- font-size: 13px;
-}
-
-.tools-table th {
- color: var(--text-secondary);
- font-weight: 500;
- text-transform: uppercase;
- font-size: 11px;
- letter-spacing: 0.5px;
-}
-
-.tools-table tr:hover td {
- background: rgba(255, 255, 255, 0.03);
-}
-
/* --- Activity tab (unified sandbox job events) --- */
.activity-terminal {
@@ -3714,10 +3729,14 @@ mark {
gap: 8px;
align-items: center;
flex-wrap: wrap;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 14px;
}
.ext-install-form input {
- padding: 6px 10px;
+ padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -3759,6 +3778,10 @@ mark {
gap: 8px;
align-items: center;
margin-bottom: 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 14px;
}
.skill-search-box input {
@@ -3795,10 +3818,10 @@ mark {
}
.skill-trust {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 8px;
- font-weight: 500;
+ font-size: 11px;
+ padding: 3px 8px;
+ border-radius: 9999px;
+ font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
@@ -3942,6 +3965,27 @@ mark {
border-bottom: 1px solid var(--border);
}
+ /* Settings layout: horizontal subtabs on mobile */
+ .settings-layout { flex-direction: column; }
+ .settings-sidebar {
+ width: 100%;
+ flex-direction: row;
+ overflow-x: auto;
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ padding: 0;
+ }
+ .settings-subtab {
+ border-left: none;
+ border-bottom: 2px solid transparent;
+ white-space: nowrap;
+ padding: 8px 16px;
+ }
+ .settings-subtab.active {
+ border-left-color: transparent;
+ border-bottom-color: var(--accent);
+ }
+
/* Extension install form */
.ext-install-form {
flex-direction: column;
@@ -3968,6 +4012,238 @@ mark {
}
}
+/* --- Settings Tab Layout --- */
+.settings-layout {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+}
+
+.settings-sidebar {
+ width: 180px;
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+ padding: 12px 0;
+ flex-shrink: 0;
+}
+
+.settings-subtab {
+ display: block;
+ width: 100%;
+ padding: 10px 20px;
+ background: none;
+ border: none;
+ border-left: 2px solid transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ text-align: left;
+ transition: color 0.2s, background 0.2s, border-color 0.2s;
+}
+
+.settings-subtab:hover {
+ color: var(--text);
+ background: var(--bg-tertiary);
+}
+
+.settings-subtab.active {
+ color: var(--accent);
+ border-left-color: var(--accent);
+ background: var(--bg-tertiary);
+}
+
+.settings-content {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.settings-subpanel {
+ display: none;
+ flex: 1;
+ overflow: hidden;
+ flex-direction: column;
+ opacity: 0;
+}
+
+.settings-subpanel.active {
+ display: flex;
+ animation: settingsFadeIn 0.2s ease forwards;
+}
+
+@keyframes settingsFadeIn {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Settings form styles (General subtab) */
+.settings-group {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 16px;
+ margin-bottom: 16px;
+}
+
+.settings-group-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.settings-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ margin: 0 -12px;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+ border-radius: 6px;
+ gap: 16px;
+ max-height: 80px;
+ overflow: hidden;
+ transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease, background var(--transition-fast);
+ opacity: 1;
+}
+
+.settings-row:hover {
+ background: var(--bg-hover);
+}
+
+.settings-row.hidden {
+ max-height: 0;
+ opacity: 0;
+ margin: 0;
+ padding: 0;
+ border-bottom: none;
+}
+
+.settings-row.search-hidden {
+ display: none;
+}
+
+.settings-row:last-child { border-bottom: none; }
+
+.settings-label {
+ font-size: 13px;
+ color: var(--text);
+ font-weight: 500;
+ flex-shrink: 0;
+ min-width: 180px;
+}
+
+.settings-input {
+ padding: 6px 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-size: 13px;
+ font-family: 'IBM Plex Mono', monospace;
+ width: 240px;
+ max-width: 100%;
+}
+
+.settings-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
+}
+
+.settings-saved-indicator {
+ font-size: 11px;
+ color: var(--success);
+ opacity: 0;
+ transform: translateY(4px);
+ transition: opacity 0.3s ease, transform 0.3s ease;
+}
+
+.settings-saved-indicator.visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.settings-description {
+ font-size: 11px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+
+.restart-banner {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ background: var(--warning-soft);
+ border: 1px solid rgba(245, 166, 35, 0.25);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-size: 12px;
+ margin: 8px 16px;
+ animation: settingsFadeIn 0.25s ease forwards;
+}
+
+.restart-banner-text {
+ flex: 1;
+}
+
+.restart-banner-btn {
+ padding: 4px 12px;
+ background: var(--warning);
+ color: #09090b;
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 600;
+ white-space: nowrap;
+ transition: opacity var(--transition-fast);
+}
+
+.restart-banner-btn:hover {
+ opacity: 0.85;
+}
+
+.settings-label-wrap {
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ min-width: 180px;
+}
+
+.settings-select {
+ padding: 6px 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-size: 13px;
+ font-family: 'IBM Plex Mono', monospace;
+ width: 240px;
+ max-width: 100%;
+ cursor: pointer;
+}
+
+.settings-select:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
+}
+
+input[type="checkbox"]:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
/* Slash command autocomplete dropdown */
.slash-autocomplete {
position: relative;
@@ -4157,6 +4433,215 @@ mark {
background: var(--bg-secondary);
}
+/* Settings toolbar (search + import/export) */
+.settings-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-secondary);
+ flex-shrink: 0;
+}
+
+.settings-search {
+ flex: 1;
+}
+
+.settings-search input {
+ width: 100%;
+ padding: 6px 10px 6px 32px;
+ background: var(--bg);
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: 10px center;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-size: 13px;
+ font-family: 'IBM Plex Mono', monospace;
+}
+
+.settings-search input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15);
+}
+
+.settings-toolbar-btn {
+ padding: 6px 12px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+ white-space: nowrap;
+}
+
+.settings-toolbar-btn:hover {
+ background: var(--bg-secondary);
+ color: var(--text);
+ border-color: rgba(255, 255, 255, 0.15);
+ transform: translateY(-1px);
+}
+
+.settings-toolbar-btn:active {
+ transform: scale(0.98);
+}
+
+/* Confirmation modal */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ animation: modalFadeIn 0.15s ease;
+}
+
+@keyframes modalFadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes modalSlideIn {
+ from { opacity: 0; transform: translateY(10px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.modal {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 0;
+ max-width: 420px;
+ width: 90%;
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ animation: modalSlideIn 0.2s ease;
+}
+
+.modal h3 {
+ margin: 0;
+ padding: 16px 20px;
+ font-size: 16px;
+ color: var(--text);
+ border-bottom: 1px solid var(--border);
+}
+
+.modal p {
+ margin: 0;
+ padding: 16px 20px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 12px 20px;
+ border-top: 1px solid var(--border);
+}
+
+.btn-secondary {
+ padding: 8px 16px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.btn-secondary:hover {
+ background: var(--bg);
+}
+
+.btn-danger {
+ padding: 8px 16px;
+ background: var(--danger);
+ border: 1px solid var(--danger);
+ border-radius: var(--radius);
+ color: white;
+ cursor: pointer;
+ font-size: 13px;
+}
+
+.btn-danger:hover {
+ opacity: 0.9;
+}
+
+/* Mobile settings responsiveness */
+@media (max-width: 768px) {
+ .settings-row {
+ flex-direction: column;
+ align-items: stretch;
+ max-height: 140px;
+ }
+ .settings-label-wrap {
+ min-width: unset;
+ }
+ .settings-input, .settings-select {
+ width: 100%;
+ }
+ .settings-toolbar {
+ flex-wrap: wrap;
+ }
+ .settings-search {
+ min-width: 150px;
+ }
+}
+
+/* Loading skeletons */
+@keyframes shimmer {
+ 0% { background-position: -200% 0; }
+ 100% { background-position: 200% 0; }
+}
+
+.skeleton-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+ gap: 16px;
+}
+
+.skeleton-bar {
+ height: 12px;
+ border-radius: 6px;
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, rgba(255,255,255,0.06) 50%, var(--bg-tertiary) 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+}
+
+.skeleton-card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+/* Settings search empty state */
+.settings-search-empty {
+ padding: 32px 16px;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 13px;
+}
+
+
/* --- Config Tab --- */
.config-section-header {
diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs
index 981eacdd..76b2a760 100644
--- a/src/channels/web/test_helpers.rs
+++ b/src/channels/web/test_helpers.rs
@@ -87,6 +87,7 @@ impl TestGatewayBuilder {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
+ active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
})
}
diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs
index 7bf50e52..8efc69f6 100644
--- a/src/channels/web/ws.rs
+++ b/src/channels/web/ws.rs
@@ -521,6 +521,7 @@ mod tests {
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
+ active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
}
}
}
diff --git a/src/db/libsql/routines.rs b/src/db/libsql/routines.rs
index b75afb47..3151e75b 100644
--- a/src/db/libsql/routines.rs
+++ b/src/db/libsql/routines.rs
@@ -476,4 +476,28 @@ impl RoutineStore for LibSqlBackend {
.map_err(|e| DatabaseError::Query(e.to_string()))?;
Ok(())
}
+
+ async fn list_dispatched_routine_runs(&self) -> Result
, DatabaseError> {
+ let conn = self.connect().await?;
+ let mut rows = conn
+ .query(
+ &format!(
+ "SELECT {} FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
+ ROUTINE_RUN_COLUMNS
+ ),
+ params![],
+ )
+ .await
+ .map_err(|e| DatabaseError::Query(e.to_string()))?;
+
+ let mut runs = Vec::new();
+ while let Some(row) = rows
+ .next()
+ .await
+ .map_err(|e| DatabaseError::Query(e.to_string()))?
+ {
+ runs.push(row_to_routine_run_libsql(&row)?);
+ }
+ Ok(runs)
+ }
}
diff --git a/src/db/mod.rs b/src/db/mod.rs
index 6d2eb296..49287308 100644
--- a/src/db/mod.rs
+++ b/src/db/mod.rs
@@ -525,6 +525,9 @@ pub trait RoutineStore: Send + Sync {
run_id: Uuid,
job_id: Uuid,
) -> Result<(), DatabaseError>;
+ /// List routine runs that were dispatched as full_job but have not yet
+ /// been finalized (status='running' with a linked job_id).
+ async fn list_dispatched_routine_runs(&self) -> Result, DatabaseError>;
}
#[async_trait]
diff --git a/src/db/postgres.rs b/src/db/postgres.rs
index 8c18e252..eaa6e049 100644
--- a/src/db/postgres.rs
+++ b/src/db/postgres.rs
@@ -503,6 +503,10 @@ impl RoutineStore for PgBackend {
) -> Result<(), DatabaseError> {
self.store.link_routine_run_to_job(run_id, job_id).await
}
+
+ async fn list_dispatched_routine_runs(&self) -> Result, DatabaseError> {
+ self.store.list_dispatched_routine_runs().await
+ }
}
// ==================== ToolFailureStore ====================
diff --git a/src/history/store.rs b/src/history/store.rs
index 04e3167f..2deffab5 100644
--- a/src/history/store.rs
+++ b/src/history/store.rs
@@ -1348,6 +1348,18 @@ impl Store {
.await?;
Ok(())
}
+
+ /// List routine runs dispatched as full_job that have not yet been finalized.
+ pub async fn list_dispatched_routine_runs(&self) -> Result, DatabaseError> {
+ let conn = self.conn().await?;
+ let rows = conn
+ .query(
+ "SELECT * FROM routine_runs WHERE status = 'running' AND job_id IS NOT NULL",
+ &[],
+ )
+ .await?;
+ rows.iter().map(row_to_routine_run).collect()
+ }
}
#[cfg(feature = "postgres")]
diff --git a/src/llm/anthropic_oauth.rs b/src/llm/anthropic_oauth.rs
index ae6674dc..8c701101 100644
--- a/src/llm/anthropic_oauth.rs
+++ b/src/llm/anthropic_oauth.rs
@@ -22,6 +22,7 @@ use crate::llm::provider::{
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
strip_unsupported_tool_params,
};
+use crate::llm::retry::cap_retry_after;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
@@ -150,6 +151,7 @@ impl AnthropicOAuthProvider {
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::().ok())
.map(std::time::Duration::from_secs)
+ .map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)));
let response_text = response
@@ -766,9 +768,14 @@ mod tests {
#[test]
fn test_retry_after_large_number() {
- // Verify large numbers are accepted
+ // Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
- assert_eq!(duration, Some(std::time::Duration::from_secs(7200)));
+ assert_eq!(
+ duration,
+ Some(std::time::Duration::from_secs(
+ crate::llm::retry::MAX_RETRY_AFTER_SECS
+ ))
+ );
}
/// Helper function to test Retry-After header parsing logic for Anthropic
@@ -779,6 +786,7 @@ mod tests {
.parse::()
.ok()
.map(std::time::Duration::from_secs)
+ .map(cap_retry_after)
.or(Some(std::time::Duration::from_secs(60)))
}
}
diff --git a/src/llm/circuit_breaker.rs b/src/llm/circuit_breaker.rs
index db47647e..46f29ded 100644
--- a/src/llm/circuit_breaker.rs
+++ b/src/llm/circuit_breaker.rs
@@ -167,6 +167,12 @@ impl CircuitBreakerProvider {
}
}
CircuitState::Open => {
+ debug_assert!(
+ false,
+ "BUG: record_success() called while circuit breaker is Open — \
+ check_allowed() was bypassed for provider {}",
+ self.inner.model_name()
+ );
// Shouldn't get here (check_allowed blocks Open), but recover
state.state = CircuitState::Closed;
state.consecutive_failures = 0;
diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs
index 0a9e1fdc..f0d711a9 100644
--- a/src/llm/nearai_chat.rs
+++ b/src/llm/nearai_chat.rs
@@ -22,7 +22,7 @@ use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
-use crate::llm::{costs, session::SessionManager};
+use crate::llm::{costs, retry::cap_retry_after, session::SessionManager};
/// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -252,7 +252,7 @@ impl NearAiChatProvider {
.and_then(|v| {
// Try delay-seconds first (most common from API providers)
if let Ok(secs) = v.trim().parse::() {
- return Some(std::time::Duration::from_secs(secs));
+ return Some(cap_retry_after(std::time::Duration::from_secs(secs)));
}
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
@@ -260,9 +260,9 @@ impl NearAiChatProvider {
let delta = dt.signed_duration_since(now);
// Use max(0) so past/present dates yield Duration::ZERO
// rather than None (which would cause an immediate retry).
- return Some(std::time::Duration::from_secs(
- delta.num_seconds().max(0) as u64
- ));
+ return Some(cap_retry_after(std::time::Duration::from_secs(
+ delta.num_seconds().max(0) as u64,
+ )));
}
None
})
@@ -2306,9 +2306,17 @@ mod tests {
#[test]
fn test_retry_after_large_number() {
- // Verify large numbers are accepted
+ // Verify large numbers are capped to the safe maximum
let duration = parse_retry_after_for_test("3600"); // 1 hour
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
+
+ let huge = parse_retry_after_for_test("18446744073709551615");
+ assert_eq!(
+ huge,
+ Some(std::time::Duration::from_secs(
+ crate::llm::retry::MAX_RETRY_AFTER_SECS
+ ))
+ );
}
/// Helper function to test Retry-After header parsing logic
@@ -2316,13 +2324,13 @@ mod tests {
fn parse_retry_after_for_test(header_value: &str) -> Option {
let trimmed = header_value.trim();
let parsed = if let Ok(secs) = trimmed.parse::() {
- Some(std::time::Duration::from_secs(secs))
+ Some(cap_retry_after(std::time::Duration::from_secs(secs)))
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
let now = chrono::Utc::now();
let delta = dt.signed_duration_since(now);
- Some(std::time::Duration::from_secs(
- delta.num_seconds().max(0) as u64
- ))
+ Some(cap_retry_after(std::time::Duration::from_secs(
+ delta.num_seconds().max(0) as u64,
+ )))
} else {
None
};
diff --git a/src/llm/reasoning_models.rs b/src/llm/reasoning_models.rs
index 307cb0a3..ab691086 100644
--- a/src/llm/reasoning_models.rs
+++ b/src/llm/reasoning_models.rs
@@ -108,6 +108,8 @@ mod tests {
assert!(has_native_thinking("nanbeige-4.1-3b"));
assert!(has_native_thinking("step-3.5-flash-197b"));
assert!(has_native_thinking("minimax-m2.5-139b"));
+ assert!(has_native_thinking("MiniMax-M2.7"));
+ assert!(has_native_thinking("MiniMax-M2.7-highspeed"));
}
#[test]
diff --git a/src/llm/retry.rs b/src/llm/retry.rs
index 2875fbd3..6250de33 100644
--- a/src/llm/retry.rs
+++ b/src/llm/retry.rs
@@ -19,6 +19,12 @@ use crate::llm::provider::{
ToolCompletionResponse,
};
+/// Upper bound for provider-suggested `Retry-After` delays.
+///
+/// This prevents malicious or malformed headers from turning a retryable
+/// response into an effectively unbounded sleep.
+pub(crate) const MAX_RETRY_AFTER_SECS: u64 = 3600;
+
/// Returns `true` if the `LlmError` is transient and the request should be retried.
///
/// Used by `RetryProvider` (retry the same provider) and `FailoverProvider`
@@ -67,6 +73,11 @@ pub(crate) fn retry_backoff_delay(attempt: u32) -> Duration {
Duration::from_millis(delay_ms)
}
+/// Clamp a provider-suggested retry delay to a safe maximum.
+pub(crate) fn cap_retry_after(duration: Duration) -> Duration {
+ duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS))
+}
+
/// Configuration for the retry decorator.
#[derive(Debug, Clone)]
pub struct RetryConfig {
@@ -421,4 +432,16 @@ mod tests {
panic!("Expected RateLimited error");
}
}
+
+ #[test]
+ fn cap_retry_after_clamps_huge_delays() {
+ assert_eq!(
+ cap_retry_after(Duration::from_secs(u64::MAX)),
+ Duration::from_secs(MAX_RETRY_AFTER_SECS)
+ );
+ assert_eq!(
+ cap_retry_after(Duration::from_secs(0)),
+ Duration::from_secs(0)
+ );
+ }
}
diff --git a/src/main.rs b/src/main.rs
index 745cae09..65c04dda 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -323,6 +323,17 @@ async fn async_main() -> anyhow::Result<()> {
}));
// Load WASM channels and register their webhook routes.
+ // Ensure the channels directory exists so the WASM runtime initializes even when
+ // no channels are installed yet — hot-activation needs the runtime to be available.
+ if config.channels.wasm_channels_enabled
+ && let Err(e) = std::fs::create_dir_all(&config.channels.wasm_channels_dir)
+ {
+ tracing::warn!(
+ path = %config.channels.wasm_channels_dir.display(),
+ error = %e,
+ "Failed to create WASM channels directory"
+ );
+ }
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
let wasm_result = ironclaw::channels::wasm::setup_wasm_channels(
&config,
@@ -511,6 +522,16 @@ async fn async_main() -> anyhow::Result<()> {
gw = gw.with_skill_catalog(Arc::clone(sc));
}
gw = gw.with_cost_guard(Arc::clone(&components.cost_guard));
+ {
+ let active_model = components.llm.model_name().to_string();
+ let mut enabled = channel_names.clone();
+ enabled.push("gateway".into());
+ gw = gw.with_active_config(ironclaw::channels::web::server::ActiveConfigSnapshot {
+ llm_backend: config.llm.backend.to_string(),
+ llm_model: active_model,
+ enabled_channels: enabled,
+ });
+ }
if config.sandbox.enabled {
gw = gw.with_prompt_queue(Arc::clone(&prompt_queue));
diff --git a/src/setup/channels.rs b/src/setup/channels.rs
index 1c184b0b..2612076d 100644
--- a/src/setup/channels.rs
+++ b/src/setup/channels.rs
@@ -518,7 +518,7 @@ pub async fn setup_http(secrets: &SecretsContext) -> Result String {
generate_secret_with_length(32)
}
+fn http_webhook_secret_hint() -> &'static str {
+ "The secret is stored in the encrypted secrets database and will be loaded automatically on startup."
+}
+
fn validate_e164(account: &str) -> Result<(), String> {
if !account.starts_with('+') {
return Err("E.164 account must start with '+'".to_string());
@@ -1136,8 +1140,9 @@ mod tests {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
use crate::setup::channels::{
- SecretsContext, generate_webhook_secret, substitute_validation_placeholders,
- validate_cloudflare_token_format, validate_public_https_url,
+ SecretsContext, generate_webhook_secret, http_webhook_secret_hint,
+ substitute_validation_placeholders, validate_cloudflare_token_format,
+ validate_public_https_url,
};
fn test_secrets_context() -> SecretsContext {
@@ -1337,4 +1342,12 @@ mod tests {
.to_string();
assert!(err.contains("DNS resolution failed"));
}
+
+ #[test]
+ fn test_http_webhook_secret_hint_reflects_current_behavior() {
+ let hint = http_webhook_secret_hint();
+ assert!(hint.contains("encrypted secrets database"));
+ assert!(hint.contains("loaded automatically on startup"));
+ assert!(!hint.contains("ironclaw secret get"));
+ }
}
diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs
index 347cb4ff..22db7c74 100644
--- a/src/tools/builtin/routine.rs
+++ b/src/tools/builtin/routine.rs
@@ -9,11 +9,13 @@
//! - `routine_history` - View past runs
//! - `event_emit` - Emit a structured system event to `system_event`-triggered routines
-use std::sync::Arc;
+use std::collections::HashMap;
+use std::sync::{Arc, OnceLock};
use std::time::Duration;
use async_trait::async_trait;
use chrono::Utc;
+use serde_json::{Map, Value};
use uuid::Uuid;
use crate::agent::routine::{
@@ -22,135 +24,1012 @@ use crate::agent::routine::{
use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext;
use crate::db::Database;
-use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
+use crate::tools::tool::{
+ ApprovalRequirement, Tool, ToolDiscoverySummary, ToolError, ToolOutput, require_str,
+};
-pub(crate) fn routine_create_parameters_schema() -> serde_json::Value {
+// ==================== routine_create ====================
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum NormalizedTriggerRequest {
+ Cron {
+ schedule: String,
+ timezone: Option,
+ },
+ Manual,
+ MessageEvent {
+ pattern: String,
+ channel: Option,
+ },
+ SystemEvent {
+ source: String,
+ event_type: String,
+ filters: HashMap,
+ },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum NormalizedExecutionMode {
+ Lightweight,
+ FullJob,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct NormalizedExecutionRequest {
+ mode: NormalizedExecutionMode,
+ context_paths: Vec,
+ use_tools: bool,
+ max_tool_rounds: u32,
+ tool_permissions: Vec,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct NormalizedDeliveryRequest {
+ channel: Option,
+ user: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct NormalizedRoutineCreateRequest {
+ name: String,
+ description: String,
+ prompt: String,
+ trigger: NormalizedTriggerRequest,
+ execution: NormalizedExecutionRequest,
+ delivery: NormalizedDeliveryRequest,
+ cooldown_secs: u64,
+}
+
+fn routine_request_properties() -> Value {
+ serde_json::json!({
+ "kind": {
+ "type": "string",
+ "enum": ["cron", "manual", "message_event", "system_event"],
+ "description": "How the routine should start."
+ },
+ "schedule": {
+ "type": "string",
+ "description": "Cron expression for request.kind='cron'. Uses 6-field cron: second minute hour day month weekday."
+ },
+ "timezone": {
+ "type": "string",
+ "description": "IANA timezone for request.kind='cron', such as 'America/New_York'."
+ },
+ "pattern": {
+ "type": "string",
+ "description": "Regex pattern for request.kind='message_event'."
+ },
+ "channel": {
+ "type": "string",
+ "description": "Optional channel filter for request.kind='message_event'."
+ },
+ "source": {
+ "type": "string",
+ "description": "Event source namespace for request.kind='system_event', such as 'github'."
+ },
+ "event_type": {
+ "type": "string",
+ "description": "Event type for request.kind='system_event', such as 'issue.opened'."
+ },
+ "filters": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": {
+ "type": ["string", "number", "boolean"]
+ },
+ "description": "Optional exact-match filters for request.kind='system_event'. Only top-level string, number, and boolean payload fields are matched."
+ }
+ })
+}
+
+fn execution_properties() -> Value {
+ serde_json::json!({
+ "mode": {
+ "type": "string",
+ "enum": ["lightweight", "full_job"],
+ "description": "Execution mode. 'lightweight' is the default. 'full_job' runs a multi-turn autonomous job."
+ },
+ "context_paths": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Workspace paths to preload for lightweight routines."
+ },
+ "use_tools": {
+ "type": "boolean",
+ "description": "Only applies to lightweight mode. When true, safe non-approval tools are available."
+ },
+ "max_tool_rounds": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT,
+ "default": 3,
+ "description": "Only applies when execution.mode='lightweight' and use_tools=true. Runtime-capped to prevent loops."
+ },
+ "tool_permissions": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Only applies when execution.mode='full_job'. These tools are pre-authorized for Always-approval checks."
+ }
+ })
+}
+
+fn delivery_properties() -> Value {
+ serde_json::json!({
+ "channel": {
+ "type": "string",
+ "description": "Default channel for notifications and routine job message calls."
+ },
+ "user": {
+ "type": "string",
+ "description": "Default user or target for notifications and routine job message calls. If omitted, the owner's last-seen notification target is used."
+ }
+ })
+}
+
+fn advanced_properties() -> Value {
+ serde_json::json!({
+ "cooldown_secs": {
+ "type": "integer",
+ "description": "Minimum seconds between automatic fires. Manual fires still bypass cooldown."
+ }
+ })
+}
+
+fn manual_request_variant() -> Value {
serde_json::json!({
"type": "object",
+ "description": "Manual routines run only when explicitly fired.",
"properties": {
- "name": {
+ "kind": {
"type": "string",
- "description": "Unique routine name, for example 'daily-pr-review'."
- },
- "description": {
+ "enum": ["manual"],
+ "description": "Manual trigger."
+ }
+ },
+ "required": ["kind"]
+ })
+}
+
+fn cron_request_variant() -> Value {
+ serde_json::json!({
+ "type": "object",
+ "description": "Cron routines require request.schedule and may optionally set request.timezone.",
+ "properties": {
+ "kind": {
"type": "string",
- "description": "Short summary of what the routine is for."
- },
- "trigger_type": {
- "type": "string",
- "enum": ["cron", "event", "system_event", "manual"],
- "description": "When the routine fires: 'cron' for schedules, 'event' for incoming messages, 'system_event' for structured emitted events, or 'manual' for explicit runs."
+ "enum": ["cron"],
+ "description": "Scheduled trigger."
},
"schedule": {
"type": "string",
- "description": "Cron schedule for 'cron' triggers. Uses 6 fields: second minute hour day month weekday."
+ "description": "Cron expression for request.kind='cron'. Uses 6-field cron: second minute hour day month weekday."
},
- "event_pattern": {
+ "timezone": {
"type": "string",
- "description": "Regex matched against incoming message text for 'event' triggers, for example '^bug\\\\b'."
+ "description": "IANA timezone for request.kind='cron', such as 'America/New_York'."
+ }
+ },
+ "required": ["kind", "schedule"]
+ })
+}
+
+fn message_event_request_variant() -> Value {
+ serde_json::json!({
+ "type": "object",
+ "description": "Message-event routines require request.pattern and may optionally filter by request.channel.",
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["message_event"],
+ "description": "Pattern-matching message trigger."
},
- "event_channel": {
+ "pattern": {
"type": "string",
- "description": "Optional platform filter for 'event' triggers, for example 'telegram'. Omit to match any channel. Not a chat or thread ID."
+ "description": "Regex pattern for request.kind='message_event'."
},
- "event_source": {
+ "channel": {
"type": "string",
- "description": "Structured event source for 'system_event' triggers, for example 'github'."
+ "description": "Optional channel filter for request.kind='message_event'."
+ }
+ },
+ "required": ["kind", "pattern"]
+ })
+}
+
+fn system_event_request_variant() -> Value {
+ serde_json::json!({
+ "type": "object",
+ "description": "System-event routines require request.source and request.event_type. request.filters is optional.",
+ "properties": {
+ "kind": {
+ "type": "string",
+ "enum": ["system_event"],
+ "description": "Structured event trigger."
+ },
+ "source": {
+ "type": "string",
+ "description": "Event source namespace for request.kind='system_event', such as 'github'."
},
"event_type": {
"type": "string",
- "description": "Structured event type for 'system_event' triggers, for example 'issue.opened'."
+ "description": "Event type for request.kind='system_event', such as 'issue.opened'."
},
- "event_filters": {
+ "filters": {
"type": "object",
"properties": {},
"additionalProperties": {
"type": ["string", "number", "boolean"]
},
- "description": "Optional exact-match payload filters for 'system_event' triggers. Values can be strings, numbers, or booleans."
- },
- "prompt": {
+ "description": "Optional exact-match filters for request.kind='system_event'. Only top-level string, number, and boolean payload fields are matched."
+ }
+ },
+ "required": ["kind", "source", "event_type"]
+ })
+}
+
+fn routine_request_discovery_schema() -> Value {
+ serde_json::json!({
+ "type": "object",
+ "description": "Canonical trigger config. Set request.kind first, then follow the matching variant branch below.",
+ "properties": routine_request_properties(),
+ "required": ["kind"],
+ "oneOf": [
+ manual_request_variant(),
+ cron_request_variant(),
+ message_event_request_variant(),
+ system_event_request_variant()
+ ],
+ "examples": [
+ { "kind": "manual" },
+ { "kind": "cron", "schedule": "0 0 9 * * MON-FRI", "timezone": "UTC" },
+ { "kind": "message_event", "pattern": "deploy\\s+prod", "channel": "slack" },
+ { "kind": "system_event", "source": "github", "event_type": "issue.opened", "filters": { "repository": "nearai/ironclaw" } }
+ ]
+ })
+}
+
+fn lightweight_execution_variant() -> Value {
+ serde_json::json!({
+ "type": "object",
+ "description": "Default lightweight execution. Applies when execution is omitted or execution.mode='lightweight'.",
+ "properties": {
+ "mode": {
"type": "string",
- "description": "Instructions for what the routine should do after it fires."
+ "enum": ["lightweight"],
+ "description": "Lightweight execution mode."
},
"context_paths": {
"type": "array",
"items": { "type": "string" },
- "description": "Workspace paths to load as extra context before running the routine."
- },
- "action_type": {
- "type": "string",
- "enum": ["lightweight", "full_job"],
- "description": "Execution mode: 'lightweight' for one LLM turn or 'full_job' for a multi-step job with tools."
+ "description": "Workspace paths to preload for lightweight routines."
},
"use_tools": {
"type": "boolean",
- "description": "Enable safe tool use in 'lightweight' mode. Ignored for 'full_job'."
+ "description": "When true, safe non-approval tools are available."
},
"max_tool_rounds": {
"type": "integer",
- "description": "Maximum tool-call rounds in 'lightweight' mode when 'use_tools' is true."
- },
- "cooldown_secs": {
- "type": "integer",
- "description": "Minimum seconds between fires."
+ "minimum": 1,
+ "maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT,
+ "default": 3,
+ "description": "Only applies when use_tools=true. Runtime-capped to prevent loops."
+ }
+ }
+ })
+}
+
+fn full_job_execution_variant() -> Value {
+ serde_json::json!({
+ "type": "object",
+ "description": "Full-job execution. Uses tool_permissions and ignores lightweight-only fields such as use_tools, max_tool_rounds, and context_paths.",
+ "properties": {
+ "mode": {
+ "type": "string",
+ "enum": ["full_job"],
+ "description": "Full-job execution mode."
},
"tool_permissions": {
"type": "array",
"items": { "type": "string" },
- "description": "Pre-authorized tool names for 'full_job' routines."
- },
- "notify_channel": {
- "type": "string",
- "description": "Where routine output should be sent, for example 'telegram' or 'slack'. This does not control what triggers the routine."
- },
- "notify_user": {
- "type": "string",
- "description": "Optional explicit user or destination to notify, for example a username or chat ID. Omit it to use the configured owner's last-seen target for that channel."
- },
- "timezone": {
- "type": "string",
- "description": "IANA timezone used to evaluate 'cron' schedules, for example 'America/New_York'."
+ "description": "Tools pre-authorized for Always-approval checks."
}
},
- "required": ["name", "trigger_type", "prompt"]
+ "required": ["mode"]
})
}
-pub(crate) fn routine_update_parameters_schema() -> serde_json::Value {
+fn execution_discovery_schema() -> Value {
+ serde_json::json!({
+ "type": "object",
+ "description": "Optional execution settings. Omit this block for the default lightweight mode.",
+ "properties": execution_properties(),
+ "oneOf": [
+ lightweight_execution_variant(),
+ full_job_execution_variant()
+ ],
+ "examples": [
+ { "mode": "lightweight", "use_tools": true, "max_tool_rounds": 3 },
+ { "mode": "full_job", "tool_permissions": ["message", "http"] }
+ ]
+ })
+}
+
+fn routine_create_examples() -> Vec {
+ vec![
+ serde_json::json!({
+ "name": "manual-check",
+ "prompt": "Inspect the repo for issues.",
+ "request": { "kind": "manual" }
+ }),
+ serde_json::json!({
+ "name": "weekday-digest",
+ "prompt": "Prepare the morning digest.",
+ "request": {
+ "kind": "cron",
+ "schedule": "0 0 9 * * MON-FRI",
+ "timezone": "UTC"
+ },
+ "delivery": {
+ "channel": "telegram",
+ "user": "ops-team"
+ }
+ }),
+ serde_json::json!({
+ "name": "deploy-watch",
+ "prompt": "Look for deploy requests.",
+ "request": {
+ "kind": "message_event",
+ "pattern": "deploy\\s+prod",
+ "channel": "slack"
+ },
+ "execution": {
+ "mode": "lightweight",
+ "use_tools": true,
+ "max_tool_rounds": 5
+ }
+ }),
+ serde_json::json!({
+ "name": "issue-watch",
+ "prompt": "Summarize new GitHub issues.",
+ "request": {
+ "kind": "system_event",
+ "source": "github",
+ "event_type": "issue.opened",
+ "filters": { "repository": "nearai/ironclaw" }
+ },
+ "execution": {
+ "mode": "full_job",
+ "tool_permissions": ["message"]
+ }
+ }),
+ ]
+}
+
+fn routine_create_tool_summary() -> ToolDiscoverySummary {
+ ToolDiscoverySummary {
+ always_required: vec!["name".into(), "prompt".into(), "request.kind".into()],
+ conditional_requirements: vec![
+ "request.kind='cron' requires request.schedule.".into(),
+ "request.kind='message_event' requires request.pattern.".into(),
+ "request.kind='system_event' requires request.source and request.event_type.".into(),
+ "execution.mode='full_job' uses tool_permissions and ignores use_tools, max_tool_rounds, and context_paths.".into(),
+ ],
+ notes: vec![
+ "Omitting execution defaults to lightweight mode.".into(),
+ "Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
+ "advanced.cooldown_secs defaults to 300.".into(),
+ "Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
+ ],
+ examples: routine_create_examples(),
+ }
+}
+
+fn routine_create_schema(include_compatibility_aliases: bool) -> Value {
+ let mut schema = serde_json::json!({
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Unique name for the routine (e.g. 'daily-pr-review')."
+ },
+ "prompt": {
+ "type": "string",
+ "description": "Instructions for what the routine should do when it fires."
+ },
+ "description": {
+ "type": "string",
+ "description": "Optional human-readable summary of what the routine does."
+ },
+ "request": if include_compatibility_aliases {
+ routine_request_discovery_schema()
+ } else {
+ serde_json::json!({
+ "type": "object",
+ "description": "Canonical trigger config. Set request.kind first, then only fill fields that match that kind.",
+ "properties": routine_request_properties(),
+ "required": ["kind"]
+ })
+ },
+ "execution": if include_compatibility_aliases {
+ execution_discovery_schema()
+ } else {
+ serde_json::json!({
+ "type": "object",
+ "description": "Optional execution settings. Omit for the default lightweight mode.",
+ "properties": execution_properties()
+ })
+ },
+ "delivery": {
+ "type": "object",
+ "description": "Optional delivery defaults for notifications and message tool calls inside routine jobs.",
+ "properties": delivery_properties()
+ },
+ "advanced": {
+ "type": "object",
+ "description": "Optional advanced knobs. Most routines can omit this block.",
+ "properties": advanced_properties()
+ }
+ },
+ "required": ["name", "prompt"]
+ });
+
+ if include_compatibility_aliases {
+ if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
+ properties.insert(
+ "trigger_type".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "enum": ["cron", "event", "system_event", "manual"],
+ "description": "Compatibility alias for request.kind. Prefer request.kind."
+ }),
+ );
+ properties.insert(
+ "schedule".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "description": "Compatibility alias for request.schedule. Prefer request.schedule."
+ }),
+ );
+ properties.insert(
+ "timezone".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "description": "Compatibility alias for request.timezone. Prefer request.timezone."
+ }),
+ );
+ properties.insert(
+ "event_pattern".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "description": "Compatibility alias for request.pattern when request.kind='message_event'."
+ }),
+ );
+ properties.insert(
+ "event_channel".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "description": "Compatibility alias for request.channel when request.kind='message_event'."
+ }),
+ );
+ properties.insert(
+ "event_source".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "description": "Compatibility alias for request.source when request.kind='system_event'."
+ }),
+ );
+ properties.insert(
+ "event_type".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "description": "Compatibility alias for request.event_type when request.kind='system_event'."
+ }),
+ );
+ properties.insert(
+ "event_filters".to_string(),
+ serde_json::json!({
+ "type": "object",
+ "properties": {},
+ "additionalProperties": {
+ "type": ["string", "number", "boolean"]
+ },
+ "description": "Compatibility alias for request.filters when request.kind='system_event'."
+ }),
+ );
+ properties.insert(
+ "action_type".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "enum": ["lightweight", "full_job"],
+ "description": "Compatibility alias for execution.mode."
+ }),
+ );
+ properties.insert(
+ "context_paths".to_string(),
+ serde_json::json!({
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Compatibility alias for execution.context_paths."
+ }),
+ );
+ properties.insert(
+ "use_tools".to_string(),
+ serde_json::json!({
+ "type": "boolean",
+ "description": "Compatibility alias for execution.use_tools."
+ }),
+ );
+ properties.insert(
+ "max_tool_rounds".to_string(),
+ serde_json::json!({
+ "type": "integer",
+ "minimum": 1,
+ "maximum": crate::agent::routine::MAX_TOOL_ROUNDS_LIMIT,
+ "default": 3,
+ "description": "Compatibility alias for execution.max_tool_rounds."
+ }),
+ );
+ properties.insert(
+ "tool_permissions".to_string(),
+ serde_json::json!({
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Compatibility alias for execution.tool_permissions."
+ }),
+ );
+ properties.insert(
+ "notify_channel".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "description": "Compatibility alias for delivery.channel."
+ }),
+ );
+ properties.insert(
+ "notify_user".to_string(),
+ serde_json::json!({
+ "type": "string",
+ "description": "Compatibility alias for delivery.user."
+ }),
+ );
+ properties.insert(
+ "cooldown_secs".to_string(),
+ serde_json::json!({
+ "type": "integer",
+ "description": "Compatibility alias for advanced.cooldown_secs."
+ }),
+ );
+ }
+ if let Some(schema_obj) = schema.as_object_mut() {
+ schema_obj.insert(
+ "anyOf".to_string(),
+ serde_json::json!([
+ { "required": ["request"] },
+ { "required": ["trigger_type"] }
+ ]),
+ );
+ schema_obj.insert(
+ "examples".to_string(),
+ Value::Array(routine_create_examples()),
+ );
+ }
+ } else if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) {
+ required.push(Value::String("request".to_string()));
+ }
+
+ schema
+}
+
+pub(crate) fn routine_create_parameters_schema() -> Value {
+ routine_create_schema(false)
+}
+
+fn routine_create_discovery_schema() -> Value {
+ static CACHE: OnceLock = OnceLock::new();
+ CACHE.get_or_init(|| routine_create_schema(true)).clone()
+}
+
+pub(crate) fn routine_update_parameters_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
- "description": "Name of the routine to update."
+ "description": "Name of the routine to update"
},
"enabled": {
"type": "boolean",
- "description": "Set to true to enable the routine or false to disable it."
+ "description": "Enable or disable the routine"
},
"prompt": {
"type": "string",
- "description": "Replace the routine instructions for what it should do after it fires."
+ "description": "New prompt/instructions"
},
"schedule": {
"type": "string",
- "description": "New cron schedule for existing 'cron' routines only. This does not convert other trigger types."
+ "description": "New cron schedule (for cron triggers)"
},
"timezone": {
"type": "string",
- "description": "New IANA timezone for existing 'cron' routines only, for example 'America/New_York'."
+ "description": "IANA timezone for cron schedule (e.g. 'America/New_York'). Only valid for cron triggers."
},
"description": {
"type": "string",
- "description": "Replace the routine summary."
+ "description": "New description"
}
},
"required": ["name"]
})
}
-// ==================== routine_create ====================
+fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map> {
+ params.get(field).and_then(Value::as_object)
+}
+
+fn string_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option {
+ nested_object(params, group)
+ .and_then(|obj| obj.get(field))
+ .and_then(Value::as_str)
+ .map(String::from)
+ .or_else(|| {
+ aliases
+ .iter()
+ .find_map(|alias| params.get(*alias).and_then(Value::as_str).map(String::from))
+ })
+}
+
+fn bool_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option {
+ nested_object(params, group)
+ .and_then(|obj| obj.get(field))
+ .and_then(Value::as_bool)
+ .or_else(|| {
+ aliases
+ .iter()
+ .find_map(|alias| params.get(*alias).and_then(Value::as_bool))
+ })
+}
+
+fn u64_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Option {
+ nested_object(params, group)
+ .and_then(|obj| obj.get(field))
+ .and_then(Value::as_u64)
+ .or_else(|| {
+ aliases
+ .iter()
+ .find_map(|alias| params.get(*alias).and_then(Value::as_u64))
+ })
+}
+
+fn string_array_field(params: &Value, group: &str, field: &str, aliases: &[&str]) -> Vec {
+ nested_object(params, group)
+ .and_then(|obj| obj.get(field))
+ .and_then(Value::as_array)
+ .or_else(|| {
+ aliases
+ .iter()
+ .find_map(|alias| params.get(*alias).and_then(Value::as_array))
+ })
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|value| value.as_str().map(String::from))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+fn object_field(
+ params: &Value,
+ group: &str,
+ field: &str,
+ aliases: &[&str],
+) -> Option