mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 17:02:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dcadef797 |
@@ -23,7 +23,6 @@ jobs:
|
||||
- windows-latest
|
||||
env:
|
||||
CI: 'true'
|
||||
CLAWX_E2E_WORKERS: '2'
|
||||
# Linux runners cannot use Electron's setuid chrome-sandbox; harmless on macOS/Windows.
|
||||
ELECTRON_DISABLE_SANDBOX: '1'
|
||||
|
||||
|
||||
@@ -21,16 +21,12 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
| Comms baseline refresh | `pnpm run comms:baseline` |
|
||||
| Comms regression compare | `pnpm run comms:compare` |
|
||||
| E2E tests (Playwright) | `pnpm run test:e2e` |
|
||||
| Chat performance profiles | `pnpm run perf:chat` |
|
||||
| Electron Main inspector | `pnpm run profile:main` |
|
||||
| Build frontend only | `pnpm run build:vite` |
|
||||
|
||||
### Non-obvious caveats
|
||||
|
||||
- **pnpm version**: The exact pnpm version is pinned via `packageManager` in `package.json`. Use `corepack enable && corepack prepare` to activate the correct version before installing.
|
||||
- **Electron on headless Linux**: The dbus errors (`Failed to connect to the bus`) are expected and harmless in a headless/cloud environment. The app still runs fine with `$DISPLAY` set (e.g., `:1` via Xvfb/VNC).
|
||||
- **Performance profiling**: `pnpm run perf:chat` writes synthetic Renderer/Main CPU profiles and versioned metrics under ignored Playwright `test-results/`. For live Renderer CDP use `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev`; for live Main inspection use `pnpm run profile:main` and port 9229.
|
||||
- **E2E parallel isolation**: Functional Electron specs run concurrently with `CLAWX_E2E_WORKERS=2` by default. Keep tests parallel-safe and test-scoped; apply `E2E_EXCLUSIVE_TAG` from `tests/e2e/parallel-policy.ts` to tests that use the real clipboard or other OS-global state, and `E2E_PERFORMANCE_TAG` to host performance profiles. Extend `tests/unit/e2e-parallel-policy.test.ts` for recognizable new global APIs.
|
||||
- **`pnpm run lint` race condition**: If `pnpm run uv:download` was recently run, ESLint may fail with `ENOENT: no such file or directory, scandir '/workspace/temp_uv_extract'` because the temp directory was created and removed during download. Simply re-run lint after the download script finishes.
|
||||
- **Build scripts warning**: `pnpm install` may warn about ignored build scripts for `@discordjs/opus` and `koffi`. These are optional messaging-channel dependencies and the warnings are safe to ignore.
|
||||
- **`pnpm run init`**: This is a convenience script that runs `pnpm install` followed by `pnpm run uv:download`. Either run `pnpm run init` or run the two steps separately.
|
||||
|
||||
+4
-22
@@ -101,13 +101,13 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
|
||||
|
||||
### 💬 インテリジェントチャットインターフェース
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴に加え、シンタックスハイライト付きのフェンスコード、CJK 対応の解析、GitHub 風テーブル、KaTeX による LaTeX 数式(`$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]`)を含む、エージェント応答のストリーミング Markdown レンダリングをサポートします。ユーザー入力は常にプレーンテキストとして表示します。さらに、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。フェンスコードはソースの改行を保持し、長い行はソフトラップされ、ストリーミング完了後にローカライズされたコピー操作を利用できます。
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、エージェント応答の Markdown レンダリング(GitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]` を含む)をサポートし、ユーザー入力は常にプレーンテキストとして表示します。さらに、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
|
||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われ、失敗した場合は会話と確認ダイアログが保持されるため再試行できます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
|
||||
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
|
||||
|
||||
Chat の右パネルにあるワークスペースとプレビューの各タブでは、Markdown、`.docx`、`.pptx` ファイルを読み取り専用でプレビューできます。Markdown ファイルのプレビューでは、同じシンタックスハイライト、ソフトラップ、コピー操作付きのフェンスコード、CJK 対応の解析、KaTeX 数式を静的レンダリングモードでサポートします。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
|
||||
Chat の右パネルにあるワークスペースとプレビューの各タブでは、`.docx` と `.pptx` ファイルを読み取り専用でプレビューできます。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
|
||||
|
||||
### ローカル HTML プレビュー
|
||||
Chat の右パネルにはワークスペース、プレビュー、変更だけがあり、汎用ウェブブラウザ、ホーム画面、アドレスバーはありません。許可済みのローカル `.html` / `.htm` 添付ファイル、ファイルアクティビティ、ワークスペースファイルは既定でプレビューに開きます。ファイル操作では ClawX 内蔵プレビューまたはシステムアプリを選択でき、プレビューのヘッダーから現在の HTML ファイルをシステムブラウザで開くこともできます。
|
||||
@@ -123,6 +123,7 @@ ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同
|
||||
### ⏰ Cronベースの自動化
|
||||
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
|
||||
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。タスクのメッセージ入力欄でも、メインのチャット入力と同じインライン `/skill` トークン記法でスキルを挿入できるようになりました(選択中のエージェントに応じて読み込み)。スケジュールされたプロンプトから直接スキルを起動できます。スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれました。繰り返しは毎時・毎日・平日・毎週・カスタム(生の cron)の頻度を時刻/曜日コントロール付きで選べ、1回のみは選択した日付(曜日を表示)と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、実行後はランタイムにより自動的に削除されます。
|
||||
定期タスクの実行中は、Gateway を情報源とする一時的なオーバーレイに進捗が表示されます。完了した会話内容の正本は引き続き ACP リプレイまたは定期タスク履歴であり、外部の定期タスク実行が Chat から停止またはキャンセルできる ACP プロンプトになることはありません。
|
||||
|
||||
|
||||
### 🧩 拡張可能なスキルシステム
|
||||
@@ -188,9 +189,6 @@ ClawXを初めて起動すると、**セットアップウィザード**が以
|
||||
|
||||
サポート対象のシステム言語がある場合、ウィザードはその言語を初期選択し、未対応の場合は英語にフォールバックします。
|
||||
|
||||
> Web searchについて:ClawXは、AgentとGatewayの両方のポリシーレイヤーでOpenClawの汎用`web_search`ツールを無効にします。
|
||||
> Moonshot(Kimi)検索も対象です。管理対象のブラウザ自動化と`web_fetch`は引き続き利用できます。
|
||||
|
||||
### プロキシ設定
|
||||
|
||||
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
|
||||
@@ -226,8 +224,6 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
||||
|
||||
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
|
||||
|
||||
OpenClaw の設定配信も Electron Main が一元管理します。Gateway の実行中は `config.get` の正規スナップショットを基準にし、変更を `config.set` でコミットします。Gateway が停止中または起動中の場合は、同じコーディネーターが解決済みの JSON5 設定ファイルだけを更新し、Gateway を起動しません。そのため、通常の Provider、Agent、Channel、バインディング、Skill、モデル変更では Gateway プロセスを置き換えません。完全な再起動は、プロキシなどのプロセス起動環境の変更とユーザーによる明示的な操作に限定されます。確認済みのプロセス終了と WebSocket 切断では、既存の自動再接続経路が引き続き使用されます。WebSocket のハートビート欠落は診断とヘルス状態だけを更新し、Gateway プロセスを置き換えないため、pong 処理の遅延によって長時間実行中の処理が中断されることはありません。認証プロファイルを SQLite に書き込んだ後は OpenClaw の `secrets.reload` を呼び出し、実行中の Agent がプロセス再起動なしで新しい認証情報を読み取れるようにします。
|
||||
|
||||
Chat は Electron Main が所有する ACP stdio bridge を使用します。Renderer は型付き host event を受け取り、メモリ上の ACP timeline を描画します。Gateway は providers、models、skills、workspace、settings、diagnostics、media configuration などの非 Chat 機能を引き続き担当します。
|
||||
|
||||
別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。
|
||||
@@ -385,8 +381,6 @@ pnpm typecheck # TypeScriptの型チェック
|
||||
pnpm test # ユニットテストを実行
|
||||
pnpm run test:e2e # Electron E2E スモークテストを実行
|
||||
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
|
||||
pnpm run perf:chat # 合成 Chat の Renderer/Main CPU プロファイルを取得
|
||||
pnpm run profile:main # ビルド済みアプリを Main inspector の 9229 番ポートで起動
|
||||
pnpm run comms:replay # 通信リプレイ指標を算出
|
||||
pnpm run comms:baseline # 通信ベースラインを更新
|
||||
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
|
||||
@@ -402,18 +396,6 @@ pnpm package:linux # Linux向けにパッケージ化
|
||||
|
||||
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
|
||||
|
||||
Electron E2E の機能テストはローカルと CI の両方で既定で 2 つの Playwright worker を使用します。通常の並列レーンは `CLAWX_E2E_WORKERS=<正の整数>` でマシンに合わせて調整できます。OS 全体の状態を扱うテストは 1 worker の `exclusive` project に入り、ホストのパフォーマンスプロファイルは機能テスト後に単独で実行されます。新しい E2E テストは既定で並列です。実クリップボードなどのマシン全体で共有されるリソースを使う場合は、`tests/e2e/parallel-policy.ts` の `E2E_EXCLUSIVE_TAG` を適用してください。
|
||||
|
||||
独占前提を必要としない通常の spec だけを実行する場合は、`pnpm exec playwright test <spec> --project=parallel --no-deps` を使用します。
|
||||
|
||||
### Electron パフォーマンス診断
|
||||
|
||||
`pnpm run perf:chat` は隔離された合成 ACP 負荷を実行し、ストリーミング応答と、リッチな静的 Markdown 会話でのサイドバーおよびスクロール操作を測定します。Playwright の `test-results/` には、バージョン付きメトリクスと Renderer/Main CPU プロファイルが出力されます。Renderer プロファイルは本番の store/render 経路とフレームペーシングを対象とします。ストリーミング Main プロファイルは Main から Renderer への IPC fanout を測定し、操作時の Main プロファイルは Renderer 操作中に Main がアイドルのままかを確認します。どちらも上流の OpenClaw/ACP サブプロセスや GPU プロセスの経路は含みません。CPU プロファイルは Chrome DevTools で開けます。アーティファクトには生成されたテスト文字列だけが含まれ、製品テレメトリーには送信されません。測定値はハードウェアに依存するため、共通の絶対閾値ではなく同じマシン上の複数回の結果を比較してください。
|
||||
|
||||
実際の Renderer を記録する場合は `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` で開発環境を起動し、Playwright または Chrome DevTools を `localhost:9223` に接続します。Electron Main を記録する場合は `pnpm run profile:main` を実行し、`chrome://inspect` で `localhost:9229` を設定して Electron Main target を選択します。WebSocket trace 自体を測定する場合を除き、`CLAWX_GATEWAY_WS_TRACE` は設定しないでください。
|
||||
|
||||
ClawX は Chromium のハードウェアアクセラレーションを既定で有効なままにし、長い文書、スクロール、レイアウトアニメーションで GPU コンポジットとラスタライズを利用します。グラフィックスドライバーに問題があるマシンでは、Chromium 標準の `--disable-gpu` コマンドラインスイッチをトラブルシューティング用のフォールバックとして利用できます。
|
||||
|
||||
### 通信回帰チェック
|
||||
|
||||
PR が通信経路(Gateway イベント、ACP Chat bridge の送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
|
||||
|
||||
@@ -101,13 +101,13 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
|
||||
Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting.
|
||||
|
||||
### 💬 Intelligent Chat Interface
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, assistant replies rendered as streaming Markdown with syntax-highlighted fenced code, CJK-aware parsing, GitHub-flavored tables, and KaTeX-powered LaTeX math (`$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`) while user input remains literal text, and direct `@agent` routing in the main composer for multi-agent setups. Fenced code preserves source line breaks, soft-wraps long lines, and provides a localized copy action after streaming completes.
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, assistant replies rendered with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`) while user input remains literal text, and direct `@agent` routing in the main composer for multi-agent setups.
|
||||
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
|
||||
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
|
||||
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. A session row is removed and navigation changes only after permanent deletion succeeds; failed deletions leave the conversation and confirmation open for retry. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
|
||||
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
|
||||
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
|
||||
|
||||
The Workspace and Preview tabs in Chat's right panel provide read-only previews for Markdown, `.docx`, and `.pptx` files. Markdown file previews use the same syntax-highlighted, soft-wrapped, copyable fenced code, CJK-aware parsing, and KaTeX math support in static rendering mode. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
|
||||
The Workspace and Preview tabs in Chat's right panel provide read-only previews for `.docx` and `.pptx` files. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
|
||||
|
||||
### Local HTML Preview
|
||||
The Chat right panel has Workspace, Preview, and Changes tabs; it no longer includes a general Web Browser, Home page, or address bar. Authorized local `.html` and `.htm` attachments, file activities, and Workspace files open in Preview by default. Their file actions let you choose the built-in Preview or a system application, and the Preview header can open the current HTML file in the system browser.
|
||||
@@ -123,6 +123,7 @@ ClawX now also bundles Tencent's official personal WeChat channel plugin, so you
|
||||
### ⏰ Cron-Based Automation
|
||||
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
|
||||
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand. The task message field also supports inserting skills with the same inline `/skill` token syntax as the main chat composer (scoped to the selected agent), so scheduled prompts can trigger skills directly. The schedule picker is split into **Recurring** and **Once** tabs: Recurring offers Hourly, Daily, Weekdays, Weekly, and Custom (raw cron) frequencies with inline time/weekday controls, while Once runs the task a single time at a chosen date (with weekday shown) and time. One-time tasks must be scheduled for a future moment and are automatically removed by the runtime once they finish.
|
||||
While a cron task is running, Chat shows its progress in a transient Gateway-backed overlay. Completed conversation content still comes from authoritative ACP replay or cron history, and external cron activity never becomes an ACP prompt that can be stopped or cancelled from Chat.
|
||||
|
||||
|
||||
### 🧩 Extensible Skill System
|
||||
@@ -188,8 +189,8 @@ When you launch ClawX for the first time, the **Setup Wizard** will guide you th
|
||||
|
||||
The wizard preselects your system language when it is supported, and falls back to English otherwise.
|
||||
|
||||
> Web search note: ClawX disables OpenClaw's general-purpose `web_search` tool at both the agent and Gateway policy layers.
|
||||
> This includes Moonshot (Kimi) search; managed browser automation and `web_fetch` remain available.
|
||||
> Note for Moonshot (Kimi): ClawX keeps Kimi web search enabled by default.
|
||||
> When Moonshot is configured, ClawX also syncs Kimi web search to the China endpoint (`https://api.moonshot.cn/v1`) in OpenClaw config.
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
@@ -226,8 +227,6 @@ Notes:
|
||||
|
||||
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
|
||||
|
||||
Electron Main also owns OpenClaw configuration delivery. While the Gateway is running, ClawX reads the authoritative `config.get` snapshot and commits changes with `config.set`; while it is stopped or starting, the same coordinator updates the resolved JSON5 config file without starting the Gateway. Ordinary provider, agent, channel, binding, skill, and model changes therefore do not replace the Gateway process. Full restarts remain for process-launch environment changes such as proxy settings and explicit user actions. Confirmed process exits and WebSocket closes retain their existing automatic reconnect paths. WebSocket heartbeat misses update diagnostics and health state but do not replace the Gateway process, so delayed pong handling cannot interrupt long-running work. Auth-profile SQLite updates use OpenClaw's `secrets.reload` RPC so running agents see new credentials without a process restart.
|
||||
|
||||
Chat uses an ACP stdio bridge owned by Electron Main. Renderer receives typed host events and renders an in-memory ACP timeline. Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration.
|
||||
|
||||
An unfinished ACP response keeps streaming when you open another conversation or page. Returning before it finishes restores the latest in-memory timeline and continues the live response; once it finishes, normal ACP history replay remains the source of truth.
|
||||
@@ -385,8 +384,6 @@ pnpm typecheck # TypeScript validation
|
||||
pnpm test # Run unit tests
|
||||
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
|
||||
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
|
||||
pnpm run perf:chat # Capture synthetic Chat Renderer/Main CPU profiles
|
||||
pnpm run profile:main # Launch the built app with Main inspector on port 9229
|
||||
pnpm run comms:replay # Compute communication replay metrics
|
||||
pnpm run comms:baseline # Refresh communication baseline snapshot
|
||||
pnpm run comms:compare # Compare replay metrics against baseline thresholds
|
||||
@@ -402,18 +399,6 @@ pnpm package:linux # Package for Linux
|
||||
|
||||
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
|
||||
|
||||
Electron E2E functional specs use two Playwright workers by default both locally and in CI; set `CLAWX_E2E_WORKERS=<positive integer>` to tune the ordinary parallel lane for the machine. Tests that touch OS-global state use the one-worker `exclusive` project, and host performance profiles run alone afterward. New E2E tests are parallel by default; apply `E2E_EXCLUSIVE_TAG` from `tests/e2e/parallel-policy.ts` when a test uses the real clipboard or another machine-global resource.
|
||||
|
||||
For a focused ordinary spec that does not need the exclusive prerequisite, run `pnpm exec playwright test <spec> --project=parallel --no-deps`.
|
||||
|
||||
### Electron Performance Diagnostics
|
||||
|
||||
`pnpm run perf:chat` runs isolated synthetic ACP workloads for streaming and for rich static Markdown sidebar/scroll interaction. It writes versioned metrics plus Renderer and Main CPU profiles under the Playwright `test-results/` directory. The Renderer profiles cover the production store/render path and frame pacing. The streaming Main profile measures Main-to-Renderer IPC fanout; the interaction Main profile shows whether Main remains idle while Renderer interactions run. Neither includes the upstream OpenClaw/ACP subprocess or GPU-process paths. Open a CPU profile in Chrome DevTools; the artifacts contain generated fixture text only and are not product telemetry. Results are hardware-dependent, so compare repeated runs on the same machine instead of applying one cross-platform absolute threshold.
|
||||
|
||||
For a live Renderer recording, start development with `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` and attach Playwright or Chrome DevTools to `localhost:9223`. For a live Electron Main recording, run `pnpm run profile:main`, open `chrome://inspect`, configure `localhost:9229`, and select the Electron Main target. Leave `CLAWX_GATEWAY_WS_TRACE` unset unless WebSocket tracing itself is being measured.
|
||||
|
||||
ClawX leaves Chromium hardware acceleration enabled by default so long documents, scrolling, and layout animations can use GPU compositing and rasterization. Chromium still honors the native `--disable-gpu` command-line switch as a troubleshooting fallback for a machine with a broken graphics driver.
|
||||
|
||||
### Communication Regression Checks
|
||||
|
||||
When a PR changes communication paths (gateway events, ACP Chat bridge send/receive flow, channel delivery, or transport fallback), run:
|
||||
@@ -436,7 +421,6 @@ from `dist/` and `dist-electron/`, so it does not require manually running
|
||||
- builds the renderer and Electron bundles with `pnpm run build:vite`
|
||||
- starts Electron in an isolated E2E mode with a temporary `HOME`
|
||||
- uses a temporary ClawX `userData` directory
|
||||
- runs ordinary spec files concurrently while fencing OS-global and performance tests
|
||||
- skips heavy startup side effects such as gateway auto-start, bundled skill
|
||||
installation, tray creation, and CLI auto-install
|
||||
|
||||
@@ -446,7 +430,7 @@ The first two baseline specs cover:
|
||||
- skipping setup and navigating to the Models page inside the Electron app
|
||||
|
||||
Add future Electron flows under `tests/e2e/` and reuse the shared fixture in
|
||||
`tests/e2e/fixtures/electron.ts`. Keep tests parallel-safe by avoiding fixed writable paths, ports, native keychains, and other external shared state; use `E2E_EXCLUSIVE_TAG` when isolation is not possible.
|
||||
`tests/e2e/fixtures/electron.ts`.
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|
||||
@@ -365,10 +365,6 @@ pnpm package:linux # Упаковать для Linux
|
||||
|
||||
На headless Linux запускайте тесты Electron под сервером отображения, например `xvfb-run -a pnpm run test:e2e`.
|
||||
|
||||
Функциональные Electron E2E-тесты локально и в CI по умолчанию используют два worker-процесса Playwright. Число worker-процессов обычной параллельной группы можно настроить через `CLAWX_E2E_WORKERS=<положительное целое>`. Тесты с глобальным состоянием ОС выполняются в однопоточном проекте `exclusive`, а профили производительности хоста запускаются отдельно после функциональных тестов. Новые E2E-тесты параллельны по умолчанию; при работе с реальным буфером обмена или другим общим ресурсом машины используйте `E2E_EXCLUSIVE_TAG` из `tests/e2e/parallel-policy.ts`.
|
||||
|
||||
Чтобы запустить только обычный spec без эксклюзивного предварительного этапа, используйте `pnpm exec playwright test <spec> --project=parallel --no-deps`.
|
||||
|
||||
### Проверка регрессии коммуникаций
|
||||
|
||||
Когда PR изменяет пути коммуникации (события шлюза, поток отправки/получения чата, доставка каналов или откат транспорта), запустите:
|
||||
@@ -389,7 +385,6 @@ pnpm run comms:compare
|
||||
- собирает рендерер и пакеты Electron с `pnpm run build:vite`
|
||||
- запускает Electron в изолированном режиме E2E с временным `HOME`
|
||||
- использует временный каталог `userData` ClawX
|
||||
- параллельно запускает обычные spec-файлы, изолируя тесты глобальных ресурсов и производительности
|
||||
- пропускает тяжёлые побочные эффекты запуска, такие как автозапуск шлюза, установку упакованных навыков, создание трея и автоустановку CLI
|
||||
|
||||
Первые два базовых спецификации покрывают:
|
||||
|
||||
+6
-21
@@ -102,13 +102,13 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
|
||||
|
||||
### 💬 智能聊天界面
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录,并以流式 Markdown 渲染智能体回复,支持带语法高亮的围栏代码块、面向中日韩文本的解析、GitHub 风格表格,以及由 KaTeX 渲染的 LaTeX 数学公式(`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`);用户输入则始终按原始文本显示。同时支持在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。围栏代码会保留源码换行、自动软换行,并在流式输出结束后提供本地化的复制操作。
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、以 Markdown 渲染智能体回复(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`),用户输入则始终按原始文本显示;同时支持在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`。
|
||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。只有永久删除成功后,会话行才会移除且页面才会跳转;删除失败时会保留会话与确认框,方便重试。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
|
||||
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
|
||||
|
||||
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 Markdown、`.docx` 和 `.pptx` 文件。Markdown 文件预览以静态渲染模式提供相同的围栏代码语法高亮、软换行与复制操作、面向中日韩文本的解析和 KaTeX 数学公式支持。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
|
||||
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 `.docx` 和 `.pptx` 文件。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
|
||||
|
||||
### 本地 HTML 预览
|
||||
Chat 右侧面板只包含工作空间、预览和变更,不再提供通用网页浏览器、主页或地址栏。已授权的本地 `.html` 和 `.htm` 附件、文件活动及工作空间文件默认在预览中打开。文件操作可以选择 ClawX 内置预览或系统应用,预览标题栏也可将当前 HTML 文件交给系统浏览器打开。
|
||||
@@ -124,6 +124,7 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
|
||||
### ⏰ 定时任务自动化
|
||||
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
|
||||
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
|
||||
定时任务运行时,Chat 会通过由 Gateway 支持的临时浮层显示进度。已完成的对话内容仍以 ACP 重放或定时任务历史为权威来源,外部定时任务活动不会变成可在 Chat 中停止或取消的 ACP 提示词。
|
||||
|
||||
|
||||
### 🧩 可扩展技能系统
|
||||
@@ -189,8 +190,8 @@ pnpm dev
|
||||
|
||||
如果系统语言在支持列表中,向导会默认选中该语言;否则回退到英文。
|
||||
|
||||
> Web search 说明:ClawX 会在 Agent 和 Gateway 两层策略中禁用 OpenClaw 的通用 `web_search` 工具。
|
||||
> 这也包括 Moonshot(Kimi)搜索;受管浏览器自动化和 `web_fetch` 仍然可用。
|
||||
> Moonshot(Kimi)说明:ClawX 默认保持开启 Kimi 的 web search。
|
||||
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。
|
||||
|
||||
### 代理设置
|
||||
|
||||
@@ -227,8 +228,6 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
||||
|
||||
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
|
||||
|
||||
OpenClaw 配置交付也统一由 Electron Main 管理。Gateway 运行时,ClawX 以 `config.get` 返回的权威快照为基线,并通过 `config.set` 提交修改;Gateway 停止或启动中时,同一个协调器只更新解析后的 JSON5 配置文件,不会因此启动 Gateway。因此,普通的 Provider、Agent、Channel、绑定、Skill 和模型修改不会替换 Gateway 进程。完整重启仅保留给代理等进程启动环境变化和用户显式操作。已确认的进程退出与 WebSocket 关闭继续使用现有的自动重连路径。WebSocket 心跳缺失只更新诊断和健康状态,不会替换 Gateway 进程,因此延迟处理 pong 不会中断长时间运行的任务。认证配置写入 SQLite 后,ClawX 会调用 OpenClaw 的 `secrets.reload`,让运行中的 Agent 无需重启即可读取新凭据。
|
||||
|
||||
Chat 使用由 Electron Main 持有的 ACP stdio bridge。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。
|
||||
|
||||
打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。
|
||||
@@ -386,8 +385,6 @@ pnpm typecheck # TypeScript 类型检查
|
||||
pnpm test # 运行单元测试
|
||||
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
|
||||
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
|
||||
pnpm run perf:chat # 采集合成 Chat 场景的 Renderer/Main CPU Profile
|
||||
pnpm run profile:main # 启动构建产物并在 9229 端口调试 Main
|
||||
pnpm run comms:replay # 计算通信回放指标
|
||||
pnpm run comms:baseline # 刷新通信基线快照
|
||||
pnpm run comms:compare # 将回放指标与基线阈值对比
|
||||
@@ -403,18 +400,6 @@ pnpm package:linux # 为 Linux 打包
|
||||
|
||||
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
|
||||
|
||||
Electron E2E 功能测试在本地和 CI 中默认使用两个 Playwright worker;可通过 `CLAWX_E2E_WORKERS=<正整数>` 按机器能力调整普通并行通道。访问操作系统全局状态的测试进入单 worker 的 `exclusive` project,主机性能采样则在功能测试结束后独占运行。新增 E2E 测试默认并行;若测试使用真实剪贴板或其他机器级共享资源,请应用 `tests/e2e/parallel-policy.ts` 中的 `E2E_EXCLUSIVE_TAG`。
|
||||
|
||||
如果只需运行一个不依赖独占前置阶段的普通 spec,可使用 `pnpm exec playwright test <spec> --project=parallel --no-deps`。
|
||||
|
||||
### Electron 性能诊断
|
||||
|
||||
`pnpm run perf:chat` 会运行隔离的合成 ACP 负载,分别覆盖流式响应,以及富 Markdown 静态会话中的侧栏和滚动交互,并在 Playwright 的 `test-results/` 目录输出版本化指标与 Renderer/Main CPU Profile。Renderer Profile 覆盖生产 store/render 路径和帧节奏;流式 Main Profile 测量 Main 到 Renderer 的 IPC fanout,交互 Main Profile 用于确认 Renderer 交互期间 Main 是否保持空闲。两者都不包含上游 OpenClaw/ACP 子进程或 GPU 进程路径。CPU Profile 可直接用 Chrome DevTools 打开;其中只包含生成的测试文本,不会上报为产品遥测。性能数据依赖硬件,应在同一机器上多次运行后对比,不应使用统一的跨平台绝对阈值。
|
||||
|
||||
录制真实 Renderer 时,使用 `CLAWX_REMOTE_DEBUGGING_PORT=9223 pnpm dev` 启动开发环境,再让 Playwright 或 Chrome DevTools 连接 `localhost:9223`。录制真实 Electron Main 时,运行 `pnpm run profile:main`,在 `chrome://inspect` 中配置 `localhost:9229` 并选择 Electron Main target。除非正在测量 WebSocket trace 本身,否则不要设置 `CLAWX_GATEWAY_WS_TRACE`。
|
||||
|
||||
ClawX 默认保留 Chromium 硬件加速,使长文档、滚动和布局动画能够使用 GPU 合成与光栅化。若某台机器的显卡驱动存在问题,仍可使用 Chromium 原生的 `--disable-gpu` 命令行参数作为排障回退。
|
||||
|
||||
### 通信回归检查
|
||||
|
||||
当 PR 涉及通信链路(Gateway 事件、ACP Chat bridge 收发流程、Channel 投递、传输回退)时,建议执行:
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
# Cron Live Run Overlay Implementation Plan
|
||||
|
||||
> **For agentic workers:** Use `subagent-driven-development` to implement this plan task-by-task. Use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Render live cron-run progress in ACP Chat without converting Gateway runtime events into ACP notifications or persisting them in the ACP timeline.
|
||||
|
||||
**Architecture:** Electron Main owns a bounded, memory-only cron live-run broker. It canonicalizes run-scoped cron keys, deduplicates and reduces Gateway runtime events into an explicit non-ACP overlay snapshot, publishes typed host events, and serves a race-safe snapshot for late subscribers. Renderer keeps that overlay separate from `AcpTimelineSnapshot`; when a visible run terminates, it removes the overlay and reloads the authoritative ACP/cron history exactly once.
|
||||
|
||||
**Tech Stack:** Electron Main, TypeScript, Zustand, React 19, typed host-api/host-events, Vitest, Playwright, react-i18next, Harness communication specs.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Gateway runtime events must never be converted into `SessionNotification`, `AcpSessionUpdateEnvelope`, or `TimelineItem` objects.
|
||||
- `src/lib/acp/reducer.ts`, `src/lib/acp/timeline-types.ts`, and ACP replay semantics remain unchanged.
|
||||
- ACP `sending`, `cancelling`, Stop behavior, and `cancelAcpSession` remain owned exclusively by ACP prompts initiated by ClawX.
|
||||
- The overlay accepts only strict run-scoped cron keys shaped as `agent:<agentId>:cron:<jobId>:run:<runSessionId>`; ordinary sessions, base-only cron keys, channel sessions, and heartbeat `:main` events are rejected.
|
||||
- Main is the sole owner of cron key canonicalization, runtime-event deduplication, memory bounds, and active-run snapshots. Renderer must not reimplement protocol switching or Gateway event reduction.
|
||||
- Keep raw `chat:runtime-event` forwarding unchanged for the existing legacy runtime graph and image-generation compatibility consumers.
|
||||
- Display assistant text, but do not display raw `thinking.delta` text. The overlay exposes only a localized running/thinking indicator.
|
||||
- Runtime approval events are read-only status rows. They must not call ACP permission response APIs.
|
||||
- A terminal overlay is never treated as history. Completed content appears only after normal `loadAcpSession` replay or the existing typed cron-history fallback.
|
||||
- Use these exact broker bounds:
|
||||
- `MAX_ACTIVE_CRON_LIVE_RUNS = 32`
|
||||
- `MAX_CRON_LIVE_ITEMS_PER_RUN = 128`
|
||||
- `MAX_CRON_LIVE_ASSISTANT_CHARS = 500_000`
|
||||
- `MAX_CRON_LIVE_ITEM_DETAIL_CHARS = 100_000`
|
||||
- `MAX_CRON_LIVE_EVENT_FINGERPRINTS = 256` per run
|
||||
- `MAX_CRON_LIVE_TERMINAL_TOMBSTONES = 128`
|
||||
- Numeric sequence values are monotonic per run: reject `seq <= lastSeq`. Sequence-less events use bounded type-specific fingerprints; reject exact repeats but retain distinct incremental chunks.
|
||||
- Namespace every process item identity by `runId` so repeated `toolCallId`, `itemId`, command names, or approval fallbacks cannot collide across runs.
|
||||
- Main emits a monotonically increasing broker `revision`. Renderer subscribes before fetching the snapshot and ignores snapshots or changes older than its current revision.
|
||||
- All new display text must be translated in `en`, `zh`, `ja`, and `ru` and use existing design tokens from `src/styles/globals.css`.
|
||||
- Update the checked-in task spec before implementation. Because this changes backend communication, run Harness validation, communication replay/compare, and Electron E2E before completion.
|
||||
- Do not commit unless the user explicitly requests it. Each task lists a commit point only for a later explicitly requested commit workflow.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update the architecture contract before code
|
||||
|
||||
**Files:**
|
||||
- Modify: `harness/specs/tasks/render-cron-run-live-status.md`
|
||||
- Modify: `harness/specs/scenarios/gateway-backend-communication.md`
|
||||
- Modify: `harness/specs/scenarios/acp-chat-experience.md`
|
||||
- Modify: `harness/specs/rules/acp-chat-state-and-history.md`
|
||||
- Modify: `harness/specs/rules/acp-compatibility-content-safety.md`
|
||||
- Create: `harness/reference/acp-cron-live-overlay.md`
|
||||
- Test: `tests/unit/harness-specs.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Existing `gateway-backend-communication` and `acp-chat-experience` scenario contracts.
|
||||
- Produces: A durable rule that permits one bounded, running-only Gateway overlay while preserving ACP replay as the sole history authority.
|
||||
|
||||
- [ ] **Step 1: Write the failing Harness assertion**
|
||||
|
||||
Extend `tests/unit/harness-specs.test.ts` to require `render-cron-run-live-status` to declare `fast`, `comms`, and `e2e`; reference `acp-cron-live-overlay.md`; require ACP authority, compatibility safety, renderer/Main boundary, host-api/host-events, i18n/design-token, communication regression, and docs-sync rules.
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the expected failure**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/harness-specs.test.ts`. Expect failure because the current task spec still describes the legacy Execution Graph and omits the overlay reference and E2E profile.
|
||||
|
||||
- [ ] **Step 3: Rewrite the task and reference contract**
|
||||
|
||||
Change the expected behavior from “Gateway events become ACP/tool timeline updates” to:
|
||||
|
||||
```text
|
||||
Gateway runtime event -> Main bounded cron broker -> explicit live overlay
|
||||
terminal event -> overlay removal -> authoritative ACP/cron-history reload
|
||||
```
|
||||
|
||||
State explicitly that the overlay is non-historical, memory-only, run-scoped, read-only, and excluded from sidebar unread/busy authority. Set `docs.required: true`, list all touched areas from this plan, and include the focused/unit/E2E/comms commands used below.
|
||||
|
||||
- [ ] **Step 4: Validate the real task spec**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/unit/harness-specs.test.ts
|
||||
pnpm harness validate --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
pnpm harness run --spec harness/specs/tasks/render-cron-run-live-status.md --dry-run
|
||||
```
|
||||
|
||||
Expect all structural validation to pass without `--no-diff`.
|
||||
|
||||
- [ ] **Step 5: Commit point**
|
||||
|
||||
If explicitly requested, commit as `docs: define bounded cron live overlay architecture`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Establish shared cron identity and correct lifecycle normalization
|
||||
|
||||
**Files:**
|
||||
- Create: `shared/chat/cron-session.ts`
|
||||
- Delete: `src/stores/chat/cron-session-utils.ts`
|
||||
- Modify: `src/stores/acp-chat-session.ts`
|
||||
- Modify: `src/stores/chat.ts`
|
||||
- Modify: `src/stores/gateway.ts`
|
||||
- Modify: `src/stores/session-attention.ts`
|
||||
- Modify: `src/stores/chat/history-actions.ts`
|
||||
- Modify: `src/stores/chat/session-selection.ts`
|
||||
- Modify: `src/stores/chat/session-catalog.ts`
|
||||
- Modify: `src/stores/chat/session-key-utils.ts`
|
||||
- Modify: `electron/services/cron-api.ts`
|
||||
- Modify: `electron/gateway/chat-runtime-events.ts`
|
||||
- Test: `tests/unit/cron-session-utils.test.ts`
|
||||
- Test: `tests/unit/gateway-event-dispatch.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Raw OpenClaw `sessionKey`, lifecycle `phase`, and `data.aborted` values.
|
||||
- Produces: `parseCronSessionKey`, `getCronSessionBaseKey`, `isCronSessionKey`, `isRunScopedCronSessionKey`, and `sessionKeysAreEquivalent` as one shared authority; normalized terminal `ChatRuntimeEvent` values.
|
||||
|
||||
- [ ] **Step 1: Write failing identity and terminal tests**
|
||||
|
||||
Update `cron-session-utils.test.ts` to import from `@shared/chat/cron-session` and cover strict base/run parsing, empty or whitespace-only agent/job/run segment rejection, malformed suffix rejection, and run-scoped detection. Replace the current test that treats lifecycle `phase: 'end'` as non-terminal with expectations that:
|
||||
|
||||
```ts
|
||||
{ phase: 'end' } -> { type: 'run.ended', status: 'completed' }
|
||||
{ phase: 'end', aborted: true } -> { type: 'run.ended', status: 'aborted' }
|
||||
{ phase: 'error' } -> { type: 'run.ended', status: 'error' }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and verify failures**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/cron-session-utils.test.ts tests/unit/gateway-event-dispatch.test.ts`. Expect missing shared imports and incorrect `phase: 'end'` normalization.
|
||||
|
||||
- [ ] **Step 3: Centralize and tighten key parsing, then update all callers**
|
||||
|
||||
Move the parser into `shared/chat/cron-session.ts`, reject empty or whitespace-only `agentId`, `jobId`, and `runSessionId` segments, require exactly four segments for a base key or exactly six segments with literal `run` for a run key, and add `isRunScopedCronSessionKey`. Migrate all eight Renderer callers listed in the Files section plus Main `cron-api.ts`, delete the duplicate Main parser, and delete the old Renderer-owned utility file. Do not leave a compatibility re-export.
|
||||
|
||||
- [ ] **Step 4: Normalize OpenClaw terminal lifecycle correctly**
|
||||
|
||||
In `normalizeGatewayChatRuntimeEvent`, accept `end`, `completed`, `done`, and `finished` as terminal. For `phase: 'end'`, map `data.aborted === true` to `aborted`; otherwise map to `completed`. Preserve `endedAt`, `livenessState`, `replayInvalid`, and `stopReason`.
|
||||
|
||||
- [ ] **Step 5: Run focused regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/cron-session-utils.test.ts \
|
||||
tests/unit/gateway-event-dispatch.test.ts \
|
||||
tests/unit/gateway-events.test.ts \
|
||||
tests/unit/cron-schedule.test.ts
|
||||
```
|
||||
|
||||
Expect all tests to pass and no imports of `src/stores/chat/cron-session-utils.ts` to remain.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `fix: share cron identity and normalize run terminals`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Build the bounded Main-process cron live-run broker
|
||||
|
||||
**Files:**
|
||||
- Create: `shared/chat/cron-live-run.ts`
|
||||
- Create: `electron/services/cron-live-run-broker.ts`
|
||||
- Create: `tests/unit/cron-live-run-broker.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ChatRuntimeEvent` and shared cron-session parsing.
|
||||
- Produces: `CronLiveRunOverlaySnapshot`, `CronLiveRunItem`, `CronLiveRunOverlayChange`, `CronLiveRunOverlaySnapshotSet`, and `CronLiveRunBroker`.
|
||||
|
||||
- [ ] **Step 1: Define the explicit non-ACP view model in the test**
|
||||
|
||||
Write broker tests against this discriminated model:
|
||||
|
||||
```ts
|
||||
type CronLiveRunStatus = 'running';
|
||||
|
||||
type CronLiveRunItem =
|
||||
| { kind: 'tool'; id: string; toolCallId: string; title: string; status: 'running' | 'completed' | 'failed'; inputText?: string; outputText?: string; error?: string }
|
||||
| { kind: 'command'; id: string; title: string; status: 'running' | 'completed' | 'failed'; output: string; exitCode?: number }
|
||||
| { kind: 'patch'; id: string; title: string; summary?: string; added?: number; modified?: number; deleted?: number }
|
||||
| { kind: 'approval'; id: string; title: string; status: 'running' | 'completed' | 'failed'; message?: string };
|
||||
|
||||
interface CronLiveRunOverlaySnapshot {
|
||||
canonicalSessionKey: string;
|
||||
sourceSessionKey: string;
|
||||
runSessionId: string;
|
||||
runId: string;
|
||||
revision: number;
|
||||
status: CronLiveRunStatus;
|
||||
startedAt?: number;
|
||||
updatedAt: number;
|
||||
lastSeq?: number;
|
||||
assistantText: string;
|
||||
thinking: boolean;
|
||||
items: CronLiveRunItem[];
|
||||
}
|
||||
|
||||
interface CronLiveRunOverlaySnapshotSet {
|
||||
revision: number;
|
||||
snapshots: CronLiveRunOverlaySnapshot[];
|
||||
}
|
||||
|
||||
type CronLiveRunOverlayChange =
|
||||
| {
|
||||
kind: 'upsert';
|
||||
revision: number;
|
||||
snapshot: CronLiveRunOverlaySnapshot;
|
||||
}
|
||||
| {
|
||||
kind: 'remove';
|
||||
revision: number;
|
||||
canonicalSessionKey: string;
|
||||
sourceSessionKey: string;
|
||||
runId: string;
|
||||
reason: 'ended' | 'evicted' | 'gateway-reset';
|
||||
terminalStatus?: 'completed' | 'error' | 'aborted';
|
||||
terminalError?: string;
|
||||
};
|
||||
```
|
||||
|
||||
The broker-level `revision` increments once for every emitted change, including removals and clears. Every upsert snapshot carries that same revision. `getSnapshotSet()` returns the current broker revision even when `snapshots` is empty, so Renderer can reject a stale empty/non-empty hydration response deterministically.
|
||||
|
||||
- [ ] **Step 2: Write failing broker scenarios**
|
||||
|
||||
Cover strict run-key admission, mid-flight adoption without `run.started`, text snapshot/replace/delta convergence, thinking boolean without retained thought text, tool updates, command output, patch and approval ordering, run-namespaced identities, numeric sequence rejection, sequence-less fingerprint dedupe, deterministic active-run eviction, text/item bounds, terminal removal, terminal tombstone suppression, gateway reset, and monotonic revisions.
|
||||
|
||||
- [ ] **Step 3: Run the broker test and verify failure**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/cron-live-run-broker.test.ts`. Expect module-not-found failures.
|
||||
|
||||
- [ ] **Step 4: Implement the minimum reducer and broker**
|
||||
|
||||
Implement one pure `reduceCronLiveRunEvent(snapshot, event)` and one stateful `CronLiveRunBroker`. Use type-specific stable fingerprints instead of generic unbounded serialization. Serialize structured input/output with stable key ordering, catch cycles, and truncate to `MAX_CRON_LIVE_ITEM_DETAIL_CHARS`. Preserve first-occurrence item ordering and update existing items in place.
|
||||
|
||||
On terminal events, emit `remove` before deleting active state, then add a bounded run tombstone so delayed duplicate/non-terminal events cannot recreate the run. `getSnapshotSet()` returns immutable clones sorted by `updatedAt`, then `runId` for deterministic hydration.
|
||||
|
||||
- [ ] **Step 5: Run focused tests and static checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/unit/cron-live-run-broker.test.ts
|
||||
pnpm run typecheck:node
|
||||
```
|
||||
|
||||
Expect broker tests and Node type checking to pass.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: add bounded cron live-run broker`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Expose broker snapshots and changes through typed Main boundaries
|
||||
|
||||
**Files:**
|
||||
- Modify: `shared/host-events/contract.ts`
|
||||
- Modify: `shared/host-api/contract.ts`
|
||||
- Modify: `electron/services/cron-live-run-broker.ts`
|
||||
- Modify: `electron/services/cron-api.ts`
|
||||
- Modify: `electron/main/ipc-handlers.ts`
|
||||
- Modify: `electron/main/index.ts`
|
||||
- Modify: `src/lib/host-events.ts`
|
||||
- Modify: `src/lib/host-api.ts`
|
||||
- Test: `tests/unit/cron-live-run-broker.test.ts`
|
||||
- Test: `tests/unit/cron-schedule.test.ts`
|
||||
- Test: `tests/unit/host-events.test.ts`
|
||||
- Test: `tests/unit/host-api-facade.test.ts`
|
||||
- Test: `tests/unit/host-services.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `CronLiveRunBroker` from Task 3 and `GatewayManager` runtime/status/exit events.
|
||||
- Produces: `hostApi.cron.liveRunOverlays()` and `hostEvents.onCronLiveRunOverlayChanged()`.
|
||||
|
||||
- [ ] **Step 1: Write failing host-boundary tests**
|
||||
|
||||
Add expectations for:
|
||||
|
||||
```ts
|
||||
HOST_EVENT_CHANNELS.cron.liveRunOverlayChanged === 'cron:live-run-overlay-changed'
|
||||
hostEvents.onCronLiveRunOverlayChanged(handler)
|
||||
hostApi.cron.liveRunOverlays()
|
||||
```
|
||||
|
||||
Extend broker tests for a `bindCronLiveRunBroker` helper that is the sole broker-ingestion owner: it listens to GatewayManager `chat:runtime-event`, publishes resulting broker changes, and clears on non-running Gateway status or `exit`. Existing raw runtime forwarding remains a separate listener and must not call `broker.ingestRuntimeEvent`.
|
||||
|
||||
- [ ] **Step 2: Run tests and verify missing contracts**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/cron-live-run-broker.test.ts \
|
||||
tests/unit/cron-schedule.test.ts \
|
||||
tests/unit/host-events.test.ts \
|
||||
tests/unit/host-api-facade.test.ts \
|
||||
tests/unit/host-services.test.ts
|
||||
```
|
||||
|
||||
Expect failures for the new API/event surface and dependency injection.
|
||||
|
||||
- [ ] **Step 3: Add typed contracts and facades**
|
||||
|
||||
Add a static `cron` host-event module with `liveRunOverlayChanged`, and add `cron.liveRunOverlays` to `HostApiContract`. The preload channel allowlist is contract-derived, so do not add a direct IPC allowlist or renderer `window.electron.ipcRenderer.invoke` call.
|
||||
|
||||
- [ ] **Step 4: Wire one broker instance in Main**
|
||||
|
||||
Instantiate `CronLiveRunBroker` next to `GatewayManager` in `electron/main/index.ts`, pass it through `registerIpcHandlers` to `createCronApi`, and call `bindCronLiveRunBroker` before Gateway auto-start. The binding publishes changes with `sendMainWindowEvent(HOST_EVENT_CHANNELS.cron.liveRunOverlayChanged, change)` and is the only code that calls `broker.ingestRuntimeEvent`. Keep the existing raw `chat:runtime-event` listener unchanged so legacy and image-generation consumers still receive the original event exactly once.
|
||||
|
||||
`createCronApi({ gatewayManager, cronLiveRunBroker })` must return `liveRunOverlays: () => cronLiveRunBroker.getSnapshotSet()` for late join/reload hydration.
|
||||
|
||||
- [ ] **Step 5: Run boundary regressions**
|
||||
|
||||
Run the focused tests from Step 2 plus:
|
||||
|
||||
```bash
|
||||
pnpm run typecheck:node
|
||||
pnpm run typecheck:web
|
||||
```
|
||||
|
||||
Expect all tests and both type-check lanes to pass.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: expose cron live overlays through host boundaries`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add the revision-safe Renderer overlay store
|
||||
|
||||
**Files:**
|
||||
- Create: `src/stores/cron-live-run-overlay.ts`
|
||||
- Create: `tests/unit/cron-live-run-overlay-store.test.ts`
|
||||
- Modify: `tests/unit/host-events.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `hostApi.cron.liveRunOverlays()` and `hostEvents.onCronLiveRunOverlayChanged()`.
|
||||
- Produces: `useCronLiveRunOverlayStore`, `ensureCronLiveRunOverlaySubscriptions`, `selectCronLiveRunsForSession`, and terminal-removal acknowledgement state.
|
||||
|
||||
- [ ] **Step 1: Write failing store tests**
|
||||
|
||||
Mock host-api and host-events and cover:
|
||||
|
||||
- subscribe-before-snapshot ordering;
|
||||
- ignoring an older snapshot after a newer change;
|
||||
- upsert by `canonicalSessionKey + runId`;
|
||||
- remove without retaining content as history;
|
||||
- bounded pending removals keyed by `canonicalSessionKey + runId + revision` so bursts cannot overwrite one another;
|
||||
- explicit `acknowledgeRemoval(revision)` that removes only the acknowledged change;
|
||||
- a burst where visible run A and inactive run B terminate before React processes either event;
|
||||
- selection by exact base cron key only;
|
||||
- gateway-reset and eviction removals never marked as terminal refreshes.
|
||||
|
||||
- [ ] **Step 2: Run and verify module-not-found failure**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/cron-live-run-overlay-store.test.ts`.
|
||||
|
||||
- [ ] **Step 3: Implement the store**
|
||||
|
||||
Keep only normalized snapshots and at most 128 pending removal changes ordered by revision. Key removals by `canonicalSessionKey + runId + revision`; never overwrite another run's terminal signal. Do not import ACP reducer/timeline modules or `ChatRuntimeEvent`. `ensureCronLiveRunOverlaySubscriptions` must be idempotent, register the event listener first, then request the Main snapshot, and compare revisions before applying either source.
|
||||
|
||||
- [ ] **Step 4: Run focused tests and Web type checking**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/cron-live-run-overlay-store.test.ts \
|
||||
tests/unit/host-events.test.ts
|
||||
pnpm run typecheck:web
|
||||
```
|
||||
|
||||
Expect all tests to pass with no ACP imports in the new store.
|
||||
|
||||
- [ ] **Step 5: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: add cron live overlay renderer store`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Build the explicit, read-only live overlay UI
|
||||
|
||||
**Files:**
|
||||
- Create: `src/pages/Chat/CronLiveRunOverlay.tsx`
|
||||
- Create: `tests/unit/cron-live-run-overlay.test.tsx`
|
||||
- Modify: `shared/i18n/locales/en/chat.json`
|
||||
- Modify: `shared/i18n/locales/zh/chat.json`
|
||||
- Modify: `shared/i18n/locales/ja/chat.json`
|
||||
- Modify: `shared/i18n/locales/ru/chat.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: One `CronLiveRunOverlaySnapshot`.
|
||||
- Produces: A clearly labeled transient panel with `data-testid="cron-live-run-overlay"` and item-specific test IDs.
|
||||
|
||||
- [ ] **Step 1: Write failing component tests**
|
||||
|
||||
Cover the localized “Live scheduled run” header, running pulse, assistant Markdown, thinking indicator without raw thought text, tool status progression, whitespace-preserving command output, patch counts, read-only approval status, and distinct test IDs (`cron-live-tool`, `cron-live-command`, `cron-live-patch`, `cron-live-approval`).
|
||||
|
||||
- [ ] **Step 2: Run and verify failure**
|
||||
|
||||
Run `pnpm exec vitest run tests/unit/cron-live-run-overlay.test.tsx`.
|
||||
|
||||
- [ ] **Step 3: Implement the presentation component**
|
||||
|
||||
Reuse `AcpRenderPart` only as a Markdown renderer for assistant text; do not create ACP message/tool items. Implement dedicated cron item rows so they cannot be mistaken for native ACP cards or interactive ACP permissions. Use `bg-surface-modal`, `bg-surface-input`, selected/status token substitutions, and `text-X-700 dark:text-X-400` status colors from `globals.css`.
|
||||
|
||||
- [ ] **Step 4: Add complete locale coverage and regressions**
|
||||
|
||||
Add labels for the panel, running/thinking, tool/command/patch/approval status, completion/failure wording, and read-only approval explanation in all four locale files. Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/unit/cron-live-run-overlay.test.tsx
|
||||
pnpm run typecheck:web
|
||||
pnpm run lint:check
|
||||
```
|
||||
|
||||
Expect the component test, type check, and lint check to pass.
|
||||
|
||||
- [ ] **Step 5: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: render transient cron run progress`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Compose the overlay with ACP Chat and refresh authoritative history
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/pages/Chat/index.tsx`
|
||||
- Modify: `tests/unit/chat-acp-page.test.tsx`
|
||||
- Modify: `tests/unit/cron-live-run-overlay-store.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Current base session key, overlay snapshots/removal markers, ACP `loadSession`, and workspace context.
|
||||
- Produces: ACP timeline plus separate live panels; one authoritative reload for a visible terminal run.
|
||||
|
||||
- [ ] **Step 1: Write failing page integration tests**
|
||||
|
||||
Cover:
|
||||
|
||||
- overlay replaces `AcpEmptyState` while history is empty;
|
||||
- ACP timeline and overlay coexist as sibling DOM regions;
|
||||
- overlay content never appears under `data-testid="acp-chat-timeline"`;
|
||||
- another cron job or ordinary session does not render the overlay;
|
||||
- multiple active snapshots render in deterministic order;
|
||||
- switching away hides the overlay and switching back restores the Main snapshot;
|
||||
- external cron activity does not set `ChatInput.sending`, show ACP Stop, or call `cancelAcpSession`;
|
||||
- a terminal `remove` for a run that was visible triggers exactly one `loadAcpSession`;
|
||||
- a burst of removals for two runs preserves and acknowledges both revisions while refreshing only runs visible in the current session;
|
||||
- terminal removal while another session is selected does not trigger a delayed duplicate reload when returning later;
|
||||
- `evicted` and `gateway-reset` removals do not trigger authoritative reloads.
|
||||
|
||||
- [ ] **Step 2: Run and verify integration failures**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/chat-acp-page.test.tsx \
|
||||
tests/unit/cron-live-run-overlay-store.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Integrate subscriptions and rendering**
|
||||
|
||||
Initialize the overlay subscription alongside `ensureAcpChatSubscriptions`. Select snapshots for `currentSessionKey`, render them after the authoritative `AcpTimeline`, and suppress `AcpEmptyState` while at least one overlay is visible. Include overlay presence in scroll-to-latest calculations.
|
||||
|
||||
- [ ] **Step 4: Implement visible-run terminal refresh**
|
||||
|
||||
Track run IDs actually rendered for the current session in a ref that resets on session switch. Process pending removals in revision order. When an unacknowledged removal has `reason: 'ended'`, matches the current base session, and its run ID was rendered there, acknowledge that exact revision and call normal `loadAcpSession({ sessionKey, workspaceRoot: cwd, cwd })` once. Acknowledge non-visible/stale removals without reload. Do not collapse multiple removals into one marker, call legacy `loadHistory`, mutate the ACP snapshot, or synthesize a generation.
|
||||
|
||||
- [ ] **Step 5: Run focused UI and state regressions**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/chat-acp-page.test.tsx \
|
||||
tests/unit/cron-live-run-overlay.test.tsx \
|
||||
tests/unit/cron-live-run-overlay-store.test.ts \
|
||||
tests/unit/acp-chat-store.test.ts \
|
||||
tests/unit/gateway-events.test.ts
|
||||
pnpm run typecheck
|
||||
```
|
||||
|
||||
Expect all existing ACP prompt, image-generation, cancellation, and runtime retention tests to remain green.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `feat: compose cron live overlay with ACP Chat`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Replace synthetic-ACP E2E coverage, update docs, and run communication proof
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/e2e/cron-run-live-status.spec.ts`
|
||||
- Modify: `README.md`
|
||||
- Modify: `README.zh-CN.md`
|
||||
- Modify: `README.ja-JP.md`
|
||||
- Modify: `harness/specs/tasks/render-cron-run-live-status.md`
|
||||
- Modify: `harness/reference/acp-cron-live-overlay.md`
|
||||
- Modify: `harness/reference/acp-chat.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: The completed Main broker, typed host event, snapshot API, Renderer store, and overlay UI.
|
||||
- Produces: User-visible regression proof and synchronized architecture documentation.
|
||||
|
||||
- [ ] **Step 1: Rewrite E2E helpers and expectations**
|
||||
|
||||
Remove fake `chat:acp-session-update` tool calls from the live cron scenarios. Add a helper that emits typed `cron:live-run-overlay-changed` upsert/remove changes and mock `cron.liveRunOverlays` for late join. Main broker reduction is covered by `cron-live-run-broker.test.ts`; E2E covers the real preload/host-event/Renderer/UI contract.
|
||||
|
||||
Verify assistant text, thinking status, tool, command, patch, and approval rows; no legacy execution graph; no runtime content inside ACP timeline; no invalid Stop state; hide/restore across session switches; mid-flight overlay hydration; terminal removal; and one authoritative `loadAcpSession` invocation.
|
||||
|
||||
- [ ] **Step 2: Run the focused Electron E2E**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm run build:vite
|
||||
pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
|
||||
```
|
||||
|
||||
Expect the spec to pass on the local platform.
|
||||
|
||||
- [ ] **Step 3: Update user and architecture documentation**
|
||||
|
||||
In all three required READMEs, state that running cron progress is a transient Gateway-backed overlay, completed conversation content remains ACP/cron-history authoritative, and external cron activity does not become an ACP-cancellable prompt. Keep the explanation concise and localized.
|
||||
|
||||
Update Harness references to document the exact bounds, revision race handling, no-CoT rule, terminal reload semantics, OpenClaw upgrade removal condition, and the prohibition against extending this exception to ordinary non-cron messages.
|
||||
|
||||
- [ ] **Step 4: Run the focused and project-wide safe validation suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
tests/unit/harness-specs.test.ts \
|
||||
tests/unit/cron-session-utils.test.ts \
|
||||
tests/unit/gateway-event-dispatch.test.ts \
|
||||
tests/unit/cron-live-run-broker.test.ts \
|
||||
tests/unit/cron-live-run-overlay-store.test.ts \
|
||||
tests/unit/cron-live-run-overlay.test.tsx \
|
||||
tests/unit/cron-schedule.test.ts \
|
||||
tests/unit/host-events.test.ts \
|
||||
tests/unit/host-api-facade.test.ts \
|
||||
tests/unit/host-services.test.ts \
|
||||
tests/unit/chat-acp-page.test.tsx \
|
||||
tests/unit/acp-chat-store.test.ts \
|
||||
tests/unit/acp-image-generation-compat.test.ts \
|
||||
tests/unit/gateway-events.test.ts
|
||||
pnpm run typecheck
|
||||
pnpm run lint:check
|
||||
pnpm run build:vite
|
||||
pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
|
||||
pnpm run comms:replay
|
||||
pnpm run comms:compare
|
||||
pnpm harness validate --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
pnpm harness run --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
pnpm run harness:ci
|
||||
```
|
||||
|
||||
Expected result: all focused tests, type checking, lint, build, E2E, communication regression comparison, task Harness run, and Harness CI pass. Re-run `pnpm run lint:check` only after any concurrent uv download has completed if the documented temporary-directory race occurs.
|
||||
|
||||
- [ ] **Step 5: Review the removal condition**
|
||||
|
||||
Record in `acp-cron-live-overlay.md` that the overlay can be deleted only after a distributed OpenClaw package proves all of these through integration tests: loaded ACP sessions receive autonomous cron assistant/thought/tool updates, generated media arrives as standard ACP content blocks, replay is complete and deduplicated, and external-run lifecycle/cancel semantics are explicitly exposed.
|
||||
|
||||
- [ ] **Step 6: Commit point**
|
||||
|
||||
If explicitly requested, commit as `test: cover authoritative cron live overlay flow`.
|
||||
|
||||
---
|
||||
|
||||
## Final Self-Review Checklist
|
||||
|
||||
- [ ] No Gateway runtime event is converted to an ACP update or inserted into `AcpTimelineSnapshot`.
|
||||
- [ ] Main owns strict cron identity, event reduction, deduplication, bounds, snapshots, and revisions.
|
||||
- [ ] Sequence-less and repeated terminal events cannot duplicate or resurrect runs.
|
||||
- [ ] Renderer shows only current base-cron overlays and never raw chain-of-thought.
|
||||
- [ ] ACP prompt sending, Stop, cancellation, permission response, replay, and image compatibility behavior remain unchanged.
|
||||
- [ ] Terminal refresh occurs once only for a run that was visible in the currently selected session.
|
||||
- [ ] E2E no longer claims live behavior by injecting synthetic ACP tool notifications.
|
||||
- [ ] Harness specs and all required README translations describe the same authority boundary.
|
||||
- [ ] No placeholders, compatibility re-exports, direct IPC invokes, Gateway HTTP calls, or undocumented protocol fallbacks remain.
|
||||
@@ -53,13 +53,15 @@ export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeE
|
||||
: null;
|
||||
}
|
||||
|
||||
if (phase === 'completed' || phase === 'done' || phase === 'finished') {
|
||||
if (phase === 'end' || phase === 'completed' || phase === 'done' || phase === 'finished') {
|
||||
const base = withBase('run.ended', raw);
|
||||
const aborted = phase === 'end' && data.aborted === true;
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
status: 'completed',
|
||||
status: aborted ? 'aborted' : 'completed',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
...(aborted ? { error: readString(data.error) } : {}),
|
||||
livenessState: readString(data.livenessState),
|
||||
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
|
||||
stopReason: readString(data.stopReason),
|
||||
@@ -90,6 +92,8 @@ export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeE
|
||||
status: 'aborted',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
error: readString(data.error),
|
||||
livenessState: readString(data.livenessState),
|
||||
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
|
||||
stopReason: readString(data.stopReason),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Gateway WebSocket Client
|
||||
* Provides a typed interface for Gateway RPC calls
|
||||
*/
|
||||
import { GatewayManager, GatewayStatus } from './manager';
|
||||
|
||||
/**
|
||||
* Channel types supported by OpenClaw
|
||||
*/
|
||||
export type ChannelType = 'whatsapp' | 'dingtalk' | 'telegram' | 'discord' | 'wechat';
|
||||
|
||||
/**
|
||||
* Channel status
|
||||
*/
|
||||
export interface Channel {
|
||||
id: string;
|
||||
type: ChannelType;
|
||||
name: string;
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'error';
|
||||
lastActivity?: string;
|
||||
error?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill definition
|
||||
*/
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
configurable?: boolean;
|
||||
version?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill bundle definition
|
||||
*/
|
||||
export interface SkillBundle {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
skills: string[];
|
||||
icon?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
channel?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call in a message
|
||||
*/
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron task definition
|
||||
*/
|
||||
export interface CronTask {
|
||||
id: string;
|
||||
name: string;
|
||||
schedule: string;
|
||||
command: string;
|
||||
enabled: boolean;
|
||||
lastRun?: string;
|
||||
nextRun?: string;
|
||||
status: 'idle' | 'running' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'openai' | 'anthropic' | 'ollama' | 'custom';
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway Client
|
||||
* Typed wrapper around GatewayManager for making RPC calls
|
||||
*/
|
||||
export class GatewayClient {
|
||||
constructor(private manager: GatewayManager) { }
|
||||
|
||||
/**
|
||||
* Get current gateway status
|
||||
*/
|
||||
getStatus(): GatewayStatus {
|
||||
return this.manager.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gateway is connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.manager.isConnected();
|
||||
}
|
||||
|
||||
// ==================== Channel Methods ====================
|
||||
|
||||
/**
|
||||
* List all channels
|
||||
*/
|
||||
async listChannels(): Promise<Channel[]> {
|
||||
return this.manager.rpc<Channel[]>('channels.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel by ID
|
||||
*/
|
||||
async getChannel(channelId: string): Promise<Channel> {
|
||||
return this.manager.rpc<Channel>('channels.get', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a channel
|
||||
*/
|
||||
async connectChannel(channelId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('channels.connect', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a channel
|
||||
*/
|
||||
async disconnectChannel(channelId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('channels.disconnect', { channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get QR code for channel connection (e.g., WhatsApp)
|
||||
*/
|
||||
async getChannelQRCode(channelType: ChannelType): Promise<string> {
|
||||
return this.manager.rpc<string>('channels.getQRCode', { channelType });
|
||||
}
|
||||
|
||||
// ==================== Skill Methods ====================
|
||||
|
||||
/**
|
||||
* List all skills
|
||||
*/
|
||||
async listSkills(): Promise<Skill[]> {
|
||||
return this.manager.rpc<Skill[]>('skills.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a skill
|
||||
*/
|
||||
async enableSkill(skillId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.enable', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a skill
|
||||
*/
|
||||
async disableSkill(skillId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.disable', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill configuration
|
||||
*/
|
||||
async getSkillConfig(skillId: string): Promise<Record<string, unknown>> {
|
||||
return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update skill configuration
|
||||
*/
|
||||
async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.updateConfig', { skillId, config });
|
||||
}
|
||||
|
||||
// ==================== Chat Methods ====================
|
||||
|
||||
/**
|
||||
* Send a chat message
|
||||
*/
|
||||
async sendMessage(content: string, channelId?: string): Promise<ChatMessage> {
|
||||
return this.manager.rpc<ChatMessage>('chat.send', { content, channelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history
|
||||
*/
|
||||
async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> {
|
||||
return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear chat history
|
||||
*/
|
||||
async clearChatHistory(): Promise<void> {
|
||||
return this.manager.rpc<void>('chat.clear');
|
||||
}
|
||||
|
||||
// ==================== Cron Methods ====================
|
||||
|
||||
/**
|
||||
* List all cron tasks
|
||||
*/
|
||||
async listCronTasks(): Promise<CronTask[]> {
|
||||
return this.manager.rpc<CronTask[]>('cron.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new cron task
|
||||
*/
|
||||
async createCronTask(task: Omit<CronTask, 'id' | 'status'>): Promise<CronTask> {
|
||||
return this.manager.rpc<CronTask>('cron.create', task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a cron task
|
||||
*/
|
||||
async updateCronTask(taskId: string, updates: Partial<CronTask>): Promise<CronTask> {
|
||||
return this.manager.rpc<CronTask>('cron.update', { taskId, ...updates });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cron task
|
||||
*/
|
||||
async deleteCronTask(taskId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('cron.delete', { taskId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a cron task immediately
|
||||
*/
|
||||
async runCronTask(taskId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('cron.run', { taskId });
|
||||
}
|
||||
|
||||
// ==================== Provider Methods ====================
|
||||
|
||||
/**
|
||||
* List configured AI providers
|
||||
*/
|
||||
async listProviders(): Promise<ProviderConfig[]> {
|
||||
return this.manager.rpc<ProviderConfig[]>('providers.list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a provider
|
||||
*/
|
||||
async setProvider(provider: ProviderConfig): Promise<void> {
|
||||
return this.manager.rpc<void>('providers.set', provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a provider
|
||||
*/
|
||||
async removeProvider(providerId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('providers.remove', { providerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
*/
|
||||
async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId });
|
||||
}
|
||||
|
||||
// ==================== System Methods ====================
|
||||
|
||||
/**
|
||||
* Get Gateway health status
|
||||
*/
|
||||
async getHealth(): Promise<{ status: string; uptime: number; version?: string }> {
|
||||
return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway configuration
|
||||
*/
|
||||
async getConfig(): Promise<Record<string, unknown>> {
|
||||
return this.manager.rpc<Record<string, unknown>>('system.config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Gateway configuration
|
||||
*/
|
||||
async updateConfig(config: Record<string, unknown>): Promise<void> {
|
||||
return this.manager.rpc<void>('system.updateConfig', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gateway version info
|
||||
*/
|
||||
async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> {
|
||||
return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available skill bundles
|
||||
*/
|
||||
async getSkillBundles(): Promise<SkillBundle[]> {
|
||||
return this.manager.rpc<SkillBundle[]>('skills.bundles');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill bundle
|
||||
*/
|
||||
async installBundle(bundleId: string): Promise<void> {
|
||||
return this.manager.rpc<void>('skills.installBundle', { bundleId });
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import JSON5 from 'json5';
|
||||
import type { GatewayManager } from './manager';
|
||||
import { withConfigLock } from '../utils/config-mutex';
|
||||
import { resolveOpenClawConfigPath } from '../utils/paths';
|
||||
|
||||
export type OpenClawConfig = Record<string, unknown>;
|
||||
/** Mutators may be replayed after a compare-and-swap conflict and must not perform external writes. */
|
||||
export type OpenClawConfigMutator = (
|
||||
config: OpenClawConfig,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type ConfigDeliveryGatewayManager = Pick<GatewayManager, 'getStatus' | 'rpc'>;
|
||||
|
||||
interface ConfigSnapshot {
|
||||
config?: unknown;
|
||||
raw?: unknown;
|
||||
hash?: unknown;
|
||||
}
|
||||
|
||||
interface ActiveMutationContext {
|
||||
config: OpenClawConfig;
|
||||
active: boolean;
|
||||
sourceExists: boolean;
|
||||
}
|
||||
|
||||
export interface OpenClawConfigSnapshot {
|
||||
config: OpenClawConfig;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface FileConfigSnapshot {
|
||||
config: OpenClawConfig;
|
||||
raw: string | undefined;
|
||||
}
|
||||
|
||||
let gatewayManager: ConfigDeliveryGatewayManager | undefined;
|
||||
let transactionTail: Promise<void> = Promise.resolve();
|
||||
const activeMutation = new AsyncLocalStorage<ActiveMutationContext>();
|
||||
|
||||
function parseConfig(raw: string): OpenClawConfig {
|
||||
const parsed = JSON5.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('OpenClaw config must be an object');
|
||||
}
|
||||
return parsed as OpenClawConfig;
|
||||
}
|
||||
|
||||
function serializeConfig(config: OpenClawConfig): string {
|
||||
return `${JSON.stringify(config, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function parseRunningConfigSnapshot(snapshot: ConfigSnapshot | undefined): OpenClawConfig {
|
||||
if (snapshot?.config && typeof snapshot.config === 'object' && !Array.isArray(snapshot.config)) {
|
||||
return structuredClone(snapshot.config) as OpenClawConfig;
|
||||
}
|
||||
const raw = typeof snapshot?.raw === 'string' ? snapshot.raw : '';
|
||||
if (!raw.trim()) {
|
||||
throw new Error('Gateway config.get returned an incomplete config snapshot');
|
||||
}
|
||||
return parseConfig(raw);
|
||||
}
|
||||
|
||||
function isBaseHashConflict(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /config changed since last load; re-run config\.get and retry/i.test(message);
|
||||
}
|
||||
|
||||
async function mutateRunningConfig(
|
||||
manager: ConfigDeliveryGatewayManager,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const snapshot = await manager.rpc<ConfigSnapshot>('config.get', {});
|
||||
const hash = typeof snapshot?.hash === 'string' ? snapshot.hash.trim() : '';
|
||||
if (!hash) {
|
||||
throw new Error('Gateway config.get returned an incomplete config snapshot');
|
||||
}
|
||||
|
||||
const config = parseRunningConfigSnapshot(snapshot);
|
||||
if (!await applyMutator(config, mutator, true)) return false;
|
||||
|
||||
try {
|
||||
await manager.rpc('config.set', {
|
||||
raw: serializeConfig(config),
|
||||
baseHash: hash,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (attempt === 0 && isBaseHashConflict(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function applyMutator(
|
||||
config: OpenClawConfig,
|
||||
mutator: OpenClawConfigMutator,
|
||||
sourceExists: boolean,
|
||||
): Promise<boolean> {
|
||||
const baseline = structuredClone(config);
|
||||
const context: ActiveMutationContext = { config, active: true, sourceExists };
|
||||
try {
|
||||
await activeMutation.run(context, async () => await mutator(config));
|
||||
} finally {
|
||||
context.active = false;
|
||||
}
|
||||
return !isDeepStrictEqual(config, baseline);
|
||||
}
|
||||
|
||||
async function applyNestedMutator(
|
||||
context: ActiveMutationContext,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
const baseline = structuredClone(context.config);
|
||||
await mutator(context.config);
|
||||
return !isDeepStrictEqual(context.config, baseline);
|
||||
}
|
||||
|
||||
async function readFileConfig(configPath: string): Promise<FileConfigSnapshot> {
|
||||
try {
|
||||
const raw = await readFile(configPath, 'utf8');
|
||||
return { config: parseConfig(raw), raw };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { config: {}, raw: undefined };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileRaw(configPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(configPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemporaryFile(temporaryPath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(temporaryPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateFileConfig(
|
||||
manager: ConfigDeliveryGatewayManager | undefined,
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
return await withConfigLock(async () => {
|
||||
const configPath = resolveOpenClawConfigPath();
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const snapshot = await readFileConfig(configPath);
|
||||
const changed = await applyMutator(snapshot.config, mutator, snapshot.raw !== undefined);
|
||||
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
if (!changed) return false;
|
||||
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
const temporaryPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, serializeConfig(snapshot.config), {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
try {
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
|
||||
const currentRaw = await readFileRaw(configPath);
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
if (currentRaw !== snapshot.raw) {
|
||||
if (attempt === 0) continue;
|
||||
throw new Error('OpenClaw config changed during file mutation; retry the mutation');
|
||||
}
|
||||
|
||||
await rename(temporaryPath, configPath);
|
||||
return true;
|
||||
} finally {
|
||||
await removeTemporaryFile(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async function runMutation(mutator: OpenClawConfigMutator): Promise<boolean> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
return await mutateRunningConfig(manager, mutator);
|
||||
}
|
||||
return await mutateFileConfig(manager, mutator);
|
||||
}
|
||||
|
||||
async function runRead(): Promise<OpenClawConfigSnapshot> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state === 'running') {
|
||||
const snapshot = await manager.rpc<ConfigSnapshot>('config.get', {});
|
||||
return { config: parseRunningConfigSnapshot(snapshot), exists: true };
|
||||
}
|
||||
|
||||
const snapshot = await readFileConfig(resolveOpenClawConfigPath());
|
||||
return { config: snapshot.config, exists: snapshot.raw !== undefined };
|
||||
}
|
||||
|
||||
async function runSecretsReload(): Promise<boolean> {
|
||||
const manager = gatewayManager;
|
||||
if (manager?.getStatus().state !== 'running') return false;
|
||||
await manager.rpc('secrets.reload', {});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerOpenClawConfigCoordinator(
|
||||
manager: ConfigDeliveryGatewayManager,
|
||||
): void {
|
||||
gatewayManager = manager;
|
||||
}
|
||||
|
||||
export function mutateOpenClawConfig(
|
||||
mutator: OpenClawConfigMutator,
|
||||
): Promise<boolean> {
|
||||
const context = activeMutation.getStore();
|
||||
if (context?.active) {
|
||||
return applyNestedMutator(context, mutator);
|
||||
}
|
||||
|
||||
const transaction = transactionTail.then(
|
||||
() => runMutation(mutator),
|
||||
() => runMutation(mutator),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function readOpenClawConfigSnapshot(): Promise<OpenClawConfigSnapshot> {
|
||||
const context = activeMutation.getStore();
|
||||
if (context?.active) {
|
||||
return Promise.resolve({
|
||||
config: structuredClone(context.config),
|
||||
exists: context.sourceExists,
|
||||
});
|
||||
}
|
||||
|
||||
const transaction = transactionTail.then(
|
||||
() => runRead(),
|
||||
() => runRead(),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function reloadOpenClawSecretsIfRunning(): Promise<boolean> {
|
||||
const transaction = transactionTail.then(
|
||||
() => runSecretsReload(),
|
||||
() => runSecretsReload(),
|
||||
);
|
||||
transactionTail = transaction.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function resetOpenClawConfigCoordinatorForTests(): void {
|
||||
gatewayManager = undefined;
|
||||
transactionTail = Promise.resolve();
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prependPathEntry } from '../utils/env-path';
|
||||
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
|
||||
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
|
||||
import { safeRmSync } from '../utils/safe-fs';
|
||||
import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
|
||||
import { ensureOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
|
||||
@@ -196,6 +196,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
|
||||
safeRmSync(fsPath(targetDir));
|
||||
cpSyncSafe(bundledDir, targetDir);
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
|
||||
succeeded = false;
|
||||
@@ -204,6 +205,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
|
||||
// Same version already installed — still patch manifest ID in case it was
|
||||
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -217,6 +219,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
|
||||
// Skip only if installed AND same version — but still patch manifest ID.
|
||||
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -226,6 +229,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
|
||||
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
|
||||
succeeded = false;
|
||||
@@ -264,7 +268,7 @@ function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolea
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
async function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): Promise<void> {
|
||||
function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): void {
|
||||
const configuredSet = new Set(configuredChannels);
|
||||
for (const [channelType, { dirName }] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
||||
if (configuredSet.has(channelType)) continue;
|
||||
@@ -272,7 +276,7 @@ async function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels
|
||||
// 2026.6.10 → 2026.7.1 migration). OpenClaw validates tracked records even
|
||||
// when the channel is no longer configured, so reconcile this on every
|
||||
// launch rather than hiding it behind the directory-maintenance cache.
|
||||
await removeTrustedOfficialPluginInstallRecord(dirName);
|
||||
removeTrustedOfficialPluginInstallRecord(dirName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,9 +540,9 @@ export async function syncGatewayConfigBeforeLaunch(
|
||||
// Always refresh trusted install metadata through ClawX — this must not
|
||||
// be skipped when plugin-maintenance is cache-hit, otherwise official
|
||||
// external plugins like WhatsApp fail openKeyedStore at runtime.
|
||||
await measureAsync(timingsMs, 'trustedPluginInstallSyncMs', async () => {
|
||||
await cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
|
||||
await repairTrustedOfficialPluginInstallRecords();
|
||||
measureSync(timingsMs, 'trustedPluginInstallSyncMs', () => {
|
||||
cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
|
||||
repairTrustedOfficialPluginInstallRecords();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to auto-upgrade plugins:', err);
|
||||
|
||||
@@ -27,6 +27,7 @@ export function dispatchProtocolEvent(
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
break;
|
||||
}
|
||||
case 'channel.status':
|
||||
@@ -52,17 +53,14 @@ export function dispatchJsonRpcNotification(
|
||||
emitter: GatewayEventEmitter,
|
||||
notification: JsonRpcNotification,
|
||||
): void {
|
||||
emitter.emit('notification', notification);
|
||||
if (notification.method === 'agent') {
|
||||
const normalized = normalizeGatewayChatRuntimeEvent(notification.params);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
} else {
|
||||
emitter.emit('notification', notification);
|
||||
}
|
||||
switch (notification.method) {
|
||||
case 'agent':
|
||||
break;
|
||||
case GatewayEventType.CHANNEL_STATUS_CHANGED:
|
||||
emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent);
|
||||
break;
|
||||
|
||||
+203
-3
@@ -50,6 +50,11 @@ import { GatewayLifecycleController, LifecycleSupersededError } from './lifecycl
|
||||
import { launchGatewayProcess } from './process-launcher';
|
||||
import { GatewayRestartController } from './restart-controller';
|
||||
import { GatewayRestartGovernor } from './restart-governor';
|
||||
import {
|
||||
DEFAULT_GATEWAY_RELOAD_POLICY,
|
||||
loadGatewayReloadPolicy,
|
||||
type GatewayReloadPolicy,
|
||||
} from './reload-policy';
|
||||
import {
|
||||
classifyGatewayStderrMessage,
|
||||
GATEWAY_STARTUP_SLOW_STAGE_MS,
|
||||
@@ -190,15 +195,22 @@ export class GatewayManager extends EventEmitter {
|
||||
private readonly lifecycleController = new GatewayLifecycleController();
|
||||
private readonly restartController = new GatewayRestartController();
|
||||
private readonly restartGovernor = new GatewayRestartGovernor();
|
||||
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private initialReadyHeartbeatRecoveryTimer: NodeJS.Timeout | null = null;
|
||||
private upgradeSnapshotCleanupAttempted = false;
|
||||
private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
private reloadPolicyLoadedAt = 0;
|
||||
private reloadPolicyRefreshPromise: Promise<void> | null = null;
|
||||
private externalShutdownSupported: boolean | null = null;
|
||||
private reconnectAttemptsTotal = 0;
|
||||
private reconnectSuccessTotal = 0;
|
||||
private static readonly RELOAD_POLICY_REFRESH_MS = 15_000;
|
||||
private static readonly HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
private static readonly HEARTBEAT_TIMEOUT_MS = 30_000;
|
||||
private static readonly HEARTBEAT_MAX_MISSES = 4;
|
||||
public static readonly RESTART_COOLDOWN_MS = 5_000;
|
||||
private static readonly GATEWAY_READY_FALLBACK_PROBE_DELAYS_MS = [1_500, 3_000, 5_000, 8_000, 12_000, 30_000] as const;
|
||||
private static readonly INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS = 5 * 60_000;
|
||||
private lastRestartAt = 0;
|
||||
/** Set by scheduleReconnect() before calling start() to signal auto-reconnect. */
|
||||
private isAutoReconnectStart = false;
|
||||
@@ -242,6 +254,7 @@ export class GatewayManager extends EventEmitter {
|
||||
|
||||
this.on('gateway:ready', () => {
|
||||
this.resetGatewayReadyFallback();
|
||||
this.clearInitialReadyHeartbeatRecoveryTimer();
|
||||
if (this.status.state === 'running' && !this.status.gatewayReady) {
|
||||
logger.info('Gateway subsystems ready (event received)');
|
||||
this.setStatus({ gatewayReady: true });
|
||||
@@ -330,6 +343,8 @@ export class GatewayManager extends EventEmitter {
|
||||
logger.info(`Gateway start requested (port=${this.status.port})`);
|
||||
this.lastSpawnSummary = null;
|
||||
this.shouldReconnect = true;
|
||||
await this.refreshReloadPolicy(true);
|
||||
|
||||
// Lazily load device identity (async file I/O + key generation).
|
||||
// Must happen before connect() which uses the identity for the handshake.
|
||||
await this.initDeviceIdentity();
|
||||
@@ -672,6 +687,138 @@ export class GatewayManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Gateway process to reload config in-place when possible.
|
||||
* Falls back to restart on unsupported platforms or signaling failures.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.refreshReloadPolicy();
|
||||
|
||||
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=policy_forced_restart policy=${this.reloadPolicy.mode}`,
|
||||
);
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.restartController.isRestartDeferred({
|
||||
state: this.status.state,
|
||||
startLock: this.startLock,
|
||||
})) {
|
||||
this.restartController.markDeferredRestart('reload', {
|
||||
state: this.status.state,
|
||||
startLock: this.startLock,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pidBefore = this.process?.pid;
|
||||
logger.info(`[gateway-refresh] mode=reload requested pid=${pidBefore ?? 'n/a'} state=${this.status.state}`);
|
||||
|
||||
if (!this.process?.pid || this.status.state !== 'running') {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=not_running');
|
||||
logger.warn('Gateway reload requested while not running; falling back to restart');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedForMs = this.status.connectedAt
|
||||
? Date.now() - this.status.connectedAt
|
||||
: Number.POSITIVE_INFINITY;
|
||||
|
||||
// Avoid signaling a process that just came up; it will already read latest config.
|
||||
if (connectedForMs < 8000) {
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=skipped_recent_connect connectedForMs=${connectedForMs} pid=${this.process.pid}`,
|
||||
);
|
||||
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows does not support SIGUSR1 for in-process reload.
|
||||
// Fall back to a full restart. The connectedForMs < 8000 guard above
|
||||
// already skips unnecessary restarts for recently-started processes.
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=windows');
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(this.process.pid, 'SIGUSR1');
|
||||
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
|
||||
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
|
||||
// If process state doesn't recover quickly, fall back to restart.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
if (this.status.state !== 'running' || !this.process?.pid) {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=post_signal_unhealthy');
|
||||
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
|
||||
await this.restart();
|
||||
} else {
|
||||
const pidAfter = this.process.pid;
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=applied_in_place pidBefore=${pidBefore} pidAfter=${pidAfter}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[gateway-refresh] mode=reload result=fallback_restart cause=signal_error');
|
||||
logger.warn('Gateway reload signal failed, falling back to restart:', error);
|
||||
await this.restart();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced reload — coalesces multiple rapid config-change events into one
|
||||
* in-process reload when possible.
|
||||
*/
|
||||
debouncedReload(delayMs?: number): void {
|
||||
void this.refreshReloadPolicy();
|
||||
const effectiveDelay = delayMs ?? this.reloadPolicy.debounceMs;
|
||||
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
|
||||
logger.debug(
|
||||
`Gateway reload policy=${this.reloadPolicy.mode}; routing debouncedReload to debouncedRestart (${effectiveDelay}ms)`,
|
||||
);
|
||||
this.debouncedRestart(effectiveDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
}
|
||||
logger.debug(`Gateway reload debounced (will fire in ${effectiveDelay}ms)`);
|
||||
this.reloadDebounceTimer = setTimeout(() => {
|
||||
this.reloadDebounceTimer = null;
|
||||
void this.reload().catch((err) => {
|
||||
logger.warn('Debounced Gateway reload failed:', err);
|
||||
});
|
||||
}, effectiveDelay);
|
||||
}
|
||||
|
||||
private async refreshReloadPolicy(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - this.reloadPolicyLoadedAt < GatewayManager.RELOAD_POLICY_REFRESH_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reloadPolicyRefreshPromise) {
|
||||
await this.reloadPolicyRefreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.reloadPolicyRefreshPromise = (async () => {
|
||||
const nextPolicy = await loadGatewayReloadPolicy();
|
||||
this.reloadPolicy = nextPolicy;
|
||||
this.reloadPolicyLoadedAt = Date.now();
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.reloadPolicyRefreshPromise;
|
||||
} finally {
|
||||
this.reloadPolicyRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active timers
|
||||
*/
|
||||
@@ -682,7 +829,12 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
this.connectionMonitor.clear();
|
||||
this.restartController.clearDebounceTimer();
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
this.reloadDebounceTimer = null;
|
||||
}
|
||||
this.resetGatewayReadyFallback();
|
||||
this.clearInitialReadyHeartbeatRecoveryTimer();
|
||||
}
|
||||
|
||||
private clearGatewayReadyFallbackTimer(): void {
|
||||
@@ -889,6 +1041,7 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private recordGatewayAlive(): void {
|
||||
this.clearInitialReadyHeartbeatRecoveryTimer();
|
||||
this.diagnostics.lastAliveAt = Date.now();
|
||||
this.diagnostics.consecutiveHeartbeatMisses = 0;
|
||||
}
|
||||
@@ -1135,7 +1288,7 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe Gateway control-plane responsiveness without owning process recovery.
|
||||
* Start ping interval to keep connection alive
|
||||
*/
|
||||
private startPing(): void {
|
||||
this.connectionMonitor.startPing({
|
||||
@@ -1150,15 +1303,62 @@ export class GatewayManager extends EventEmitter {
|
||||
onHeartbeatTimeout: ({ consecutiveMisses, timeoutMs }) => {
|
||||
this.recordHeartbeatTimeout(consecutiveMisses);
|
||||
const pid = this.process?.pid ?? 'unknown';
|
||||
const shouldAttemptRecovery = this.shouldReconnect && this.status.state === 'running';
|
||||
logger.warn(
|
||||
`Gateway heartbeat: ${consecutiveMisses} consecutive pong misses ` +
|
||||
`(timeout=${timeoutMs}ms, pid=${pid}, state=${this.status.state}, autoReconnect=${this.shouldReconnect}). ` +
|
||||
'No restart requested; relying on process exit and socket close recovery.',
|
||||
`(timeout=${timeoutMs}ms, pid=${pid}, state=${this.status.state}, autoReconnect=${this.shouldReconnect}).`,
|
||||
);
|
||||
if (!shouldAttemptRecovery) {
|
||||
logger.warn('Gateway heartbeat recovery skipped (lifecycle is not in auto-recoverable running state)');
|
||||
return;
|
||||
}
|
||||
const initialReadyRecoveryDelayMs = this.getInitialReadyHeartbeatRecoveryDelayMs();
|
||||
if (initialReadyRecoveryDelayMs > 0) {
|
||||
logger.warn(
|
||||
`Gateway heartbeat recovery deferred while waiting for initial gateway.ready ` +
|
||||
`(retryAfterMs=${initialReadyRecoveryDelayMs})`,
|
||||
);
|
||||
this.scheduleInitialReadyHeartbeatRecovery(initialReadyRecoveryDelayMs);
|
||||
return;
|
||||
}
|
||||
logger.warn('Gateway heartbeat recovery: restarting unresponsive gateway process');
|
||||
void this.restart().catch((error) => {
|
||||
logger.warn('Gateway heartbeat recovery failed:', error);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getInitialReadyHeartbeatRecoveryDelayMs(now = Date.now()): number {
|
||||
if (this.status.gatewayReady || !this.status.connectedAt) return 0;
|
||||
const connectedForMs = Math.max(0, now - this.status.connectedAt);
|
||||
return Math.max(0, GatewayManager.INITIAL_READY_HEARTBEAT_RECOVERY_GRACE_MS - connectedForMs);
|
||||
}
|
||||
|
||||
private scheduleInitialReadyHeartbeatRecovery(delayMs: number): void {
|
||||
if (this.initialReadyHeartbeatRecoveryTimer) return;
|
||||
this.initialReadyHeartbeatRecoveryTimer = setTimeout(() => {
|
||||
this.initialReadyHeartbeatRecoveryTimer = null;
|
||||
if (
|
||||
!this.shouldReconnect
|
||||
|| this.status.state !== 'running'
|
||||
|| this.status.gatewayReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
logger.warn('Gateway heartbeat recovery: initial gateway.ready grace expired, restarting unresponsive gateway process');
|
||||
void this.restart().catch((error) => {
|
||||
logger.warn('Gateway heartbeat recovery failed:', error);
|
||||
});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private clearInitialReadyHeartbeatRecoveryTimer(): void {
|
||||
if (!this.initialReadyHeartbeatRecoveryTimer) return;
|
||||
clearTimeout(this.initialReadyHeartbeatRecoveryTimer);
|
||||
this.initialReadyHeartbeatRecoveryTimer = null;
|
||||
}
|
||||
|
||||
private async cleanupOpenClawUpgradeSnapshot(): Promise<void> {
|
||||
if (this.upgradeSnapshotCleanupAttempted) return;
|
||||
this.upgradeSnapshotCleanupAttempted = true;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type GatewayReloadMode = 'hybrid' | 'reload' | 'restart' | 'off';
|
||||
|
||||
export type GatewayReloadPolicy = {
|
||||
mode: GatewayReloadMode;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_GATEWAY_RELOAD_POLICY: GatewayReloadPolicy = {
|
||||
mode: 'hybrid',
|
||||
debounceMs: 1200,
|
||||
};
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const MAX_DEBOUNCE_MS = 60_000;
|
||||
|
||||
function normalizeMode(value: unknown): GatewayReloadMode {
|
||||
if (value === 'off' || value === 'reload' || value === 'restart' || value === 'hybrid') {
|
||||
return value;
|
||||
}
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.mode;
|
||||
}
|
||||
|
||||
function normalizeDebounceMs(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.debounceMs;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < 0) return 0;
|
||||
if (rounded > MAX_DEBOUNCE_MS) return MAX_DEBOUNCE_MS;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
export function parseGatewayReloadPolicy(config: unknown): GatewayReloadPolicy {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
const root = config as Record<string, unknown>;
|
||||
const gateway = (root.gateway && typeof root.gateway === 'object'
|
||||
? root.gateway
|
||||
: {}) as Record<string, unknown>;
|
||||
const reload = (gateway.reload && typeof gateway.reload === 'object'
|
||||
? gateway.reload
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
mode: normalizeMode(reload.mode),
|
||||
debounceMs: normalizeDebounceMs(reload.debounceMs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadGatewayReloadPolicy(): Promise<GatewayReloadPolicy> {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return parseGatewayReloadPolicy(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
type GatewayRpcRunner = (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
|
||||
|
||||
type QueuedRpc = {
|
||||
run: () => Promise<void>;
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) => (
|
||||
`${JSON.stringify(key)}:${stableStringify(record[key])}`
|
||||
)).join(',')}}`;
|
||||
}
|
||||
|
||||
export interface GatewayRpcBackpressureOptions {
|
||||
maxConcurrentHistory?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents renderer fan-out from forwarding an unbounded number of expensive
|
||||
* chat.history RPCs to OpenClaw. The Gateway still owns the canonical response;
|
||||
* this class only coalesces duplicate in-flight history calls and runs distinct
|
||||
* history requests through a small FIFO queue.
|
||||
*/
|
||||
export class GatewayRpcBackpressure {
|
||||
private readonly maxConcurrentHistory: number;
|
||||
private readonly inFlightHistory = new Map<string, Promise<unknown>>();
|
||||
private readonly queue: QueuedRpc[] = [];
|
||||
private activeHistory = 0;
|
||||
|
||||
constructor(options: GatewayRpcBackpressureOptions = {}) {
|
||||
this.maxConcurrentHistory = Math.max(1, options.maxConcurrentHistory ?? 2);
|
||||
}
|
||||
|
||||
run(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number | undefined,
|
||||
runner: GatewayRpcRunner,
|
||||
): Promise<unknown> {
|
||||
if (method !== 'chat.history') {
|
||||
return runner(method, params, timeoutMs);
|
||||
}
|
||||
|
||||
const key = `${method}:${stableStringify(params)}:${timeoutMs ?? 'default'}`;
|
||||
const existing = this.inFlightHistory.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = this.enqueueHistory(() => runner(method, params, timeoutMs))
|
||||
.finally(() => {
|
||||
if (this.inFlightHistory.get(key) === promise) {
|
||||
this.inFlightHistory.delete(key);
|
||||
}
|
||||
});
|
||||
this.inFlightHistory.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
getDiagnostics(): { activeHistory: number; queuedHistory: number; inFlightHistory: number } {
|
||||
return {
|
||||
activeHistory: this.activeHistory,
|
||||
queuedHistory: this.queue.length,
|
||||
inFlightHistory: this.inFlightHistory.size,
|
||||
};
|
||||
}
|
||||
|
||||
private enqueueHistory(work: () => Promise<unknown>): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const queued: QueuedRpc = {
|
||||
run: async () => {
|
||||
this.activeHistory += 1;
|
||||
try {
|
||||
resolve(await work());
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
this.activeHistory -= 1;
|
||||
this.drain();
|
||||
}
|
||||
},
|
||||
};
|
||||
this.queue.push(queued);
|
||||
this.drain();
|
||||
});
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
while (this.activeHistory < this.maxConcurrentHistory) {
|
||||
const next = this.queue.shift();
|
||||
if (!next) return;
|
||||
void next.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ const SECRET_KEYS = new Set([
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
]);
|
||||
const CONFIG_WRITE_METHODS = new Set(['config.set', 'config.patch', 'config.apply']);
|
||||
|
||||
export function isGatewayWsTraceEnabled(): boolean {
|
||||
return process.env.CLAWX_GATEWAY_WS_TRACE === '1';
|
||||
@@ -23,17 +22,9 @@ export function redactGatewayFrameForTrace(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const redactConfigRaw = typeof record.method === 'string' && CONFIG_WRITE_METHODS.has(record.method);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(record)) {
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (redactConfigRaw && key === 'params' && item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const params = redactGatewayFrameForTrace(item) as Record<string, unknown>;
|
||||
if (Object.hasOwn(params, 'raw')) params.raw = '[redacted]';
|
||||
result[key] = params;
|
||||
continue;
|
||||
}
|
||||
result[key] = SECRET_KEYS.has(normalizedKey)
|
||||
? '[redacted]'
|
||||
: redactGatewayFrameForTrace(item);
|
||||
|
||||
+32
-2
@@ -5,8 +5,12 @@
|
||||
import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { registerOpenClawConfigCoordinator } from '../gateway/config-delivery';
|
||||
import {
|
||||
bindCronLiveRunBroker,
|
||||
CronLiveRunBroker,
|
||||
} from '../services/cron-live-run-broker';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
|
||||
import { HostApiRegistry } from './ipc/host-invoke';
|
||||
import { createTray } from './tray';
|
||||
import { createMenu } from './menu';
|
||||
@@ -68,6 +72,22 @@ if (isE2EMode && requestedUserDataDir) {
|
||||
app.setPath('userData', requestedUserDataDir);
|
||||
}
|
||||
|
||||
// Disable GPU hardware acceleration globally for maximum stability across
|
||||
// all GPU configurations (no GPU, integrated, discrete).
|
||||
//
|
||||
// Rationale (following VS Code's philosophy):
|
||||
// - Page/file loading is async data fetching — zero GPU dependency.
|
||||
// - The original per-platform GPU branching was added to avoid CPU rendering
|
||||
// competing with sync I/O on Windows, but all file I/O is now async
|
||||
// (fs/promises), so that concern no longer applies.
|
||||
// - Software rendering is deterministic across all hardware; GPU compositing
|
||||
// behaviour varies between vendors (Intel, AMD, NVIDIA, Apple Silicon) and
|
||||
// driver versions, making it the #1 source of rendering bugs in Electron.
|
||||
//
|
||||
// Users who want GPU acceleration can pass `--enable-gpu` on the CLI or
|
||||
// set `"disable-hardware-acceleration": false` in the app config (future).
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
// On Linux, set CHROME_DESKTOP so Chromium can find the correct .desktop file.
|
||||
// On Wayland this maps the running window to clawx.desktop (→ icon + app grouping);
|
||||
// on X11 it supplements the StartupWMClass matching.
|
||||
@@ -118,6 +138,7 @@ const gotTheLock = gotElectronLock && gotFileLock;
|
||||
// Global references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let gatewayManager!: GatewayManager;
|
||||
let cronLiveRunBroker!: CronLiveRunBroker;
|
||||
let clawHubService!: ClawHubService;
|
||||
const hostApiRegistry = new HostApiRegistry();
|
||||
const webBrowserGuestRegistry = new WebBrowserGuestRegistry();
|
||||
@@ -357,6 +378,7 @@ async function initialize(): Promise<void> {
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(
|
||||
gatewayManager,
|
||||
cronLiveRunBroker,
|
||||
clawHubService,
|
||||
window,
|
||||
hostApiRegistry,
|
||||
@@ -491,6 +513,14 @@ async function initialize(): Promise<void> {
|
||||
sendMainWindowEvent('gateway:exit', { code });
|
||||
});
|
||||
|
||||
bindCronLiveRunBroker({
|
||||
gatewayManager,
|
||||
broker: cronLiveRunBroker,
|
||||
publishChange: (change) => {
|
||||
sendMainWindowEvent(HOST_EVENT_CHANNELS.cron.liveRunOverlayChanged, change);
|
||||
},
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:code', (payload) => {
|
||||
sendMainWindowEvent('oauth:code', payload);
|
||||
});
|
||||
@@ -589,7 +619,7 @@ if (gotTheLock) {
|
||||
}
|
||||
|
||||
gatewayManager = new GatewayManager();
|
||||
registerOpenClawConfigCoordinator(gatewayManager);
|
||||
cronLiveRunBroker = new CronLiveRunBroker();
|
||||
clawHubService = new ClawHubService();
|
||||
|
||||
// Register builtin extensions and load manifest
|
||||
|
||||
+101
-21
@@ -25,6 +25,8 @@ import { resolveAgentIdFromChannel } from '../utils/agent-config';
|
||||
import { resolveAccountIdFromSessionHistory } from '../utils/session-util';
|
||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||
import { getProviderConfig } from '../utils/provider-registry';
|
||||
import { deviceOAuthManager } from '../utils/device-oauth';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
} from '../services/providers/provider-runtime-sync';
|
||||
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
|
||||
import { appUpdater } from './updater';
|
||||
import { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
|
||||
import { HostApiRegistry, registerHostInvokeHandler } from './ipc/host-invoke';
|
||||
import { createAppApi } from '../services/app-api';
|
||||
import { createOpenClawApi } from '../services/openclaw-api';
|
||||
@@ -58,6 +61,7 @@ import { AcpSessionAccessRegistry } from '../services/acp-session-access-registr
|
||||
import { createAttachmentAccess, StagedAttachmentRegistry } from '../services/attachment-access';
|
||||
import { createAttachmentOpenWithService } from '../services/attachment-open-with';
|
||||
import { createCronApi } from '../services/cron-api';
|
||||
import type { CronLiveRunBroker } from '../services/cron-live-run-broker';
|
||||
import { createFilesApi } from '../services/files-api';
|
||||
import { createMediaApi } from '../services/media-api';
|
||||
import { createProvidersApi } from '../services/providers-api';
|
||||
@@ -75,11 +79,14 @@ import {
|
||||
} from './ipc/request-helpers';
|
||||
import { createMenu } from './menu';
|
||||
|
||||
const gatewayRpcBackpressure = new GatewayRpcBackpressure();
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
*/
|
||||
export function registerIpcHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
cronLiveRunBroker: CronLiveRunBroker,
|
||||
clawHubService: ClawHubService,
|
||||
mainWindow: BrowserWindow,
|
||||
hostApiRegistry: HostApiRegistry,
|
||||
@@ -92,6 +99,7 @@ export function registerIpcHandlers(
|
||||
// Typed host invoke handlers (new renderer facade; legacy channels remain available)
|
||||
registerTypedHostHandlers(
|
||||
gatewayManager,
|
||||
cronLiveRunBroker,
|
||||
clawHubService,
|
||||
mainWindow,
|
||||
hostApiRegistry,
|
||||
@@ -138,6 +146,7 @@ export function registerIpcHandlers(
|
||||
|
||||
function registerTypedHostHandlers(
|
||||
gatewayManager: GatewayManager,
|
||||
cronLiveRunBroker: CronLiveRunBroker,
|
||||
clawHubService: ClawHubService,
|
||||
mainWindow: BrowserWindow,
|
||||
hostApiRegistry: HostApiRegistry,
|
||||
@@ -162,7 +171,7 @@ function registerTypedHostHandlers(
|
||||
updates: createUpdatesApi(appUpdater),
|
||||
uv: createUvApi(),
|
||||
settings: createSettingsApi(gatewayManager),
|
||||
gateway: createGatewayApi(gatewayManager),
|
||||
gateway: createGatewayApi(gatewayManager, gatewayRpcBackpressure),
|
||||
logs: createLogsApi(),
|
||||
channels: createChannelsApi({ gatewayManager, mainWindow }),
|
||||
agents: createAgentsApi({ gatewayManager }),
|
||||
@@ -175,7 +184,7 @@ function registerTypedHostHandlers(
|
||||
media: createMediaApi({ attachmentAccess }),
|
||||
sessions: createSessionsApi(),
|
||||
chat: createChatApi({ gatewayManager, mainWindow, acpSessionAccessRegistry }),
|
||||
cron: createCronApi({ gatewayManager }),
|
||||
cron: createCronApi({ gatewayManager, cronLiveRunBroker }),
|
||||
skills: createSkillsApi({ clawHubService, gatewayManager }),
|
||||
usage: createUsageApi(),
|
||||
});
|
||||
@@ -293,7 +302,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
}
|
||||
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
try {
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw provider config:', err);
|
||||
}
|
||||
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
@@ -308,10 +321,14 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
|
||||
try {
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
if (existing?.type) {
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
}
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
if (existing?.type) {
|
||||
try {
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
}
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
@@ -332,7 +349,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||
await saveProviderKeyToOpenClaw(ock, apiKey);
|
||||
try {
|
||||
await saveProviderKeyToOpenClaw(ock, apiKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
data = { success: false, error: String(error) };
|
||||
@@ -378,7 +399,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
}
|
||||
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
try {
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw config after provider update:', err);
|
||||
}
|
||||
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
@@ -408,8 +433,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||
if (ock) {
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
try {
|
||||
if (ock) {
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
data = { success: true };
|
||||
} catch (error) {
|
||||
@@ -426,7 +455,11 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
await providerService.setDefaultLegacyProvider(providerId);
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
if (provider) {
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
try {
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to set OpenClaw default model:', err);
|
||||
}
|
||||
}
|
||||
|
||||
data = { success: true };
|
||||
@@ -695,7 +728,12 @@ function registerGatewayHandlers(gatewayManager: GatewayManager): void {
|
||||
// Gateway RPC call
|
||||
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => {
|
||||
try {
|
||||
const result = await gatewayManager.rpc(method, params, timeoutMs);
|
||||
const result = await gatewayRpcBackpressure.run(
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
|
||||
);
|
||||
return { success: true, result };
|
||||
} catch (error) {
|
||||
logger.warn(`[gateway:rpc] ${method} failed (timeoutMs=${timeoutMs ?? 30000}): ${String(error)}`);
|
||||
@@ -784,6 +822,18 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
);
|
||||
};
|
||||
|
||||
// Listen for OAuth success to automatically restart the Gateway with new tokens/configs.
|
||||
// Keep a longer debounce (8s) so provider config writes and OAuth token persistence
|
||||
// can settle before applying the process-level refresh.
|
||||
deviceOAuthManager.on('oauth:success', ({ provider, accountId }) => {
|
||||
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
|
||||
gatewayManager.debouncedRestart(8000);
|
||||
});
|
||||
browserOAuthManager.on('oauth:success', ({ provider, accountId }) => {
|
||||
logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`);
|
||||
gatewayManager.debouncedRestart(8000);
|
||||
});
|
||||
|
||||
// Get all providers with key info
|
||||
ipcMain.handle('provider:list', async () => {
|
||||
logLegacyProviderChannel('provider:list');
|
||||
@@ -810,12 +860,20 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
|
||||
|
||||
// Also write to OpenClaw auth-profiles.json so the gateway can use it
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
try {
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the provider configuration to openclaw.json so Gateway knows about it
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
try {
|
||||
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw provider config:', err);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -828,11 +886,17 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
logLegacyProviderChannel('provider:delete');
|
||||
try {
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
if (existing?.type) {
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
}
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
|
||||
// Best-effort cleanup in OpenClaw auth profiles & openclaw.json config
|
||||
if (existing?.type) {
|
||||
try {
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
@@ -848,7 +912,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
// Also write to OpenClaw auth-profiles.json
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
const providerType = provider?.type || providerId;
|
||||
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
|
||||
try {
|
||||
await syncProviderApiKeyToRuntime(providerType, providerId, apiKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -897,7 +965,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
}
|
||||
|
||||
// Sync the provider configuration to openclaw.json so Gateway knows about it
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
try {
|
||||
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw config after provider update:', err);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -928,7 +1000,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
|
||||
// Keep OpenClaw auth-profiles.json in sync with local key storage
|
||||
const provider = await providerService.getLegacyProvider(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
|
||||
try {
|
||||
await syncDeletedProviderApiKeyToRuntime(provider, providerId);
|
||||
} catch (err) {
|
||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -955,7 +1031,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||
await providerService.setDefaultLegacyProvider(providerId);
|
||||
|
||||
// Update OpenClaw config to use this provider's default model
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
try {
|
||||
await syncDefaultProviderToRuntime(providerId, gatewayManager);
|
||||
} catch (err) {
|
||||
console.warn('Failed to set OpenClaw default model:', err);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,7 +27,24 @@ function requireString(payload: unknown, key: string): string {
|
||||
return payload[key].trim();
|
||||
}
|
||||
|
||||
export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
|
||||
function scheduleGatewayReload(ctx: AgentsApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
|
||||
ctx.gatewayManager.debouncedReload();
|
||||
return;
|
||||
}
|
||||
void reason;
|
||||
}
|
||||
|
||||
async function restartGatewayForAgentDeletion(ctx: AgentsApiContext): Promise<void> {
|
||||
try {
|
||||
await ctx.gatewayManager.restart();
|
||||
console.log('[agents] Gateway restart completed after agent deletion');
|
||||
} catch (err) {
|
||||
console.warn('[agents] Gateway restart after agent deletion failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentsApi(ctx: AgentsApiContext): CompleteHostServiceRegistry['agents'] {
|
||||
return {
|
||||
list: async () => ({ success: true, ...(await listAgentsSnapshot()) }),
|
||||
create: async (payload) => {
|
||||
@@ -37,6 +54,7 @@ export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegi
|
||||
syncAllProviderAuthToRuntime().catch((err) => {
|
||||
console.warn('[agents] Failed to sync provider auth after agent creation:', err);
|
||||
});
|
||||
scheduleGatewayReload(ctx, 'create-agent');
|
||||
void ensureClawXContext({ waitForAllConfiguredWorkspaces: true }).catch((err) => {
|
||||
console.warn('[agents] Failed to ensure ClawX context after agent creation:', err);
|
||||
});
|
||||
@@ -46,19 +64,29 @@ export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegi
|
||||
const agentId = requireString(payload, 'id');
|
||||
const name = requireString(payload, 'name');
|
||||
const snapshot = await updateAgentName(agentId, name);
|
||||
scheduleGatewayReload(ctx, 'update-agent');
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
updateModel: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const modelRef = isRecord(payload) && typeof payload.modelRef === 'string' ? payload.modelRef : null;
|
||||
const snapshot = await updateAgentModel(agentId, modelRef);
|
||||
await syncAllProviderAuthToRuntime();
|
||||
await syncAgentModelOverrideToRuntime(agentId);
|
||||
try {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
await syncAgentModelOverrideToRuntime(agentId);
|
||||
} catch (syncError) {
|
||||
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
|
||||
}
|
||||
// Agent model changes must be picked up by the running Gateway before
|
||||
// the next send; otherwise the UI can show the new selection while the
|
||||
// active runtime still answers with the previous model.
|
||||
scheduleGatewayReload(ctx, 'update-agent-model');
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
delete: async (payload) => {
|
||||
const agentId = requireString(payload, 'id');
|
||||
const { snapshot, removedEntry } = await deleteAgentConfig(agentId);
|
||||
await restartGatewayForAgentDeletion(ctx);
|
||||
await removeAgentWorkspaceDirectory(removedEntry).catch((err) => {
|
||||
console.warn('[agents] Failed to remove workspace after agent deletion:', err);
|
||||
});
|
||||
@@ -68,6 +96,7 @@ export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegi
|
||||
const agentId = requireString(payload, 'id');
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
const snapshot = await assignChannelToAgent(agentId, channelType);
|
||||
scheduleGatewayReload(ctx, 'assign-channel');
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
removeChannel: async (payload) => {
|
||||
@@ -93,6 +122,7 @@ export function createAgentsApi(_ctx: AgentsApiContext): CompleteHostServiceRegi
|
||||
await clearChannelBinding(channelType, accountId);
|
||||
}
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
scheduleGatewayReload(ctx, 'remove-agent-channel');
|
||||
return { success: true, ...snapshot };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
assignChannelAccountToAgent,
|
||||
clearAllBindingsForChannel,
|
||||
clearChannelBinding,
|
||||
ensureScopedChannelBinding as ensureAgentScopedChannelBinding,
|
||||
listAgentsSnapshot,
|
||||
listAgentsSnapshotFromConfig,
|
||||
} from '../utils/agent-config';
|
||||
@@ -154,6 +153,11 @@ const CHANNEL_TARGET_CACHE_TTL_MS = 60_000;
|
||||
const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true';
|
||||
const channelTargetCache = new Map<string, { expiresAt: number; targets: ChannelTargetOptionView[] }>();
|
||||
|
||||
const FORCE_RESTART_CHANNELS = new Set([
|
||||
'dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE,
|
||||
'discord', 'telegram', 'signal', 'imessage', 'matrix', 'line', 'msteams', 'googlechat', 'mattermost',
|
||||
]);
|
||||
|
||||
function requireString(payload: unknown, key: string): string {
|
||||
if (!isRecord(payload) || typeof payload[key] !== 'string' || !payload[key].trim()) {
|
||||
throw new Error(`${key} is required`);
|
||||
@@ -920,8 +924,83 @@ async function listChannelTargetOptions(params: {
|
||||
return targets;
|
||||
}
|
||||
|
||||
async function readChannelBindingOwner(channelType: string, accountId?: string): Promise<string | null> {
|
||||
const config = await readOpenClawConfig();
|
||||
const bindings = Array.isArray((config as { bindings?: unknown }).bindings)
|
||||
? (config as { bindings: unknown[] }).bindings
|
||||
: [];
|
||||
for (const binding of bindings) {
|
||||
if (!binding || typeof binding !== 'object') continue;
|
||||
const candidate = binding as {
|
||||
agentId?: unknown;
|
||||
match?: { channel?: unknown; accountId?: unknown } | unknown;
|
||||
};
|
||||
if (typeof candidate.agentId !== 'string' || !candidate.agentId.trim()) continue;
|
||||
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) continue;
|
||||
const match = candidate.match as { channel?: unknown; accountId?: unknown };
|
||||
if (match.channel !== channelType) continue;
|
||||
const bindingAccountId = typeof match.accountId === 'string' ? match.accountId.trim() : '';
|
||||
if ((accountId?.trim() || '') !== bindingAccountId) continue;
|
||||
return candidate.agentId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function migrateLegacyChannelWideBinding(channelType: string): Promise<void> {
|
||||
const explicitDefaultOwner = await readChannelBindingOwner(channelType, 'default');
|
||||
const legacyOwner = await readChannelBindingOwner(channelType);
|
||||
if (!legacyOwner) return;
|
||||
|
||||
const agents = await listAgentsSnapshot();
|
||||
const validAgentIds = new Set(agents.agents.map((agent) => agent.id));
|
||||
const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner)
|
||||
? explicitDefaultOwner
|
||||
: (legacyOwner && validAgentIds.has(legacyOwner) ? legacyOwner : null);
|
||||
|
||||
if (defaultOwner) {
|
||||
await assignChannelAccountToAgent(defaultOwner, channelType, 'default');
|
||||
}
|
||||
await clearChannelBinding(channelType);
|
||||
}
|
||||
|
||||
async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
|
||||
await ensureAgentScopedChannelBinding(resolveStoredChannelType(channelType), accountId);
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (!accountId) return;
|
||||
const agents = await listAgentsSnapshot();
|
||||
if (!agents.agents || agents.agents.length === 0) return;
|
||||
|
||||
if (accountId === 'default') {
|
||||
if (agents.agents.some((entry) => entry.id === 'main')) {
|
||||
await assignChannelAccountToAgent('main', storedChannelType, 'default');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (agents.agents.some((entry) => entry.id === accountId)) {
|
||||
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||
}
|
||||
|
||||
function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
void reason;
|
||||
}
|
||||
|
||||
function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void {
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
|
||||
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
|
||||
ctx.gatewayManager.debouncedRestart(150);
|
||||
void reason;
|
||||
return;
|
||||
}
|
||||
ctx.gatewayManager.debouncedReload(150);
|
||||
void reason;
|
||||
}
|
||||
|
||||
function toComparableConfig(input: Record<string, unknown>): Record<string, string> {
|
||||
@@ -993,6 +1072,7 @@ async function awaitWeChatQrLogin(
|
||||
});
|
||||
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
|
||||
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
|
||||
|
||||
if (activeQrLogins.get(loginKey) !== sessionKey) return;
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
|
||||
@@ -1055,6 +1135,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const accountId = requireString(payload, 'accountId');
|
||||
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||
await setChannelDefaultAccount(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
bindingSave: async (payload) => {
|
||||
@@ -1067,16 +1148,11 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (accountId === 'default') {
|
||||
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
|
||||
} else {
|
||||
await assignChannelAccountToAgent(
|
||||
agentId,
|
||||
storedChannelType,
|
||||
accountId,
|
||||
{ migrateLegacy: true },
|
||||
);
|
||||
if (accountId !== 'default') {
|
||||
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||
}
|
||||
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
bindingDelete: async (payload) => {
|
||||
@@ -1084,6 +1160,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const accountId = optionalString(payload, 'accountId');
|
||||
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||
await clearChannelBinding(resolveStoredChannelType(channelType), accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
validateConfig: async (payload) => {
|
||||
@@ -1105,16 +1182,19 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const existingValues = await getChannelFormValues(channelType, accountId);
|
||||
if (isSameConfigValues(existingValues, config)) {
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
|
||||
return { success: true, noChange: true };
|
||||
}
|
||||
await saveChannelConfig(channelType, config, accountId);
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
|
||||
return { success: true };
|
||||
},
|
||||
setEnabled: async (payload) => {
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
const enabled = isRecord(payload) && payload.enabled === true;
|
||||
await setChannelEnabled(channelType, enabled);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
|
||||
return { success: true };
|
||||
},
|
||||
formValues: async (payload) => {
|
||||
@@ -1129,9 +1209,11 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
if (accountId) {
|
||||
await deleteChannelAccountConfig(channelType, accountId);
|
||||
await clearChannelBinding(storedChannelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
|
||||
} else {
|
||||
await deleteChannelConfig(channelType);
|
||||
await clearAllBindingsForChannel(storedChannelType);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
@@ -1,8 +1,45 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { logger } from '../utils/logger';
|
||||
import { createAcpChatService } from './acp-chat-service';
|
||||
import type { AcpSessionAccessRegistry } from './acp-session-access-registry';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
const VISION_MIME_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/bmp',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
type ChatSendWithMediaPayload = {
|
||||
sessionKey?: unknown;
|
||||
message?: unknown;
|
||||
deliver?: unknown;
|
||||
idempotencyKey?: unknown;
|
||||
media?: unknown;
|
||||
};
|
||||
|
||||
type MediaPayload = {
|
||||
filePath?: unknown;
|
||||
mimeType?: unknown;
|
||||
fileName?: unknown;
|
||||
};
|
||||
|
||||
function normalizeMedia(media: unknown): Array<{ filePath: string; mimeType: string; fileName: string }> {
|
||||
if (!Array.isArray(media)) return [];
|
||||
return media.flatMap((entry): Array<{ filePath: string; mimeType: string; fileName: string }> => {
|
||||
if (!isRecord(entry)) return [];
|
||||
const item = entry as MediaPayload;
|
||||
if (typeof item.filePath !== 'string' || !item.filePath) return [];
|
||||
return [{
|
||||
filePath: item.filePath,
|
||||
mimeType: typeof item.mimeType === 'string' && item.mimeType ? item.mimeType : 'application/octet-stream',
|
||||
fileName: typeof item.fileName === 'string' && item.fileName ? item.fileName : item.filePath.split(/[\\/]/).pop() || 'file',
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export function createChatApi({
|
||||
gatewayManager,
|
||||
@@ -16,6 +53,75 @@ export function createChatApi({
|
||||
const acpChat = createAcpChatService(mainWindow, acpSessionAccessRegistry, gatewayManager);
|
||||
|
||||
return {
|
||||
sendWithMedia: async (payload) => {
|
||||
const body = isRecord(payload) ? payload as ChatSendWithMediaPayload : {};
|
||||
const sessionKey = typeof body.sessionKey === 'string' ? body.sessionKey : '';
|
||||
const idempotencyKey = typeof body.idempotencyKey === 'string' ? body.idempotencyKey : '';
|
||||
if (!sessionKey || !idempotencyKey) {
|
||||
return { success: false, error: 'Invalid chat send payload' };
|
||||
}
|
||||
|
||||
try {
|
||||
let message = typeof body.message === 'string' ? body.message : '';
|
||||
const imageAttachments: Array<Record<string, unknown>> = [];
|
||||
const fileReferences: string[] = [];
|
||||
const media = normalizeMedia(body.media);
|
||||
|
||||
if (media.length > 0) {
|
||||
const fsP = await import('node:fs/promises');
|
||||
for (const item of media) {
|
||||
const exists = await fsP.access(item.filePath).then(() => true, () => false);
|
||||
logger.info(
|
||||
`[chat:sendWithMedia] Processing media: name=${item.fileName}, mimeType=${item.mimeType}, exists=${exists}, isVision=${VISION_MIME_TYPES.has(item.mimeType)}`,
|
||||
);
|
||||
|
||||
fileReferences.push(
|
||||
`[media attached: ${item.filePath} (${item.mimeType}) | ${item.filePath}]`,
|
||||
);
|
||||
|
||||
if (VISION_MIME_TYPES.has(item.mimeType)) {
|
||||
const fileBuffer = await fsP.readFile(item.filePath);
|
||||
const base64Data = fileBuffer.toString('base64');
|
||||
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
|
||||
imageAttachments.push({
|
||||
content: base64Data,
|
||||
mimeType: item.mimeType,
|
||||
fileName: item.fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileReferences.length > 0) {
|
||||
const refs = fileReferences.join('\n');
|
||||
message = message ? `${message}\n\n${refs}` : refs;
|
||||
}
|
||||
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
sessionKey,
|
||||
message,
|
||||
deliver: body.deliver ?? false,
|
||||
idempotencyKey,
|
||||
};
|
||||
if (imageAttachments.length > 0) {
|
||||
rpcParams.attachments = imageAttachments;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[chat:sendWithMedia] Sending: messageLength=${message.length}, attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`,
|
||||
);
|
||||
const result = await gatewayManager.rpc('chat.send', rpcParams, 120000);
|
||||
const hasRunId = isRecord(result) && typeof result.runId === 'string';
|
||||
logger.info(`[chat:sendWithMedia] RPC result: runId=${hasRunId ? 'present' : 'absent'}`);
|
||||
const response = hasRunId
|
||||
? { runId: result.runId as string }
|
||||
: undefined;
|
||||
return { success: true, ...(response ? { result: response } : {}) };
|
||||
} catch (error) {
|
||||
logger.error(`[chat:sendWithMedia] Error: ${String(error)}`);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
loadAcpSession: (payload) => acpChat.loadSession(payload),
|
||||
sendAcpPrompt: (payload) => acpChat.sendPrompt(payload),
|
||||
cancelAcpSession: (payload) => acpChat.cancelSession(payload),
|
||||
|
||||
@@ -2,8 +2,10 @@ import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
import { parseCronSessionKey, type CronSessionKeyParts } from '@shared/chat/cron-session';
|
||||
import type { CronJob, CronJobDelivery, CronSchedule } from '@shared/types/cron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CronLiveRunBroker } from './cron-live-run-broker';
|
||||
import { getOpenClawConfigDir } from '../utils/paths';
|
||||
import { resolveAgentIdFromChannel } from '../utils/agent-config';
|
||||
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
|
||||
@@ -47,12 +49,6 @@ interface CronRunLogEntry {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
interface CronSessionKeyParts {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
}
|
||||
|
||||
interface CronSessionFallbackMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
@@ -64,20 +60,6 @@ interface CronSessionFallbackMessage {
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
const OPENCLAW_CRON_SUMMARY_TRUNCATION_MIN_CHARS = 2_000;
|
||||
|
||||
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 4 || parts[2] !== 'cron') return null;
|
||||
const agentId = parts[1] || 'main';
|
||||
const jobId = parts[3];
|
||||
if (!jobId) return null;
|
||||
if (parts.length === 4) return { agentId, jobId };
|
||||
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
|
||||
return { agentId, jobId, runSessionId: parts[5] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
@@ -579,8 +561,15 @@ function getId(payload: unknown): string {
|
||||
return id.trim();
|
||||
}
|
||||
|
||||
export function createCronApi({ gatewayManager }: { gatewayManager: GatewayManager }): CompleteHostServiceRegistry['cron'] {
|
||||
export function createCronApi({
|
||||
gatewayManager,
|
||||
cronLiveRunBroker,
|
||||
}: {
|
||||
gatewayManager: GatewayManager;
|
||||
cronLiveRunBroker: CronLiveRunBroker;
|
||||
}): CompleteHostServiceRegistry['cron'] {
|
||||
return {
|
||||
liveRunOverlays: () => cronLiveRunBroker.getSnapshotSet(),
|
||||
list: async () => listCronJobs(gatewayManager),
|
||||
create: async (payload) => {
|
||||
const input = payload;
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
import { createHash, type Hash } from 'node:crypto';
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type {
|
||||
CronLiveRunItem,
|
||||
CronLiveRunOverlayChange,
|
||||
CronLiveRunOverlaySnapshot,
|
||||
CronLiveRunOverlaySnapshotSet,
|
||||
} from '../../shared/chat/cron-live-run';
|
||||
import {
|
||||
getCronSessionBaseKey,
|
||||
parseCronSessionKey,
|
||||
} from '../../shared/chat/cron-session';
|
||||
|
||||
interface ActiveCronLiveRun {
|
||||
snapshot: CronLiveRunOverlaySnapshot;
|
||||
fingerprintOrder: string[];
|
||||
fingerprints: Set<string>;
|
||||
}
|
||||
|
||||
export const MAX_CRON_LIVE_EVENT_FINGERPRINTS = 256;
|
||||
export const MAX_ACTIVE_CRON_LIVE_RUNS = 32;
|
||||
export const MAX_CRON_LIVE_ITEMS_PER_RUN = 128;
|
||||
export const MAX_CRON_LIVE_ASSISTANT_CHARS = 500_000;
|
||||
export const MAX_CRON_LIVE_ITEM_DETAIL_CHARS = 100_000;
|
||||
export const MAX_CRON_LIVE_TERMINAL_TOMBSTONES = 128;
|
||||
export const MAX_CRON_LIVE_TRAVERSAL_DEPTH = 64;
|
||||
export const MAX_CRON_LIVE_TRAVERSAL_NODES = 2_048;
|
||||
export const MAX_CRON_LIVE_TRAVERSAL_KEYS = 1_024;
|
||||
export const MAX_CRON_LIVE_TRAVERSAL_STRING_CHARS = 16_384;
|
||||
|
||||
const DEPTH_MARKER = '[Truncated:Depth]';
|
||||
const NODE_MARKER = '[Truncated:Nodes]';
|
||||
const KEY_MARKER = '[Truncated:Keys]';
|
||||
const PROPERTY_MARKER = '[Unserializable:Property]';
|
||||
const INVALID_DATE_MARKER = '[Invalid:Date]';
|
||||
const OUTPUT_MARKER = '[Truncated:Output]';
|
||||
|
||||
interface TraversalState {
|
||||
nodes: number;
|
||||
keys: number;
|
||||
}
|
||||
|
||||
type BoundedKeys = { keys: string[] } | { marker: string };
|
||||
|
||||
function stringMarker(length: number): string {
|
||||
return `[Truncated:String:${length}]`;
|
||||
}
|
||||
|
||||
function truncateTraversalString(value: string): string {
|
||||
if (value.length <= MAX_CRON_LIVE_TRAVERSAL_STRING_CHARS) return value;
|
||||
const marker = stringMarker(value.length);
|
||||
return `${value.slice(0, MAX_CRON_LIVE_TRAVERSAL_STRING_CHARS - marker.length)}${marker}`;
|
||||
}
|
||||
|
||||
function enterTraversalNode(state: TraversalState, depth: number): string | undefined {
|
||||
if (depth > MAX_CRON_LIVE_TRAVERSAL_DEPTH) return DEPTH_MARKER;
|
||||
state.nodes += 1;
|
||||
return state.nodes > MAX_CRON_LIVE_TRAVERSAL_NODES ? NODE_MARKER : undefined;
|
||||
}
|
||||
|
||||
function collectBoundedKeys(value: object, state: TraversalState): BoundedKeys {
|
||||
const keys: string[] = [];
|
||||
let scanned = 0;
|
||||
try {
|
||||
for (const key in value) {
|
||||
scanned += 1;
|
||||
if (scanned > MAX_CRON_LIVE_TRAVERSAL_KEYS || state.keys >= MAX_CRON_LIVE_TRAVERSAL_KEYS) {
|
||||
return { marker: KEY_MARKER };
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
||||
if (key.length > MAX_CRON_LIVE_TRAVERSAL_STRING_CHARS) {
|
||||
return { marker: stringMarker(key.length) };
|
||||
}
|
||||
state.keys += 1;
|
||||
keys.push(key);
|
||||
}
|
||||
} catch {
|
||||
return { marker: PROPERTY_MARKER };
|
||||
}
|
||||
keys.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
||||
return { keys };
|
||||
}
|
||||
|
||||
function readProperty(value: object, key: string): { value: unknown } | { marker: string } {
|
||||
try {
|
||||
return { value: (value as Record<string, unknown>)[key] };
|
||||
} catch {
|
||||
return { marker: PROPERTY_MARKER };
|
||||
}
|
||||
}
|
||||
|
||||
function hashToken(hash: Hash, value: string): void {
|
||||
hash.update(String(value.length));
|
||||
hash.update(':');
|
||||
hash.update(value);
|
||||
hash.update(';');
|
||||
}
|
||||
|
||||
function hashUnknown(
|
||||
hash: Hash,
|
||||
value: unknown,
|
||||
state: TraversalState,
|
||||
seen: Map<object, number>,
|
||||
depth = 0,
|
||||
): void {
|
||||
const valueType = typeof value;
|
||||
if (value === null || valueType !== 'object') {
|
||||
const marker = enterTraversalNode(state, depth);
|
||||
if (marker) {
|
||||
hashToken(hash, marker);
|
||||
return;
|
||||
}
|
||||
if (valueType === 'string') {
|
||||
hashToken(hash, `string:${truncateTraversalString(value as string)}`);
|
||||
} else if (valueType === 'bigint') {
|
||||
hashToken(hash, '[Unsupported:bigint]');
|
||||
} else if (valueType === 'number' || valueType === 'boolean' || valueType === 'undefined') {
|
||||
hashToken(hash, `${valueType}:${String(value)}`);
|
||||
} else {
|
||||
hashToken(hash, `[Unsupported:${valueType}]`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const objectValue = value as object;
|
||||
const seenId = seen.get(objectValue);
|
||||
if (seenId !== undefined) {
|
||||
hashToken(hash, `ref:${seenId}`);
|
||||
return;
|
||||
}
|
||||
const marker = enterTraversalNode(state, depth);
|
||||
if (marker) {
|
||||
hashToken(hash, marker);
|
||||
return;
|
||||
}
|
||||
seen.set(objectValue, seen.size);
|
||||
|
||||
if (value instanceof Date) {
|
||||
const time = value.getTime();
|
||||
hashToken(hash, Number.isFinite(time) ? `date:${value.toISOString()}` : INVALID_DATE_MARKER);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
hashToken(hash, `array:${value.length}`);
|
||||
if (value.length > MAX_CRON_LIVE_TRAVERSAL_NODES - state.nodes) {
|
||||
hashToken(hash, NODE_MARKER);
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const property = readProperty(value, String(index));
|
||||
if ('marker' in property) {
|
||||
hashToken(hash, property.marker);
|
||||
} else {
|
||||
hashUnknown(hash, property.value, state, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const boundedKeys = collectBoundedKeys(objectValue, state);
|
||||
if ('marker' in boundedKeys) {
|
||||
hashToken(hash, boundedKeys.marker);
|
||||
return;
|
||||
}
|
||||
hashToken(hash, `object:${boundedKeys.keys.length}`);
|
||||
for (const key of boundedKeys.keys) {
|
||||
hashToken(hash, key);
|
||||
const property = readProperty(objectValue, key);
|
||||
if ('marker' in property) {
|
||||
hashToken(hash, property.marker);
|
||||
} else {
|
||||
hashUnknown(hash, property.value, state, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeEventFingerprint(event: ChatRuntimeEvent): string {
|
||||
try {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(`${event.type}|`);
|
||||
const state: TraversalState = { nodes: 0, keys: 0 };
|
||||
const seen = new Map<object, number>();
|
||||
let fingerprintValue: unknown;
|
||||
|
||||
switch (event.type) {
|
||||
case 'run.started':
|
||||
fingerprintValue = event.startedAt;
|
||||
break;
|
||||
case 'run.ended':
|
||||
fingerprintValue = [event.status, event.endedAt, event.error, event.livenessState, event.replayInvalid, event.stopReason];
|
||||
break;
|
||||
case 'assistant.delta':
|
||||
fingerprintValue = [event.text, event.delta, event.replace, event.phase, event.mediaUrls];
|
||||
break;
|
||||
case 'thinking.delta':
|
||||
fingerprintValue = [event.text, event.delta];
|
||||
break;
|
||||
case 'tool.started':
|
||||
fingerprintValue = [event.toolCallId, event.name, event.args];
|
||||
break;
|
||||
case 'tool.updated':
|
||||
fingerprintValue = [event.toolCallId, event.name, event.partialResult];
|
||||
break;
|
||||
case 'tool.completed':
|
||||
fingerprintValue = [event.toolCallId, event.name, event.result, event.meta, event.isError];
|
||||
break;
|
||||
case 'command.output':
|
||||
fingerprintValue = [
|
||||
event.itemId,
|
||||
event.toolCallId,
|
||||
event.name,
|
||||
event.title,
|
||||
event.output,
|
||||
event.status,
|
||||
event.phase,
|
||||
event.exitCode,
|
||||
event.durationMs,
|
||||
event.cwd,
|
||||
];
|
||||
break;
|
||||
case 'patch.completed':
|
||||
fingerprintValue = [
|
||||
event.itemId,
|
||||
event.toolCallId,
|
||||
event.name,
|
||||
event.title,
|
||||
event.summary,
|
||||
event.added,
|
||||
event.modified,
|
||||
event.deleted,
|
||||
];
|
||||
break;
|
||||
case 'approval.updated':
|
||||
fingerprintValue = [
|
||||
event.itemId,
|
||||
event.toolCallId,
|
||||
event.title,
|
||||
event.kind,
|
||||
event.phase,
|
||||
event.status,
|
||||
event.message,
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
hashUnknown(hash, fingerprintValue, state, seen);
|
||||
return hash.digest('hex');
|
||||
} catch {
|
||||
return createHash('sha256').update(`${event.type}|[FingerprintError]`).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
class LimitedStringWriter {
|
||||
private readonly chunks: string[] = [];
|
||||
private length = 0;
|
||||
private truncated = false;
|
||||
|
||||
constructor(private readonly limit: number) {}
|
||||
|
||||
get full(): boolean {
|
||||
return this.length >= this.limit;
|
||||
}
|
||||
|
||||
append(value: string): void {
|
||||
if (this.full) {
|
||||
this.truncated = true;
|
||||
return;
|
||||
}
|
||||
const available = this.limit - this.length;
|
||||
const chunk = value.slice(0, available);
|
||||
this.chunks.push(chunk);
|
||||
this.length += chunk.length;
|
||||
if (chunk.length < value.length) this.truncated = true;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const rendered = this.chunks.join('');
|
||||
return this.truncated
|
||||
? `${rendered.slice(0, this.limit - OUTPUT_MARKER.length)}${OUTPUT_MARKER}`
|
||||
: rendered;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonString(writer: LimitedStringWriter, value: string): void {
|
||||
writer.append('"');
|
||||
for (const character of value) {
|
||||
if (writer.full) return;
|
||||
writer.append(JSON.stringify(character).slice(1, -1));
|
||||
}
|
||||
writer.append('"');
|
||||
}
|
||||
|
||||
function writeStableJson(
|
||||
writer: LimitedStringWriter,
|
||||
value: unknown,
|
||||
depth: number,
|
||||
state: TraversalState,
|
||||
ancestors: WeakSet<object>,
|
||||
): void {
|
||||
if (writer.full) {
|
||||
writer.append('');
|
||||
return;
|
||||
}
|
||||
const marker = enterTraversalNode(state, depth);
|
||||
if (marker) {
|
||||
writeJsonString(writer, marker);
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
writeJsonString(writer, truncateTraversalString(value));
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
writeJsonString(writer, '[Unsupported:bigint]');
|
||||
return;
|
||||
}
|
||||
if (value === undefined) {
|
||||
writer.append('null');
|
||||
return;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') {
|
||||
writer.append(JSON.stringify(value) ?? 'null');
|
||||
return;
|
||||
}
|
||||
if (ancestors.has(value)) {
|
||||
writeJsonString(writer, '[Circular]');
|
||||
return;
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
if (value instanceof Date) {
|
||||
const time = value.getTime();
|
||||
writeJsonString(writer, Number.isFinite(time) ? value.toISOString() : INVALID_DATE_MARKER);
|
||||
ancestors.delete(value);
|
||||
return;
|
||||
}
|
||||
const indent = ' '.repeat(depth + 1);
|
||||
const closingIndent = ' '.repeat(depth);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > MAX_CRON_LIVE_TRAVERSAL_NODES - state.nodes) {
|
||||
writeJsonString(writer, NODE_MARKER);
|
||||
ancestors.delete(value);
|
||||
return;
|
||||
}
|
||||
writer.append('[');
|
||||
for (let index = 0; index < value.length && !writer.full; index += 1) {
|
||||
writer.append(`${index === 0 ? '\n' : ',\n'}${indent}`);
|
||||
const property = readProperty(value, String(index));
|
||||
if ('marker' in property) {
|
||||
writeJsonString(writer, property.marker);
|
||||
} else {
|
||||
writeStableJson(writer, property.value, depth + 1, state, ancestors);
|
||||
}
|
||||
}
|
||||
if (value.length > 0) writer.append(`\n${closingIndent}`);
|
||||
writer.append(']');
|
||||
} else {
|
||||
const boundedKeys = collectBoundedKeys(value, state);
|
||||
if ('marker' in boundedKeys) {
|
||||
writeJsonString(writer, boundedKeys.marker);
|
||||
ancestors.delete(value);
|
||||
return;
|
||||
}
|
||||
writer.append('{');
|
||||
let written = 0;
|
||||
for (const key of boundedKeys.keys) {
|
||||
if (writer.full) break;
|
||||
const property = readProperty(value, key);
|
||||
const child = 'marker' in property ? property.marker : property.value;
|
||||
if (child === undefined) continue;
|
||||
const index = written;
|
||||
written += 1;
|
||||
writer.append(`${index === 0 ? '\n' : ',\n'}${indent}`);
|
||||
writeJsonString(writer, key);
|
||||
writer.append(': ');
|
||||
if ('marker' in property) {
|
||||
writeJsonString(writer, property.marker);
|
||||
} else {
|
||||
writeStableJson(writer, child, depth + 1, state, ancestors);
|
||||
}
|
||||
}
|
||||
if (written > 0) writer.append(`\n${closingIndent}`);
|
||||
writer.append('}');
|
||||
}
|
||||
ancestors.delete(value);
|
||||
}
|
||||
|
||||
function truncateStart(value: string, limit = MAX_CRON_LIVE_ITEM_DETAIL_CHARS): string {
|
||||
return value.length <= limit ? value : value.slice(0, limit);
|
||||
}
|
||||
|
||||
function truncateEnd(value: string, limit: number): string {
|
||||
return value.length <= limit ? value : value.slice(-limit);
|
||||
}
|
||||
|
||||
function encodeTuple(parts: readonly string[]): string {
|
||||
return `${parts.length}|${parts.map((part) => `${part.length}:${part}`).join('')}`;
|
||||
}
|
||||
|
||||
function isBoundedIdentityComponent(value: unknown): value is string {
|
||||
return typeof value === 'string'
|
||||
&& value.length > 0
|
||||
&& value.length <= MAX_CRON_LIVE_ITEM_DETAIL_CHARS;
|
||||
}
|
||||
|
||||
function processIdentityComponent(event: ChatRuntimeEvent): string | undefined {
|
||||
if (event.type === 'tool.started' || event.type === 'tool.updated' || event.type === 'tool.completed') {
|
||||
return event.toolCallId;
|
||||
}
|
||||
if (event.type === 'command.output') {
|
||||
return event.itemId ?? event.toolCallId ?? event.name ?? 'command';
|
||||
}
|
||||
if (event.type === 'patch.completed') {
|
||||
return event.itemId ?? event.toolCallId ?? event.name ?? 'patch';
|
||||
}
|
||||
if (event.type === 'approval.updated') {
|
||||
return event.itemId ?? event.toolCallId ?? event.kind ?? 'approval';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasBoundedEventIdentity(event: ChatRuntimeEvent): event is ChatRuntimeEvent & { sessionKey: string } {
|
||||
if (!isBoundedIdentityComponent(event.sessionKey) || !isBoundedIdentityComponent(event.runId)) return false;
|
||||
const itemIdentity = processIdentityComponent(event);
|
||||
return itemIdentity === undefined || isBoundedIdentityComponent(itemIdentity);
|
||||
}
|
||||
|
||||
function stableDetail(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === 'string') return truncateTraversalString(value);
|
||||
|
||||
try {
|
||||
const writer = new LimitedStringWriter(MAX_CRON_LIVE_ITEM_DETAIL_CHARS);
|
||||
writeStableJson(writer, value, 0, { nodes: 0, keys: 0 }, new WeakSet<object>());
|
||||
return writer.toString();
|
||||
} catch {
|
||||
return '[Unserializable]';
|
||||
}
|
||||
}
|
||||
|
||||
function upsertItem(
|
||||
items: CronLiveRunItem[],
|
||||
item: CronLiveRunItem,
|
||||
): void {
|
||||
const existingIndex = items.findIndex(({ id }) => id === item.id);
|
||||
if (existingIndex === -1) {
|
||||
items.push(item);
|
||||
if (items.length > MAX_CRON_LIVE_ITEMS_PER_RUN) items.splice(0, items.length - MAX_CRON_LIVE_ITEMS_PER_RUN);
|
||||
} else {
|
||||
items[existingIndex] = item;
|
||||
}
|
||||
}
|
||||
|
||||
function commandStatus(event: Extract<ChatRuntimeEvent, { type: 'command.output' }>): 'running' | 'completed' | 'failed' {
|
||||
if (event.status === 'failed' || event.status === 'error' || (event.exitCode != null && event.exitCode !== 0)) {
|
||||
return 'failed';
|
||||
}
|
||||
if (
|
||||
event.phase === 'end'
|
||||
|| event.phase === 'completed'
|
||||
|| event.status === 'completed'
|
||||
|| event.status === 'success'
|
||||
|| event.exitCode === 0
|
||||
) {
|
||||
return 'completed';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function approvalStatus(event: Extract<ChatRuntimeEvent, { type: 'approval.updated' }>): 'running' | 'completed' | 'failed' {
|
||||
if (event.status === 'denied' || event.status === 'rejected' || event.status === 'failed' || event.status === 'error') {
|
||||
return 'failed';
|
||||
}
|
||||
if (
|
||||
event.phase === 'resolved'
|
||||
|| event.phase === 'completed'
|
||||
|| event.status === 'approved'
|
||||
|| event.status === 'granted'
|
||||
|| event.status === 'completed'
|
||||
) {
|
||||
return 'completed';
|
||||
}
|
||||
return 'running';
|
||||
}
|
||||
|
||||
function cloneSnapshot(snapshot: CronLiveRunOverlaySnapshot): CronLiveRunOverlaySnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
items: snapshot.items.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
function compareSnapshots(left: CronLiveRunOverlaySnapshot, right: CronLiveRunOverlaySnapshot): number {
|
||||
return left.updatedAt - right.updatedAt
|
||||
|| left.runId.localeCompare(right.runId)
|
||||
|| left.sourceSessionKey.localeCompare(right.sourceSessionKey);
|
||||
}
|
||||
|
||||
export function reduceCronLiveRunEvent(
|
||||
snapshot: CronLiveRunOverlaySnapshot,
|
||||
event: ChatRuntimeEvent,
|
||||
): CronLiveRunOverlaySnapshot {
|
||||
const next = cloneSnapshot(snapshot);
|
||||
next.updatedAt = event.ts ?? snapshot.updatedAt;
|
||||
|
||||
if (event.type === 'run.started') {
|
||||
next.startedAt = event.startedAt ?? next.startedAt;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'assistant.delta') {
|
||||
if (event.text !== undefined) {
|
||||
next.assistantText = event.text;
|
||||
} else if (event.replace) {
|
||||
next.assistantText = event.delta ?? '';
|
||||
} else if (event.delta) {
|
||||
next.assistantText += event.delta;
|
||||
}
|
||||
next.assistantText = truncateEnd(next.assistantText, MAX_CRON_LIVE_ASSISTANT_CHARS);
|
||||
next.thinking = false;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'thinking.delta') {
|
||||
next.thinking = true;
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'tool.started' || event.type === 'tool.updated' || event.type === 'tool.completed') {
|
||||
const id = encodeTuple([snapshot.runId, 'tool', event.toolCallId]);
|
||||
const existingItem = next.items.find((item) => item.id === id);
|
||||
const existing = existingItem?.kind === 'tool' ? existingItem : undefined;
|
||||
const inputText = event.type === 'tool.started' ? stableDetail(event.args) : existing?.inputText;
|
||||
const outputValue = event.type === 'tool.updated' ? event.partialResult : event.type === 'tool.completed' ? event.result : undefined;
|
||||
const outputText = outputValue === undefined ? existing?.outputText : stableDetail(outputValue);
|
||||
const error = event.type === 'tool.completed' && event.isError ? outputText : undefined;
|
||||
upsertItem(next.items, {
|
||||
kind: 'tool',
|
||||
id,
|
||||
toolCallId: event.toolCallId,
|
||||
title: truncateStart(event.name),
|
||||
status: event.type === 'tool.completed' ? (event.isError ? 'failed' : 'completed') : 'running',
|
||||
...(inputText === undefined ? {} : { inputText }),
|
||||
...(outputText === undefined ? {} : { outputText }),
|
||||
...(error === undefined ? {} : { error }),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'command.output') {
|
||||
const sourceId = event.itemId ?? event.toolCallId ?? event.name ?? 'command';
|
||||
const id = encodeTuple([snapshot.runId, 'command', sourceId]);
|
||||
const existingItem = next.items.find((item) => item.id === id);
|
||||
const existing = existingItem?.kind === 'command' ? existingItem : undefined;
|
||||
upsertItem(next.items, {
|
||||
kind: 'command',
|
||||
id,
|
||||
title: truncateStart(event.title ?? existing?.title ?? `${event.name ?? 'Command'} output`),
|
||||
status: commandStatus(event),
|
||||
output: truncateEnd(`${existing?.output ?? ''}${event.output ?? ''}`, MAX_CRON_LIVE_ITEM_DETAIL_CHARS),
|
||||
...(event.exitCode === undefined && existing?.exitCode === undefined
|
||||
? {}
|
||||
: { exitCode: event.exitCode ?? existing?.exitCode }),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'patch.completed') {
|
||||
const sourceId = event.itemId ?? event.toolCallId ?? event.name ?? 'patch';
|
||||
const id = encodeTuple([snapshot.runId, 'patch', sourceId]);
|
||||
upsertItem(next.items, {
|
||||
kind: 'patch',
|
||||
id,
|
||||
title: truncateStart(event.title ?? event.name ?? 'Patch'),
|
||||
...(event.summary === undefined ? {} : { summary: truncateStart(event.summary) }),
|
||||
...(event.added === undefined ? {} : { added: event.added }),
|
||||
...(event.modified === undefined ? {} : { modified: event.modified }),
|
||||
...(event.deleted === undefined ? {} : { deleted: event.deleted }),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'approval.updated') {
|
||||
const sourceId = event.itemId ?? event.toolCallId ?? event.kind ?? 'approval';
|
||||
const id = encodeTuple([snapshot.runId, 'approval', sourceId]);
|
||||
const existingItem = next.items.find((item) => item.id === id);
|
||||
const existing = existingItem?.kind === 'approval' ? existingItem : undefined;
|
||||
upsertItem(next.items, {
|
||||
kind: 'approval',
|
||||
id,
|
||||
title: truncateStart(event.title ?? existing?.title ?? 'Approval'),
|
||||
status: approvalStatus(event),
|
||||
...(event.message === undefined && existing?.message === undefined
|
||||
? {}
|
||||
: { message: truncateStart(event.message ?? existing?.message ?? '') }),
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export class CronLiveRunBroker {
|
||||
private readonly activeRuns = new Map<string, ActiveCronLiveRun>();
|
||||
private readonly terminalTombstones = new Set<string>();
|
||||
private readonly terminalTombstoneOrder: string[] = [];
|
||||
private revision = 0;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
ingestRuntimeEvent(event: ChatRuntimeEvent): CronLiveRunOverlayChange[] {
|
||||
if (!hasBoundedEventIdentity(event)) return [];
|
||||
const parts = parseCronSessionKey(event.sessionKey);
|
||||
if (!parts?.runSessionId) return [];
|
||||
|
||||
const identity = encodeTuple([event.sessionKey, event.runId]);
|
||||
if (this.terminalTombstones.has(identity)) return [];
|
||||
|
||||
const active = this.activeRuns.get(identity);
|
||||
|
||||
if (active && Number.isFinite(event.seq) && event.seq! <= (active.snapshot.lastSeq ?? -Infinity)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (event.type === 'run.ended') {
|
||||
const changes: CronLiveRunOverlayChange[] = [];
|
||||
if (active) {
|
||||
this.revision += 1;
|
||||
changes.push({
|
||||
kind: 'remove',
|
||||
revision: this.revision,
|
||||
canonicalSessionKey: active.snapshot.canonicalSessionKey,
|
||||
sourceSessionKey: active.snapshot.sourceSessionKey,
|
||||
runId: active.snapshot.runId,
|
||||
reason: 'ended',
|
||||
terminalStatus: event.status,
|
||||
...(event.error === undefined ? {} : { terminalError: truncateStart(event.error) }),
|
||||
});
|
||||
this.activeRuns.delete(identity);
|
||||
}
|
||||
this.addTerminalTombstone(identity);
|
||||
return changes;
|
||||
}
|
||||
|
||||
let fingerprint: string | undefined;
|
||||
if (!Number.isFinite(event.seq)) {
|
||||
fingerprint = runtimeEventFingerprint(event);
|
||||
if (active?.fingerprints.has(fingerprint)) return [];
|
||||
}
|
||||
|
||||
const changes: CronLiveRunOverlayChange[] = [];
|
||||
if (!active && this.activeRuns.size >= MAX_ACTIVE_CRON_LIVE_RUNS) {
|
||||
const [evictedIdentity, evicted] = [...this.activeRuns.entries()]
|
||||
.sort(([, left], [, right]) => compareSnapshots(left.snapshot, right.snapshot))[0];
|
||||
this.revision += 1;
|
||||
changes.push({
|
||||
kind: 'remove',
|
||||
revision: this.revision,
|
||||
canonicalSessionKey: evicted.snapshot.canonicalSessionKey,
|
||||
sourceSessionKey: evicted.snapshot.sourceSessionKey,
|
||||
runId: evicted.snapshot.runId,
|
||||
reason: 'evicted',
|
||||
});
|
||||
this.activeRuns.delete(evictedIdentity);
|
||||
}
|
||||
|
||||
const current = active?.snapshot ?? {
|
||||
canonicalSessionKey: getCronSessionBaseKey(event.sessionKey),
|
||||
sourceSessionKey: event.sessionKey,
|
||||
runSessionId: parts.runSessionId,
|
||||
runId: event.runId,
|
||||
revision: this.revision,
|
||||
status: 'running',
|
||||
updatedAt: event.ts ?? this.now(),
|
||||
assistantText: '',
|
||||
thinking: false,
|
||||
items: [],
|
||||
} satisfies CronLiveRunOverlaySnapshot;
|
||||
|
||||
const next = reduceCronLiveRunEvent(current, event);
|
||||
next.updatedAt = event.ts ?? this.now();
|
||||
if (Number.isFinite(event.seq)) next.lastSeq = event.seq;
|
||||
this.revision += 1;
|
||||
next.revision = this.revision;
|
||||
const fingerprintOrder = active?.fingerprintOrder ?? [];
|
||||
const fingerprints = active?.fingerprints ?? new Set<string>();
|
||||
if (fingerprint) {
|
||||
fingerprintOrder.push(fingerprint);
|
||||
fingerprints.add(fingerprint);
|
||||
if (fingerprintOrder.length > MAX_CRON_LIVE_EVENT_FINGERPRINTS) {
|
||||
const removed = fingerprintOrder.shift();
|
||||
if (removed) fingerprints.delete(removed);
|
||||
}
|
||||
}
|
||||
this.activeRuns.set(identity, { snapshot: next, fingerprintOrder, fingerprints });
|
||||
|
||||
changes.push({
|
||||
kind: 'upsert',
|
||||
revision: this.revision,
|
||||
snapshot: cloneSnapshot(next),
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
getSnapshotSet(): CronLiveRunOverlaySnapshotSet {
|
||||
return {
|
||||
revision: this.revision,
|
||||
snapshots: [...this.activeRuns.values()]
|
||||
.map(({ snapshot }) => cloneSnapshot(snapshot))
|
||||
.sort(compareSnapshots),
|
||||
};
|
||||
}
|
||||
|
||||
clear(): CronLiveRunOverlayChange[] {
|
||||
const changes: CronLiveRunOverlayChange[] = [];
|
||||
const entries = [...this.activeRuns.entries()]
|
||||
.sort(([, left], [, right]) => compareSnapshots(left.snapshot, right.snapshot));
|
||||
for (const [identity, active] of entries) {
|
||||
this.revision += 1;
|
||||
changes.push({
|
||||
kind: 'remove',
|
||||
revision: this.revision,
|
||||
canonicalSessionKey: active.snapshot.canonicalSessionKey,
|
||||
sourceSessionKey: active.snapshot.sourceSessionKey,
|
||||
runId: active.snapshot.runId,
|
||||
reason: 'gateway-reset',
|
||||
});
|
||||
this.activeRuns.delete(identity);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
private addTerminalTombstone(identity: string): void {
|
||||
this.terminalTombstones.add(identity);
|
||||
this.terminalTombstoneOrder.push(identity);
|
||||
if (this.terminalTombstoneOrder.length > MAX_CRON_LIVE_TERMINAL_TOMBSTONES) {
|
||||
const removed = this.terminalTombstoneOrder.shift();
|
||||
if (removed) this.terminalTombstones.delete(removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function bindCronLiveRunBroker({
|
||||
gatewayManager,
|
||||
broker,
|
||||
publishChange,
|
||||
}: {
|
||||
gatewayManager: GatewayManager;
|
||||
broker: CronLiveRunBroker;
|
||||
publishChange: (change: CronLiveRunOverlayChange) => void;
|
||||
}): void {
|
||||
let ingestionEnabled = true;
|
||||
const publishChanges = (changes: CronLiveRunOverlayChange[]) => {
|
||||
changes.forEach((change) => publishChange(change));
|
||||
};
|
||||
|
||||
gatewayManager.on('chat:runtime-event', (runtimeEvent) => {
|
||||
if (!ingestionEnabled) return;
|
||||
publishChanges(broker.ingestRuntimeEvent(runtimeEvent));
|
||||
});
|
||||
gatewayManager.on('status', (status) => {
|
||||
if (status.state === 'running') {
|
||||
ingestionEnabled = true;
|
||||
return;
|
||||
}
|
||||
ingestionEnabled = false;
|
||||
publishChanges(broker.clear());
|
||||
});
|
||||
gatewayManager.on('exit', () => {
|
||||
ingestionEnabled = false;
|
||||
publishChanges(broker.clear());
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { GatewayRpcBackpressure } from '../gateway/rpc-backpressure';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import { PORTS } from '../utils/config';
|
||||
import { approvePendingLocalDeviceRequests } from '../utils/control-ui-device-pairing';
|
||||
@@ -25,7 +26,10 @@ function parseTimeoutMs(timeoutMs: unknown): number | undefined {
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
export function createGatewayApi(gatewayManager: GatewayManager): CompleteHostServiceRegistry['gateway'] {
|
||||
export function createGatewayApi(
|
||||
gatewayManager: GatewayManager,
|
||||
gatewayRpcBackpressure: GatewayRpcBackpressure,
|
||||
): CompleteHostServiceRegistry['gateway'] {
|
||||
return {
|
||||
status: () => gatewayManager.getStatus(),
|
||||
start: async () => {
|
||||
@@ -61,7 +65,12 @@ export function createGatewayApi(gatewayManager: GatewayManager): CompleteHostSe
|
||||
throw new Error('Invalid gateway RPC method');
|
||||
}
|
||||
const timeoutMs = parseTimeoutMs(body.timeoutMs);
|
||||
return gatewayManager.rpc(method, body.params, timeoutMs);
|
||||
return gatewayRpcBackpressure.run(
|
||||
method,
|
||||
body.params,
|
||||
timeoutMs,
|
||||
(rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,8 +216,8 @@ async function deleteProvider(payload: ProviderPayload<'delete'>, gatewayManager
|
||||
const providerId = getProviderId(payload, 'delete');
|
||||
try {
|
||||
const existing = await providerService._getProviderInternal(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
await providerService._deleteProviderInternal(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
@@ -372,12 +372,12 @@ async function deleteAccount(
|
||||
? 'openai'
|
||||
: undefined;
|
||||
if (apiKeyOnly) {
|
||||
await providerService._deleteProviderApiKeyInternal(accountId);
|
||||
await syncDeletedProviderApiKeyToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
await providerService._deleteProviderApiKeyInternal(accountId);
|
||||
return { success: true };
|
||||
}
|
||||
const currentDefaultAccountId = await providerService.getDefaultAccountId();
|
||||
@@ -385,9 +385,10 @@ async function deleteAccount(
|
||||
? selectReplacementDefaultAccount(await providerService.listAccounts(), accountId)
|
||||
: undefined;
|
||||
|
||||
await providerService.deleteAccount(accountId);
|
||||
if (replacementDefault) {
|
||||
await syncDefaultProviderToRuntime(replacementDefault.id);
|
||||
await providerService.setDefaultAccount(replacementDefault.id);
|
||||
await syncDefaultProviderToRuntime(replacementDefault.id);
|
||||
}
|
||||
await syncDeletedProviderToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
@@ -395,7 +396,6 @@ async function deleteAccount(
|
||||
gatewayManager,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
await providerService.deleteAccount(accountId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
|
||||
@@ -185,6 +185,29 @@ export async function getProviderFallbackModelRefs(config: ProviderConfig): Prom
|
||||
return results;
|
||||
}
|
||||
|
||||
type GatewayRefreshMode = 'reload' | 'restart';
|
||||
|
||||
function scheduleGatewayRefresh(
|
||||
gatewayManager: GatewayManager | undefined,
|
||||
message: string,
|
||||
options?: { delayMs?: number; onlyIfRunning?: boolean; mode?: GatewayRefreshMode },
|
||||
): void {
|
||||
if (!gatewayManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(message);
|
||||
if (options?.mode === 'restart') {
|
||||
gatewayManager.debouncedRestart(options?.delayMs);
|
||||
return;
|
||||
}
|
||||
gatewayManager.debouncedReload(options?.delayMs);
|
||||
}
|
||||
|
||||
export async function syncProviderApiKeyToRuntime(
|
||||
providerType: string,
|
||||
providerId: string,
|
||||
@@ -499,21 +522,29 @@ export async function syncAgentModelOverrideToRuntime(agentId: string): Promise<
|
||||
export async function syncSavedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
_gatewayManager?: GatewayManager,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const context = await syncProviderToRuntime(config, apiKey);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
await syncAgentModelsToRuntime();
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
|
||||
}
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncUpdatedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
_gatewayManager?: GatewayManager,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const context = await syncProviderToRuntime(config, apiKey);
|
||||
if (!context) {
|
||||
@@ -548,14 +579,22 @@ export async function syncUpdatedProviderToRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
await syncAgentModelsToRuntime();
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
|
||||
}
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after updating provider "${ock}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderToRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
_gatewayManager?: GatewayManager,
|
||||
gatewayManager?: GatewayManager,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (!provider?.type) {
|
||||
@@ -565,6 +604,11 @@ export async function syncDeletedProviderToRuntime(
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeDeletedProviderFromOpenClaw(provider, providerId, ock);
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after deleting provider "${ock}"`,
|
||||
{ mode: 'restart' },
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderApiKeyToRuntime(
|
||||
@@ -582,7 +626,7 @@ export async function syncDeletedProviderApiKeyToRuntime(
|
||||
|
||||
export async function syncDefaultProviderToRuntime(
|
||||
providerId: string,
|
||||
_gatewayManager?: GatewayManager,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const provider = await getProvider(providerId);
|
||||
if (!provider) {
|
||||
@@ -698,7 +742,15 @@ export async function syncDefaultProviderToRuntime(
|
||||
fallbackModels.map((fallback) => fallback.replace(/^openai-codex\//, `${browserOAuthRuntimeProvider}/`)),
|
||||
);
|
||||
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
||||
await syncAgentModelsToRuntime();
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
|
||||
}
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -723,14 +775,18 @@ export async function syncDefaultProviderToRuntime(
|
||||
|
||||
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
|
||||
|
||||
const defaultModelId = provider.model?.split('/').pop();
|
||||
await updateAgentModelProvider(targetProviderKey, {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
|
||||
});
|
||||
try {
|
||||
const defaultModelId = provider.model?.split('/').pop();
|
||||
await updateAgentModelProvider(targetProviderKey, {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
models: defaultModelId ? [piAiModelsJsonModelEntry(defaultModelId)] : [],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -747,6 +803,15 @@ export async function syncDefaultProviderToRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
await syncAgentModelsToRuntime();
|
||||
try {
|
||||
await syncAgentModelsToRuntime();
|
||||
} catch (err) {
|
||||
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
|
||||
}
|
||||
|
||||
scheduleGatewayRefresh(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway reload after provider switch to "${ock}"`,
|
||||
{ onlyIfRunning: true },
|
||||
);
|
||||
}
|
||||
|
||||
+85
-223
@@ -1,9 +1,9 @@
|
||||
import { copyFile, lstat, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { join, normalize } from 'path';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { mutateOpenClawConfig } from '../gateway/config-delivery';
|
||||
import { deleteAgentChannelAccounts, listConfiguredChannelsFromConfig, readOpenClawConfig } from './channel-config';
|
||||
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import type { OpenClawConfig } from './channel-config';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import { expandPath, getOpenClawConfigDir } from './paths';
|
||||
import * as logger from './logger';
|
||||
import { toUiChannelType } from './channel-alias';
|
||||
@@ -62,11 +62,6 @@ interface BindingConfig extends Record<string, unknown> {
|
||||
match?: BindingMatch;
|
||||
}
|
||||
|
||||
interface ChannelBindingConfig extends BindingConfig {
|
||||
agentId: string;
|
||||
match: BindingMatch & { channel: string };
|
||||
}
|
||||
|
||||
interface ChannelSectionConfig extends Record<string, unknown> {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
defaultAccount?: string;
|
||||
@@ -152,7 +147,7 @@ function slugifyAgentId(name: string): string {
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await lstat(path);
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -222,7 +217,7 @@ function normalizeAgentsConfig(config: AgentConfigDocument): {
|
||||
};
|
||||
}
|
||||
|
||||
function isChannelBinding(binding: unknown): binding is ChannelBindingConfig {
|
||||
function isChannelBinding(binding: unknown): binding is BindingConfig {
|
||||
if (!binding || typeof binding !== 'object') return false;
|
||||
const candidate = binding as BindingConfig;
|
||||
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
|
||||
@@ -465,8 +460,7 @@ function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channel
|
||||
|
||||
async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedChannels?: string[]): Promise<AgentsSnapshot> {
|
||||
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
|
||||
const configuredChannels = preloadedChannels
|
||||
?? await listConfiguredChannelsFromConfig(config as OpenClawConfig);
|
||||
const configuredChannels = preloadedChannels ?? await listConfiguredChannels();
|
||||
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
|
||||
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
|
||||
const channelOwners: Record<string, string> = {};
|
||||
@@ -478,11 +472,15 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
|
||||
for (const channelType of configuredChannels) {
|
||||
const accountIds = listConfiguredAccountIdsForChannel(config, channelType);
|
||||
let primaryOwner: string | undefined;
|
||||
const hasExplicitAccountBindingForChannel = accountIds.some((accountId) =>
|
||||
accountToAgent.has(`${channelType}:${accountId}`),
|
||||
);
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const owner =
|
||||
accountToAgent.get(`${channelType}:${accountId}`)
|
||||
|| (
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel
|
||||
? channelToAgent.get(channelType)
|
||||
: undefined
|
||||
);
|
||||
@@ -545,25 +543,16 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument, preloadedCha
|
||||
}
|
||||
|
||||
export async function listAgentsSnapshot(): Promise<AgentsSnapshot> {
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
let prunedRuntimeModelRefs = false;
|
||||
const {
|
||||
getActiveAuthProfileProviders,
|
||||
pruneStaleRuntimeAgentModelRefs,
|
||||
} = await import('./openclaw-auth');
|
||||
const authProfileProviders = await getActiveAuthProfileProviders();
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
prunedRuntimeModelRefs = await pruneStaleRuntimeAgentModelRefs(
|
||||
config as unknown as Record<string, unknown>,
|
||||
authProfileProviders,
|
||||
);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { pruneStaleRuntimeAgentModelRefs } = await import('./openclaw-auth');
|
||||
const modified = await pruneStaleRuntimeAgentModelRefs(config as unknown as Record<string, unknown>);
|
||||
if (modified) {
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Pruned stale runtime agent model refs from openclaw.json');
|
||||
}
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
if (prunedRuntimeModelRefs) {
|
||||
logger.info('Pruned stale runtime agent model refs from openclaw.json');
|
||||
}
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function listAgentsSnapshotFromConfig(config: OpenClawConfig, configuredChannels?: string[]): Promise<AgentsSnapshot> {
|
||||
@@ -600,12 +589,8 @@ export async function createAgent(
|
||||
name: string,
|
||||
options?: { inheritWorkspace?: boolean },
|
||||
): Promise<AgentsSnapshot> {
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
let createdAgentId = '';
|
||||
let agentToProvision: AgentListEntry | undefined;
|
||||
let provisioningConfig: AgentConfigDocument | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
|
||||
const normalizedName = normalizeAgentName(name);
|
||||
const existingIds = new Set(entries.map((entry) => entry.id));
|
||||
@@ -636,61 +621,18 @@ export async function createAgent(
|
||||
list: nextEntries,
|
||||
};
|
||||
|
||||
createdAgentId = nextId;
|
||||
agentToProvision = newAgent;
|
||||
provisioningConfig = structuredClone(config);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
await provisionAgentFilesystem(config, newAgent, { inheritWorkspace: options?.inheritWorkspace });
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Created agent config entry', { agentId: nextId, inheritWorkspace: !!options?.inheritWorkspace });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
const createdAgent = agentToProvision!;
|
||||
const workspaceExisted = await fileExists(expandPath(createdAgent.workspace!));
|
||||
const runtimeDirectory = join(getOpenClawConfigDir(), 'agents', createdAgent.id);
|
||||
const runtimeDirectoryExisted = await fileExists(runtimeDirectory);
|
||||
try {
|
||||
await provisionAgentFilesystem(provisioningConfig!, createdAgent, { inheritWorkspace: options?.inheritWorkspace });
|
||||
} catch (provisioningError) {
|
||||
let rollbackError: unknown;
|
||||
try {
|
||||
await mutateOpenClawConfig((configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||
const createdIndex = entries.findIndex((entry) => (
|
||||
entry.id === createdAgent.id && isDeepStrictEqual(entry, createdAgent)
|
||||
));
|
||||
if (createdIndex === -1) return;
|
||||
config.agents = {
|
||||
...agentsConfig,
|
||||
list: entries.filter((_, index) => index !== createdIndex),
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackError = error;
|
||||
}
|
||||
|
||||
if (!workspaceExisted) {
|
||||
await removeAgentWorkspaceDirectory(createdAgent);
|
||||
}
|
||||
if (!runtimeDirectoryExisted) {
|
||||
await removeAgentRuntimeDirectory(createdAgent.id);
|
||||
}
|
||||
if (rollbackError) {
|
||||
throw new AggregateError(
|
||||
[provisioningError, rollbackError],
|
||||
`Failed to provision agent "${createdAgent.id}" and roll back its config entry`,
|
||||
{ cause: provisioningError },
|
||||
);
|
||||
}
|
||||
throw provisioningError;
|
||||
}
|
||||
logger.info('Created agent config entry', { agentId: createdAgentId, inheritWorkspace: !!options?.inheritWorkspace });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function updateAgentName(agentId: string, name: string): Promise<AgentsSnapshot> {
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
const normalizedName = normalizeAgentName(name);
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||
const normalizedName = normalizeAgentName(name);
|
||||
const index = entries.findIndex((entry) => entry.id === agentId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
@@ -706,10 +648,10 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
|
||||
list: entries,
|
||||
};
|
||||
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Updated agent name', { agentId, name: normalizedName });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Updated agent name', { agentId, name: normalizedName });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
function isValidModelRef(modelRef: string): boolean {
|
||||
@@ -718,16 +660,15 @@ function isValidModelRef(modelRef: string): boolean {
|
||||
}
|
||||
|
||||
export async function updateAgentModel(agentId: string, modelRef: string | null): Promise<AgentsSnapshot> {
|
||||
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||
const index = entries.findIndex((entry) => entry.id === agentId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
|
||||
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
|
||||
const nextEntry: AgentListEntry = { ...entries[index] };
|
||||
|
||||
if (!normalizedModelRef) {
|
||||
@@ -767,24 +708,21 @@ export async function updateAgentModel(agentId: string, modelRef: string | null)
|
||||
list: entries,
|
||||
};
|
||||
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
|
||||
if (agentId === MAIN_AGENT_ID) {
|
||||
throw new Error('The main agent cannot be deleted');
|
||||
}
|
||||
return withConfigLock(async () => {
|
||||
if (agentId === MAIN_AGENT_ID) {
|
||||
throw new Error('The main agent cannot be deleted');
|
||||
}
|
||||
|
||||
let result: { snapshot: AgentsSnapshot; removedEntry: AgentListEntry } | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
|
||||
const bindingsBeforeDeletion = Array.isArray(config.bindings)
|
||||
? config.bindings.filter(isChannelBinding)
|
||||
: [];
|
||||
const snapshotBeforeDeletion = await buildSnapshotFromConfig(config);
|
||||
const removedEntry = entries.find((entry) => entry.id === agentId);
|
||||
const nextEntries = entries.filter((entry) => entry.id !== agentId);
|
||||
if (!removedEntry || nextEntries.length === entries.length) {
|
||||
@@ -808,87 +746,81 @@ export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: Ag
|
||||
|
||||
const normalizedAgentId = normalizeAgentIdForBinding(agentId);
|
||||
const legacyAccountId = resolveAccountIdForAgent(agentId);
|
||||
const { channelToAgent, accountToAgent } = getChannelBindingMap(bindingsBeforeDeletion);
|
||||
const boundChannelTypes = new Set(bindingsBeforeDeletion.map((binding) => binding.match.channel));
|
||||
const ownedLegacyAccounts = new Set(
|
||||
[...boundChannelTypes]
|
||||
.filter((channelType) => {
|
||||
const accountOwner = accountToAgent.get(`${channelType}:${legacyAccountId}`);
|
||||
const effectiveOwner = accountOwner
|
||||
?? (legacyAccountId === DEFAULT_ACCOUNT_ID ? channelToAgent.get(channelType) : undefined);
|
||||
return effectiveOwner === normalizedAgentId;
|
||||
Object.entries(snapshotBeforeDeletion.channelAccountOwners)
|
||||
.filter(([channelAccountKey, owner]) => {
|
||||
if (owner !== normalizedAgentId) return false;
|
||||
const accountId = channelAccountKey.slice(channelAccountKey.indexOf(':') + 1);
|
||||
return accountId === legacyAccountId;
|
||||
})
|
||||
.map((channelType) => `${channelType}:${legacyAccountId}`),
|
||||
.map(([channelAccountKey]) => channelAccountKey),
|
||||
);
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
await deleteAgentChannelAccounts(agentId, ownedLegacyAccounts);
|
||||
result = { snapshot: await buildSnapshotFromConfig(config), removedEntry };
|
||||
await removeAgentRuntimeDirectory(agentId);
|
||||
// NOTE: workspace directory is NOT deleted here intentionally.
|
||||
// The caller (route handler) defers workspace removal until after
|
||||
// the Gateway process has fully restarted, so that any in-flight
|
||||
// process.chdir(workspace) calls complete before the directory
|
||||
// disappears (otherwise process.cwd() throws ENOENT for the rest
|
||||
// of the Gateway's lifetime).
|
||||
logger.info('Deleted agent config entry', { agentId });
|
||||
return { snapshot: await buildSnapshotFromConfig(config), removedEntry };
|
||||
});
|
||||
await removeAgentRuntimeDirectory(agentId);
|
||||
// The caller removes the workspace only after the coordinator commit above.
|
||||
logger.info('Deleted agent config entry', { agentId });
|
||||
return result!;
|
||||
}
|
||||
|
||||
export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
const accountId = resolveAccountIdForAgent(agentId);
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
if (!entries.some((entry) => entry.id === agentId)) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
|
||||
const accountId = resolveAccountIdForAgent(agentId);
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function assignChannelAccountToAgent(
|
||||
agentId: string,
|
||||
channelType: string,
|
||||
accountId: string,
|
||||
options?: { migrateLegacy?: boolean },
|
||||
): Promise<AgentsSnapshot> {
|
||||
const trimmedAccountId = accountId.trim();
|
||||
if (!trimmedAccountId) {
|
||||
throw new Error('accountId is required');
|
||||
}
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
if (!entries.some((entry) => entry.id === agentId)) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
if (options?.migrateLegacy) {
|
||||
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
|
||||
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
|
||||
if (!accountId.trim()) {
|
||||
throw new Error('accountId is required');
|
||||
}
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, trimmedAccountId);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId.trim());
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: accountId.trim() });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: trimmedAccountId });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
|
||||
let snapshot: AgentsSnapshot | undefined;
|
||||
await mutateOpenClawConfig(async (configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
|
||||
snapshot = await buildSnapshotFromConfig(config);
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Cleared channel binding', { channelType, accountId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
logger.info('Cleared channel binding', { channelType, accountId });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
|
||||
await mutateOpenClawConfig((configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
if (!Array.isArray(config.bindings)) return;
|
||||
|
||||
const nextBindings = config.bindings.filter((binding) => {
|
||||
@@ -897,77 +829,7 @@ export async function clearAllBindingsForChannel(channelType: string): Promise<v
|
||||
});
|
||||
|
||||
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Cleared all bindings for channel', { channelType });
|
||||
});
|
||||
logger.info('Cleared all bindings for channel', { channelType });
|
||||
}
|
||||
|
||||
function migrateLegacyChannelBindingInConfig(
|
||||
config: AgentConfigDocument,
|
||||
channelType: string,
|
||||
validAgentIds: Set<string>,
|
||||
): void {
|
||||
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
|
||||
const legacyOwner = channelToAgent.get(channelType);
|
||||
if (!legacyOwner) return;
|
||||
|
||||
const explicitDefaultOwner = accountToAgent.get(`${channelType}:${DEFAULT_ACCOUNT_ID}`);
|
||||
const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner)
|
||||
? explicitDefaultOwner
|
||||
: (validAgentIds.has(legacyOwner) ? legacyOwner : null);
|
||||
if (defaultOwner) {
|
||||
config.bindings = upsertBindingsForChannel(
|
||||
config.bindings,
|
||||
channelType,
|
||||
defaultOwner,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
}
|
||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null);
|
||||
}
|
||||
|
||||
export async function migrateLegacyChannelWideBinding(channelType: string): Promise<void> {
|
||||
await mutateOpenClawConfig((configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
|
||||
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
|
||||
});
|
||||
logger.info('Migrated legacy channel-wide binding', { channelType });
|
||||
}
|
||||
|
||||
export async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
|
||||
const normalizedAccountId = accountId?.trim();
|
||||
if (!normalizedAccountId) return;
|
||||
|
||||
await mutateOpenClawConfig((configSnapshot) => {
|
||||
const config = configSnapshot as AgentConfigDocument;
|
||||
const { entries } = normalizeAgentsConfig(config);
|
||||
if (entries.length === 0) return;
|
||||
const validAgentIds = new Set(entries.map((entry) => normalizeAgentIdForBinding(entry.id)));
|
||||
|
||||
if (normalizedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
const mainAgent = entries.find((entry) => entry.id === MAIN_AGENT_ID);
|
||||
if (mainAgent) {
|
||||
config.bindings = upsertBindingsForChannel(
|
||||
config.bindings,
|
||||
channelType,
|
||||
mainAgent.id,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
migrateLegacyChannelBindingInConfig(config, channelType, validAgentIds);
|
||||
const accountAgent = entries.find((entry) => entry.id === normalizedAccountId);
|
||||
if (accountAgent) {
|
||||
config.bindings = upsertBindingsForChannel(
|
||||
config.bindings,
|
||||
channelType,
|
||||
accountAgent.id,
|
||||
normalizedAccountId,
|
||||
);
|
||||
}
|
||||
});
|
||||
logger.info('Ensured scoped channel binding', { channelType, accountId: normalizedAccountId });
|
||||
}
|
||||
|
||||
+140
-206
@@ -8,10 +8,10 @@ import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promis
|
||||
import { constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery';
|
||||
import { getOpenClawResolvedDir, resolveOpenClawConfigPath } from './paths';
|
||||
import { getOpenClawResolvedDir } from './paths';
|
||||
import * as logger from './logger';
|
||||
import { proxyAwareFetch } from './proxy-fetch';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import {
|
||||
OPENCLAW_WECHAT_CHANNEL_TYPE,
|
||||
isWechatChannelType,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './channel-alias';
|
||||
|
||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||
const WECOM_PLUGIN_ID = 'wecom';
|
||||
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin ID needed.
|
||||
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
|
||||
@@ -153,7 +154,8 @@ function sanitizeDiscordGuilds(config: unknown): void {
|
||||
/**
|
||||
* Strip `defaultAccount` from channel sections whose plugin schema
|
||||
* declares additionalProperties:false without listing `defaultAccount`.
|
||||
* Call before committing channel-config mutations.
|
||||
* Call right before every `writeOpenClawConfig` in channel-config
|
||||
* mutation functions.
|
||||
*/
|
||||
function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
|
||||
if (!config.channels) return;
|
||||
@@ -364,51 +366,6 @@ function ensurePluginRegistration(currentConfig: OpenClawConfig, pluginId: strin
|
||||
currentConfig.plugins.entries[pluginId].enabled = true;
|
||||
}
|
||||
|
||||
function syncPluginChannelAccountMirror(currentConfig: OpenClawConfig, channelType: string): void {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) return;
|
||||
const channelSection = currentConfig.channels?.[channelType];
|
||||
if (!channelSection) {
|
||||
removePluginRegistration(currentConfig, channelType);
|
||||
return;
|
||||
}
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
if (!pluginEntry) return;
|
||||
const accounts = getChannelAccountsMap(channelSection);
|
||||
pluginEntry.enabled = channelSection.enabled;
|
||||
pluginEntry.defaultAccount = channelSection.defaultAccount;
|
||||
if (accounts && Object.keys(accounts).length > 0) {
|
||||
pluginEntry.accounts = structuredClone(accounts);
|
||||
} else {
|
||||
delete pluginEntry.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePluginChannelAccountMirror(
|
||||
currentConfig: OpenClawConfig,
|
||||
channelType: string,
|
||||
accountId: string,
|
||||
): boolean {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) return false;
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
if (!pluginEntry) return false;
|
||||
const accounts = getChannelAccountsMap(pluginEntry);
|
||||
if (!accounts?.[accountId]) return false;
|
||||
|
||||
delete accounts[accountId];
|
||||
const remainingAccountIds = Object.keys(accounts).sort((a, b) => {
|
||||
if (a === DEFAULT_ACCOUNT_ID) return -1;
|
||||
if (b === DEFAULT_ACCOUNT_ID) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
if (remainingAccountIds.length === 0) {
|
||||
delete pluginEntry.accounts;
|
||||
delete pluginEntry.defaultAccount;
|
||||
} else if (pluginEntry.defaultAccount === accountId) {
|
||||
pluginEntry.defaultAccount = remainingAccountIds[0];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cleanupLegacyBuiltInChannelPluginRegistration(
|
||||
currentConfig: OpenClawConfig,
|
||||
channelType: string,
|
||||
@@ -498,10 +455,22 @@ export interface OpenClawConfig {
|
||||
|
||||
// ── Config I/O ───────────────────────────────────────────────────
|
||||
|
||||
async function ensureConfigDir(): Promise<void> {
|
||||
if (!(await fileExists(OPENCLAW_DIR))) {
|
||||
await mkdir(OPENCLAW_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
|
||||
await ensureConfigDir();
|
||||
|
||||
if (!(await fileExists(CONFIG_FILE))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await readOpenClawConfigSnapshot();
|
||||
return snapshot.config as OpenClawConfig;
|
||||
const content = await readFile(CONFIG_FILE, 'utf-8');
|
||||
return JSON.parse(content) as OpenClawConfig;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read OpenClaw config', error);
|
||||
console.error('Failed to read OpenClaw config:', error);
|
||||
@@ -509,6 +478,26 @@ export async function readOpenClawConfig(): Promise<OpenClawConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
|
||||
await ensureConfigDir();
|
||||
|
||||
try {
|
||||
// Enable graceful in-process reload authorization for SIGUSR1 flows.
|
||||
const commands =
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {};
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
|
||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to write OpenClaw config', error);
|
||||
console.error('Failed to write OpenClaw config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Channel operations ───────────────────────────────────────────
|
||||
|
||||
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
|
||||
@@ -843,12 +832,10 @@ export async function saveChannelConfig(
|
||||
config: ChannelConfigData,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||
let transformedKeys: string[] = [];
|
||||
|
||||
await mutateOpenClawConfig(async (snapshot) => {
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||
|
||||
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
|
||||
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
|
||||
@@ -872,7 +859,6 @@ export async function saveChannelConfig(
|
||||
|
||||
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
|
||||
const transformedConfig = transformChannelConfig(resolvedChannelType, config, existingAccountConfig);
|
||||
transformedKeys = Object.keys(transformedConfig);
|
||||
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[resolvedChannelType];
|
||||
if (uniqueKey && typeof transformedConfig[uniqueKey] === 'string') {
|
||||
const rawCredentialValue = transformedConfig[uniqueKey] as string;
|
||||
@@ -934,15 +920,16 @@ export async function saveChannelConfig(
|
||||
}
|
||||
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Channel config saved', {
|
||||
channelType: resolvedChannelType,
|
||||
accountId: resolvedAccountId,
|
||||
configFile: CONFIG_FILE,
|
||||
rawKeys: Object.keys(config),
|
||||
transformedKeys: Object.keys(transformedConfig),
|
||||
});
|
||||
console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`);
|
||||
});
|
||||
logger.info('Channel config saved', {
|
||||
channelType: resolvedChannelType,
|
||||
accountId: resolvedAccountId,
|
||||
configFile: resolveOpenClawConfigPath(),
|
||||
rawKeys: Object.keys(config),
|
||||
transformedKeys,
|
||||
});
|
||||
console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`);
|
||||
}
|
||||
|
||||
export async function getChannelConfig(channelType: string, accountId?: string): Promise<ChannelConfigData | undefined> {
|
||||
@@ -1016,54 +1003,35 @@ export async function getChannelFormValues(channelType: string, accountId?: stri
|
||||
}
|
||||
|
||||
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
let deleteWeChatAccount = false;
|
||||
let deletedAccount = false;
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
deleteWeChatAccount = false;
|
||||
deletedAccount = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const deletedPluginAccount = deletePluginChannelAccountMirror(
|
||||
currentConfig,
|
||||
resolvedChannelType,
|
||||
accountId,
|
||||
);
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const channelSection = currentConfig.channels?.[resolvedChannelType];
|
||||
if (!channelSection) {
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
deleteWeChatAccount = true;
|
||||
}
|
||||
if (deletedPluginAccount) {
|
||||
deletedAccount = true;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
await deleteWeChatAccountState(accountId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const existingAccounts = getChannelAccountsMap(channelSection);
|
||||
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
|
||||
&& Object.keys(getLegacyChannelPayload(channelSection)).length > 0;
|
||||
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) {
|
||||
if (deletedPluginAccount) {
|
||||
deletedAccount = true;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
|
||||
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||
const accounts = getChannelAccountsMap(channelSection);
|
||||
if (!accounts?.[accountId]) {
|
||||
// Account not found; just ensure top-level mirror is consistent
|
||||
const mirroredAccountId = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() ? channelSection.defaultAccount : DEFAULT_ACCOUNT_ID;
|
||||
const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccountData) {
|
||||
for (const [key, value] of Object.entries(defaultAccountData)) {
|
||||
channelSection[key] = value;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string'
|
||||
&& channelSection.defaultAccount.trim()
|
||||
? channelSection.defaultAccount.trim()
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
migrateLegacyChannelConfigToAccounts(channelSection, currentDefaultAccountId);
|
||||
const accounts = getChannelAccountsMap(channelSection);
|
||||
if (!accounts?.[accountId]) return;
|
||||
|
||||
delete accounts[accountId];
|
||||
deletedAccount = true;
|
||||
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
delete currentConfig.channels![resolvedChannelType];
|
||||
@@ -1096,31 +1064,21 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
}
|
||||
}
|
||||
|
||||
syncPluginChannelAccountMirror(currentConfig, resolvedChannelType);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
deleteWeChatAccount = true;
|
||||
await deleteWeChatAccountState(accountId);
|
||||
}
|
||||
});
|
||||
if (deleteWeChatAccount) {
|
||||
await deleteWeChatAccountState(accountId);
|
||||
}
|
||||
if (deletedAccount) {
|
||||
logger.info('Deleted channel account config', { channelType: resolvedChannelType, accountId });
|
||||
console.log(`Deleted channel account config for ${resolvedChannelType}/${accountId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
let deleteWeChat = false;
|
||||
let deletedConfig: 'channel' | 'plugin' | undefined;
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
deleteWeChat = false;
|
||||
deletedConfig = undefined;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
|
||||
|
||||
if (currentConfig.channels?.[resolvedChannelType]) {
|
||||
@@ -1145,43 +1103,37 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
removePluginRegistration(currentConfig, WECOM_PLUGIN_ID);
|
||||
}
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
deleteWeChat = true;
|
||||
await deleteWeChatState();
|
||||
}
|
||||
deletedConfig = 'channel';
|
||||
console.log(`Deleted channel config for ${resolvedChannelType}`);
|
||||
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
if (currentConfig.plugins?.entries?.[resolvedChannelType] || currentConfig.plugins?.allow?.includes(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, resolvedChannelType);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
deletedConfig = 'plugin';
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
|
||||
}
|
||||
} else if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
deleteWeChat = true;
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
await deleteWeChatState();
|
||||
}
|
||||
|
||||
if (resolvedChannelType === 'whatsapp') {
|
||||
try {
|
||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||
if (await fileExists(whatsappDir)) {
|
||||
await rm(whatsappDir, { recursive: true, force: true });
|
||||
console.log('Deleted WhatsApp credentials directory');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete WhatsApp credentials:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (deleteWeChat) {
|
||||
await deleteWeChatState();
|
||||
}
|
||||
if (deletedConfig === 'channel') {
|
||||
console.log(`Deleted channel config for ${resolvedChannelType}`);
|
||||
} else if (deletedConfig === 'plugin') {
|
||||
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
|
||||
}
|
||||
|
||||
if (resolvedChannelType === 'whatsapp') {
|
||||
try {
|
||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||
if (await fileExists(whatsappDir)) {
|
||||
await rm(whatsappDir, { recursive: true, force: true });
|
||||
console.log('Deleted WhatsApp credentials directory');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete WhatsApp credentials:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
|
||||
@@ -1295,14 +1247,14 @@ export async function listConfiguredChannelAccounts(): Promise<Record<string, Co
|
||||
}
|
||||
|
||||
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const trimmedAccountId = accountId.trim();
|
||||
if (!trimmedAccountId) {
|
||||
throw new Error('accountId is required');
|
||||
}
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const trimmedAccountId = accountId.trim();
|
||||
if (!trimmedAccountId) {
|
||||
throw new Error('accountId is required');
|
||||
}
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const channelSection = currentConfig.channels?.[resolvedChannelType];
|
||||
if (!channelSection) {
|
||||
throw new Error(`Channel "${resolvedChannelType}" is not configured`);
|
||||
@@ -1323,37 +1275,38 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s
|
||||
}
|
||||
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
|
||||
});
|
||||
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
|
||||
}
|
||||
|
||||
export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAccounts?: Set<string>): Promise<void> {
|
||||
let modified = false;
|
||||
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
|
||||
return withConfigLock(async () => {
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
if (!currentConfig.channels) return;
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
modified = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const channels = currentConfig.channels ?? {};
|
||||
for (const channelType of Object.keys(channels)) {
|
||||
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) continue;
|
||||
const section = channels[channelType];
|
||||
const existingAccounts = getChannelAccountsMap(section);
|
||||
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
|
||||
&& Object.keys(getLegacyChannelPayload(section)).length > 0;
|
||||
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) continue;
|
||||
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
|
||||
let modified = false;
|
||||
|
||||
const currentDefaultAccountId = typeof section.defaultAccount === 'string'
|
||||
&& section.defaultAccount.trim()
|
||||
? section.defaultAccount.trim()
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
migrateLegacyChannelConfigToAccounts(section, currentDefaultAccountId);
|
||||
for (const channelType of Object.keys(currentConfig.channels)) {
|
||||
const section = currentConfig.channels[channelType];
|
||||
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
|
||||
const accounts = getChannelAccountsMap(section);
|
||||
if (!accounts?.[accountId]) continue;
|
||||
if (!accounts?.[accountId] || (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`))) {
|
||||
// Ensure top-level mirror is consistent.
|
||||
const mirroredAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount : DEFAULT_ACCOUNT_ID;
|
||||
const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccountData) {
|
||||
for (const [key, value] of Object.entries(defaultAccountData)) {
|
||||
section[key] = value;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
delete accounts[accountId];
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
delete channels[channelType];
|
||||
delete currentConfig.channels[channelType];
|
||||
} else {
|
||||
if (section.defaultAccount === accountId) {
|
||||
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
|
||||
@@ -1378,37 +1331,21 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
|
||||
}
|
||||
}
|
||||
}
|
||||
syncPluginChannelAccountMirror(currentConfig, channelType);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
const pluginChannelTypes = ownedChannelAccounts
|
||||
? [...ownedChannelAccounts]
|
||||
.filter((channelAccountKey) => channelAccountKey.endsWith(`:${accountId}`))
|
||||
.map((channelAccountKey) => channelAccountKey.slice(0, -accountId.length - 1))
|
||||
: Object.keys(currentConfig.plugins?.entries ?? {});
|
||||
for (const channelType of pluginChannelTypes) {
|
||||
if (deletePluginChannelAccountMirror(currentConfig, channelType, accountId)) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
|
||||
}
|
||||
});
|
||||
if (modified) {
|
||||
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
let pluginChannel = false;
|
||||
|
||||
await mutateOpenClawConfig(async (snapshot) => {
|
||||
pluginChannel = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
|
||||
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
@@ -1420,7 +1357,6 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
||||
}
|
||||
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
pluginChannel = true;
|
||||
if (enabled) {
|
||||
ensurePluginRegistration(currentConfig, resolvedChannelType);
|
||||
} else {
|
||||
@@ -1433,6 +1369,8 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
||||
if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`);
|
||||
pluginEntry.enabled = enabled;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1440,36 +1378,32 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
||||
if (!currentConfig.channels[resolvedChannelType]) currentConfig.channels[resolvedChannelType] = {};
|
||||
currentConfig.channels[resolvedChannelType].enabled = enabled;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig, enabled ? [resolvedChannelType] : []);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Set channel ${resolvedChannelType} enabled: ${enabled}`);
|
||||
});
|
||||
console.log(`Set ${pluginChannel ? 'plugin channel' : 'channel'} ${resolvedChannelType} enabled: ${enabled}`);
|
||||
}
|
||||
|
||||
export async function cleanupDanglingWeChatPluginState(): Promise<{ cleanedDanglingState: boolean }> {
|
||||
let cleanedDanglingState = false;
|
||||
let hasConfiguredWeChatAccounts = false;
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
cleanedDanglingState = false;
|
||||
hasConfiguredWeChatAccounts = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
return withConfigLock(async () => {
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const channelSection = currentConfig.channels?.[WECHAT_PLUGIN_ID];
|
||||
hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
|
||||
const hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
|
||||
const hadPluginRegistration = Boolean(
|
||||
currentConfig.plugins?.entries?.[WECHAT_PLUGIN_ID]
|
||||
|| currentConfig.plugins?.allow?.includes(WECHAT_PLUGIN_ID),
|
||||
);
|
||||
|
||||
if (hasConfiguredWeChatAccounts) {
|
||||
return;
|
||||
return { cleanedDanglingState: false };
|
||||
}
|
||||
|
||||
const modified = removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
cleanedDanglingState = hadPluginRegistration || modified;
|
||||
});
|
||||
if (!hasConfiguredWeChatAccounts) {
|
||||
if (modified) {
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
}
|
||||
await deleteWeChatState();
|
||||
}
|
||||
return { cleanedDanglingState };
|
||||
return { cleanedDanglingState: hadPluginRegistration || modified };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
|
||||
+257
-269
@@ -28,11 +28,7 @@ import {
|
||||
isOpenClawOAuthPluginProviderKey,
|
||||
} from './provider-keys';
|
||||
import { normalizePiAiModelCost, type PiAiModelCostRates } from '../shared/pi-ai-model-cost';
|
||||
import {
|
||||
mutateOpenClawConfig,
|
||||
readOpenClawConfigSnapshot,
|
||||
reloadOpenClawSecretsIfRunning,
|
||||
} from '../gateway/config-delivery';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import {
|
||||
ensureMemorySearchFtsDefault,
|
||||
hasUserMemorySearchConfig,
|
||||
@@ -434,6 +430,12 @@ async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
|
||||
|
||||
const jsonStore = await readAuthProfilesJson(agentId);
|
||||
if (jsonStore?.profiles && Object.keys(jsonStore.profiles).length > 0) {
|
||||
try {
|
||||
writeAuthProfilesToSqlite(jsonStore, agentId);
|
||||
console.log(`[auth-sync] Backfilled SQLite auth store from JSON for agent "${agentId}"`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to backfill SQLite auth store for agent "${agentId}":`, error);
|
||||
}
|
||||
return jsonStore;
|
||||
}
|
||||
|
||||
@@ -442,27 +444,19 @@ async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
|
||||
|
||||
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
|
||||
writeAuthProfilesToSqlite(store, agentId);
|
||||
try {
|
||||
await writeJsonFile(getAuthProfilesPath(agentId), store);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to update compatibility auth-profiles.json for agent "${agentId}":`, error);
|
||||
}
|
||||
await writeJsonFile(getAuthProfilesPath(agentId), store);
|
||||
}
|
||||
|
||||
/** Migrate legacy JSON-only auth profiles into SQLite for all configured agents. */
|
||||
export async function migrateAllAgentAuthProfilesToSqlite(): Promise<void> {
|
||||
const agentIds = await discoverAgentIds();
|
||||
let migrated = false;
|
||||
for (const agentId of agentIds) {
|
||||
try {
|
||||
migrated = await migrateAuthProfilesJsonToSqliteIfNeeded(agentId) || migrated;
|
||||
await migrateAuthProfilesJsonToSqliteIfNeeded(agentId);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to migrate auth profiles to SQLite for agent "${agentId}":`, error);
|
||||
}
|
||||
}
|
||||
if (migrated) {
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
}
|
||||
}
|
||||
|
||||
function getApiKeyFromAuthProfilesStore(
|
||||
@@ -530,6 +524,7 @@ async function discoverAgentIds(): Promise<string[]> {
|
||||
|
||||
// ── OpenClaw Config Helpers ──────────────────────────────────────
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
|
||||
/** Matches OpenClaw's 200k+ context-window recommendation (see computeContextAwareReserveTokensFloor). */
|
||||
@@ -696,11 +691,8 @@ async function getProvidersFromAuthProfileStores(
|
||||
return providers;
|
||||
}
|
||||
|
||||
function collectActiveProviderIdsFromConfig(
|
||||
config: Record<string, unknown>,
|
||||
authProfileProviders: Iterable<string> = [],
|
||||
): Set<string> {
|
||||
const activeProviders = new Set(authProfileProviders);
|
||||
async function collectActiveProviderIdsFromConfig(config: Record<string, unknown>): Promise<Set<string>> {
|
||||
const activeProviders = new Set<string>();
|
||||
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
|
||||
if (providers && typeof providers === 'object') {
|
||||
for (const key of Object.keys(providers as Record<string, unknown>)) {
|
||||
@@ -732,6 +724,11 @@ function collectActiveProviderIdsFromConfig(
|
||||
{ includeRawKeys: true },
|
||||
);
|
||||
|
||||
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
|
||||
for (const provider of authProfileProviders) {
|
||||
activeProviders.add(provider);
|
||||
}
|
||||
|
||||
for (const deprecated of DEPRECATED_PROVIDER_IDS) {
|
||||
activeProviders.delete(deprecated);
|
||||
}
|
||||
@@ -740,7 +737,7 @@ function collectActiveProviderIdsFromConfig(
|
||||
}
|
||||
|
||||
async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
||||
return (await readOpenClawConfigSnapshot()).config;
|
||||
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
|
||||
}
|
||||
|
||||
async function resolveInstalledFeishuPluginId(): Promise<string | null> {
|
||||
@@ -940,6 +937,21 @@ function backfillCustomProviderModelContextWindows(config: Record<string, unknow
|
||||
return backfilled;
|
||||
}
|
||||
|
||||
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
|
||||
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
|
||||
const commands = (
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
|
||||
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
|
||||
}
|
||||
|
||||
// ── Exported Functions (all async) ───────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -986,7 +998,6 @@ export async function saveOAuthTokenToOpenClaw(
|
||||
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
@@ -1048,7 +1059,6 @@ export async function saveProviderKeyToOpenClaw(
|
||||
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
@@ -1061,18 +1071,13 @@ export async function removeProviderKeyFromOpenClaw(
|
||||
): Promise<void> {
|
||||
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
let modified = false;
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = await readAuthProfiles(id);
|
||||
if (removeProfileFromStore(store, `${provider}:default`, 'api_key')) {
|
||||
await writeAuthProfiles(store, id);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (modified) {
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
}
|
||||
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
@@ -1130,6 +1135,7 @@ function isRuntimeGeneratedProviderKey(providerKey: string): boolean {
|
||||
function pruneStaleRuntimeModelConfig(
|
||||
modelCfg: Record<string, unknown>,
|
||||
activeProviders: Set<string>,
|
||||
context: string,
|
||||
): boolean {
|
||||
let modified = false;
|
||||
const primary = typeof modelCfg.primary === 'string' ? modelCfg.primary.trim() : '';
|
||||
@@ -1142,6 +1148,7 @@ function pruneStaleRuntimeModelConfig(
|
||||
) {
|
||||
delete modelCfg.primary;
|
||||
modified = true;
|
||||
console.log(`Removed stale runtime model ref "${primary}" from ${context}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1165,13 +1172,8 @@ function pruneStaleRuntimeModelConfig(
|
||||
* Drop agent model refs that point at deleted custom/ollama runtime providers.
|
||||
* Built-in providers are left intact because they may still resolve via auth/env.
|
||||
*/
|
||||
export async function pruneStaleRuntimeAgentModelRefs(
|
||||
config: Record<string, unknown>,
|
||||
authProfileProviders?: Iterable<string>,
|
||||
): Promise<boolean> {
|
||||
const activeProviders = authProfileProviders
|
||||
? collectActiveProviderIdsFromConfig(config, authProfileProviders)
|
||||
: await getActiveOpenClawProviders();
|
||||
export async function pruneStaleRuntimeAgentModelRefs(config: Record<string, unknown>): Promise<boolean> {
|
||||
const activeProviders = await getActiveOpenClawProviders();
|
||||
const agents = config.agents;
|
||||
if (!isPlainRecord(agents)) return false;
|
||||
|
||||
@@ -1179,7 +1181,7 @@ export async function pruneStaleRuntimeAgentModelRefs(
|
||||
|
||||
const agentDefaults = agents.defaults;
|
||||
if (isPlainRecord(agentDefaults) && isPlainRecord(agentDefaults.model)) {
|
||||
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders)) {
|
||||
if (pruneStaleRuntimeModelConfig(agentDefaults.model, activeProviders, 'agents.defaults.model')) {
|
||||
deleteModelConfigIfEmpty(agentDefaults);
|
||||
modified = true;
|
||||
}
|
||||
@@ -1188,7 +1190,8 @@ export async function pruneStaleRuntimeAgentModelRefs(
|
||||
if (Array.isArray(agents.list)) {
|
||||
for (const entry of agents.list) {
|
||||
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
|
||||
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders)) {
|
||||
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
|
||||
if (pruneStaleRuntimeModelConfig(entry.model, activeProviders, `agent "${agentId}" model override`)) {
|
||||
deleteModelConfigIfEmpty(entry);
|
||||
modified = true;
|
||||
}
|
||||
@@ -1199,13 +1202,50 @@ export async function pruneStaleRuntimeAgentModelRefs(
|
||||
}
|
||||
|
||||
export async function removeProviderFromOpenClaw(provider: string): Promise<void> {
|
||||
// 1. Remove from auth-profiles.json.
|
||||
// We must also remove entries whose raw `provider` field maps to this UI
|
||||
// provider key via AUTH_PROFILE_PROVIDER_KEY_MAP (e.g. "openai-codex" → "openai").
|
||||
// If those entries survive, getProvidersFromAuthProfileStores() will re-add
|
||||
// the provider and trigger a re-seed loop in listAccounts().
|
||||
const providerKeysToRemove = expandProviderKeysForDeletion(provider);
|
||||
const agentIds = await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
let authProfilesModified = false;
|
||||
// Commit the authoritative config first. If this fails, sidecar credentials
|
||||
// and model registries remain untouched and the caller can safely retry.
|
||||
await mutateOpenClawConfig(async (config) => {
|
||||
for (const id of agentIds) {
|
||||
const store = await readAuthProfiles(id);
|
||||
let storeModified = false;
|
||||
for (const key of providerKeysToRemove) {
|
||||
if (removeProfilesForProvider(store, key)) {
|
||||
storeModified = true;
|
||||
}
|
||||
}
|
||||
if (storeModified) {
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
|
||||
for (const id of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
|
||||
try {
|
||||
if (await fileExists(modelsPath)) {
|
||||
const raw = await readFile(modelsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const providers = data.providers as Record<string, unknown> | undefined;
|
||||
if (providers && providers[provider]) {
|
||||
delete providers[provider];
|
||||
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from models.json (agent "${id}"):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove from openclaw.json
|
||||
try {
|
||||
await withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
let modified = false;
|
||||
|
||||
// Remove plugin registrations for OAuth providers (e.g. MiniMax).
|
||||
@@ -1213,6 +1253,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
const { canonicalPluginId, stalePluginIds } = getOAuthPluginRegistration(provider);
|
||||
if (removePluginRegistrations(config, [canonicalPluginId, ...stalePluginIds])) {
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw plugin registrations for provider "${provider}"`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1222,6 +1263,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
if (providers[provider]) {
|
||||
delete providers[provider];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||
}
|
||||
|
||||
const auth = (config.auth && typeof config.auth === 'object'
|
||||
@@ -1242,6 +1284,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
}
|
||||
delete authProfiles[profileId];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw auth profile: ${profileId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,6 +1301,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
if (removeProviderPrefixFromModelConfig(modelCfg, providerPrefix)) {
|
||||
deleteModelConfigIfEmpty(agentDefaults);
|
||||
modified = true;
|
||||
console.log(`Removed deleted provider "${provider}" from agents.defaults.model`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1265,69 +1309,21 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
||||
if (Array.isArray(agentList)) {
|
||||
for (const entry of agentList) {
|
||||
if (!isPlainRecord(entry) || !isPlainRecord(entry.model)) continue;
|
||||
const agentId = typeof entry.id === 'string' ? entry.id : 'unknown';
|
||||
if (removeProviderPrefixFromModelConfig(entry.model, providerPrefix)) {
|
||||
deleteModelConfigIfEmpty(entry);
|
||||
modified = true;
|
||||
console.log(`Removed deleted provider "${provider}" from agent "${agentId}" model override`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove the provider from each per-agent model registry used by pi-ai.
|
||||
for (const id of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
|
||||
if (!(await fileExists(modelsPath))) continue;
|
||||
const raw = await readFile(modelsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const providers = data.providers as Record<string, unknown> | undefined;
|
||||
if (providers && providers[provider]) {
|
||||
delete providers[provider];
|
||||
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove auth entries whose raw provider maps to this UI provider key
|
||||
// (for example "openai-codex" -> "openai"). Keep this last so every
|
||||
// successful auth batch can immediately refresh the running snapshot.
|
||||
let authWriteError: unknown;
|
||||
try {
|
||||
for (const id of agentIds) {
|
||||
const store = await readAuthProfiles(id);
|
||||
let storeModified = false;
|
||||
for (const key of providerKeysToRemove) {
|
||||
if (removeProfilesForProvider(store, key)) {
|
||||
storeModified = true;
|
||||
}
|
||||
}
|
||||
if (storeModified) {
|
||||
await writeAuthProfiles(store, id);
|
||||
authProfilesModified = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
authWriteError = error;
|
||||
}
|
||||
if (authProfilesModified) {
|
||||
try {
|
||||
await reloadOpenClawSecretsIfRunning();
|
||||
} catch (reloadError) {
|
||||
if (authWriteError) {
|
||||
throw new AggregateError(
|
||||
[authWriteError, reloadError],
|
||||
`Failed to remove provider "${provider}" auth profiles and refresh OpenClaw secrets`,
|
||||
{ cause: reloadError },
|
||||
);
|
||||
}
|
||||
throw reloadError;
|
||||
}
|
||||
}
|
||||
if (authWriteError) {
|
||||
throw authWriteError;
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,8 +1452,8 @@ function migrateOpenAiCodexOAuthRuntimeToOpenAiInConfig(config: Record<string, u
|
||||
|
||||
export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
|
||||
const removed: string[] = [];
|
||||
await mutateOpenClawConfig((config) => {
|
||||
removed.length = 0;
|
||||
await withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
let modified = false;
|
||||
@@ -1489,7 +1485,7 @@ export async function pruneInvalidApiProviderEntries(): Promise<string[]> {
|
||||
if (modified) {
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
@@ -1519,7 +1515,8 @@ export async function setOpenClawDefaultModel(
|
||||
modelOverride?: string,
|
||||
fallbackModels: string[] = []
|
||||
): Promise<void> {
|
||||
await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||
|
||||
const model = normalizeModelRef(provider, modelOverride);
|
||||
@@ -1599,7 +1596,7 @@ export async function setOpenClawDefaultModel(
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
|
||||
});
|
||||
}
|
||||
@@ -1808,8 +1805,8 @@ function healAnthropicMessagesMaxTokensInConfig(config: Record<string, unknown>)
|
||||
*/
|
||||
export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]> {
|
||||
const healed: string[] = [];
|
||||
await mutateOpenClawConfig((config) => {
|
||||
healed.length = 0;
|
||||
await withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
let modified = false;
|
||||
@@ -1828,7 +1825,7 @@ export async function ensureAnthropicMessagesModelMaxTokens(): Promise<string[]>
|
||||
if (modified) {
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
}
|
||||
});
|
||||
return healed;
|
||||
@@ -1994,11 +1991,12 @@ function upsertOpenClawProviderEntry(
|
||||
*/
|
||||
export async function ensureOpenClawProviderAgentRuntimePins(): Promise<string[]> {
|
||||
let pinned: string[] = [];
|
||||
await mutateOpenClawConfig((config) => {
|
||||
await withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
pinned = applyOpenClawProviderAgentRuntimePinsToConfig(config);
|
||||
|
||||
if (pinned.length > 0) {
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
}
|
||||
});
|
||||
return pinned;
|
||||
@@ -2089,7 +2087,8 @@ export async function syncProviderConfigToOpenClaw(
|
||||
modelId: string | undefined,
|
||||
override: RuntimeProviderConfigOverride
|
||||
): Promise<void> {
|
||||
await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||
|
||||
if (override.baseUrl && override.api) {
|
||||
@@ -2110,7 +2109,7 @@ export async function syncProviderConfigToOpenClaw(
|
||||
ensureOAuthPluginEnabled(config, provider);
|
||||
}
|
||||
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2178,7 +2177,9 @@ export async function syncOpenAiCompatibleImageRelay(params: {
|
||||
apiKey?: string;
|
||||
imageModelIds?: string[];
|
||||
}): Promise<void> {
|
||||
await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
if (!params.enabled) {
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
@@ -2195,24 +2196,15 @@ export async function syncOpenAiCompatibleImageRelay(params: {
|
||||
const primary = typeof imageGenerationModel?.primary === 'string'
|
||||
? imageGenerationModel.primary.trim().toLowerCase()
|
||||
: '';
|
||||
if (defaults && imageGenerationModel && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) {
|
||||
const remainingFallbacks = Array.isArray(imageGenerationModel.fallbacks)
|
||||
? imageGenerationModel.fallbacks.filter((fallback): fallback is string => (
|
||||
typeof fallback === 'string'
|
||||
&& !fallback.trim().toLowerCase().startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)
|
||||
))
|
||||
: [];
|
||||
if (remainingFallbacks.length > 0) {
|
||||
imageGenerationModel.primary = remainingFallbacks.shift();
|
||||
} else {
|
||||
delete imageGenerationModel.primary;
|
||||
}
|
||||
if (Array.isArray(imageGenerationModel.fallbacks)) {
|
||||
imageGenerationModel.fallbacks = remainingFallbacks;
|
||||
}
|
||||
if (defaults && primary.startsWith(`${CLAWX_OPENAI_IMAGE_PROVIDER_KEY}/`)) {
|
||||
delete defaults.imageGenerationModel;
|
||||
}
|
||||
removePluginRegistrations(config, [CLAWX_OPENAI_IMAGE_PROVIDER_KEY]);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
if (params.apiKey?.trim()) {
|
||||
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2223,12 +2215,6 @@ export async function syncOpenAiCompatibleImageRelay(params: {
|
||||
if (modelIds.length === 0) {
|
||||
modelIds.push(CLAWX_OPENAI_IMAGE_DEFAULT_MODEL);
|
||||
}
|
||||
const existingModels = readModelsProvider(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY)?.models;
|
||||
const existingModelsById = new Map(
|
||||
(Array.isArray(existingModels) ? existingModels : [])
|
||||
.filter((model): model is Record<string, unknown> => isPlainRecord(model) && typeof model.id === 'string')
|
||||
.map((model) => [model.id as string, model]),
|
||||
);
|
||||
upsertOpenClawProviderEntry(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY, {
|
||||
baseUrl,
|
||||
api: 'openai-completions',
|
||||
@@ -2236,24 +2222,13 @@ export async function syncOpenAiCompatibleImageRelay(params: {
|
||||
mergeExistingModels: false,
|
||||
request: { allowPrivateNetwork: true },
|
||||
});
|
||||
const relayProvider = readModelsProvider(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
if (relayProvider && Array.isArray(relayProvider.models)) {
|
||||
relayProvider.models = relayProvider.models.map((model) => {
|
||||
if (!isPlainRecord(model) || typeof model.id !== 'string') return model;
|
||||
const existing = existingModelsById.get(model.id);
|
||||
return existing ? { ...model, ...existing, id: model.id } : model;
|
||||
});
|
||||
}
|
||||
ensurePluginRegistrationEnabled(config, CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
});
|
||||
await writeOpenClawJson(config);
|
||||
|
||||
if (!params.enabled) {
|
||||
await removeProviderKeyFromOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
}
|
||||
if (params.apiKey?.trim()) {
|
||||
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
|
||||
}
|
||||
if (params.apiKey?.trim()) {
|
||||
await saveProviderKeyToOpenClaw(CLAWX_OPENAI_IMAGE_PROVIDER_KEY, params.apiKey.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function readOpenAiCompatibleImageRelayState(
|
||||
@@ -2284,7 +2259,8 @@ export async function setOpenClawDefaultModelWithOverride(
|
||||
override: RuntimeProviderConfigOverride,
|
||||
fallbackModels: string[] = []
|
||||
): Promise<void> {
|
||||
await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||
|
||||
const model = normalizeModelRef(provider, modelOverride);
|
||||
@@ -2328,7 +2304,7 @@ export async function setOpenClawDefaultModelWithOverride(
|
||||
ensureOAuthPluginEnabled(config, provider);
|
||||
}
|
||||
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
console.log(
|
||||
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
|
||||
);
|
||||
@@ -2343,24 +2319,67 @@ export async function setOpenClawDefaultModelWithOverride(
|
||||
// These may still linger in openclaw.json from older versions.
|
||||
const DEPRECATED_PROVIDER_IDS = new Set(['qwen-portal']);
|
||||
|
||||
export async function getActiveAuthProfileProviders(): Promise<Set<string>> {
|
||||
return await getProvidersFromAuthProfileStores({ includeRawKeys: true });
|
||||
}
|
||||
|
||||
export async function getActiveOpenClawProviders(): Promise<Set<string>> {
|
||||
const activeProviders = new Set<string>();
|
||||
|
||||
try {
|
||||
const [config, authProfileProviders] = await Promise.all([
|
||||
readOpenClawJson(),
|
||||
getActiveAuthProfileProviders(),
|
||||
]);
|
||||
return collectActiveProviderIdsFromConfig(
|
||||
config,
|
||||
authProfileProviders,
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
// 1. models.providers
|
||||
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
|
||||
if (providers && typeof providers === 'object') {
|
||||
for (const key of Object.keys(providers as Record<string, unknown>)) {
|
||||
activeProviders.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. plugins.entries for OAuth providers
|
||||
const plugins = (config.plugins as Record<string, unknown> | undefined)?.entries;
|
||||
if (plugins && typeof plugins === 'object') {
|
||||
for (const [pluginId, meta] of Object.entries(plugins as Record<string, unknown>)) {
|
||||
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
|
||||
activeProviders.add(pluginId.replace(/-auth$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. agents.defaults.model.primary — the default model reference encodes
|
||||
// the provider prefix (e.g. "modelstudio/qwen3.6-plus" → "modelstudio").
|
||||
// This covers providers that are active via OAuth or env-key but don't
|
||||
// have an explicit models.providers entry.
|
||||
const agents = config.agents as Record<string, unknown> | undefined;
|
||||
const defaults = agents?.defaults as Record<string, unknown> | undefined;
|
||||
const modelConfig = defaults?.model as Record<string, unknown> | undefined;
|
||||
const primaryModel = typeof modelConfig?.primary === 'string' ? modelConfig.primary : undefined;
|
||||
if (primaryModel?.includes('/')) {
|
||||
activeProviders.add(primaryModel.split('/')[0]);
|
||||
}
|
||||
|
||||
// 4. auth.profiles — OAuth/device-token based providers may exist only in
|
||||
// auth-profiles without explicit models.providers entries yet.
|
||||
// Raw keys (e.g. "openai-codex") are included so downstream logic can
|
||||
// distinguish OAuth runtime providers from their UI alias ("openai").
|
||||
const auth = config.auth as Record<string, unknown> | undefined;
|
||||
addProvidersFromProfileEntries(
|
||||
auth?.profiles as Record<string, unknown> | undefined,
|
||||
activeProviders,
|
||||
{ includeRawKeys: true },
|
||||
);
|
||||
|
||||
const authProfileProviders = await getProvidersFromAuthProfileStores({ includeRawKeys: true });
|
||||
for (const provider of authProfileProviders) {
|
||||
activeProviders.add(provider);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json for active providers:', err);
|
||||
return new Set();
|
||||
}
|
||||
|
||||
// Remove deprecated providers that may still linger in config/auth files.
|
||||
for (const deprecated of DEPRECATED_PROVIDER_IDS) {
|
||||
activeProviders.delete(deprecated);
|
||||
}
|
||||
|
||||
return activeProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2429,8 +2448,9 @@ function applyControlUiAllowedOrigins(controlUi: Record<string, unknown>, port:
|
||||
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
|
||||
*/
|
||||
export async function syncGatewayTokenToConfig(token: string): Promise<void> {
|
||||
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
|
||||
await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const gateway = (
|
||||
config.gateway && typeof config.gateway === 'object'
|
||||
? { ...(config.gateway as Record<string, unknown>) }
|
||||
@@ -2452,15 +2472,16 @@ export async function syncGatewayTokenToConfig(token: string): Promise<void> {
|
||||
? { ...(gateway.controlUi as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
|
||||
applyControlUiAllowedOrigins(controlUi, gatewayPort);
|
||||
gateway.controlUi = controlUi;
|
||||
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced gateway token to openclaw.json');
|
||||
});
|
||||
console.log('Synced gateway token to openclaw.json');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2514,7 +2535,9 @@ function ensureWebFetchSsrfPolicyInConfig(config: Record<string, unknown>): bool
|
||||
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
|
||||
*/
|
||||
export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||
await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const browser = (
|
||||
config.browser && typeof config.browser === 'object'
|
||||
? { ...(config.browser as Record<string, unknown>) }
|
||||
@@ -2550,7 +2573,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||
if (!changed) return;
|
||||
|
||||
config.browser = browser;
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced browser and web_fetch config to openclaw.json');
|
||||
});
|
||||
}
|
||||
@@ -2568,7 +2591,9 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||
export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
|
||||
const DEFAULT_IDLE_MINUTES = 10_080; // 7 days
|
||||
|
||||
await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const session = (
|
||||
config.session && typeof config.session === 'object'
|
||||
? { ...(config.session as Record<string, unknown>) }
|
||||
@@ -2587,36 +2612,22 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
|
||||
session.idleMinutes = DEFAULT_IDLE_MINUTES;
|
||||
config.session = session;
|
||||
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
console.log(`Synced session.idleMinutes=${DEFAULT_IDLE_MINUTES} (7d) to openclaw.json`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-apply gateway token, browser config, and session idle minutes in a
|
||||
* single coordinator transaction. Replaces three separate config mutations
|
||||
* during pre-launch sync.
|
||||
* single config lock + read + write cycle. Replaces three separate
|
||||
* withConfigLock calls during pre-launch sync.
|
||||
*/
|
||||
export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
const DEFAULT_IDLE_MINUTES = 10_080; // 7 days
|
||||
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
|
||||
const memorySearchMigrationVersion = Number(
|
||||
await getSetting('memorySearchFtsMigrationVersion'),
|
||||
) || 0;
|
||||
const shouldMigrateLegacyMemorySearch =
|
||||
memorySearchMigrationVersion < MEMORY_SEARCH_FTS_MIGRATION_VERSION;
|
||||
const hasOpenAiEmbeddingKey = Boolean(await getProviderApiKeyFromOpenClaw('openai'));
|
||||
let pinnedProviderRuntimes: string[] = [];
|
||||
let compactionLog: string | undefined;
|
||||
let memorySearchDefaultResult: 'migrated' | 'seeded' | 'unchanged' = 'unchanged';
|
||||
let backfilledContextWindows: string[] = [];
|
||||
|
||||
const changed = await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawJson();
|
||||
let modified = true;
|
||||
pinnedProviderRuntimes = [];
|
||||
compactionLog = undefined;
|
||||
memorySearchDefaultResult = 'unchanged';
|
||||
backfilledContextWindows = [];
|
||||
|
||||
// ── Gateway token + controlUi ──
|
||||
const gateway = (
|
||||
@@ -2639,6 +2650,7 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
? { ...(gateway.controlUi as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
const gatewayPort = (await getSetting('gatewayPort')) || PORTS.OPENCLAW_GATEWAY;
|
||||
applyControlUiAllowedOrigins(controlUi, gatewayPort);
|
||||
gateway.controlUi = controlUi;
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
@@ -2679,9 +2691,10 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
|
||||
const pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config);
|
||||
if (pinnedProviderRuntimes.length > 0) {
|
||||
modified = true;
|
||||
console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`);
|
||||
}
|
||||
|
||||
// ── Session idle minutes ──
|
||||
@@ -2703,65 +2716,58 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
// ── Compaction safeguard default ──
|
||||
if (ensureCompactionSafeguardDefault(config)) {
|
||||
modified = true;
|
||||
compactionLog = `[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`;
|
||||
console.log(`[batch-sync] Seeded agents.defaults.compaction.mode=safeguard reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
|
||||
} else if (backfillCompactionReserveTokensFloor(config)) {
|
||||
modified = true;
|
||||
compactionLog = `[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`;
|
||||
console.log(`[batch-sync] Backfilled agents.defaults.compaction.reserveTokensFloor=${DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR}`);
|
||||
}
|
||||
|
||||
// ── Memory search default ──
|
||||
// OpenClaw 2026.7.1 supports provider=none as an explicit FTS-only mode.
|
||||
// Migrate ClawX's exact legacy disabled default once, and otherwise seed
|
||||
// FTS only when the user has no memorySearch config or OpenAI embedding key.
|
||||
memorySearchDefaultResult = shouldMigrateLegacyMemorySearch
|
||||
const memorySearchMigrationVersion = Number(
|
||||
await getSetting('memorySearchFtsMigrationVersion'),
|
||||
) || 0;
|
||||
const shouldMigrateLegacyMemorySearch =
|
||||
memorySearchMigrationVersion < MEMORY_SEARCH_FTS_MIGRATION_VERSION;
|
||||
let memorySearchDefaultResult = shouldMigrateLegacyMemorySearch
|
||||
&& hasUserMemorySearchConfig(config)
|
||||
? ensureMemorySearchFtsDefault(config, true)
|
||||
: 'unchanged';
|
||||
|
||||
if (memorySearchDefaultResult === 'unchanged'
|
||||
&& !hasUserMemorySearchConfig(config)
|
||||
&& !hasOpenAiEmbeddingKey) {
|
||||
&& !(await getProviderApiKeyFromOpenClaw('openai'))) {
|
||||
memorySearchDefaultResult = ensureMemorySearchFtsDefault(config);
|
||||
}
|
||||
|
||||
if (memorySearchDefaultResult !== 'unchanged') {
|
||||
modified = true;
|
||||
console.log(
|
||||
`[batch-sync] ${memorySearchDefaultResult === 'migrated' ? 'Migrated' : 'Seeded'} `
|
||||
+ 'agents.defaults.memorySearch to FTS-only mode',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Custom provider contextWindow backfill ──
|
||||
backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
|
||||
const backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
|
||||
if (backfilledContextWindows.length > 0) {
|
||||
modified = true;
|
||||
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
|
||||
}
|
||||
if (shouldMigrateLegacyMemorySearch) {
|
||||
await setSetting(
|
||||
'memorySearchFtsMigrationVersion',
|
||||
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
|
||||
);
|
||||
}
|
||||
});
|
||||
if (pinnedProviderRuntimes.length > 0) {
|
||||
console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`);
|
||||
}
|
||||
if (compactionLog) {
|
||||
console.log(compactionLog);
|
||||
}
|
||||
if (memorySearchDefaultResult !== 'unchanged') {
|
||||
console.log(
|
||||
`[batch-sync] ${memorySearchDefaultResult === 'migrated' ? 'Migrated' : 'Seeded'} `
|
||||
+ 'agents.defaults.memorySearch to FTS-only mode',
|
||||
);
|
||||
}
|
||||
if (backfilledContextWindows.length > 0) {
|
||||
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
|
||||
}
|
||||
if (changed) {
|
||||
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
|
||||
}
|
||||
if (shouldMigrateLegacyMemorySearch) {
|
||||
await setSetting(
|
||||
'memorySearchFtsMigrationVersion',
|
||||
MEMORY_SEARCH_FTS_MIGRATION_VERSION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2888,7 +2894,6 @@ export async function updateSingleAgentModelProvider(
|
||||
* (`runOpenClawDoctorRepair`) runs `openclaw doctor --fix` as a fallback.
|
||||
*/
|
||||
const SKILL_WORKSHOP_TOOL_DENY_ENTRY = 'skill_workshop';
|
||||
const WEB_SEARCH_TOOL_DENY_ENTRY = 'web_search';
|
||||
const SKILL_CREATOR_SKILL_KEY = 'skill-creator';
|
||||
|
||||
function normalizeToolDenyList(value: unknown): string[] {
|
||||
@@ -2908,22 +2913,25 @@ function ensureToolDenyIncludes(
|
||||
}
|
||||
|
||||
export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
// The prelaunch file fallback must not turn a missing or corrupt config into
|
||||
// a valid-looking skeleton. The coordinator performs the successful mutation.
|
||||
let sourceExists: boolean;
|
||||
try {
|
||||
sourceExists = (await readOpenClawConfigSnapshot()).exists;
|
||||
} catch {
|
||||
console.log('[sanitize] openclaw.json could not be parsed, skipping sanitization to preserve data');
|
||||
return;
|
||||
}
|
||||
if (!sourceExists) {
|
||||
console.log('[sanitize] openclaw.json does not exist yet, skipping sanitization');
|
||||
return;
|
||||
}
|
||||
const authProfileProviders = await getActiveAuthProfileProviders();
|
||||
return withConfigLock(async () => {
|
||||
// Skip sanitization if the config file does not exist yet.
|
||||
// Creating a skeleton config here would overwrite any data written
|
||||
// by the Gateway on its first run.
|
||||
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
|
||||
console.log('[sanitize] openclaw.json does not exist yet, skipping sanitization');
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateOpenClawConfig(async (config) => {
|
||||
// Read the raw file directly instead of going through readOpenClawJson()
|
||||
// which coalesces null → {}. We need to distinguish a genuinely empty
|
||||
// file (valid, proceed normally) from a corrupt/unreadable file (null,
|
||||
// bail out to avoid overwriting the user's data with a skeleton config).
|
||||
const rawConfig = await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH);
|
||||
if (rawConfig === null) {
|
||||
console.log('[sanitize] openclaw.json could not be parsed, skipping sanitization to preserve data');
|
||||
return;
|
||||
}
|
||||
const config: Record<string, unknown> = rawConfig;
|
||||
let modified = false;
|
||||
|
||||
// ── skills section ──────────────────────────────────────────────
|
||||
@@ -3014,6 +3022,20 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── commands section ───────────────────────────────────────────
|
||||
// Required for SIGUSR1 in-process reload authorization.
|
||||
const commands = (
|
||||
config.commands && typeof config.commands === 'object'
|
||||
? { ...(config.commands as Record<string, unknown>) }
|
||||
: {}
|
||||
) as Record<string, unknown>;
|
||||
if (commands.restart !== true) {
|
||||
commands.restart = true;
|
||||
config.commands = commands;
|
||||
modified = true;
|
||||
console.log('[sanitize] Enabling commands.restart for graceful reload support');
|
||||
}
|
||||
|
||||
// ── tools.web.search.kimi ─────────────────────────────────────
|
||||
// OpenClaw moved moonshot web search config under
|
||||
// plugins.entries.moonshot.config.webSearch. Migrate the old key and strip
|
||||
@@ -3089,24 +3111,6 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
toolsModified = true;
|
||||
}
|
||||
|
||||
// ClawX uses the managed browser and web_fetch for explicit navigation,
|
||||
// but does not expose general-purpose internet search to agents.
|
||||
const webSearchDenyResult = ensureToolDenyIncludes(
|
||||
normalizeToolDenyList(toolsConfig.deny),
|
||||
WEB_SEARCH_TOOL_DENY_ENTRY,
|
||||
);
|
||||
if (webSearchDenyResult.modified) {
|
||||
toolsConfig.deny = webSearchDenyResult.deny;
|
||||
toolsModified = true;
|
||||
console.log('[sanitize] Added "web_search" to tools.deny for ClawX desktop');
|
||||
} else if (
|
||||
!Array.isArray(toolsConfig.deny)
|
||||
|| toolsConfig.deny.length !== webSearchDenyResult.deny.length
|
||||
) {
|
||||
toolsConfig.deny = webSearchDenyResult.deny;
|
||||
toolsModified = true;
|
||||
}
|
||||
|
||||
// ── tools.exec approvals (OpenClaw 3.28+) ──────────────────────
|
||||
// ClawX is a local desktop app where the user is the trusted operator.
|
||||
// Exec approval prompts add unnecessary friction in this context, so we
|
||||
@@ -3168,22 +3172,6 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
gatewayTools.deny = gatewayDenyResult.deny;
|
||||
gatewayModified = true;
|
||||
}
|
||||
const gatewayWebSearchDenyResult = ensureToolDenyIncludes(
|
||||
normalizeToolDenyList(gatewayTools.deny),
|
||||
WEB_SEARCH_TOOL_DENY_ENTRY,
|
||||
);
|
||||
if (gatewayWebSearchDenyResult.modified) {
|
||||
gatewayTools.deny = gatewayWebSearchDenyResult.deny;
|
||||
gatewayModified = true;
|
||||
console.log('[sanitize] Added "web_search" to gateway.tools.deny for ClawX desktop');
|
||||
} else if (
|
||||
!Array.isArray(gatewayTools.deny)
|
||||
|| gatewayTools.deny.length !== gatewayWebSearchDenyResult.deny.length
|
||||
) {
|
||||
gatewayTools.deny = gatewayWebSearchDenyResult.deny;
|
||||
gatewayModified = true;
|
||||
}
|
||||
|
||||
if (gatewayModified) {
|
||||
gateway.tools = gatewayTools;
|
||||
config.gateway = gateway;
|
||||
@@ -3524,7 +3512,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
const bundled = discoverBundledPlugins();
|
||||
const installedExtensionIds = await discoverInstalledExtensionPluginIds();
|
||||
const loadedPluginIds = await discoverLoadedPluginIdsFromConfig(config);
|
||||
const activeProviderIds = collectActiveProviderIdsFromConfig(config, authProfileProviders);
|
||||
const activeProviderIds = await collectActiveProviderIdsFromConfig(config);
|
||||
|
||||
const explicitlyEnabledBundledPluginIds = Object.keys(pEntries)
|
||||
.filter((pluginId) => {
|
||||
@@ -3733,7 +3721,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
normalizeAgentsDefaultsCompactionMode(config);
|
||||
await writeOpenClawJson(config);
|
||||
console.log('[sanitize] openclaw.json sanitized successfully');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Read/write agents.defaults.imageGenerationModel and per-agent auth readiness.
|
||||
*/
|
||||
import { mutateOpenClawConfig } from '../gateway/config-delivery';
|
||||
import { readOpenClawConfig } from './channel-config';
|
||||
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import {
|
||||
getOAuthTokenFromOpenClaw,
|
||||
getProviderApiKeyFromOpenClaw,
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
syncOpenAiCompatibleImageRelay,
|
||||
} from './openclaw-auth';
|
||||
import { ensureClawXOpenAiImagePluginInstalled } from './plugin-install';
|
||||
import {
|
||||
listAgentsSnapshot,
|
||||
listAgentsSnapshotFromConfig,
|
||||
type AgentsSnapshot,
|
||||
} from './agent-config';
|
||||
import { listAgentsSnapshot, type AgentsSnapshot } from './agent-config';
|
||||
import { expandPath } from './paths';
|
||||
import {
|
||||
generateImageInProcess,
|
||||
@@ -88,7 +84,6 @@ type AgentModelConfigShape = {
|
||||
primary?: string;
|
||||
fallbacks?: string[];
|
||||
timeoutMs?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -135,12 +130,12 @@ function parseImageGenerationModelConfig(raw: unknown): ImageGenerationModelConf
|
||||
|
||||
function buildImageGenerationModelConfigWrite(
|
||||
config: ImageGenerationModelConfig,
|
||||
existing: unknown,
|
||||
): AgentModelConfigShape | undefined {
|
||||
const next: AgentModelConfigShape = isRecord(existing) ? { ...existing } : {};
|
||||
delete next.primary;
|
||||
delete next.fallbacks;
|
||||
delete next.timeoutMs;
|
||||
if (!config.primary && config.fallbacks.length === 0 && config.timeoutMs === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const next: AgentModelConfigShape = {};
|
||||
if (config.primary) {
|
||||
next.primary = config.primary;
|
||||
}
|
||||
@@ -150,7 +145,7 @@ function buildImageGenerationModelConfigWrite(
|
||||
if (config.timeoutMs !== null) {
|
||||
next.timeoutMs = config.timeoutMs;
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function parseProviderFromModelRef(modelRef: string): string | null {
|
||||
@@ -212,8 +207,8 @@ export async function setImageGenerationConfig(
|
||||
}
|
||||
}
|
||||
|
||||
let savedConfig: ImageGenerationModelConfig | undefined;
|
||||
await mutateOpenClawConfig((config) => {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig();
|
||||
const agents = (config.agents && typeof config.agents === 'object'
|
||||
? { ...(config.agents as Record<string, unknown>) }
|
||||
: {}) as Record<string, unknown>;
|
||||
@@ -225,7 +220,7 @@ export async function setImageGenerationConfig(
|
||||
primary: next.primary,
|
||||
fallbacks: [...new Set(next.fallbacks.map((ref) => ref.trim()).filter(Boolean))],
|
||||
timeoutMs: next.timeoutMs,
|
||||
}, defaults.imageGenerationModel);
|
||||
});
|
||||
|
||||
if (writeValue) {
|
||||
defaults.imageGenerationModel = writeValue;
|
||||
@@ -239,9 +234,10 @@ export async function setImageGenerationConfig(
|
||||
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
savedConfig = parseImageGenerationModelConfig(defaults.imageGenerationModel);
|
||||
await writeOpenClawConfig(config);
|
||||
|
||||
return readImageGenerationConfig();
|
||||
});
|
||||
return savedConfig!;
|
||||
}
|
||||
|
||||
async function buildAgentAuthRows(
|
||||
@@ -320,10 +316,10 @@ function resolveOpenAiImageRelayModelId(
|
||||
}
|
||||
|
||||
export async function getImageGenerationSettingsSnapshot(): Promise<ImageGenerationSettingsSnapshot> {
|
||||
const config = await readImageGenerationConfig();
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
const openclawConfig = await readOpenClawConfig();
|
||||
const defaults = getAgentsDefaults(openclawConfig);
|
||||
const config = parseImageGenerationModelConfig(defaults?.imageGenerationModel);
|
||||
const snapshot = await listAgentsSnapshotFromConfig(openclawConfig);
|
||||
const autoProviderFallback = defaults?.mediaGenerationAutoProviderFallback !== false;
|
||||
|
||||
const providerKey = config.primary ? parseProviderFromModelRef(config.primary) : null;
|
||||
@@ -352,12 +348,6 @@ export async function applyOpenAiImageRelaySettings(params: {
|
||||
apiKey?: string;
|
||||
model?: string | null;
|
||||
}): Promise<void> {
|
||||
if (params.enabled) {
|
||||
const plugin = await ensureClawXOpenAiImagePluginInstalled();
|
||||
if (!plugin.installed) {
|
||||
throw new Error(plugin.warning || 'Failed to install ClawX OpenAI Image plugin');
|
||||
}
|
||||
}
|
||||
const imageModelIds: string[] = [];
|
||||
const explicitModel = params.model?.trim();
|
||||
if (explicitModel) {
|
||||
@@ -374,11 +364,14 @@ export async function applyOpenAiImageRelaySettings(params: {
|
||||
apiKey: params.apiKey,
|
||||
imageModelIds,
|
||||
});
|
||||
if (params.enabled) {
|
||||
ensureClawXOpenAiImagePluginInstalled();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listImageGenerationProvidersFromRuntime(): Promise<ImageGenerationProviderRow[]> {
|
||||
const cfg = await readOpenClawConfig();
|
||||
const snapshot = await listAgentsSnapshotFromConfig(cfg);
|
||||
const snapshot = await listAgentsSnapshot();
|
||||
const rows = await listImageGenerationProvidersInProcess({
|
||||
config: cfg,
|
||||
isProviderConfigured: (providerId) => isImageProviderAuthenticated(providerId, snapshot.defaultAgentId),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mutateOpenClawConfig } from '../gateway/config-delivery';
|
||||
import type { OpenClawConfig } from './channel-config';
|
||||
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { resolveProxySettings, type ProxySettings } from './proxy';
|
||||
import { logger } from './logger';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
|
||||
interface SyncProxyOptions {
|
||||
/**
|
||||
@@ -19,26 +19,23 @@ export async function syncProxyConfigToOpenClaw(
|
||||
settings: ProxySettings,
|
||||
options: SyncProxyOptions = {},
|
||||
): Promise<void> {
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false;
|
||||
const nextProxy = settings.proxyEnabled
|
||||
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
|
||||
: '';
|
||||
const syncState: { result: 'unchanged' | 'preserved' | 'updated' } = { result: 'unchanged' };
|
||||
|
||||
await mutateOpenClawConfig((snapshot) => {
|
||||
syncState.result = 'unchanged';
|
||||
const config = snapshot as OpenClawConfig;
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig();
|
||||
const telegramConfig = config.channels?.telegram;
|
||||
|
||||
if (!telegramConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const preserveExistingWhenDisabled = options.preserveExistingWhenDisabled !== false;
|
||||
const nextProxy = settings.proxyEnabled
|
||||
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
|
||||
: '';
|
||||
const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : '';
|
||||
|
||||
if (!settings.proxyEnabled && preserveExistingWhenDisabled && currentProxy) {
|
||||
syncState.result = 'preserved';
|
||||
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,12 +57,7 @@ export async function syncProxyConfigToOpenClaw(
|
||||
delete config.channels.telegram.proxy;
|
||||
}
|
||||
|
||||
syncState.result = 'updated';
|
||||
});
|
||||
|
||||
if (syncState.result === 'preserved') {
|
||||
logger.info('Skipped Telegram proxy sync because ClawX proxy is disabled and preserve mode is enabled');
|
||||
} else if (syncState.result === 'updated') {
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
removePluginInstallRecordsFromSqlite,
|
||||
ensureOpenClawStateDirExists,
|
||||
} from './plugin-install-index';
|
||||
import { mutateOpenClawConfig } from '../gateway/config-delivery';
|
||||
|
||||
function normalizeFsPathForWindows(filePath: string): string {
|
||||
if (process.platform !== 'win32') return filePath;
|
||||
@@ -265,6 +264,8 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
|
||||
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
|
||||
};
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
/**
|
||||
* Channel plugins whose ClawX-managed mirrors need synchronized install
|
||||
* metadata. OpenClaw 2026.6+ reads these records from SQLite for trust checks;
|
||||
@@ -380,30 +381,33 @@ function pluginInstallRecordIds(pluginDirName: string): string[] {
|
||||
].filter((value): value is string => Boolean(value)))];
|
||||
}
|
||||
|
||||
async function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): Promise<boolean> {
|
||||
const removedIds = new Set<string>();
|
||||
const changed = await mutateOpenClawConfig((config) => {
|
||||
removedIds.clear();
|
||||
const plugins = config.plugins;
|
||||
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return;
|
||||
const pluginsRecord = plugins as Record<string, unknown>;
|
||||
const installs = pluginsRecord.installs;
|
||||
if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return;
|
||||
function removeLegacyPluginInstallMetadataFromConfig(pluginIds: string[]): boolean {
|
||||
if (!existsSync(fsPath(OPENCLAW_CONFIG_PATH))) return false;
|
||||
|
||||
const installsRecord = installs as Record<string, unknown>;
|
||||
for (const pluginId of pluginIds) {
|
||||
if (!Object.hasOwn(installsRecord, pluginId)) continue;
|
||||
delete installsRecord[pluginId];
|
||||
removedIds.add(pluginId);
|
||||
}
|
||||
if (removedIds.size > 0 && Object.keys(installsRecord).length === 0) {
|
||||
delete pluginsRecord.installs;
|
||||
}
|
||||
});
|
||||
if (removedIds.size > 0) {
|
||||
logger.info(`[plugin] Removed legacy config install metadata for: ${[...removedIds].join(', ')}`);
|
||||
const raw = readFileSync(fsPath(OPENCLAW_CONFIG_PATH), 'utf-8');
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
const plugins = config.plugins;
|
||||
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return false;
|
||||
const pluginsRecord = plugins as Record<string, unknown>;
|
||||
const installs = pluginsRecord.installs;
|
||||
if (!installs || typeof installs !== 'object' || Array.isArray(installs)) return false;
|
||||
|
||||
const installsRecord = installs as Record<string, unknown>;
|
||||
const removedIds = pluginIds.filter((pluginId) => Object.hasOwn(installsRecord, pluginId));
|
||||
if (removedIds.length === 0) return false;
|
||||
for (const pluginId of removedIds) {
|
||||
delete installsRecord[pluginId];
|
||||
}
|
||||
return changed;
|
||||
if (Object.keys(installsRecord).length === 0) {
|
||||
delete pluginsRecord.installs;
|
||||
}
|
||||
writeFileSync(
|
||||
fsPath(OPENCLAW_CONFIG_PATH),
|
||||
`${JSON.stringify(config, null, 2)}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
logger.info(`[plugin] Removed legacy config install metadata for: ${removedIds.join(', ')}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function canonicalComparablePath(filePath: string): string {
|
||||
@@ -520,10 +524,10 @@ function persistTrustedOfficialPluginInstallRecordsToSqlite(
|
||||
* migration input, so remove that transient copy instead of recreating it.
|
||||
* Safe to call repeatedly; no-ops when metadata is already current.
|
||||
*/
|
||||
export async function syncTrustedOfficialPluginInstallRecord(
|
||||
export function syncTrustedOfficialPluginInstallRecord(
|
||||
pluginDirName: string,
|
||||
targetDir: string,
|
||||
): Promise<boolean> {
|
||||
): boolean {
|
||||
const expected = buildTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
if (!expected) return false;
|
||||
|
||||
@@ -540,7 +544,7 @@ export async function syncTrustedOfficialPluginInstallRecord(
|
||||
let jsonChanged = false;
|
||||
try {
|
||||
ensureOpenClawStateDirExists();
|
||||
jsonChanged = await removeLegacyPluginInstallMetadataFromConfig(recordIds);
|
||||
jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds);
|
||||
} catch (error) {
|
||||
// Keep the canonical SQLite repair available even if legacy config cleanup
|
||||
// cannot be completed in this pass.
|
||||
@@ -563,13 +567,13 @@ export async function syncTrustedOfficialPluginInstallRecord(
|
||||
* run even when its extension directory is already missing: stale records are
|
||||
* themselves enough to fail OpenClaw's post-core payload smoke check.
|
||||
*/
|
||||
export async function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): Promise<boolean> {
|
||||
export function removeTrustedOfficialPluginInstallRecord(pluginDirName: string): boolean {
|
||||
const recordIds = pluginInstallRecordIds(pluginDirName);
|
||||
if (recordIds.length === 0) return false;
|
||||
|
||||
let jsonChanged = false;
|
||||
try {
|
||||
jsonChanged = await removeLegacyPluginInstallMetadataFromConfig(recordIds);
|
||||
jsonChanged = removeLegacyPluginInstallMetadataFromConfig(recordIds);
|
||||
} catch (error) {
|
||||
logger.warn(`[plugin] Failed to remove stale config install metadata for ${pluginDirName}:`, error);
|
||||
}
|
||||
@@ -578,13 +582,13 @@ export async function removeTrustedOfficialPluginInstallRecord(pluginDirName: st
|
||||
}
|
||||
|
||||
/** Repair managed install metadata and host peer links for all mirrors on disk. */
|
||||
export async function repairTrustedOfficialPluginInstallRecords(): Promise<void> {
|
||||
export function repairTrustedOfficialPluginInstallRecords(): void {
|
||||
for (const pluginDirName of Object.keys(TRUSTED_OFFICIAL_EXTENSION_PLUGINS)) {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
|
||||
if (!existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
|
||||
continue;
|
||||
}
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,11 +727,11 @@ export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string,
|
||||
|
||||
// ── Core install / upgrade logic ─────────────────────────────────────────────
|
||||
|
||||
export async function ensurePluginInstalled(
|
||||
export function ensurePluginInstalled(
|
||||
pluginDirName: string,
|
||||
candidateSources: string[],
|
||||
pluginLabel: string,
|
||||
): Promise<{ installed: boolean; warning?: string }> {
|
||||
): { installed: boolean; warning?: string } {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName);
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
const targetPkgJson = join(targetDir, 'package.json');
|
||||
@@ -737,13 +741,13 @@ export async function ensurePluginInstalled(
|
||||
// If already installed, check whether an upgrade is available
|
||||
if (existsSync(fsPath(targetManifest))) {
|
||||
if (!sourceDir) {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // no bundled source to compare, keep existing
|
||||
}
|
||||
const installedVersion = readPluginVersion(targetPkgJson);
|
||||
const sourceVersion = readPluginVersion(join(sourceDir, 'package.json'));
|
||||
if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // same version or unable to compare
|
||||
}
|
||||
// Version differs — fall through to overwrite install
|
||||
@@ -767,7 +771,7 @@ export async function ensurePluginInstalled(
|
||||
return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` };
|
||||
}
|
||||
fixupPluginManifest(targetDir);
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
|
||||
return { installed: true };
|
||||
} catch (error) {
|
||||
@@ -816,7 +820,7 @@ export async function ensurePluginInstalled(
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
if (existsSync(fsPath(join(targetDir, 'openclaw.plugin.json')))) {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true };
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -834,7 +838,7 @@ export async function ensurePluginInstalled(
|
||||
);
|
||||
}
|
||||
} else if (existsSync(fsPath(targetManifest))) {
|
||||
await syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(pluginDirName, targetDir);
|
||||
return { installed: true }; // same version, already installed
|
||||
}
|
||||
}
|
||||
@@ -870,15 +874,15 @@ export function buildCandidateSources(pluginDirName: string): string[] {
|
||||
|
||||
// ── Per-channel plugin helpers ───────────────────────────────────────────────
|
||||
|
||||
export function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureDingTalkPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk');
|
||||
}
|
||||
|
||||
export function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureWeComPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom');
|
||||
}
|
||||
|
||||
export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled(
|
||||
'feishu-openclaw-plugin',
|
||||
buildCandidateSources('feishu-openclaw-plugin'),
|
||||
@@ -888,23 +892,23 @@ export function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; war
|
||||
|
||||
|
||||
|
||||
export function ensureWeChatPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
|
||||
}
|
||||
|
||||
export function ensureDiscordPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureDiscordPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('discord', buildCandidateSources('discord'), 'Discord');
|
||||
}
|
||||
|
||||
export function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQBot');
|
||||
}
|
||||
|
||||
export function ensureWhatsAppPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureWhatsAppPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('whatsapp', buildCandidateSources('whatsapp'), 'WhatsApp');
|
||||
}
|
||||
|
||||
export function ensureClawXOpenAiImagePluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
export function ensureClawXOpenAiImagePluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled(
|
||||
'clawx-openai-image',
|
||||
buildCandidateSources('clawx-openai-image'),
|
||||
@@ -937,7 +941,7 @@ const ALL_BUNDLED_PLUGINS = [
|
||||
export async function ensureAllBundledPluginsInstalled(): Promise<void> {
|
||||
for (const { fn, label } of ALL_BUNDLED_PLUGINS) {
|
||||
try {
|
||||
const result = await fn();
|
||||
const result = fn();
|
||||
if (result.warning) {
|
||||
logger.warn(`[plugin] ${label}: ${result.warning}`);
|
||||
}
|
||||
@@ -945,5 +949,5 @@ export async function ensureAllBundledPluginsInstalled(): Promise<void> {
|
||||
logger.warn(`[plugin] Failed to install/upgrade ${label} plugin:`, error);
|
||||
}
|
||||
}
|
||||
await repairTrustedOfficialPluginInstallRecords();
|
||||
repairTrustedOfficialPluginInstallRecords();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Skill Config Utilities
|
||||
* Skill configuration reads and coordinated mutations for openclaw.json.
|
||||
* Direct read/write access to skill configuration in ~/.openclaw/openclaw.json
|
||||
* This bypasses the Gateway RPC for faster and more reliable config updates.
|
||||
*
|
||||
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { readFile, writeFile, access, mkdir, readdir, rm } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getOpenClawDir, getOpenClawResolvedDir, getResourcesDir } from './paths';
|
||||
import { logger } from './logger';
|
||||
import { cpAsyncSafe } from './plugin-install';
|
||||
import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const BUNDLED_OPENCLAW_SKILL_ALLOWLIST = new Set(['skill-creator']);
|
||||
|
||||
export interface SkillConfigUpdates {
|
||||
@@ -55,35 +60,52 @@ interface PreinstalledMarker {
|
||||
installedAt: string;
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current OpenClaw config
|
||||
*/
|
||||
async function readConfig(): Promise<OpenClawConfig> {
|
||||
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return (await readOpenClawConfigSnapshot()).config as OpenClawConfig;
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error('Failed to read openclaw config:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the OpenClaw config
|
||||
*/
|
||||
async function writeConfig(config: OpenClawConfig): Promise<void> {
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
|
||||
}
|
||||
|
||||
async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<void> {
|
||||
if (skillKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
await mutateOpenClawConfig((config) => {
|
||||
const skillConfig = config as OpenClawConfig;
|
||||
if (!skillConfig.skills) {
|
||||
skillConfig.skills = {};
|
||||
return withConfigLock(async () => {
|
||||
const config = await readConfig();
|
||||
if (!config.skills) {
|
||||
config.skills = {};
|
||||
}
|
||||
if (!skillConfig.skills.entries) {
|
||||
skillConfig.skills.entries = {};
|
||||
if (!config.skills.entries) {
|
||||
config.skills.entries = {};
|
||||
}
|
||||
for (const skillKey of skillKeys) {
|
||||
const entry = skillConfig.skills.entries[skillKey] || {};
|
||||
const entry = config.skills.entries[skillKey] || {};
|
||||
entry.enabled = enabled;
|
||||
skillConfig.skills.entries[skillKey] = entry;
|
||||
config.skills.entries[skillKey] = entry;
|
||||
}
|
||||
await writeConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -187,10 +209,12 @@ export async function updateSkillConfigs(
|
||||
updates: Array<{ skillKey: string } & SkillConfigUpdates>,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await mutateOpenClawConfig(async (config) => {
|
||||
await applySkillConfigUpdates(config as OpenClawConfig, updates);
|
||||
return await withConfigLock(async () => {
|
||||
const config = await readConfig();
|
||||
await applySkillConfigUpdates(config, updates);
|
||||
await writeConfig(config);
|
||||
return { success: true };
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to update skill config:', err);
|
||||
return { success: false, error: String(err) };
|
||||
@@ -203,25 +227,25 @@ export async function removeSkillConfig(skillKey: string): Promise<{ success: bo
|
||||
|
||||
export async function removeSkillConfigs(skillKeys: string[]): Promise<{ success: boolean; removed: number; error?: string }> {
|
||||
try {
|
||||
const normalizedSkillKeys = skillKeys
|
||||
.map((skillKey) => skillKey.trim())
|
||||
.filter(Boolean);
|
||||
let removed = 0;
|
||||
return await withConfigLock(async () => {
|
||||
const config = await readConfig();
|
||||
const existingEntries = config.skills?.entries || {};
|
||||
const normalizedSkillKeys = skillKeys
|
||||
.map((skillKey) => skillKey.trim())
|
||||
.filter(Boolean);
|
||||
const removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length;
|
||||
|
||||
await mutateOpenClawConfig(async (config) => {
|
||||
const skillConfig = config as OpenClawConfig;
|
||||
const existingEntries = skillConfig.skills?.entries || {};
|
||||
removed = normalizedSkillKeys.filter((skillKey) => Object.prototype.hasOwnProperty.call(existingEntries, skillKey)).length;
|
||||
if (removed === 0) {
|
||||
return;
|
||||
return { success: true, removed: 0 };
|
||||
}
|
||||
|
||||
await applySkillConfigUpdates(
|
||||
skillConfig,
|
||||
config,
|
||||
normalizedSkillKeys.map((skillKey) => ({ skillKey, remove: true })),
|
||||
);
|
||||
await writeConfig(config);
|
||||
return { success: true, removed };
|
||||
});
|
||||
return { success: true, removed };
|
||||
} catch (err) {
|
||||
console.error('Failed to remove skill configs:', err);
|
||||
return { success: false, removed: 0, error: String(err) };
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { readOpenClawConfigSnapshot } from '../gateway/config-delivery';
|
||||
import { normalizeOpenClawAccountId } from './channel-alias';
|
||||
import { resolveOpenClawRuntimeModulePath } from './runtime-package-resolution';
|
||||
|
||||
@@ -209,9 +209,18 @@ function isLoginFresh(login: ActiveLogin): boolean {
|
||||
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
|
||||
}
|
||||
|
||||
async function loadWeChatRouteTag(accountId?: string): Promise<string | undefined> {
|
||||
function resolveConfigPath(): string {
|
||||
const envPath = process.env.OPENCLAW_CONFIG?.trim();
|
||||
if (envPath) return envPath;
|
||||
return join(OPENCLAW_DIR, 'openclaw.json');
|
||||
}
|
||||
|
||||
function loadWeChatRouteTag(accountId?: string): string | undefined {
|
||||
try {
|
||||
const parsed = (await readOpenClawConfigSnapshot()).config as {
|
||||
const configPath = resolveConfigPath();
|
||||
if (!existsSync(configPath)) return undefined;
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as {
|
||||
channels?: Record<string, {
|
||||
routeTag?: string | number;
|
||||
accounts?: Record<string, { routeTag?: string | number }>;
|
||||
@@ -237,7 +246,7 @@ async function fetchWeChatQrCode(apiBaseUrl: string, accountId?: string, botType
|
||||
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
|
||||
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
|
||||
const headers: Record<string, string> = {};
|
||||
const routeTag = await loadWeChatRouteTag(accountId);
|
||||
const routeTag = loadWeChatRouteTag(accountId);
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
@@ -256,7 +265,7 @@ async function pollWeChatQrStatus(apiBaseUrl: string, qrcode: string, accountId?
|
||||
const headers: Record<string, string> = {
|
||||
'iLink-App-ClientVersion': '1',
|
||||
};
|
||||
const routeTag = await loadWeChatRouteTag(accountId);
|
||||
const routeTag = loadWeChatRouteTag(accountId);
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# ACP Chat Architecture And Timeline
|
||||
|
||||
Status: current architecture reference, reviewed 2026-07-27.
|
||||
Status: current architecture reference, reviewed 2026-08-05.
|
||||
|
||||
Related scenario: `acp-chat-experience`
|
||||
|
||||
Related rules: `acp-chat-state-and-history`, `attachment-access-safety`, `renderer-main-boundary`
|
||||
|
||||
Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session`
|
||||
Related tasks: `acp-native-chat`, `acp-media-attachments`, `filter-openclaw-heartbeat-session`, `render-cron-run-live-status`
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -19,7 +19,9 @@ Chat UI -> host-api -> Main ACP service -> openclaw acp
|
||||
session/update -> Main routing envelope -> Renderer reducer -> timeline -> React
|
||||
```
|
||||
|
||||
Gateway remains responsible for non-Chat capabilities. Renderer Chat does not call ordinary Gateway `chat.history` or `chat.send`, and Main has no Chat-history polling, coalescing, or backpressure specialization. Generic Gateway RPC requests retain Main-owned validation and timeout handling before direct `GatewayManager.rpc` dispatch. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history.
|
||||
Gateway remains responsible for non-Chat capabilities. Restricted Gateway host-event evidence may supplement asynchronous image-generation completion, but it is not a source for ordinary Chat messages or tool history.
|
||||
|
||||
The only live assistant/process exception is the bounded cron overlay documented in `harness/reference/acp-cron-live-overlay.md`. Main accepts strict run-scoped cron identities only and keeps Gateway progress in a memory-only view model rendered beside, never inside, the ACP timeline. This exception is prohibited for ordinary non-cron messages, channel sessions, heartbeats, historical replay, and arbitrary Gateway content.
|
||||
|
||||
## Identity And Race Protection
|
||||
|
||||
@@ -33,10 +35,12 @@ While `session/prompt` is pending, Main retains a bounded session-id routing con
|
||||
|
||||
ACP `session/load` replay is the primary source of Chat history. ClawX does not persist an ACP ledger, reduced timeline, replay cache, or reconstructed tool history. Full structured replay can restore tools and file activity; transcript-only fallback must not invent them.
|
||||
|
||||
OpenClaw emits replay through ordinary `session/update` notifications and completes the replay before `session/load` returns. Main collects those raw notifications for the active load generation and returns them with the load result instead of forwarding them incrementally. Renderer temporarily groups generation-matching host events that arrive during the IPC result handoff, then runs the normal reducer over the combined batch and publishes the resulting timeline in one state update. This is an in-flight transaction buffer only, not a history cache; after load, each live update continues through the normal host-event route and is applied immediately without a Renderer batching timer. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters.
|
||||
OpenClaw emits replay through ordinary `session/update` notifications and completes the replay before `session/load` returns. Main collects those raw notifications for the active load generation and returns them with the load result instead of forwarding them incrementally. Renderer temporarily groups generation-matching host events that arrive during the IPC result handoff, then runs the normal reducer over the combined batch and publishes the resulting timeline in one state update. This is an in-flight transaction buffer only, not a history cache; after load, live updates continue through the normal host-event route. Permission requests are accepted only after the current loaded session starts a prompt, preventing load-time or handoff requests from creating invisible waiters.
|
||||
|
||||
There are exactly two approved transcript-derived content supplements. ClawX may recover asynchronous image-generation completions with proven `image_generate` context, and it may recover explicit line-leading assistant `MEDIA:` attachment directives omitted by OpenClaw ACP. Both are bounded, marked, memory-only projections. Separately, Main may extract metadata-only whole-turn timing because ACP replay omits original timestamps. Renderer can attach that timing only to an unambiguously matched ACP turn; it cannot reconstruct ordinary assistant text, thoughts, tool cards, plans, permissions, file activity, or missing turns. See `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions` for the content compatibility grammar and timing boundary.
|
||||
|
||||
The cron live overlay is not a transcript-derived supplement or a third history source. Its exact Main bounds are 32 active runs, 128 items per run, 500000 assistant characters, 100000 characters per item detail, 256 sequence-less fingerprints per run, and 128 terminal tombstones. Renderer subscribes to typed changes before fetching the snapshot and rejects older revisions, so late hydration cannot overwrite newer state. Raw thinking text is never retained or shown; only a localized activity indicator is allowed.
|
||||
|
||||
## Timeline Model
|
||||
|
||||
The Renderer keeps an in-memory `AcpTimelineSnapshot` with ordered item ids, item records, open message segments, tool and permission state, and ACP metadata. The exact TypeScript types in `src/lib/acp/` are authoritative; the stable conceptual item kinds are:
|
||||
@@ -88,9 +92,10 @@ Available attachment cards contain a primary semantic action with keyboard activ
|
||||
|
||||
## Chat Behaviors
|
||||
|
||||
- The primary Chat view renders process activity directly in the ordered ACP timeline.
|
||||
- The primary Chat view does not render the legacy Execution Graph.
|
||||
- A recoverable initial `reply was never sent` load failure may leave an empty new-chat page usable; prompt failures remain visible.
|
||||
- The working indicator follows the same sending state as the Stop action and supports reduced motion.
|
||||
- External cron activity never enters ACP sending/cancelling state or exposes ACP Stop, cancellation, or permission controls. When a terminal removal belongs to a run rendered in the currently selected base cron session, Renderer removes the overlay and calls normal `loadAcpSession` exactly once; hidden, evicted, gateway-reset, or already acknowledged removals cannot trigger a delayed reload. The resulting ACP replay, with typed cron-history fallback only when replay is empty, is the completed-content authority.
|
||||
- The question directory is derived only from active user message segments. Duplicate text remains separate, titles use the first non-empty Markdown part, and textless entries use a localized fallback. Fewer than two questions disables navigation. When open, the directory floats above the conversation without changing the chat column width. Selection scrolls smoothly to the current-snapshot anchor; a missing anchor is a safe no-op. The UI caps the directory at 300 recent entries and reports the hidden count when older entries are omitted.
|
||||
- Heartbeat-only desktop sessions are hidden only when the exact OpenClaw heartbeat sentinel is present and there is no real user content. A title such as `ClawX` or `main` is never sufficient. The guard applies to list, startup selection, refresh, and cached summary hydration without deleting OpenClaw history.
|
||||
|
||||
@@ -99,3 +104,5 @@ Available attachment cards contain a primary semantic action with keyboard activ
|
||||
Key tests live in `tests/unit/acp-*.test.*`, `tests/unit/acp-timeline-groups.test.ts`, `tests/unit/attachment-access.test.ts`, `tests/unit/chat-question-directory.test.tsx`, `tests/e2e/chat-acp-inline-timeline.spec.ts`, and `tests/e2e/chat-acp-attachments.spec.ts`.
|
||||
|
||||
This reference consolidates the former ACP native Chat, Chat polish, turn grouping, and question-directory design documents. Later implementation decisions supersede the original no-optimistic-message rule, the assumption that ACP id always equals Gateway session key, and segment-level assistant copy controls.
|
||||
|
||||
The cron broker and overlay may be removed only after a distributed OpenClaw package proves through integration tests that loaded ACP sessions receive autonomous cron assistant, thought, and tool updates; generated media arrives as standard ACP content blocks; replay is complete and deduplicated; and external-run lifecycle and cancellation semantics are explicitly exposed. Until all four conditions hold, Gateway progress remains a separate transient authority rather than synthetic ACP.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# ACP Cron Live Overlay
|
||||
|
||||
Status: approved architecture contract, reviewed 2026-08-05.
|
||||
|
||||
Related scenarios: `gateway-backend-communication`, `acp-chat-experience`
|
||||
|
||||
Related rules: `acp-chat-state-and-history`, `acp-compatibility-content-safety`, `renderer-main-boundary`, `host-api-fallback-policy`, `host-events-fallback-policy`, `ui-i18n-design-tokens`
|
||||
|
||||
Related task: `render-cron-run-live-status`
|
||||
|
||||
## Authority And Purpose
|
||||
|
||||
ACP `session/load` replay remains the primary authority for historical Chat content. When ACP replay for a cron session is empty, the existing typed cron-history fallback remains the only approved historical projection. Gateway runtime events are neither history nor ACP evidence for reconstructing history.
|
||||
|
||||
ClawX may expose current progress for an autonomous cron run through one narrow exception: a bounded, Main-owned, running-only overlay composed beside the ACP timeline. It exists only to bridge the upstream period in which autonomous cron activity emits useful Gateway runtime events but does not arrive as complete live ACP updates.
|
||||
|
||||
The normative flow is:
|
||||
|
||||
```text
|
||||
Gateway runtime event -> Main bounded cron broker -> explicit live overlay
|
||||
terminal event -> overlay removal -> authoritative ACP/cron-history reload
|
||||
```
|
||||
|
||||
The overlay is non-historical, memory-only, run-scoped, read-only, and excluded from sidebar unread/busy authority. It is not an `AcpTimelineSnapshot` supplement and cannot survive a terminal event, Gateway reset, broker eviction, process exit, or application restart.
|
||||
|
||||
## Admission And Identity
|
||||
|
||||
Main accepts only strict run-scoped cron keys shaped as `agent:<agentId>:cron:<jobId>:run:<runSessionId>`, with every identity segment non-empty after trimming. Ordinary sessions, base-only cron keys, channel sessions, malformed suffixes, and heartbeat `:main` events are rejected.
|
||||
|
||||
Main is the sole owner of cron key parsing and canonicalization. It maps an admitted run to its exact base cron key for selection while retaining the source run key and run identity. Renderer may select snapshots for the exact current base key, but it must not parse keys, adopt arbitrary runtime sessions, reduce Gateway events, choose transports, or implement protocol fallback.
|
||||
|
||||
Every process-item identity is namespaced by `runId`. Repeated `toolCallId`, `itemId`, command names, or approval fallback identities from different runs cannot collide.
|
||||
|
||||
## Main Broker Contract
|
||||
|
||||
The broker owns runtime-event normalization, type-specific deduplication, reduction, ordering, active snapshots, terminal tombstones, and all memory bounds. It may adopt a valid run mid-flight without observing `run.started`, but a terminal tombstone prevents delayed events from resurrecting a completed run only while that tombstone remains in the bounded FIFO. Gateway reset removals do not create terminal tombstones: the Main binding disables ingestion before clearing, ignores runtime events while disconnected or reconnecting, and re-enables ingestion on `running` so the same identity can be adopted again mid-flight.
|
||||
|
||||
The exact bounds are:
|
||||
|
||||
- `MAX_ACTIVE_CRON_LIVE_RUNS = 32`
|
||||
- `MAX_CRON_LIVE_ITEMS_PER_RUN = 128`
|
||||
- `MAX_CRON_LIVE_ASSISTANT_CHARS = 500_000`
|
||||
- `MAX_CRON_LIVE_ITEM_DETAIL_CHARS = 100_000`
|
||||
- `MAX_CRON_LIVE_EVENT_FINGERPRINTS = 256` per run
|
||||
- `MAX_CRON_LIVE_TERMINAL_TOMBSTONES = 128`
|
||||
|
||||
Numeric sequence values are monotonic per run; Main rejects `seq <= lastSeq`. Events without a sequence use bounded, type-specific fingerprints that reject exact repeats while preserving distinct incremental chunks. Structured details are serialized deterministically, tolerate cyclic input, and are truncated before entering the snapshot.
|
||||
|
||||
The overlay has only `running` status. Assistant text may be displayed, including bounded snapshot, replacement, and delta convergence. `thinking.delta` content is never retained or displayed; the view model exposes only a boolean that Renderer presents as a localized thinking indicator. Tool, command, patch, and approval items are bounded status rows. Approval rows are read-only and never call ACP permission-response APIs.
|
||||
|
||||
Terminal events produce a removal and delete the active snapshot. Renderer applies that removal to its overlay state before starting any authoritative history reload. Terminal content is never retained as a completed overlay. Gateway reset and deterministic capacity eviction also remove snapshots, but do not claim that authoritative history changed.
|
||||
|
||||
## Revision And Hydration Safety
|
||||
|
||||
Main emits a monotonically increasing broker revision for every upsert, removal, and clear. Every upsert snapshot carries the revision of its emitted change. Snapshot hydration returns the current broker revision even when no active snapshots exist.
|
||||
|
||||
Renderer subscribes to the typed change event before requesting the typed snapshot. It applies changes and hydration only when their revision is not older than the current store revision. This ordering prevents a late snapshot response, including an empty response, from replacing newer live events in the subscribe/snapshot revision race. Renderer bounds pending removals and acknowledges each removal by its exact revision so concurrent run completions cannot overwrite one another.
|
||||
|
||||
The supported boundary is:
|
||||
|
||||
```text
|
||||
GatewayManager -> Main cron live-run broker -> typed host event / typed host API
|
||||
Renderer overlay store -> explicit cron overlay component beside ACP timeline
|
||||
```
|
||||
|
||||
No page or component may invoke IPC directly, fetch Gateway HTTP, open a Gateway WebSocket, or switch between transports. Existing raw `chat:runtime-event` forwarding remains unchanged for the legacy runtime graph and image-generation compatibility consumers; broker ingestion is a separate Main listener and must not duplicate raw forwarding.
|
||||
|
||||
## ACP And UI Separation
|
||||
|
||||
Gateway runtime events must never be converted into `SessionNotification`, `AcpSessionUpdateEnvelope`, `TimelineItem`, or any other synthetic ACP value. `src/lib/acp/reducer.ts`, `src/lib/acp/timeline-types.ts`, and ACP replay semantics remain unchanged. The overlay is rendered as a sibling region and its content never appears inside the ACP timeline DOM.
|
||||
|
||||
External cron activity cannot set ACP `sending` or `cancelling`, show Stop, call `cancelAcpSession`, respond to ACP permissions, synthesize a generation, or mutate a retained live prompt. It also cannot create, clear, or reconcile sidebar busy or unread state; Gateway session rows remain the sole sidebar authority.
|
||||
|
||||
When a terminal removal identifies a run that was actually rendered for the currently selected base cron session, Renderer keeps that removal pending while an ACP prompt is sending or cancelling, then acknowledges the exact removal and invokes normal `loadAcpSession` exactly once after the ACP lifecycle and existing workspace/load coordination permit it. The resulting ACP replay is authoritative. Only if that replay is empty may the existing typed cron-history fallback populate historical content. A run removed while hidden, an already acknowledged removal, or a removal for `evicted` or `gateway-reset` does not create a delayed reload when the user later returns. Sequence-less and repeated terminal events are suppressed while the corresponding bounded FIFO tombstone is retained, so they cannot duplicate the reload or resurrect the run during that retention window.
|
||||
|
||||
The panel and every status label use `react-i18next` with English, Chinese, Japanese, and Russian coverage. Presentation follows `src/styles/globals.css`, including semantic modal/input surfaces, selected-state substitutions, paired light/dark status colors, accessible labels, and reduced-motion behavior. The live panel must be visibly distinct from native ACP cards and the removed legacy Execution Graph.
|
||||
|
||||
## Scope And Removal Condition
|
||||
|
||||
This exception cannot be generalized to ordinary non-cron messages, channel sessions, heartbeats, historical event replay, or arbitrary Gateway content. It must remain simpler to delete than to expand.
|
||||
|
||||
The overlay may be removed only after a distributed OpenClaw package proves through integration tests that loaded ACP sessions receive autonomous cron assistant, thought, and tool updates; generated media arrives as standard ACP content blocks; replay is complete and deduplicated; and external-run lifecycle and cancellation semantics are explicitly exposed. At that point ClawX should remove the broker and overlay rather than retain two live authorities.
|
||||
|
||||
## Validation Anchors
|
||||
|
||||
Contract validation begins with `tests/unit/harness-specs.test.ts`. Broker identity and reduction are covered by `tests/unit/cron-session-utils.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, and `tests/unit/cron-live-run-broker.test.ts`. Typed boundaries and revision-safe Renderer state are covered by `tests/unit/host-events.test.ts`, `tests/unit/host-api-facade.test.ts`, `tests/unit/host-services.test.ts`, and `tests/unit/cron-live-run-overlay-store.test.ts`. Presentation and ACP separation are covered by `tests/unit/cron-live-run-overlay.test.tsx`, `tests/unit/chat-acp-page.test.tsx`, and `tests/e2e/cron-run-live-status.spec.ts`.
|
||||
|
||||
Communication changes require type checking, lint, Vite build, the focused Electron E2E spec, `pnpm run comms:replay`, `pnpm run comms:compare`, real task-spec validation without `--no-diff`, a task Harness run, and Harness CI.
|
||||
@@ -1,15 +0,0 @@
|
||||
# Electron E2E Parallelism
|
||||
|
||||
ClawX launches one Electron process per Playwright test with a test-scoped HOME and user-data directory. Ordinary specs can therefore run in separate workers without sharing application stores or OpenClaw files.
|
||||
|
||||
The Playwright project graph has three ordered lanes:
|
||||
|
||||
1. `exclusive` runs tests tagged `@exclusive` with one worker.
|
||||
2. `parallel` runs all ordinary functional tests with the configured worker count after `exclusive` succeeds.
|
||||
3. `performance` runs tests tagged `@performance` with one worker after functional tests finish.
|
||||
|
||||
Real clipboard tests are exclusive because Electron renderer instances read and write the same OS clipboard. Renderer performance tests run last because concurrent Electron processes distort CPU, GPU, frame-pacing, and elapsed-time evidence even when their files are otherwise isolated. `test.describe.configure({ mode: 'serial' })` is not sufficient for either case because it does not prevent another spec file or project from running at the same time.
|
||||
|
||||
New tests are parallel by default. A test that uses an OS-global resource must import and apply `E2E_EXCLUSIVE_TAG`; a host performance profile must use `E2E_PERFORMANCE_TAG`. Extend `tests/unit/e2e-parallel-policy.test.ts` when another recognizable global API is introduced. No static check can identify every possible external side effect, so reviewers must classify tests that use native dialogs, keychains, fixed ports, fixed writable paths, external runtimes, or other machine-global state.
|
||||
|
||||
Use `CLAWX_E2E_WORKERS` to override the ordinary worker count on constrained or high-capacity machines. Playwright project dependencies make a directly filtered ordinary spec run the exclusive prerequisite first; add `--project=parallel --no-deps` when a focused command intentionally needs only an audited ordinary spec. `pnpm run perf:chat` selects the performance project without running its dependencies.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Electron Rendering Performance
|
||||
|
||||
Status: hardware-acceleration policy and interaction profile baselined 2026-08-01.
|
||||
|
||||
Related scenarios: `acp-chat-experience`, `chat-workspace-and-navigation`
|
||||
|
||||
Related rule: `electron-rendering-performance`
|
||||
|
||||
Related task: `restore-hardware-accelerated-rendering`
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
ClawX leaves Electron and Chromium hardware acceleration enabled by default. Main must not call `app.disableHardwareAcceleration()` or append a global `disable-gpu` switch. Chromium owns driver detection and fallback; users with a broken driver may still launch ClawX with Chromium's native `--disable-gpu` switch.
|
||||
|
||||
Headless Linux and virtualized CI may report software compositing because no usable GPU is present. Tests must distinguish that environment fallback from an application-owned global disable policy. Desktop GPU assertions therefore run only where the test environment provides a real desktop GPU.
|
||||
|
||||
## Diagnostic Contract
|
||||
|
||||
`pnpm run perf:chat` covers both high-frequency ACP streaming and idle interaction with a rich static Markdown document. The interaction workload records sidebar-collapse and vertical-scroll frame intervals, Renderer performance metrics, DOM size, GPU feature status, and Renderer/Main CPU profiles. It uses generated content and writes only ignored Playwright artifacts.
|
||||
|
||||
For a reported desktop regression, first reproduce with the user's real conversation and record `app.isHardwareAccelerationEnabled()` plus `app.getGPUFeatureStatus()` after `gpu-info-update`. Compare repeated runs on the same machine. Main CPU profiles do not include browser/GPU process rasterization or compositing, so a profile dominated by Chromium `(program)` time must be interpreted together with frame pacing and GPU status rather than as unexplained React work.
|
||||
|
||||
Do not add machine-independent frame-time gates. Preserve semantic assertions, generated workload shape, and artifact schemas; compare repeated local or controlled-run medians when reviewing rendering changes.
|
||||
|
||||
## Validation Anchors
|
||||
|
||||
- Main policy: `electron/main/index.ts` and `tests/unit/main-hardware-acceleration.test.ts`.
|
||||
- Desktop runtime behavior: `tests/e2e/hardware-acceleration.spec.ts`.
|
||||
- Streaming and interaction profiles: `tests/e2e/renderer-performance.spec.ts` through `pnpm run perf:chat`.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Markdown Rendering
|
||||
|
||||
Status: migration contract baselined 2026-08-01; implementation is validated by the related task.
|
||||
|
||||
Related scenarios: `acp-chat-experience`, `chat-workspace-and-navigation`
|
||||
|
||||
Related rule: `markdown-rendering-safety-and-performance`
|
||||
|
||||
Related task: `replace-markdown-renderer-with-streamdown`
|
||||
|
||||
## Rendering Ownership
|
||||
|
||||
ClawX has two application Markdown surfaces with distinct update behavior and one shared renderer configuration:
|
||||
|
||||
| Surface | Mode | Content |
|
||||
| --- | --- | --- |
|
||||
| ACP Chat | `streaming` | Assistant message and process Markdown parts |
|
||||
| Markdown file preview | `static` | Authorized local Markdown file content |
|
||||
|
||||
User messages remain literal React text and tool output remains preformatted. Neither enters Streamdown. The migration is presentation-only: ACP transport, event ordering, timeline reduction, store cadence, history, and Renderer/Main boundaries do not change.
|
||||
|
||||
The shared plugin, rehype, component, animation, controls, and link-safety values remain module-scoped. Stable references allow Streamdown to retain completed block output instead of invalidating memoized blocks on each chunk.
|
||||
|
||||
## Plugin Contract
|
||||
|
||||
Exactly these optional capabilities are enabled:
|
||||
|
||||
- `@streamdown/code` for Shiki-backed fenced-code highlighting.
|
||||
- `@streamdown/math` for KaTeX, configured with `singleDollarTextMath: true`.
|
||||
- `@streamdown/cjk` for CJK-aware autolink and punctuation boundaries.
|
||||
|
||||
`@streamdown/mermaid` is not a direct dependency and is not configured. A `mermaid` fence remains an ordinary highlighted code block and never becomes a diagram, SVG, or interactive Mermaid container.
|
||||
|
||||
KaTeX remains a direct dependency because the math plugin requires its CSS. The application imports `katex/dist/katex.min.css` exactly once. It also imports `streamdown/styles.css` exactly once so the selected animation keyframes and data-attribute styles exist. Tailwind scans Streamdown and each installed plugin distribution, but no Mermaid distribution path.
|
||||
|
||||
## Content Safety
|
||||
|
||||
Streamdown does not expand the authority of generated content:
|
||||
|
||||
- User text and tool output remain literal outside the Markdown renderer.
|
||||
- The shared rehype list retains Streamdown sanitization and hardening but omits raw-HTML parsing. Source HTML such as `<script>alert(1)</script>` remains visible text and does not create an element.
|
||||
- Links render through `BrowserLink`, which has no interactive anchor role or navigation. Streamdown link-safety UI is disabled because links are already inert.
|
||||
- ACP Markdown images continue through `isSafeAcpImageSource`; an unapproved source does not become an image request.
|
||||
- Table, Mermaid, code download, and line-number controls are disabled. Fenced code alone exposes Streamdown's copy control with its label supplied through `react-i18next`; the control remains disabled while a response is streaming.
|
||||
|
||||
Static preview keeps `remark-frontmatter` for YAML (`---`) and TOML (`+++`) frontmatter. Parsed frontmatter is omitted from visible output. There is no custom frontmatter splitter, metadata card, or metadata `<pre>`.
|
||||
|
||||
## Streaming And Animation
|
||||
|
||||
ACP Chat repairs incomplete Markdown while the response is active, but animation state is narrower than transport state. The Renderer derives active segment IDs from the open ACP assistant message segments only while send or cancel is active. A Markdown part receives `isAnimating`, word animation, and `caret="circle"` only when all of these conditions hold:
|
||||
|
||||
- Its assistant segment is currently open.
|
||||
- It is the segment's final part.
|
||||
- Its part kind is Markdown.
|
||||
|
||||
Earlier parts, completed segments, thoughts, user messages, and tool output never animate. The animation is word-level `fadeIn` with `duration: 140` and `stagger: 0`; character-level animation is forbidden. Previously completed words and blocks must remain stable as later chunks arrive, and the caret disappears when the send settles.
|
||||
|
||||
## Presentation Contract
|
||||
|
||||
Chat keeps the assistant-without-bubble layout and ClawX's established prose rhythm. Scoped Streamdown selectors restore heading and horizontal-rule margins over Streamdown's root spacing utility, compact ordered, unordered, and task-list items, and remove table wrapper borders while retaining the cell grid. Fenced code preserves Shiki's source-row spans as block lines, soft-wraps long lines, uses a compact right-aligned language header with vertically centered actions, and exposes copy without download; file preview keeps its preview-specific headings and inline code. Styling uses existing ClawX surfaces, text colors, dark-mode variants, and other design tokens; Streamdown defaults must not leak broad global changes into unrelated prose.
|
||||
|
||||
The supported math contract includes `$...$`, `$$...$$`, `\(...\)`, and `\[...\]`. CJK tests anchor punctuation exclusion from autolinks. Code tests wait for Shiki token output rather than assuming highlighting is synchronous.
|
||||
|
||||
## Performance Baseline And Review
|
||||
|
||||
`pnpm run perf:chat` builds the production Renderer and executes a deterministic 80-turn history plus 300 streaming chunks. Before renderer changes and after migration, run it three times on the same machine and retain each generated `renderer-benchmark.json`, `renderer.cpuprofile`, and `main.cpuprofile` under ignored `test-results/` paths.
|
||||
|
||||
Compare before/after medians for elapsed time, Renderer TaskDuration, ScriptDuration, layout and style duration, long-task count and duration, and sampled Markdown/React CPU stacks. Median TaskDuration and ScriptDuration must each stay within 10 percent of baseline. At least one of median ScriptDuration or sampled Markdown/render CPU time must improve by 10 percent or more. A miss requires profiling animation, Shiki, and last-block costs rather than weakening the threshold. Absolute machine timings are evidence for the local comparison, not automated cross-machine gates.
|
||||
|
||||
Build with `pnpm exec vite build --sourcemap` and inspect chunk sizes and source maps. Streamdown and Shiki are expected costs. The review must confirm no direct Mermaid plugin and no unexpected eager Mermaid renderer chunk. Dormant code retained by Streamdown core is measured and documented rather than described as Mermaid UI support.
|
||||
|
||||
## Validation Anchors
|
||||
|
||||
Shared configuration is anchored by `src/components/markdown/streamdown-config.ts` and `tests/unit/streamdown-config.test.tsx`.
|
||||
|
||||
Static preview behavior is anchored by `src/components/file-preview/MarkdownPreview.tsx`, `tests/unit/markdown-preview.test.tsx`, `tests/unit/file-preview-body.test.tsx`, and `tests/e2e/markdown-file-preview.spec.ts`.
|
||||
|
||||
Streaming state and rendering are anchored by `src/pages/Chat/AcpTimeline.tsx`, `src/pages/Chat/AcpAssistantTurn.tsx`, `src/pages/Chat/AcpMessageSegment.tsx`, `tests/unit/acp-chat-components.test.tsx`, and `tests/e2e/chat-streamdown-rendering.spec.ts`.
|
||||
|
||||
Existing soft-wrap, KaTeX, plain-assistant, and table-theme behavior remains anchored by `tests/e2e/chat-code-block-wrap.spec.ts`, `tests/e2e/chat-latex-rendering.spec.ts`, `tests/e2e/chat-assistant-markdown-plain.spec.ts`, and `tests/e2e/chat-table-header-light.spec.ts`. Performance evidence is produced by `tests/e2e/renderer-performance.spec.ts` through `pnpm run perf:chat`.
|
||||
@@ -1,21 +0,0 @@
|
||||
# OpenClaw Config Delivery
|
||||
|
||||
ClawX bundles OpenClaw 2026.7.1. OpenClaw owns the field-level decision between a no-op snapshot update, hot application, subsystem restart, and in-process Gateway restart.
|
||||
|
||||
Provider, Agent, Channel, skill, proxy, image-generation, and plugin-install helpers express config changes as mutators. One Main-owned coordinator owns selection of the authoritative baseline and the commit:
|
||||
|
||||
1. If Gateway is running, call `config.get` and require its runtime-shaped `config` object and `hash`. The coordinator accepts `raw` only as a compatibility fallback for older responses.
|
||||
2. Clone the runtime-shaped config, apply the mutator, and call `config.set` with the serialized result and `baseHash: hash`. Using source-shaped `raw` as the preferred baseline can misalign redacted secret paths with OpenClaw's runtime-shaped restore baseline.
|
||||
3. Retry one base-hash conflict from a fresh `config.get`; fail other RPC errors without writing around the running Gateway.
|
||||
4. Treat success as converged and do not send `SIGUSR1` or replace the process.
|
||||
5. If Gateway is stopped or starting, apply the same mutator to `resolveOpenClawConfigPath()` under the shared config lock and do not start the Gateway.
|
||||
|
||||
This is not a write-then-notify design. No provider, Agent, Channel, skill, proxy, image-generation, or plugin-install helper may write the active config independently. The coordinator prevents a locally read stale snapshot from overwriting concurrent Gateway or CLI config changes.
|
||||
|
||||
Gateway WebSocket tracing must redact the complete serialized `raw` payload for `config.set`, `config.patch`, and `config.apply`; key-based structural redaction cannot inspect secrets embedded inside that string.
|
||||
|
||||
Coordinator-backed reads follow the same authority rule: prefer the runtime-shaped `config.get.config` object while Gateway is running and use JSON5 file parsing while it is not. Compound views derive all config-backed fields from one snapshot.
|
||||
|
||||
OpenClaw 2026.7.1 keeps auth-profile SQLite snapshots in memory. After a completed auth-store write batch, ClawX calls `secrets.reload` once when Gateway is running. `config.set` does not replace this refresh. Agent `models.json` needs no explicit RPC because OpenClaw re-reads it when its file fingerprint changes.
|
||||
|
||||
Full ClawX process replacement remains necessary after a successful coordinator commit when values are injected only at process creation, including proxy environment changes, or for explicit manual lifecycle and health/crash recovery. OpenClaw config categories must not be duplicated as a ClawX restart whitelist.
|
||||
@@ -182,6 +182,6 @@ Do not begin this migration merely because a type exists in an unbundled upstrea
|
||||
|
||||
Primary implementation anchors are `shared/chat/types.ts`, `src/stores/gateway.ts`, `src/stores/chat.ts`, `src/stores/chat/session-catalog.ts`, `src/stores/chat/session-status.ts`, `src/stores/chat/session-label-hydration.ts`, `src/stores/session-attention.ts`, `src/components/layout/Sidebar.tsx`, and `src/pages/Chat/index.tsx`.
|
||||
|
||||
Focused unit anchors are `tests/unit/session-status.test.ts`, `tests/unit/session-catalog.test.ts`, `tests/unit/session-attention.test.ts`, `tests/unit/session-label-hydration.test.ts`, `tests/unit/gateway-events.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/chat-session-management.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/i18n-locale-parity.test.ts`, and `tests/unit/harness-specs.test.ts`. End-to-end presentation and navigation are covered by `tests/e2e/chat-sidebar-session-attention.spec.ts`.
|
||||
Focused unit anchors are `tests/unit/session-status.test.ts`, `tests/unit/session-catalog.test.ts`, `tests/unit/session-attention.test.ts`, `tests/unit/session-label-hydration.test.ts`, `tests/unit/gateway-events.test.ts`, `tests/unit/gateway-event-dispatch.test.ts`, `tests/unit/chat-store-session-label-fetch.test.ts`, `tests/unit/chat-store-history-retry.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/i18n-locale-parity.test.ts`, and `tests/unit/harness-specs.test.ts`. End-to-end presentation and navigation are covered by `tests/e2e/chat-sidebar-session-attention.spec.ts`.
|
||||
|
||||
Communication changes require the task's Harness validation, communication replay/compare, typecheck, lint, Vite build, targeted unit tests, and Electron E2E test.
|
||||
|
||||
@@ -8,8 +8,10 @@ appliesTo:
|
||||
- gateway-backend-communication
|
||||
---
|
||||
|
||||
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while each ordinary live prompt update continues through host events and is applied immediately without a Renderer batching timer. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
|
||||
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while ordinary live prompt updates continue through host events. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
|
||||
|
||||
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
|
||||
|
||||
The sole live Gateway exception for cron assistant/process progress is a bounded, Main-reduced overlay for strict run-scoped cron keys. It is memory-only, running-only, read-only, and structurally separate from `SessionNotification`, `AcpSessionUpdateEnvelope`, `TimelineItem`, and `AcpTimelineSnapshot`; it cannot create or supplement history. A terminal event removes the overlay, after which a visible run may trigger exactly one ordinary ACP load or existing typed cron-history fallback. ACP replay remains the authority whenever it is non-empty. The overlay cannot mutate ACP sending, cancelling, Stop, permission, generation, or replay state, and it cannot drive sidebar busy or unread authority. Do not extend this exception to ordinary sessions, base-only cron keys, channels, or heartbeats. See `harness/reference/acp-cron-live-overlay.md`.
|
||||
|
||||
Historical transcript reads are limited to the newest `1000` message records. A successful live prompt reads content immediately and retries exactly once after `1500 ms`. General attachment and timing alignment treat history as a suffix and match the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; they must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live content alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline or timing annotations.
|
||||
|
||||
@@ -12,3 +12,5 @@ Standard ACP content is authoritative and preferred. A compatibility supplement
|
||||
Approved transcript evidence has three bounded forms: asynchronous image-generation completion with proven image-generation context, including explicit internal-UI `message` tool source replies; canonical persisted assistant `__openclaw.media` facts; and general attachment recovery from whole-line, line-leading assistant OpenClaw `MEDIA:` directives outside fenced code blocks. Canonical facts and directives accept only the documented local path, `file:`, execution-cwd-relative, HTTP, and HTTPS forms. Quoted directive references may contain spaces, while unquoted directives may not; canonical structured values may contain spaces. General recovery projects only ordered attachment references and declared media metadata, never surrounding transcript prose. A trusted image-generation source reply may provide user-facing completion or failure text. Reject malformed or wrapped directives, bare or inline prose paths without canonical media facts, unknown URI schemes, incidental tool paths, and unrelated assistant prose.
|
||||
|
||||
Compatibility logic must not reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, or a parallel Chat history. User-side OpenClaw prompt projection may be reconstructed only from structured ACP content already present in the same timeline; generated-looking user prose is not evidence and must not be stripped or parsed. Unmatched or ambiguous evidence is skipped rather than attached by guesswork. Deduplication is turn-scoped and uses only a Main-authorized opaque identity; native ACP resource content wins over equivalent compatibility evidence, generated-image evidence remains inline, and an unavailable result does not block a later available upgrade.
|
||||
|
||||
A bounded live cron overlay is not compatibility ACP content. It may display current assistant text and read-only process status from strict run-scoped Gateway events only as a separately typed, memory-only, running-only view model. Raw thought text is prohibited, approval rows are never interactive ACP permissions, and no overlay value may be represented as a native or synthetic ACP event, inserted into the ACP timeline, persisted, or retained after terminal removal. Completed content must come from ordinary ACP replay or the existing typed cron-history fallback. This narrow exception must not become a route for ordinary messages, historical reconstruction, or replacement of standard ACP content. See `harness/reference/acp-cron-live-overlay.md`.
|
||||
|
||||
@@ -15,7 +15,6 @@ Rules:
|
||||
- allowlists and entries must agree about which package owns a single-owner capability
|
||||
- disabling a bundled plugin is required when removing it from an allowlist is not sufficient to stop runtime loading
|
||||
- stale plugin registrations for unconfigured capabilities must be removed during sanitize or recovery paths
|
||||
- ClawX must include `web_search` in both `tools.deny` and `gateway.tools.deny`; existing deny entries remain user-owned and browser automation plus `web_fetch` remain available
|
||||
- when no embedding credentials or user-owned memory-search config exist, preserve `memory_search` through OpenClaw's explicit FTS-only provider instead of disabling the tool
|
||||
- migrations may replace only the exact legacy ClawX-managed memory-search default, must run at most once, and must preserve later user opt-outs
|
||||
- tests for config rewrites should assert the final active config, not only intermediate helper output
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
id: e2e-parallel-isolation
|
||||
title: E2E Parallel Isolation
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
---
|
||||
|
||||
Electron E2E tests are parallel by default because each test owns its HOME, OpenClaw state directory, Electron user-data directory, and Host API configuration. Keep those fixtures test-scoped.
|
||||
|
||||
Tests that mutate OS-global state must use `E2E_EXCLUSIVE_TAG`. Tests that profile shared host CPU, GPU, display, or frame pacing must use `E2E_PERFORMANCE_TAG`. Do not use Playwright serial mode as a cross-file mutex; serial mode only orders tests within its own group.
|
||||
|
||||
When adding another global resource, extend the automated policy check where the resource has a recognizable API. Unknown external resources still require reviewer classification.
|
||||
|
||||
The project graph, environment isolation, and validation commands are documented in `harness/reference/e2e-parallelism.md`.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
id: electron-rendering-performance
|
||||
title: Electron Rendering Performance
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- acp-chat-experience
|
||||
- chat-workspace-and-navigation
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
---
|
||||
|
||||
Leave Electron hardware acceleration enabled by default. Do not call `app.disableHardwareAcceleration()` or globally append `disable-gpu`; Chromium must retain driver detection and its native `--disable-gpu` troubleshooting fallback. Treat software compositing reported by headless or GPU-less CI as an environment result, not a reason to force every desktop renderer onto software rasterization.
|
||||
|
||||
Rendering performance investigations must combine frame pacing, Renderer metrics/profile data, and `app.getGPUFeatureStatus()` captured after `gpu-info-update`. Main CPU profiles do not cover browser/GPU process rasterization. Do not attribute Chromium `(program)` samples to React without an isolated variable that changes the result.
|
||||
|
||||
Keep `pnpm run perf:chat` coverage for both ACP streaming and rich static Markdown interaction. The interaction workload must exercise the production sidebar width animation and vertical Chat scroll path, record generated-only artifacts, and avoid hardware-independent timing gates. Compare repeated runs on the same machine while preserving semantic E2E assertions.
|
||||
|
||||
The full runtime policy and validation anchors are recorded in `harness/reference/electron-rendering-performance.md`.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
id: gateway-heartbeat-safety
|
||||
title: Gateway Heartbeat Safety
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredTests:
|
||||
- tests/unit/gateway-manager-heartbeat.test.ts
|
||||
- tests/unit/gateway-manager-diagnostics.test.ts
|
||||
---
|
||||
|
||||
WebSocket heartbeat misses are availability and diagnostic evidence, not proof that the local Gateway process is dead.
|
||||
|
||||
Reaching the heartbeat miss threshold must update diagnostics and health state, but must not by itself terminate the socket, kill the owned Gateway process, or request `GatewayManager.restart`. Long-running model, tool, compaction, and scheduled work may temporarily block Gateway control-plane responses while remaining valid.
|
||||
|
||||
Automatic lifecycle recovery remains owned by authoritative transport and process signals such as child-process exit, WebSocket close, and Gateway restart close code 1012. Explicit user restart remains available.
|
||||
|
||||
Do not weaken this rule by only increasing heartbeat intervals or miss thresholds. A timeout change delays false recovery but does not make missing pong frames proof of process death.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
id: markdown-rendering-safety-and-performance
|
||||
title: Markdown Rendering Safety And Performance
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- acp-chat-experience
|
||||
- chat-workspace-and-navigation
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
---
|
||||
|
||||
Use one module-scoped Streamdown configuration for application Markdown. ACP assistant and process Markdown uses streaming mode with incomplete-Markdown repair; Markdown file preview uses static mode. User messages and tool output remain literal and must not enter Streamdown. Enable only the code, math, and CJK plugins. Keep single-dollar math enabled, retain the direct KaTeX dependency and one application KaTeX stylesheet import, and import Streamdown styles once. Do not install or configure the Mermaid plugin; Mermaid fences remain code.
|
||||
|
||||
Preserve the existing content boundary. Build the rehype list from Streamdown defaults without raw-HTML parsing while retaining sanitization and hardening, so raw HTML is visible literal text and never becomes active DOM. Render links through inert `BrowserLink` and disable Streamdown link-safety UI because no anchor remains interactive. Markdown images must continue through `isSafeAcpImageSource`. Enable only the localized code-copy control; disable table, Mermaid, code-download, and line-number controls. Parse YAML and TOML frontmatter in static preview and omit it from output; do not restore the custom splitter or metadata card.
|
||||
|
||||
Keep plugin arrays, component maps, animation options, and security options at module scope so reference churn does not invalidate block memoization. Only the open assistant message segment's final Markdown part may set animation or caret props. Use word-level `fadeIn` with duration 140, stagger 0, and a circle caret; never animate by character. Completed segments, user messages, thoughts, earlier parts, and inactive sends must remain stable and must not acquire or restart animation.
|
||||
|
||||
Preserve ClawX design tokens, assistant-without-bubble layout, prose block spacing, compact lists, the cell-only table grid and themes, source-line-preserving soft-wrapped code with a compact right-aligned language header, vertically centered copy action, and existing inert-link and image styling. Add Electron E2E coverage for streaming Chat and static preview. Tests must cover incomplete Markdown, highlighted and copyable multiline code, all supported math delimiters, CJK punctuation, Mermaid-as-code, literal raw HTML, inert links, safe images, literal user and tool output, frontmatter omission, active-part-only animation, completed-block stability, and the existing visual contracts.
|
||||
|
||||
Capture three successful 80-turn and 300-chunk `pnpm run perf:chat` profiles before and after renderer changes on the same machine, retaining ignored Renderer metrics plus Renderer/Main CPU profiles. Compare three-run medians for elapsed time, Renderer TaskDuration, ScriptDuration, layout duration, long-task count and duration, and sampled Markdown/React stacks. TaskDuration and ScriptDuration may each regress by at most 10 percent; median ScriptDuration or sampled Markdown/render CPU time must improve by at least 10 percent. Inspect a production sourcemap build for Streamdown, Shiki, and unexpected Mermaid cost. Do not replace these relative checks with machine-specific automated timing gates.
|
||||
|
||||
The complete rationale, ownership, safety policy, and validation anchors are recorded in `harness/reference/markdown-rendering.md`.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
id: openclaw-config-delivery
|
||||
title: OpenClaw Config Delivery
|
||||
type: ai-coding-rule
|
||||
appliesTo:
|
||||
- gateway-backend-communication
|
||||
requiredProfiles:
|
||||
- comms
|
||||
references:
|
||||
- harness/reference/openclaw-config-delivery.md
|
||||
---
|
||||
|
||||
ClawX must defer runtime config planning to the bundled OpenClaw Gateway.
|
||||
|
||||
The Main-owned config coordinator must own the entire read-modify-write transaction. Production helpers must not write the active OpenClaw config and then notify another layer afterward.
|
||||
|
||||
When the Gateway is running, the coordinator prefers the runtime-shaped `config.get.config` object as the mutation baseline, applies the caller's mutator, and commits through `config.set` with the returned `hash` as `baseHash`. Source-shaped `raw` is only a compatibility fallback because its redacted secret paths may not align with OpenClaw's write-side runtime snapshot. A successful mutation must not be followed by `SIGUSR1` or a ClawX process restart. Base-hash conflicts retry once from a new snapshot; other RPC failures fail closed instead of performing an out-of-band file write.
|
||||
|
||||
Coordinator mutators are replayable transformations. They must not perform filesystem writes, SQLite writes, settings writes, lifecycle actions, or other non-idempotent external effects; preload required external inputs before entering the mutator and perform follow-up effects only after a successful commit.
|
||||
|
||||
Gateway WebSocket traces must replace serialized `raw` config-write payloads with a redacted marker. They must not log credentials introduced by a mutator.
|
||||
|
||||
When the Gateway is stopped or starting, the same coordinator mutates the resolved config file under the shared config lock. It must not start the Gateway solely to apply a config mutation.
|
||||
|
||||
ClawX may replace the Gateway process for process-launch environment or argument changes, explicit user restart, application lifecycle, health/crash recovery, or a failed config-delivery fallback. Provider, Agent, Channel, binding, skill, model, and ordinary plugin-entry config changes must not carry a blanket ClawX restart policy when OpenClaw can plan them.
|
||||
|
||||
All coordinator file fallback reads and writes must resolve the active config through `resolveOpenClawConfigPath()` so file delivery and Gateway RPC target the same config. No other production module may write that file.
|
||||
@@ -5,6 +5,8 @@ type: user-visible-flow
|
||||
ownedPaths:
|
||||
- shared/acp-chat/**
|
||||
- shared/host-api/contract.ts
|
||||
- shared/host-events/contract.ts
|
||||
- shared/chat/cron-live-run.ts
|
||||
- shared/file-preview/**
|
||||
- electron/services/acp-chat-service.ts
|
||||
- electron/services/acp-session-access-registry.ts
|
||||
@@ -12,7 +14,6 @@ ownedPaths:
|
||||
- electron/services/attachment-access.ts
|
||||
- electron/services/attachment-open-with.ts
|
||||
- electron/services/files-api.ts
|
||||
- electron/main/index.ts
|
||||
- resources/scripts/attachment-open-with.ps1
|
||||
- src/lib/acp/**
|
||||
- src/lib/file-preview-client.ts
|
||||
@@ -28,20 +29,16 @@ ownedPaths:
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- tests/e2e/chat-acp-attachments.spec.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
- tests/e2e/chat-code-block-wrap.spec.ts
|
||||
- tests/e2e/chat-latex-rendering.spec.ts
|
||||
- tests/e2e/chat-assistant-markdown-plain.spec.ts
|
||||
- tests/e2e/chat-table-header-light.spec.ts
|
||||
- tests/e2e/hardware-acceleration.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
- src/stores/cron-live-run-overlay.ts
|
||||
- tests/unit/cron-live-run-overlay-store.test.ts
|
||||
- tests/unit/cron-live-run-overlay.test.tsx
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
conditionalProfiles:
|
||||
e2e:
|
||||
- ACP timeline presentation changes
|
||||
- Chat Markdown rendering, syntax highlighting, or animation changes
|
||||
- send, cancel, permission, media, or history behavior changes
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
@@ -53,8 +50,6 @@ requiredRules:
|
||||
- tool-derived-file-safety
|
||||
- office-preview-safety
|
||||
- ui-i18n-design-tokens
|
||||
- markdown-rendering-safety-and-performance
|
||||
- electron-rendering-performance
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
---
|
||||
@@ -63,4 +58,6 @@ ACP Chat covers session load, prompt, cancel, permission, replay, timeline reduc
|
||||
|
||||
Main owns ACP transport, routing, transcript retrieval and timing extraction, workspace grants, and session/generation-scoped attachment authorization. Renderer owns the in-memory timeline, bounded compatibility and timing alignment, attachment presentation, and display grouping, including user-image thumbnails and user-selected source-path labels. ACP replay remains authoritative for historical turns and content; transcript-derived timing may only annotate an unambiguously matched ACP turn. Standard ACP content remains preferred over compatibility projections, and incidental tool paths never enter the attachment pipeline.
|
||||
|
||||
The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, Markdown rendering, Electron rendering performance policy, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, `harness/reference/office-document-preview.md`, `harness/reference/markdown-rendering.md`, and `harness/reference/electron-rendering-performance.md`.
|
||||
An externally triggered cron run may appear only as the separate, bounded, running-only overlay documented in `harness/reference/acp-cron-live-overlay.md`. Gateway runtime events never become ACP notifications or timeline items. The overlay is read-only and cannot own ACP sending, cancellation, permissions, replay, history, or sidebar attention; terminal content becomes visible only through an authoritative ACP or typed cron-history reload after overlay removal.
|
||||
|
||||
The durable architecture, exceptions, access boundary, file-activity separation, Office preview behavior, cron live-overlay boundary, and validation anchors are documented in `harness/reference/acp-chat.md`, `harness/reference/acp-generated-media-and-diagnostics.md`, `harness/reference/acp-attachment-access-control.md`, `harness/reference/openclaw-file-activity.md`, `harness/reference/office-document-preview.md`, and `harness/reference/acp-cron-live-overlay.md`.
|
||||
|
||||
@@ -6,11 +6,11 @@ ownedPaths:
|
||||
- shared/workspace.ts
|
||||
- shared/chat/session-title.ts
|
||||
- electron/services/sessions-api.ts
|
||||
- electron/main/index.ts
|
||||
- src/lib/workspace-context.ts
|
||||
- src/hooks/use-workspace-availability.ts
|
||||
- src/stores/settings.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/session-catalog.ts
|
||||
- src/stores/session-attention.ts
|
||||
- src/stores/chat/session-status.ts
|
||||
@@ -19,7 +19,6 @@ ownedPaths:
|
||||
- src/components/file-preview/ArtifactPanel.tsx
|
||||
- src/components/file-preview/WorkspaceBrowserBody.tsx
|
||||
- src/components/file-preview/FilePreviewBody.tsx
|
||||
- src/components/file-preview/MarkdownPreview.tsx
|
||||
- src/components/file-preview/DocxViewer.tsx
|
||||
- src/components/file-preview/PptxViewer.tsx
|
||||
- src/components/file-preview/build-preview-target.ts
|
||||
@@ -31,7 +30,6 @@ ownedPaths:
|
||||
- src/pages/Chat/AcpTurnFileActivity.tsx
|
||||
- src/pages/Chat/AcpAttachmentPart.tsx
|
||||
- src/components/web-browser/**
|
||||
- src/components/markdown/**
|
||||
- src/stores/artifact-panel.ts
|
||||
- src/components/layout/MainLayout.tsx
|
||||
- src/pages/Chat/ChatInput.tsx
|
||||
@@ -51,10 +49,9 @@ ownedPaths:
|
||||
- tests/unit/i18n-locale-parity.test.ts
|
||||
- tests/unit/session-buckets.test.ts
|
||||
- tests/unit/generated-files.test.ts
|
||||
- tests/unit/generated-files-panel.test.tsx
|
||||
- tests/unit/open-file-utils.test.ts
|
||||
- tests/unit/file-preview-body.test.tsx
|
||||
- tests/unit/markdown-preview.test.tsx
|
||||
- tests/unit/streamdown-config.test.tsx
|
||||
- tests/unit/workspace-browser-body.test.tsx
|
||||
- tests/unit/office-file-viewers.test.tsx
|
||||
- tests/unit/artifact-panel.test.tsx
|
||||
@@ -67,15 +64,11 @@ ownedPaths:
|
||||
- tests/e2e/chat-acp-attachments.spec.ts
|
||||
- tests/e2e/chat-file-changes.spec.ts
|
||||
- tests/e2e/office-document-preview.spec.ts
|
||||
- tests/e2e/markdown-file-preview.spec.ts
|
||||
- tests/e2e/hardware-acceleration.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
requiredProfiles:
|
||||
- fast
|
||||
conditionalProfiles:
|
||||
e2e:
|
||||
- workspace selection, binding, sidebar, browser, or question navigation changes
|
||||
- Markdown file-preview rendering or syntax highlighting changes
|
||||
requiredRules:
|
||||
- session-workspace-authority
|
||||
- renderer-main-boundary
|
||||
@@ -83,13 +76,11 @@ requiredRules:
|
||||
- sidebar-session-attention-authority
|
||||
- office-preview-safety
|
||||
- web-browser-security-and-lifecycle
|
||||
- markdown-rendering-safety-and-performance
|
||||
- electron-rendering-performance
|
||||
- docs-sync
|
||||
---
|
||||
|
||||
This scenario covers inheriting the selected conversation's effective workspace when creating a new Chat; selecting persisted recent, known-session, or newly browsed workspaces while the new Chat remains unbound; validating workspace availability before ACP load; deriving a newly visible local-session title atomically from its first prompt; replacing matching synthetic UUID-date fallback titles with transcript prompts; recovering from deleted global or inherited workspace paths; marking unavailable non-default sidebar groups; permanently deleting their sessions after confirmation; binding workspaces through OpenClaw ACP cwd; targeting another agent without losing that agent's workspace or first prompt; restoring historical workspace context; renaming imported workspace display labels; navigating workspace-grouped sessions with busy, unread, and relative-time status; browsing the effective workspace; previewing authorized local HTML and supported Office documents under their documented safety boundaries; and jumping among user questions from an overlay that leaves the conversation width unchanged.
|
||||
|
||||
Workspace file browsing keeps the store value `browser`; local HTML uses the existing `preview` tab and has no independent browser tab or toolbar. Current workspace resolution, ordering, title normalization, and file-browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`; the HTML guest contract is documented in `harness/reference/web-browser.md`; static Markdown rendering and safety requirements are documented in `harness/reference/markdown-rendering.md`; desktop compositing and interaction profiling requirements are documented in `harness/reference/electron-rendering-performance.md`.
|
||||
Workspace file browsing keeps the store value `browser`; local HTML uses the existing `preview` tab and has no independent browser tab or toolbar. Current workspace resolution, ordering, title normalization, and file-browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`; the HTML guest contract is documented in `harness/reference/web-browser.md`.
|
||||
|
||||
DOCX and PPTX files are accepted as read-only inline previews only at or below the 20 MB compressed-input boundary. Scoped workspace and attachment references retain their authorized read route without naked-path fallback, while Workspace Browser retains its Host-validated absolute-path flow. PPTX visibility must preserve the single mounted PPTX viewer invariant across the kept-mounted Workspace and Preview surfaces. Workspace ownership remains in `harness/reference/chat-workspace-and-navigation.md`; the complete Office contract is `harness/reference/office-document-preview.md`.
|
||||
|
||||
@@ -21,7 +21,7 @@ ownedPaths:
|
||||
- tests/unit/session-catalog.test.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/session-label-hydration.test.ts
|
||||
- tests/e2e/chat-sidebar-session-attention.spec.ts
|
||||
@@ -33,6 +33,18 @@ ownedPaths:
|
||||
- tests/unit/web-browser-policy.test.ts
|
||||
- tests/unit/web-browser-session.test.ts
|
||||
- tests/unit/web-browser-api.test.ts
|
||||
- shared/chat/cron-session.ts
|
||||
- shared/chat/cron-live-run.ts
|
||||
- shared/host-api/contract.ts
|
||||
- shared/host-events/contract.ts
|
||||
- electron/services/cron-live-run-broker.ts
|
||||
- src/lib/host-events.ts
|
||||
- src/stores/cron-live-run-overlay.ts
|
||||
- src/pages/Chat/CronLiveRunOverlay.tsx
|
||||
- tests/unit/cron-live-run-broker.test.ts
|
||||
- tests/unit/cron-live-run-overlay-store.test.ts
|
||||
- tests/unit/cron-live-run-overlay.test.tsx
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
@@ -44,23 +56,23 @@ conditionalProfiles:
|
||||
- channels/agents/settings UI depends on new backend response shape
|
||||
- Web Browser guest, navigation, session, permission, or data policy changes
|
||||
requiredRules:
|
||||
- openclaw-config-delivery
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- gateway-heartbeat-safety
|
||||
- channel-plugin-migration-guards
|
||||
- capability-owner-resolution
|
||||
- active-config-guards
|
||||
- provider-default-invariant
|
||||
- provider-model-metadata-preservation
|
||||
- provider-model-selection-authority
|
||||
- acp-chat-state-and-history
|
||||
- acp-compatibility-content-safety
|
||||
- ui-i18n-design-tokens
|
||||
- sidebar-session-attention-authority
|
||||
- web-browser-security-and-lifecycle
|
||||
- e2e-parallel-isolation
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
forbiddenPatterns:
|
||||
@@ -78,8 +90,6 @@ forbiddenPatterns:
|
||||
|
||||
Gateway backend communication covers all ClawX paths that move data between the visual desktop UI and OpenClaw runtime/backend services.
|
||||
|
||||
Coordinator-owned OpenClaw config mutations and their `config.get`/`config.set` transaction contract are documented in `harness/reference/openclaw-config-delivery.md`.
|
||||
|
||||
Allowed flow:
|
||||
Renderer page/component -> `src/lib/host-api.ts` or `src/lib/api-client.ts` -> Electron Main typed host service or IPC handler -> Main-owned OpenClaw Gateway WebSocket -> runtime result -> store/UI.
|
||||
|
||||
@@ -87,16 +97,12 @@ Renderer code must not own transport selection, direct IPC channels, direct Gate
|
||||
|
||||
Renderer code must not create direct Gateway WebSocket connections. Gateway frame diagnostics must be emitted by Main-process Gateway logging.
|
||||
|
||||
Typed generic Gateway RPC requests are validated by `electron/services/gateway-api.ts` and delegated directly to `GatewayManager.rpc`, including an optional positive finite timeout. This path has no Renderer Chat history/send specialization, polling queue, coalescing, or backpressure layer. ACP `session/load`, `session/prompt`, and `session/cancel` own ordinary Chat history and composer behavior independently.
|
||||
|
||||
Channel/plugin migration behavior is also part of this scenario when ClawX rewrites OpenClaw config before Gateway launch. Upgrades must preserve single-owner channel registration for migrated plugin-backed channels such as Feishu/Lark.
|
||||
|
||||
ClawX's prelaunch config sanitizer also owns desktop tool policy. It must keep `web_search` in both the agent-level and Gateway-level deny lists without replacing existing deny entries or disabling managed browser automation and `web_fetch`.
|
||||
|
||||
Scheduled-task history is Main-owned backend data. Current OpenClaw versions must be queried through the Gateway `cron.runs` RPC; direct run-log file reads are allowed only as a compatibility fallback for older file-backed runtimes. When a run's bounded summary ends with OpenClaw's truncation ellipsis, Main may recover the complete final assistant reply from the run transcript identified by that `cron.runs` entry, but only when the transcript reply is longer and shares the entire summary prefix. When a cron base session has no ACP replay, Renderer may project that typed host result into a generation-scoped, in-memory historical ACP timeline, but must not replace or duplicate non-empty ACP replay.
|
||||
|
||||
Running cron progress has one narrower, non-historical path: strict run-scoped cron Gateway events may enter a bounded Main-owned broker and reach Renderer as an explicit read-only live overlay through typed host-api and host-events contracts. The overlay remains separate from ACP updates, ACP timeline reduction, cron history, prompt state, and sidebar attention; terminal events remove it before authoritative ACP or typed cron-history reload. The durable exception and its bounds are documented in `harness/reference/acp-cron-live-overlay.md`.
|
||||
|
||||
The local HTML Preview privileged bridge is also Main-owned: Renderer may load a validated local HTML file or open that current file externally through the typed Host API. The guest is an implementation detail of the existing `preview` tab; there is no `web-browser` artifact tab or general address navigation. The durable guest contract is `harness/reference/web-browser.md`.
|
||||
|
||||
Gateway session-catalog subscription, normalization, ordered list/event replay, attention transitions, and reconnect recovery are documented in `harness/reference/sidebar-session-attention.md`. Electron test-process isolation and global-resource scheduling are documented in `harness/reference/e2e-parallelism.md`.
|
||||
|
||||
Gateway WebSocket heartbeat misses are diagnostic availability signals only. They may mark health unresponsive, but must not terminate the socket or replace the Gateway process; authoritative process-exit and socket-close signals retain automatic lifecycle recovery ownership.
|
||||
Gateway session-catalog subscription, normalization, ordered list/event replay, attention transitions, and reconnect recovery are documented in `harness/reference/sidebar-session-attention.md`.
|
||||
|
||||
@@ -12,7 +12,6 @@ requiredProfiles:
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- gateway-heartbeat-safety
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
@@ -33,8 +32,6 @@ ClawX should prefer OpenClaw-native signals over stderr string matching:
|
||||
|
||||
stderr is supporting evidence only. It should not be the primary source for deciding whether the Gateway is ready, blocked, or should be restarted.
|
||||
|
||||
WebSocket heartbeat misses likewise prove only that the Gateway control plane did not answer within the observation window. They update heartbeat diagnostics and may mark health unresponsive, but do not terminate the socket or restart the process. Process exit and socket close remain the authoritative automatic recovery signals, so long-running work is not killed solely because pong handling is delayed.
|
||||
|
||||
## Failure Shape
|
||||
|
||||
Treat these as the same incident family until proven otherwise:
|
||||
@@ -45,7 +42,7 @@ Treat these as the same incident family until proven otherwise:
|
||||
- `[gateway-startup] Slow managed Gateway startup detected`
|
||||
- `[gateway:rpc] doctor.memory.status failed`
|
||||
- `[gateway:rpc] doctor.memory.dreamDiary failed`
|
||||
- `sessions.list unavailable during gateway startup`
|
||||
- `chat.history unavailable during gateway startup`
|
||||
- Port `18789` is listening, but Gateway HTTP or WebSocket RPC does not return.
|
||||
|
||||
Important distinction:
|
||||
@@ -191,14 +188,14 @@ Expected mitigation:
|
||||
|
||||
Symptoms:
|
||||
|
||||
- Gateway handshake completes, but `system-presence`, `sessions.list`, or `doctor.memory.*` times out during the first minutes.
|
||||
- Gateway handshake completes, but `system-presence`, `chat.history`, or `doctor.memory.*` times out during the first minutes.
|
||||
- Logs mention cron repair, channel account checks, session lock cleanup, memory-core cron reconciliation, or active embedded/task runs.
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- Do not mark Gateway fully ready from a pure timer fallback.
|
||||
- The fallback must probe `system-presence` before emitting ready.
|
||||
- Heartbeat misses remain observable during startup work, but do not trigger process restart.
|
||||
- Heartbeat recovery may defer restart during the initial grace window, but it should not loop restart while the Gateway is still performing startup work.
|
||||
|
||||
### Capability Degraded But Core Alive
|
||||
|
||||
@@ -278,7 +275,6 @@ pnpm exec openclaw gateway call status >/tmp/clawx-status.json
|
||||
- `health` and `status` are captured in Gateway diagnostics when available.
|
||||
- Memory doctor calls return when the memory capability is available.
|
||||
- `doctor.memory.*` and `channels.status` failures degrade their capability only and do not trigger Gateway restart.
|
||||
- Consecutive heartbeat misses update diagnostics and health state without terminating the socket or replacing the Gateway process.
|
||||
- Logs no longer repeat stale runtime cache or escaped managed-skill symlink warnings for entries ClawX can safely clean.
|
||||
|
||||
## Required Regression Coverage
|
||||
@@ -292,7 +288,7 @@ pnpm exec vitest run tests/unit/openclaw-auth.test.ts tests/unit/skills-symlink-
|
||||
pnpm run build:vite
|
||||
```
|
||||
|
||||
If the change touches Gateway send/receive, generic RPC dispatch, fallback, or readiness, also run:
|
||||
If the change touches Gateway send/receive, fallback, readiness, or chat history, also run:
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
|
||||
@@ -16,7 +16,6 @@ touchedAreas:
|
||||
- src/stores/acp-chat-session.ts
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/pages/Chat/ChatInput.tsx
|
||||
- src/pages/Chat/AcpImagePart.tsx
|
||||
- tests/unit/acp-image-generation-compat.test.ts
|
||||
- tests/unit/acp-reducer.test.ts
|
||||
- tests/unit/acp-chat-store.test.ts
|
||||
@@ -65,8 +64,6 @@ acceptance:
|
||||
- Duplicate completion records do not create duplicate assistant image replies.
|
||||
- Live background image generation shows its dedicated generating label until its success or failure completion is projected, without changing the existing sending/thinking behavior.
|
||||
- Switching conversations preserves each live image-generation pending state and restores its indicator on return.
|
||||
- Previously rendered generated images remain visible while a later image-generation task is pending, including across session reloads and navigation.
|
||||
- Completion evidence received during a session reload is projected after the new load generation is active instead of being overwritten or dropped as stale.
|
||||
- Completion evidence received while the image conversation is inactive is deferred to that conversation, and a second prompt cannot be sent until the image task settles.
|
||||
- Stale preview resolution does not append to a different active session or generation.
|
||||
docs:
|
||||
|
||||
@@ -76,7 +76,7 @@ touchedAreas:
|
||||
- tests/unit/acp-host-contract.test.ts
|
||||
- tests/unit/acp-chat-service.test.ts
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/chat-acp-inline-timeline.test.tsx
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/attachment-access.test.ts
|
||||
- tests/unit/files-api-workspace.test.ts
|
||||
- tests/unit/media-api.test.ts
|
||||
@@ -98,6 +98,7 @@ touchedAreas:
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- tests/e2e/chat-assistant-markdown-plain.spec.ts
|
||||
- tests/e2e/chat-code-block-wrap.spec.ts
|
||||
- tests/e2e/chat-history-startup-retry.spec.ts
|
||||
- tests/e2e/chat-latex-rendering.spec.ts
|
||||
- tests/e2e/chat-new-session-date.spec.ts
|
||||
- tests/e2e/chat-question-directory.spec.ts
|
||||
@@ -105,7 +106,7 @@ touchedAreas:
|
||||
- tests/e2e/chat-scroll-pin-bottom.spec.ts
|
||||
- tests/e2e/chat-scroll-to-latest.spec.ts
|
||||
- tests/e2e/chat-table-header-light.spec.ts
|
||||
- tests/e2e/chat-acp-process-timeline.spec.ts
|
||||
- tests/e2e/chat-task-visualizer.spec.ts
|
||||
- tests/e2e/chat-workspace-context.spec.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
- tests/e2e/fixtures/electron.ts
|
||||
|
||||
@@ -30,14 +30,15 @@ touchedAreas:
|
||||
- tests/unit/acp-*.test.tsx
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/chat-acp-inline-timeline.test.tsx
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/host-api-facade.test.ts
|
||||
- tests/unit/host-events.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/openclaw-cli.test.ts
|
||||
- tests/unit/task-visualization.test.ts
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/e2e/chat-acp-process-timeline.spec.ts
|
||||
- tests/e2e/chat-task-visualizer.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
@@ -45,6 +46,7 @@ expectedUserBehavior:
|
||||
- Opening a Chat session loads history through ACP session/load replay.
|
||||
- Sending a Chat prompt uses ACP session/prompt, shows an optimistic user segment, and coalesces it with the ACP user echo.
|
||||
- Thinking, tool calls, permission requests, plans, generated files, and generated images appear as inline timeline blocks in ACP event order.
|
||||
- The old Execution Graph aggregation is not used for the ACP Chat path.
|
||||
- Renderer does not call Gateway HTTP or WebSocket endpoints directly.
|
||||
- Gateway-backed models, providers, plugins, skills, doctor, workspace, settings, and media configuration continue to work.
|
||||
requiredProfiles:
|
||||
@@ -65,9 +67,9 @@ requiredRules:
|
||||
requiredTests:
|
||||
- pnpm run typecheck
|
||||
- pnpm exec vitest run tests/unit/acp-host-contract.test.ts tests/unit/acp-chat-service.test.ts tests/unit/acp-reducer.test.ts tests/unit/acp-chat-store.test.ts tests/unit/acp-chat-components.test.tsx tests/unit/chat-acp-page.test.tsx
|
||||
- pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/openclaw-cli.test.ts tests/unit/host-services.test.ts tests/unit/chat-acp-inline-timeline.test.tsx
|
||||
- pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/openclaw-cli.test.ts tests/unit/host-services.test.ts tests/unit/chat-page-execution-graph.test.tsx tests/unit/task-visualization.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-acp-process-timeline.spec.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-task-visualizer.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
@@ -75,7 +77,7 @@ acceptance:
|
||||
- Main forwards ACP SessionNotification envelopes and permission request envelopes without translating text, thinking, tools, or media into legacy Chat events.
|
||||
- Renderer reduces ACP notifications into an in-memory ordered timeline.
|
||||
- No ClawX ACP replay ledger, Chat history cache, or reduced timeline persistence is introduced.
|
||||
- The primary Chat page uses ACP notifications and replay as its ordinary timeline sources; bounded image-generation compatibility evidence remains allowed.
|
||||
- The primary Chat page does not use gateway:chat-message or chat:runtime-event as ordinary Chat timeline sources; restricted image-generation compatibility evidence remains allowed.
|
||||
- Inline process blocks preserve ordering between assistant message segments.
|
||||
docs:
|
||||
required: true
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
id: add-chat-performance-diagnostics
|
||||
title: Add chat performance diagnostics
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Establish repeatable Renderer and Electron Main profiling for ACP chat streaming, then remove measured per-update work without changing OpenClaw.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/add-chat-performance-diagnostics.md
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
- tests/e2e/fixtures/electron.ts
|
||||
- src/pages/Chat/**
|
||||
- src/lib/acp/**
|
||||
- src/stores/acp-chat-session.ts
|
||||
- electron/services/acp-trace.ts
|
||||
- electron/gateway/event-dispatch.ts
|
||||
- electron/main/index.ts
|
||||
- tests/unit/**
|
||||
- package.json
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
- AGENTS.md
|
||||
expectedUserBehavior:
|
||||
- ACP chat output remains semantically identical while long streaming responses stay responsive.
|
||||
- Developers can capture bounded Renderer and Main CPU profiles from a deterministic synthetic chat workload.
|
||||
- Profiling uses isolated synthetic data and never modifies OpenClaw.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- host-events-fallback-policy
|
||||
- diagnostics-trace-safety
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm run perf:chat
|
||||
- pnpm exec vitest run tests/unit/acp-chat-store.test.ts tests/unit/acp-trace.test.ts tests/unit/gateway-event-dispatch.test.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run build:vite
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
- The performance command writes versioned Renderer metrics plus standard Renderer and Main CPU profile artifacts.
|
||||
- The synthetic workload covers a populated ACP timeline and a growing live Markdown response.
|
||||
- Performance artifacts contain only generated fixture content and remain outside source control.
|
||||
- Each confirmed optimization has focused regression coverage and preserves ACP event ordering and rendered output.
|
||||
- Renderer continues to receive backend events through the existing typed host event boundary.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task creates a deterministic profiling loop for Electron Main and Renderer, records a baseline, and applies only optimizations supported by those recordings.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Changes to the OpenClaw source tree or bundled package.
|
||||
- Hardware-independent absolute timing gates.
|
||||
- Uploading CPU profiles or performance traces as product telemetry.
|
||||
- GPU-process or native-code profiling.
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
id: apply-acp-stream-updates-immediately
|
||||
title: Apply live ACP stream updates immediately
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Remove Renderer-side live ACP chunk batching so Streamdown receives each accepted host event without a 16 ms queue.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/apply-acp-stream-updates-immediately.md
|
||||
- harness/reference/acp-chat.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- src/stores/acp-chat-session.ts
|
||||
- tests/unit/acp-chat-store.test.ts
|
||||
- tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Live assistant output advances as each ACP host event arrives instead of waiting for a Renderer batching timer.
|
||||
- Stream ordering, stale-generation rejection, permission handling, and completed prompt behavior remain unchanged.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- host-events-fallback-policy
|
||||
- acp-chat-state-and-history
|
||||
- comms-regression
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/acp-chat-store.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
- Each accepted live ACP session update is applied synchronously through the existing typed host-event subscription.
|
||||
- Two consecutive live ACP chunks produce two ordered Store notifications rather than one timer-batched notification.
|
||||
- Historical session replay retains its existing generation-scoped reduction behavior.
|
||||
- The Renderer introduces no replacement queue, timer, transport, or fallback path.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task changes only the Renderer Store update cadence for live ACP host events. It does not change ACP transport, Main-process routing, timeline reduction semantics, Streamdown configuration, or persisted history authority.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: chat-tool-events-runtime-pipeline
|
||||
title: Make Chat runtime event-first with Main-owned Gateway communication
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Move Chat send/history/control and streamed runtime events to a Main-owned pipeline, consume OpenClaw tool events as the active-run source of truth, simplify Execution Graph active rendering, and remove the legacy dual-track Chat store path.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/chat-tool-events-runtime-pipeline.md
|
||||
- electron/api/routes/gateway.ts
|
||||
- electron/gateway/chat-runtime-events.ts
|
||||
- electron/gateway/event-dispatch.ts
|
||||
- electron/gateway/manager.ts
|
||||
- electron/gateway/ws-client.ts
|
||||
- electron/main/index.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/preload/index.ts
|
||||
- shared/chat-runtime-events.ts
|
||||
- src/lib/host-events.ts
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/pages/Chat/image-generation-status.ts
|
||||
- src/pages/Chat/task-visualization.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/**
|
||||
- src/stores/chat/runtime-graph.ts
|
||||
- src/stores/gateway.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/e2e/chat-task-visualizer.spec.ts
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/chat-runtime-event-handlers.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/unit/host-events.test.ts
|
||||
- tests/unit/image-generation-status.test.ts
|
||||
- tests/unit/task-visualization.test.ts
|
||||
expectedUserBehavior:
|
||||
- Chat send/history/abort flows no longer depend on renderer direct Gateway WebSocket transport.
|
||||
- Active chat runs stream tool lifecycle and related process updates through Main-owned runtime events.
|
||||
- Execution Graph for the active run reflects streamed runtime events instead of inferring the live timeline from history polling.
|
||||
- Final assistant reply continues to render as a normal chat bubble, while Execution Graph focuses on process steps.
|
||||
- Tool-produced file artifacts continue to surface under the final assistant message.
|
||||
- Historical sessions still reconstruct process graphs from transcript/message history as a fallback.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
requiredTests:
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/chat-runtime-event-handlers.test.ts
|
||||
- tests/e2e/chat-task-visualizer.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
acceptance:
|
||||
- Renderer Chat code uses Host API / Host events rather than renderer-owned Gateway transport.
|
||||
- Main Gateway connection declares the capability needed to receive streamed tool events.
|
||||
- Main normalizes OpenClaw chat/agent runtime events before forwarding them to the renderer.
|
||||
- Active-run Execution Graph is driven by streamed runtime events and only updates existing steps by stable runtime identifiers.
|
||||
- Default chat history polling is removed from the active-run happy path.
|
||||
- Chat store no longer maintains separate live logic in both the monolithic store and an unused duplicate action path.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -47,7 +47,7 @@ touchedAreas:
|
||||
- tests/unit/session-title.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/sessions-api-workspace.test.ts
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/workspace-browser-body.test.tsx
|
||||
@@ -79,7 +79,7 @@ requiredRules:
|
||||
requiredTests:
|
||||
- pnpm harness validate --spec harness/specs/tasks/chat-workspace-context.md
|
||||
- pnpm run typecheck
|
||||
- pnpm exec vitest run tests/unit/workspace-context.test.ts tests/unit/session-title.test.ts tests/unit/host-services.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-session-management.test.ts tests/unit/sidebar-session-buckets.test.ts tests/unit/sessions-api-workspace.test.ts tests/unit/session-buckets.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/workspace-browser-body.test.tsx
|
||||
- pnpm exec vitest run tests/unit/workspace-context.test.ts tests/unit/session-title.test.ts tests/unit/host-services.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-store-history-retry.test.ts tests/unit/sidebar-session-buckets.test.ts tests/unit/sessions-api-workspace.test.ts tests/unit/session-buckets.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/workspace-browser-body.test.tsx
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/chat-workspace-context.spec.ts
|
||||
- pnpm run comms:replay
|
||||
|
||||
@@ -68,3 +68,4 @@ for the model. Try /reset (or /new) ...". Users also had no
|
||||
- Renderer UI for editing contextWindow per model.
|
||||
- Writing `maxTokens` for non-anthropic providers (changes request payloads).
|
||||
- Backfill for non-custom (registry/ollama) provider entries.
|
||||
- Image attachment compression in `chat:sendWithMedia`.
|
||||
|
||||
@@ -25,8 +25,8 @@ touchedAreas:
|
||||
- src/stores/settings.ts
|
||||
- tests/unit/use-workspace-availability.test.tsx
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/chat-acp-inline-timeline.test.tsx
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/settings-store.test.ts
|
||||
- tests/e2e/chat-workspace-context.spec.ts
|
||||
expectedUserBehavior:
|
||||
@@ -51,7 +51,7 @@ requiredRules:
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm harness validate --spec harness/specs/tasks/delete-unavailable-chat-workspace.md
|
||||
- pnpm exec vitest run tests/unit/use-workspace-availability.test.tsx tests/unit/chat-session-management.test.ts tests/unit/settings-store.test.ts
|
||||
- pnpm exec vitest run tests/unit/use-workspace-availability.test.tsx tests/unit/chat-store-history-retry.test.ts tests/unit/settings-store.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-workspace-context.spec.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run build:vite
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
id: disable-web-search
|
||||
title: Disable general-purpose web search in ClawX
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent ClawX agents from using OpenClaw's general-purpose web_search tool while retaining explicit browser automation and URL fetching.
|
||||
scenario: gateway-backend-communication
|
||||
touchedAreas:
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/sanitize-config.test.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
- harness/specs/rules/active-config-guards.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/tasks/disable-web-search.md
|
||||
expectedUserBehavior:
|
||||
- ClawX agents cannot invoke the web_search tool.
|
||||
- Gateway-exposed tools cannot bypass the agent-level web_search restriction.
|
||||
- Existing user-defined tool deny entries remain intact.
|
||||
- Managed browser automation and web_fetch remain available.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/sanitize-config.test.ts
|
||||
acceptance:
|
||||
- Config sanitization adds web_search to tools.deny and gateway.tools.deny.
|
||||
- Repeated sanitization is idempotent and does not duplicate deny entries.
|
||||
- Existing deny entries are preserved in their original order.
|
||||
- No browser or web_fetch deny entry is introduced.
|
||||
- Targeted unit tests, type checks, communication regression checks, and harness validation pass.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
ClawX uses managed browser automation and explicit URL fetching when internet access is needed. General-purpose OpenClaw web search is disabled at both policy layers so agent and Gateway tool surfaces remain consistent.
|
||||
@@ -12,9 +12,11 @@ touchedAreas:
|
||||
- shared/chat/types.ts
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/session-catalog.ts
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/unit/chat-load-sessions-startup.test.ts
|
||||
- tests/unit/chat-session-actions.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/session-catalog.test.ts
|
||||
- tests/e2e/chat-new-session-date.spec.ts
|
||||
@@ -30,7 +32,7 @@ requiredRules:
|
||||
- session-workspace-authority
|
||||
- sidebar-session-attention-authority
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-acp-page.test.tsx
|
||||
- pnpm exec vitest run tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-session-actions.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/chat-acp-page.test.tsx
|
||||
- pnpm run build:vite && pnpm exec playwright test tests/e2e/chat-new-session-date.spec.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: fix-chat-history-gateway-timeout
|
||||
title: Fix chat history timeout through gateway backend communication
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Adjust backend communication behavior for chat history loading.
|
||||
touchedAreas:
|
||||
- src/lib/api-client.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
expectedUserBehavior:
|
||||
- Chat history loads through the existing host API boundary.
|
||||
- Gateway timeout does not leave the visible chat in a stale state.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/chat-history-actions.test.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- Comms replay and compare pass.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
Example task spec for gateway backend communication work. Copy this file to a task-specific name before starting an AI Coding change.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: fix-chat-history-gateway-timeout
|
||||
title: Fix chat history timeout through gateway backend communication
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Reduce startup chat.history contention so foreground history loads do not time out behind sidebar background hydration.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-chat-history-gateway-timeout.md
|
||||
- electron/services/sessions-api.ts
|
||||
- electron/gateway/rpc-backpressure.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/pages/Chat/message-utils.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
- src/stores/chat/history-startup-retry.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/session-label-hydration.ts
|
||||
- src/stores/chat/store-api.ts
|
||||
- src/stores/chat/types.ts
|
||||
- tests/setup.ts
|
||||
- tests/e2e/chat-history-startup-retry.spec.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/gateway-rpc-backpressure.test.ts
|
||||
- tests/unit/history-startup-retry.test.ts
|
||||
- tests/unit/session-label-fetch.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
expectedUserBehavior:
|
||||
- Foreground chat history loading is prioritized during gateway startup and restart.
|
||||
- Sidebar/session label hydration does not compete with the first visible history load.
|
||||
- Sidebar label hydration uses the existing host API boundary instead of heavy gateway chat.history fan-out.
|
||||
- Renderer continues to use the existing host/store boundary.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm run typecheck
|
||||
- tests/e2e/chat-history-startup-retry.spec.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/history-startup-retry.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/gateway-rpc-backpressure.test.ts
|
||||
- tests/unit/session-label-fetch.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- Startup/restart no longer fans out sidebar label chat.history calls before the visible session history finishes loading.
|
||||
- Sidebar label hydration no longer depends on gateway chat.history full-session scans.
|
||||
- Foreground history uses a bounded startup RPC wait and falls back to local transcript reads instead of surfacing transient RPC timeout errors.
|
||||
- Foreground startup history can show local transcript data while chat.history is pending, then replace it with Gateway history without disabling startup retry.
|
||||
- Main-process chat.history RPCs are single-flighted/backpressured before reaching the Gateway.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: fix-chat-stuck-processing-tool-results
|
||||
title: Clear stale chat run state when Gateway reports the session is idle
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent the renderer from staying stuck on “Processing tool results…” when a terminal Gateway lifecycle event was missed but sessions.list reports the current session has no active run.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-chat-stuck-processing-tool-results.md
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat/types.ts
|
||||
- tests/unit/chat-session-actions.test.ts
|
||||
expectedUserBehavior:
|
||||
- If Gateway reports the current session has status done or hasActiveRun=false after the user's send timestamp, the chat composer and tool-processing indicator return to idle.
|
||||
- A fresh in-flight send is not prematurely cleared by stale sessions.list metadata from before the user message.
|
||||
- Renderer continues to use the existing gateway:rpc Main-process boundary.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/chat-session-actions.test.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls outside the existing api-client invocation path.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- sessions.list idle metadata reconciles stale sending/activeRunId/pendingFinal state for the current session.
|
||||
- sessions.list metadata older than the current user send does not clear active state.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: fix-first-chat-no-response-fallback-poll
|
||||
title: Restore fallback transcript polling so missing streamed events do not fail the first chat
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent the false "The model did not respond within 120 seconds" / "No response received from the model" errors on the first chat after Gateway startup when streamed chat/runtime events never reach the renderer, by polling chat.history as a fallback progress source during active sends.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-first-chat-no-response-fallback-poll.md
|
||||
- src/stores/chat.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
expectedUserBehavior:
|
||||
- When a send receives no streamed chat/runtime events (e.g. first run after Gateway startup or a silent WS drop), the renderer polls chat.history and surfaces transcript progress instead of firing the 120s/130s no-response safety errors.
|
||||
- When the transcript shows a conclusive assistant reply, the run closes normally (sending cleared, reply rendered) without any error banner.
|
||||
- While streamed events are fresh, the fallback poll issues no extra chat.history RPCs, so healthy streamed runs are unaffected.
|
||||
- Renderer continues to use the existing gateway rpc Main-process boundary for chat.history polling.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/chat-store-history-retry.test.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- The active sendMessage path arms a fallback transcript poll that only issues chat.history RPCs after sustained streamed-event silence.
|
||||
- Streamed chat events no longer permanently clear the fallback poll timer; the poll self-throttles via event freshness instead.
|
||||
- A run whose transcript contains a final assistant reply closes without emitting the no-response safety errors even when zero streamed events arrive.
|
||||
- Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client invocation path.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
id: fix-gateway-agent-phase-end-state
|
||||
title: Keep chat run active across non-terminal gateway phase end events
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent Gateway agent lifecycle hints from prematurely clearing the chat sending state while tool execution continues.
|
||||
touchedAreas:
|
||||
- .gitignore
|
||||
- harness/specs/tasks/fix-gateway-agent-phase-end-state.md
|
||||
- src/stores/gateway.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/unit/gateway-events.test.ts
|
||||
expectedUserBehavior:
|
||||
- The chat composer keeps showing the stop control while an agent run continues across tool rounds.
|
||||
- Intermediate `phase: end` notifications refresh history without making the run look interrupted.
|
||||
- Progressive streaming delta notifications without sequence numbers continue updating the visible response.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/gateway-events.test.ts tests/unit/chat-event-dedupe.test.ts
|
||||
- pnpm run build:vite && pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- `phase: end` no longer clears `sending`, `activeRunId`, `pendingFinal`, or `lastUserMessageAt`.
|
||||
- Terminal phases such as `completed` still clear chat run state.
|
||||
- Gateway event dedupe does not suppress same-run delta notifications that do not carry `seq`.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
+9
-8
@@ -1,23 +1,24 @@
|
||||
---
|
||||
id: fix-gateway-ready-session-catalog-reload
|
||||
title: Fix delayed sidebar session catalog reload after gateway restart
|
||||
id: fix-gateway-ready-chat-history-reload
|
||||
title: Fix delayed sidebar chat history reload after gateway restart
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Align gateway readiness signaling with sidebar session catalog refresh after restart.
|
||||
intent: Align gateway readiness signaling with sidebar history refresh after restart.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-gateway-ready-session-catalog-reload.md
|
||||
- harness/specs/tasks/fix-gateway-ready-chat-history-reload.md
|
||||
- electron/gateway/manager.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
- src/pages/Chat/ChatInput.tsx
|
||||
- src/pages/Settings/index.tsx
|
||||
- src/pages/Setup/index.tsx
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-load-sessions-startup.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/e2e/gateway-lifecycle.spec.ts
|
||||
expectedUserBehavior:
|
||||
- After gateway restart, the sidebar session catalog reloads as soon as the gateway becomes RPC-ready.
|
||||
- After gateway restart, sidebar history reloads as soon as the gateway becomes RPC-ready.
|
||||
- UI does not show a fully healthy green running state while gatewayReady is still false.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
@@ -30,13 +31,13 @@ requiredRules:
|
||||
requiredTests:
|
||||
- tests/unit/gateway-ready-fallback.test.ts
|
||||
- tests/unit/chat-input.test.tsx
|
||||
- tests/unit/chat-load-sessions-startup.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/e2e/gateway-lifecycle.spec.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- Gateway running-but-not-ready state is surfaced distinctly from fully ready.
|
||||
- Sidebar reloads the session catalog when gatewayReady becomes true after a restart.
|
||||
- Sidebar reloads sessions/history when gatewayReady becomes true after a restart.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -1,22 +1,21 @@
|
||||
---
|
||||
id: fix-image-generation-message-delivery
|
||||
title: Surface async image-generation deliveries in ACP Chat
|
||||
title: Surface async image-generation message-tool deliveries in Chat
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Ensure trusted OpenClaw image-generation completion deliveries remain visible in ACP Chat through the bounded compatibility projection.
|
||||
intent: Ensure generated images delivered through the OpenClaw message tool remain visible in ClawX chat even when Gateway does not append an assistant-media transcript bubble.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-image-generation-message-delivery.md
|
||||
- src/lib/acp/image-generation-compat.ts
|
||||
- src/lib/acp/reducer.ts
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/stores/acp-chat-session.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/chat/helpers.ts
|
||||
- tests/e2e/chat-run-state-events.spec.ts
|
||||
- tests/unit/acp-image-generation-compat.test.ts
|
||||
- tests/unit/acp-chat-store.test.ts
|
||||
- tests/unit/chat-helpers-enrichment.test.ts
|
||||
- tests/unit/chat-page-execution-graph.test.tsx
|
||||
expectedUserBehavior:
|
||||
- When async image generation completes with trusted internal-UI delivery evidence, the sourceReply caption and image appear in the matching ACP timeline.
|
||||
- ACP image-generation pending state settles from accepted completion evidence without relying on a legacy Chat message renderer.
|
||||
- Renderer continues to use existing Host API and Host event boundaries and does not call Gateway HTTP directly.
|
||||
- When async image generation completes and the message tool returns mediaUrl/mediaUrls, the sourceReply caption and image appear as a final assistant reply.
|
||||
- Chat image generation pending state can settle from message-tool delivery records without relying solely on assistant-media bubbles.
|
||||
- Renderer continues to use existing Host API / Gateway history paths and does not call Gateway HTTP directly.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
@@ -26,17 +25,14 @@ requiredRules:
|
||||
- api-client-transport-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- acp-chat-state-and-history
|
||||
- acp-compatibility-content-safety
|
||||
- diagnostics-trace-safety
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/acp-image-generation-compat.test.ts tests/unit/acp-chat-store.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts -g "projects OpenClaw image-generation"
|
||||
- pnpm exec vitest run tests/unit/chat-helpers-enrichment.test.ts tests/unit/chat-page-execution-graph.test.tsx
|
||||
- pnpm exec playwright test tests/e2e/chat-run-state-events.spec.ts
|
||||
- pnpm run typecheck
|
||||
acceptance:
|
||||
- ClawX accepts only trusted ACP or Gateway completion evidence that matches the ACP session and recent image-generation context.
|
||||
- Internal-UI sourceReply text remains authoritative for successful media replies and text-only failure replies.
|
||||
- Existing safeguards still reject arbitrary image paths and generic tool output without approved image-generation context.
|
||||
- message tool call arguments using media/mediaUrl/mediaUrls are promoted to chat attachments unless a matching internal-UI delivery reply exists.
|
||||
- successful internal-UI message tool results with mediaUrl/mediaUrls/sourceReply.mediaUrls become a standalone assistant reply before toolresult rows are filtered.
|
||||
- Existing safeguards still avoid promoting arbitrary image paths from read/exec tool output.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
@@ -11,6 +11,7 @@ touchedAreas:
|
||||
- harness/specs/rules/session-workspace-authority.md
|
||||
- src/pages/Chat/index.tsx
|
||||
- shared/chat/types.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat.ts
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
|
||||
@@ -8,9 +8,9 @@ touchedAreas:
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/services/sessions-api.ts
|
||||
- electron/utils/session-files.ts
|
||||
- src/stores/chat/session-actions.ts
|
||||
- src/stores/chat.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- harness/specs/tasks/hard-delete-session-jsonl.md
|
||||
- AGENTS.md
|
||||
expectedUserBehavior:
|
||||
@@ -19,18 +19,19 @@ expectedUserBehavior:
|
||||
- When the pointer's runtimeFile points outside the sessions/ folder (the OPENCLAW_TRAJECTORY_DIR override), that off-disk runtime file is also unlinked so no orphan trajectory remains anywhere on disk.
|
||||
- The session entry is removed from sessions.json so OpenClaw sessions.list stops returning it.
|
||||
- The sidebar list, sessionLabels and sessionLastActivity for the deleted key are cleared in the renderer store.
|
||||
- Any pending optimistic user message cache for the deleted session key is cleared so a later history reload cannot resurrect deleted chat bubbles.
|
||||
- Token usage history reported by the Dashboard stops including the deleted session.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/chat-session-actions.test.ts
|
||||
acceptance:
|
||||
- Renderer continues to use src/lib/host-api.ts and src/lib/api-client.ts; no new direct ipcRenderer or Gateway HTTP calls.
|
||||
- Typed host session deletion and the legacy session:delete IPC handler unlink the same set of files for a given session id, sharing electron/utils/session-files.ts so the disk contract cannot drift.
|
||||
- The handler tolerates ENOENT (file already gone) and still updates sessions.json so the sidebar stops listing the entry.
|
||||
- Renderer delete-session paths clear catalog labels, activity, selection, and persisted attention for deleted keys.
|
||||
- Renderer delete-session paths clear any in-memory pending optimistic user messages for the deleted key before subsequent history loads run.
|
||||
- agentId from the sessionKey is validated against /^[A-Za-z0-9][A-Za-z0-9_-]*$/ in both surfaces and any sessionFile resolved to a path outside the agent sessions/ directory is refused (defence-in-depth against a corrupt sessions.json).
|
||||
- Absolute-path detection accepts POSIX paths, Windows backslash paths (C:\...) and Windows forward-slash paths (C:/...) so the sweep works on every supported OS.
|
||||
- The sweep also unlinks <id>.trajectory.jsonl and <id>.trajectory-path.json sidecars produced by OpenClaw's runtime trajectory writer.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
id: maintain-session-catalog-reconciliation
|
||||
title: Maintain session catalog reconciliation alongside ACP Chat
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Adjust session catalog reconciliation without creating a second ACP history or prompt-state authority.
|
||||
touchedAreas:
|
||||
- src/stores/chat/session-catalog.ts
|
||||
- src/stores/chat.ts
|
||||
expectedUserBehavior:
|
||||
- Gateway reconnects reconcile normalized session rows without replacing the selected ACP timeline.
|
||||
- Delayed catalog metadata cannot overwrite a newer local session selection or mutation.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredTests:
|
||||
- tests/unit/session-catalog.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
acceptance:
|
||||
- Renderer does not add direct IPC calls.
|
||||
- Renderer does not fetch Gateway HTTP directly.
|
||||
- ACP session/load remains the ordinary Chat history authority.
|
||||
- Session catalog updates remain generation- and mutation-fenced.
|
||||
- Comms replay and compare pass.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
|
||||
Structural example for maintaining the Gateway-backed session catalog alongside ACP Chat through the backend communication scenario. Copy it to a task-specific file and replace the example identity before starting an AI Coding change.
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
id: make-gateway-heartbeat-observability-only
|
||||
title: Make Gateway heartbeat misses observability-only
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Prevent long-running OpenClaw work from being interrupted when the Gateway control plane temporarily stops answering WebSocket ping frames.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/make-gateway-heartbeat-observability-only.md
|
||||
- harness/specs/rules/gateway-heartbeat-safety.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/scenarios/gateway-startup-diagnostics.md
|
||||
- electron/gateway/manager.ts
|
||||
- tests/unit/gateway-manager-heartbeat.test.ts
|
||||
- tests/unit/gateway-manager-diagnostics.test.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Long-running model, tool, and scheduled tasks are not interrupted solely because Gateway pong frames are delayed.
|
||||
- Consecutive heartbeat misses still mark Gateway health as unresponsive and remain visible in diagnostics.
|
||||
- A real Gateway process exit or WebSocket close continues to use the existing reconnect and crash-recovery paths.
|
||||
- Users can still explicitly restart Gateway when they decide recovery is necessary.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- gateway-heartbeat-safety
|
||||
- gateway-readiness-policy
|
||||
- backend-communication-boundary
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/gateway-manager-heartbeat.test.ts
|
||||
- tests/unit/gateway-manager-diagnostics.test.ts
|
||||
- tests/unit/gateway-connection-monitor.test.ts
|
||||
acceptance:
|
||||
- Reaching the consecutive heartbeat miss threshold records heartbeat timeout diagnostics but does not terminate the socket or call GatewayManager.restart on any platform.
|
||||
- Heartbeat recovery through a pong or any incoming Gateway message resets the miss counter as before.
|
||||
- Gateway health becomes unresponsive at the existing miss threshold.
|
||||
- Process exit, socket close, explicit restart, and code-1012 reconnect behavior are unchanged.
|
||||
- The initial gateway.ready heartbeat restart timer is removed because heartbeat misses no longer own process recovery.
|
||||
- README translations describe heartbeat misses as diagnostic evidence rather than a process-restart trigger.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
Use this task spec for the first-stage heartbeat safety change. It deliberately does not add active-work tracking or a new automatic hard-restart policy.
|
||||
@@ -30,6 +30,7 @@ touchedAreas:
|
||||
- shared/i18n/locales/ru/chat.json
|
||||
- tests/unit/harness-specs.test.ts
|
||||
- tests/unit/generated-files.test.ts
|
||||
- tests/unit/generated-files-panel.test.tsx
|
||||
- tests/unit/open-file-utils.test.ts
|
||||
- tests/unit/file-preview-body.test.tsx
|
||||
- tests/unit/workspace-browser-body.test.tsx
|
||||
@@ -53,7 +54,7 @@ expectedUserBehavior:
|
||||
- Authorized PPTX files at or below 20 MB render one slide at a time with localized previous and next controls, while at most one viewer is mounted in the Renderer.
|
||||
- The Chat Preview header offers a localized fullscreen toggle that fills the Renderer viewport, preserves the current target and slide position, and exits from its header control or Escape.
|
||||
- Legacy DOC and PPT files, remote attachments, and over-limit Office files retain their existing safe system-open, unsupported, or too-large behavior according to target authority.
|
||||
- Existing image, PDF, spreadsheet, HTML, Markdown, source, ACP Changes, attachment, and workspace behavior remains unchanged.
|
||||
- Existing image, PDF, spreadsheet, HTML, Markdown, source, diff, attachment, and workspace behavior remains unchanged.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
@@ -65,7 +66,7 @@ requiredRules:
|
||||
- office-preview-safety
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/open-file-utils.test.ts tests/unit/file-preview-body.test.tsx tests/unit/workspace-browser-body.test.tsx tests/unit/rich-file-viewers.test.tsx tests/unit/office-file-viewers.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/i18n-locale-parity.test.ts tests/unit/harness-specs.test.ts
|
||||
- pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/generated-files-panel.test.tsx tests/unit/open-file-utils.test.ts tests/unit/file-preview-body.test.tsx tests/unit/workspace-browser-body.test.tsx tests/unit/rich-file-viewers.test.tsx tests/unit/office-file-viewers.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/i18n-locale-parity.test.ts tests/unit/harness-specs.test.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
id: parallelize-electron-e2e
|
||||
title: Parallelize Electron E2E safely
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Run isolated Electron E2E specs concurrently while fencing OS-global resources and host-sensitive performance profiles.
|
||||
touchedAreas:
|
||||
- playwright.config.ts
|
||||
- package.json
|
||||
- .github/workflows/electron-e2e.yml
|
||||
- AGENTS.md
|
||||
- tests/e2e/fixtures/electron.ts
|
||||
- tests/e2e/parallel-policy.ts
|
||||
- tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
- tests/e2e/chat-acp-attachments.spec.ts
|
||||
- tests/e2e/markdown-file-preview.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
- tests/unit/e2e-parallel-policy.test.ts
|
||||
- harness/reference/e2e-parallelism.md
|
||||
- harness/specs/rules/e2e-parallel-isolation.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/tasks/parallelize-electron-e2e.md
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
- README.ru-RU.md
|
||||
expectedUserBehavior:
|
||||
- Local and CI Electron E2E runs execute independent spec files concurrently.
|
||||
- Tests that use the OS clipboard and host performance profiles never overlap incompatible tests.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/e2e-parallel-policy.test.ts
|
||||
- pnpm harness validate --spec harness/specs/tasks/parallelize-electron-e2e.md
|
||||
- pnpm run test:e2e
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
acceptance:
|
||||
- The ordinary E2E project uses more than one worker by default and can be overridden for constrained machines.
|
||||
- OS-global clipboard tests execute in a one-worker prerequisite project.
|
||||
- Renderer performance profiles execute alone after functional E2E tests.
|
||||
- Per-test HOME, Electron profile, OpenClaw state, and Host API configuration remain isolated.
|
||||
- CI opts into the checked-in parallel worker policy on every supported OS.
|
||||
- A durable policy and automated guard explain how future global-resource tests enter the exclusive lane.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task changes only Electron E2E scheduling and fixture isolation. It does not change application transport behavior, production Host API routing, or user-visible ClawX features.
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
id: remove-pre-acp-chat
|
||||
title: Remove pre-ACP Chat implementation
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Remove the superseded pre-ACP Chat history, send, runtime-state, and visualization pipeline while preserving ACP Chat, bounded image-generation compatibility, and session-management behavior.
|
||||
touchedAreas:
|
||||
- AGENTS.md
|
||||
- package.json
|
||||
- harness/specs/tasks/**
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/scenarios/chat-workspace-and-navigation.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/scenarios/gateway-startup-diagnostics.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- harness/specs/rules/acp-compatibility-content-safety.md
|
||||
- harness/specs/rules/host-events-fallback-policy.md
|
||||
- harness/specs/rules/sidebar-session-attention-authority.md
|
||||
- harness/reference/acp-chat.md
|
||||
- harness/reference/acp-generated-media-and-diagnostics.md
|
||||
- harness/reference/sidebar-session-attention.md
|
||||
- shared/acp-chat/**
|
||||
- shared/chat/**
|
||||
- shared/chat-runtime-events.ts
|
||||
- shared/host-api/contract.ts
|
||||
- shared/host-events/contract.ts
|
||||
- electron/gateway/event-dispatch.ts
|
||||
- electron/gateway/chat-runtime-events.ts
|
||||
- electron/gateway/client.ts
|
||||
- electron/gateway/manager.ts
|
||||
- electron/gateway/rpc-backpressure.ts
|
||||
- electron/main/index.ts
|
||||
- electron/main/ipc/**
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/services/acp-chat-service.ts
|
||||
- electron/services/chat-api.ts
|
||||
- electron/services/gateway-api.ts
|
||||
- src/lib/acp/**
|
||||
- src/lib/generated-files.ts
|
||||
- src/components/file-preview/**
|
||||
- src/lib/host-api.ts
|
||||
- src/lib/host-events.ts
|
||||
- src/stores/**
|
||||
- src/pages/Chat/**
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/styles/globals.css
|
||||
- shared/i18n/locales/en/chat.json
|
||||
- shared/i18n/locales/zh/chat.json
|
||||
- shared/i18n/locales/ja/chat.json
|
||||
- shared/i18n/locales/ru/chat.json
|
||||
- tests/unit/**
|
||||
- tests/e2e/**
|
||||
- tests/e2e/fixtures/**
|
||||
- tests/e2e/chat-acp-process-timeline.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Opening or revisiting a normal conversation renders its history only from ACP session/load replay, with the existing bounded ACP transcript supplements retained.
|
||||
- Sending and stopping from the composer use only ACP session/prompt and session/cancel.
|
||||
- ACP text, thinking, tools, permissions, plans, attachments, generated files, generated images, and errors continue to render through the ACP timeline without the legacy ChatMessage or Execution Graph pipeline.
|
||||
- Session discovery, sidebar ordering and attention, title hydration, history supplementation, selection, deletion, and rename continue to behave as before.
|
||||
- Catalog reconciliation repairs removed selections atomically, resets suppression and timestamp fences across Gateway generations, withholds a selected persisted session until its workspace summary settles, and never overwrites a newer local catalog or selection mutation with the delayed snapshot.
|
||||
- A failed hard delete keeps the session and confirmation dialog intact; navigation and dialog closure happen only after host-confirmed deletion.
|
||||
- Bulk deletion repairs a deleted current selection atomically with the remaining catalog, preferring a valid non-cron/non-channel session or creating an agent-local placeholder.
|
||||
- Asynchronous OpenClaw image-generation completions continue to appear when accepted by the existing bounded ACP compatibility checks.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- acp-chat-state-and-history
|
||||
- acp-compatibility-content-safety
|
||||
- attachment-access-safety
|
||||
- diagnostics-trace-safety
|
||||
- session-workspace-authority
|
||||
- sidebar-session-attention-authority
|
||||
- tool-derived-file-safety
|
||||
- ui-i18n-design-tokens
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm run typecheck
|
||||
- pnpm exec vitest run tests/unit/acp-host-contract.test.ts tests/unit/acp-chat-service.test.ts tests/unit/acp-reducer.test.ts tests/unit/acp-chat-store.test.ts tests/unit/acp-chat-components.test.tsx tests/unit/chat-acp-page.test.tsx tests/unit/chat-toolbar.test.tsx
|
||||
- pnpm exec vitest run tests/unit/acp-image-generation-compat.test.ts tests/unit/session-catalog.test.ts tests/unit/chat-load-sessions-startup.test.ts tests/unit/chat-session-management.test.ts tests/unit/chat-session-selection.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
|
||||
- pnpm exec vitest run tests/unit/host-api-facade.test.ts tests/unit/host-events.test.ts tests/unit/host-services.test.ts tests/unit/gateway-event-dispatch.test.ts
|
||||
- pnpm exec vitest run tests/unit/gateway-manager-diagnostics.test.ts tests/unit/gateway-ws-trace.test.ts tests/unit/stores.test.ts
|
||||
- pnpm exec vitest run tests/unit/generated-files.test.ts tests/unit/file-preview-body.test.tsx tests/unit/artifact-panel.test.tsx tests/unit/openclaw-file-activities.test.ts tests/unit/i18n-locale-parity.test.ts
|
||||
- pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts tests/e2e/chat-acp-attachments.spec.ts tests/e2e/chat-run-state-events.spec.ts tests/e2e/chat-acp-process-timeline.spec.ts tests/e2e/chat-sidebar-session-attention.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
- pnpm harness validate --spec harness/specs/tasks/remove-pre-acp-chat.md
|
||||
- pnpm run harness:ci
|
||||
- git diff --check
|
||||
acceptance:
|
||||
- ACP session/load replay is the only normal Chat history render path; no Gateway Chat history, transcript reconstruction, legacy message array, or persisted parallel replay ledger renders ordinary Chat turns.
|
||||
- ACP session/prompt and session/cancel are the only active composer send and stop paths; ACP permission responses remain a separate protocol action.
|
||||
- Legacy ChatMessage, Execution Graph, task visualization, RawMessage rendering helpers, and useChatStore send/runtime state and actions are removed from production code and their legacy-only tests are deleted or replaced with ACP assertions.
|
||||
- ACP-owned in-memory prompt state, including sending, cancelling, optimistic user segments, retained live prompt snapshots, and generation guards, remains intact.
|
||||
- gateway:chat-message and chat:runtime-event contracts, Main forwarding, and Renderer subscriptions remain only where required for bounded ACP image-generation completion compatibility; they do not feed ordinary messages, history, composer state, tool state, task visualization, or session run state.
|
||||
- Existing image-generation compatibility continues to require matching ACP session, generation, recent image_generate context, trusted completion evidence, deduplication, and stale-result rejection.
|
||||
- Session catalog, sidebar and attention state, ACP history supplements, automatic title hydration, session selection, hard delete, and rename behavior remain supported without depending on removed legacy message or runtime state.
|
||||
- Session deletion or hiding never leaves a dangling current selection; fallback selection follows the normal non-cron/non-channel policy or creates a fresh agent-local placeholder.
|
||||
- Single and bulk deletion use the same current-selection repair policy and never select a cron/channel survivor implicitly or leave the absent default key selected.
|
||||
- Selected persisted sessions with missing workspace identity are not published to ACP consumers before batched summary hydration settles, and stale summaries remain fenced by Gateway generation, session incarnation, and a local session catalog/selection mutation revision.
|
||||
- The unreachable chat media-send shared/Main/Renderer contract is removed; ACP load, prompt, cancel, and permission operations remain the exact typed Chat host actions.
|
||||
- The unused standalone Gateway client and Chat-history-only RPC backpressure layer are removed; typed generic Gateway RPC validates the same method and timeout inputs before delegating directly to GatewayManager.rpc.
|
||||
- Generic Gateway RPC diagnostics and proxy tests use non-Chat methods; one Electron E2E assertion prevents ordinary Chat from resurrecting a chat.history call.
|
||||
- No OpenClaw source, installed package source, bundled OpenClaw distribution, or OpenClaw patch is modified.
|
||||
- Typecheck, focused ACP and host tests, relevant ACP Electron E2E, communication regression checks, task validation, and harness CI pass.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Delete the pre-ACP Chat implementation after separating the session catalog and session-management responsibilities that are still consumed by ACP Chat. Remove legacy renderer history reduction, Gateway send orchestration, runtime send state, Execution Graph/task derivation, and their dead contracts, services, localization, and tests.
|
||||
|
||||
Keep the ACP timeline and composer as the sole normal Chat implementation. Preserve only the explicitly bounded Gateway-event bridge needed to project asynchronous image-generation completion evidence into the ACP timeline.
|
||||
|
||||
## Preserved Boundaries
|
||||
|
||||
- ACP `session/load`, `session/prompt`, `session/cancel`, `session/update`, and `session/request_permission` retain their existing Main/Renderer ownership and generation guards.
|
||||
- The existing transcript-derived ACP supplements remain bounded to approved generated-media and attachment content plus metadata-only timing; they do not become a second ordinary history path.
|
||||
- Session catalog, sidebar attention, workspace binding, title hydration, selection, deletion, and rename remain independent of legacy message rendering and runtime send state.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Modifying OpenClaw source, package contents, or bundled runtime behavior.
|
||||
- Replacing ACP replay with Gateway Chat history or broad transcript reconstruction.
|
||||
- Removing or widening the bounded ACP image-generation completion compatibility path.
|
||||
- Redesigning the ACP timeline, composer, sidebar, workspace, attachment, permission, or file-activity experiences.
|
||||
@@ -1,39 +1,130 @@
|
||||
---
|
||||
id: render-cron-run-live-status
|
||||
title: Render live execution status for cron-triggered runs without a session switch
|
||||
title: Render a bounded live overlay for cron-triggered runs
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: When a scheduled cron job runs while the user is viewing that session, render its ACP updates and available cron history in the inline timeline without requiring a session switch.
|
||||
intent: Show transient progress for an externally triggered cron run without converting Gateway runtime events into ACP notifications, timeline items, history, prompt state, or sidebar attention state.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/render-cron-run-live-status.md
|
||||
- src/lib/cron-session-history.ts
|
||||
- src/stores/acp-chat-session.ts
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- harness/specs/rules/acp-compatibility-content-safety.md
|
||||
- harness/reference/acp-cron-live-overlay.md
|
||||
- harness/reference/acp-chat.md
|
||||
- docs/plans/2026-08-04-cron-live-run-overlay.md
|
||||
- tests/unit/harness-specs.test.ts
|
||||
- shared/chat/cron-session.ts
|
||||
- shared/chat/cron-live-run.ts
|
||||
- shared/host-events/contract.ts
|
||||
- shared/host-api/contract.ts
|
||||
- electron/services/cron-api.ts
|
||||
- electron/services/cron-live-run-broker.ts
|
||||
- electron/gateway/chat-runtime-events.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/main/index.ts
|
||||
- src/lib/host-events.ts
|
||||
- src/lib/host-api.ts
|
||||
- src/stores/chat/cron-session-utils.ts
|
||||
- src/stores/acp-chat-session.ts
|
||||
- src/stores/chat.ts
|
||||
- src/stores/gateway.ts
|
||||
- src/stores/session-attention.ts
|
||||
- src/stores/chat/history-actions.ts
|
||||
- src/stores/chat/session-selection.ts
|
||||
- src/stores/chat/session-catalog.ts
|
||||
- src/stores/chat/session-key-utils.ts
|
||||
- src/stores/cron-live-run-overlay.ts
|
||||
- src/pages/Chat/CronLiveRunOverlay.tsx
|
||||
- src/pages/Chat/index.tsx
|
||||
- shared/i18n/locales/en/chat.json
|
||||
- shared/i18n/locales/zh/chat.json
|
||||
- shared/i18n/locales/ja/chat.json
|
||||
- shared/i18n/locales/ru/chat.json
|
||||
- tests/unit/cron-session-utils.test.ts
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/cron-live-run-broker.test.ts
|
||||
- tests/unit/cron-schedule.test.ts
|
||||
- tests/unit/host-events.test.ts
|
||||
- tests/unit/host-api-facade.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/cron-live-run-overlay-store.test.ts
|
||||
- tests/unit/cron-live-run-overlay.test.tsx
|
||||
- tests/unit/chat-acp-page.test.tsx
|
||||
- tests/e2e/fixtures/electron.ts
|
||||
- tests/e2e/cron-run-live-status.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- ACP tool and message updates for the selected cron session render inline without a manual session switch.
|
||||
- Run-scoped cron session keys are normalized to the equivalent base cron session where catalog reconciliation requires it.
|
||||
- When ACP replay is empty, authorized cron session history can supply the visible prompt and final reply.
|
||||
- Renderer continues to use Host events / api-client boundaries; no new direct IPC or Gateway HTTP calls are added.
|
||||
- While the user views a cron session, each active run for that exact base cron key appears in a clearly labeled, read-only live overlay next to, but never inside, the authoritative ACP timeline.
|
||||
- The overlay may show assistant text and bounded process status, but thinking exposes only a localized activity indicator and never raw thought text.
|
||||
- A terminal event removes the transient overlay and reloads authoritative ACP replay or the existing typed cron-history fallback exactly once when that run was visible.
|
||||
- External cron activity never enters ACP sending or cancelling state, exposes ACP Stop or permission controls, or sets sidebar busy or unread authority.
|
||||
- Ordinary sessions, base-only cron events, channel sessions, and heartbeat :main events never enter the overlay.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- api-client-transport-policy
|
||||
- host-api-fallback-policy
|
||||
- host-events-fallback-policy
|
||||
- gateway-readiness-policy
|
||||
- acp-chat-state-and-history
|
||||
- acp-compatibility-content-safety
|
||||
- ui-i18n-design-tokens
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/cron-session-utils.test.ts
|
||||
- pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
|
||||
- pnpm exec vitest run tests/unit/harness-specs.test.ts
|
||||
- pnpm exec vitest run tests/unit/cron-session-utils.test.ts tests/unit/gateway-event-dispatch.test.ts tests/unit/cron-live-run-broker.test.ts tests/unit/cron-live-run-overlay-store.test.ts tests/unit/cron-live-run-overlay.test.tsx tests/unit/cron-schedule.test.ts
|
||||
- pnpm exec vitest run tests/unit/host-events.test.ts tests/unit/host-api-facade.test.ts tests/unit/host-services.test.ts tests/unit/chat-acp-page.test.tsx tests/unit/acp-chat-store.test.ts tests/unit/acp-image-generation-compat.test.ts tests/unit/gateway-events.test.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/cron-run-live-status.spec.ts
|
||||
- pnpm run comms:replay
|
||||
- pnpm run comms:compare
|
||||
- pnpm harness validate --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
- pnpm harness run --spec harness/specs/tasks/render-cron-run-live-status.md
|
||||
- pnpm run harness:ci
|
||||
acceptance:
|
||||
- A cron session-key equivalence helper treats the base cron key and its run-scoped variant as the same session.
|
||||
- ACP notifications for the selected cron session reduce into the same ordered in-memory timeline as other Chat sessions.
|
||||
- Empty ACP replay may be supplemented only by the authorized cron session-history route for that session.
|
||||
- Renderer does not add direct IPC calls or Gateway HTTP fetches outside the existing api-client / host-events path.
|
||||
- Only strict run-scoped cron keys shaped as agent:<agentId>:cron:<jobId>:run:<runSessionId> enter the Main-owned broker; Main alone canonicalizes identity, deduplicates events, applies memory bounds, and publishes revisioned snapshots.
|
||||
- Main enforces exactly 32 active runs, 128 items per run, 500000 assistant characters, 100000 characters per item detail, 256 sequence-less event fingerprints per run, and 128 terminal tombstones.
|
||||
- Gateway runtime events remain outside SessionNotification, AcpSessionUpdateEnvelope, TimelineItem, AcpTimelineSnapshot, and every persisted or reconstructed history path.
|
||||
- The overlay is non-historical, memory-only, run-scoped, running-only, read-only, and excluded from ACP prompt state and sidebar unread or busy authority; raw thinking text is neither retained nor rendered.
|
||||
- Renderer subscribes through typed host-events before hydrating through host-api, rejects snapshots and changes older than its current broker revision, and never implements Gateway reduction or protocol fallback.
|
||||
- Terminal removal precedes exactly one authoritative ACP reload for a run that was rendered in the currently selected base cron session; hidden, evicted, gateway-reset, or previously acknowledged removals never cause a delayed reload, and terminal content is never retained as overlay history.
|
||||
- This exception is prohibited for ordinary non-cron messages, base-only cron events, channel sessions, heartbeat sessions, historical event replay, and arbitrary Gateway content.
|
||||
- Existing raw chat:runtime-event forwarding and ACP replay, cancellation, permission, compatibility-media, and cron-history behavior remain unchanged.
|
||||
- All overlay display text is translated in English, Chinese, Japanese, and Russian and uses the semantic design tokens in src/styles/globals.css.
|
||||
- The broker and overlay may be removed only after a distributed OpenClaw package proves through integration tests that loaded ACP sessions receive autonomous cron assistant, thought, and tool updates; generated media arrives as standard ACP content blocks; replay is complete and deduplicated; and external-run lifecycle and cancellation semantics are explicitly exposed.
|
||||
- Focused unit, type, lint, build, Electron E2E, communication replay/compare, Harness task, Harness CI, and synchronized README documentation checks pass.
|
||||
docs:
|
||||
required: false
|
||||
required: true
|
||||
---
|
||||
|
||||
## Architecture Contract
|
||||
|
||||
The only approved live cron assistant/process-progress exception is the bounded overlay documented in `harness/reference/acp-cron-live-overlay.md`:
|
||||
|
||||
```text
|
||||
Gateway runtime event -> Main bounded cron broker -> explicit live overlay
|
||||
terminal event -> overlay removal -> authoritative ACP/cron-history reload
|
||||
```
|
||||
|
||||
The overlay is a transient view model, not an ACP compatibility event or historical projection. The primary ACP timeline, existing typed cron-history fallback, external-run controls, and sidebar attention authority remain separate.
|
||||
|
||||
Main bounds the broker to 32 active runs, 128 items per run, 500000 assistant characters, 100000 characters per item detail, 256 sequence-less fingerprints per run, and 128 terminal tombstones. Renderer subscribes before snapshot hydration and rejects older revisions. Raw thinking text is never retained or rendered. Only terminal removal for a run rendered in the currently selected base cron session causes one authoritative `loadAcpSession`; all other removals are acknowledged without a delayed reload.
|
||||
|
||||
This narrow exception must not be extended to ordinary non-cron traffic. It can be deleted only when a distributed OpenClaw package proves all four upstream capabilities through integration tests: autonomous cron assistant/thought/tool updates reach loaded ACP sessions, generated media uses standard ACP content blocks, replay is complete and deduplicated, and external-run lifecycle/cancellation semantics are explicit.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Converting Gateway runtime events into ACP notifications, tools, permissions, messages, or timeline items.
|
||||
- Restoring the legacy Execution Graph in ACP Chat.
|
||||
- Making externally triggered cron runs ACP-cancellable or permission-interactive.
|
||||
- Extending the overlay exception to ordinary, channel, heartbeat, or base-only cron session events.
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
---
|
||||
id: replace-markdown-renderer-with-streamdown
|
||||
title: Replace application Markdown rendering with Streamdown
|
||||
scenario: acp-chat-experience
|
||||
taskType: runtime-bridge
|
||||
intent: Replace ReactMarkdown with one stable Streamdown configuration for streaming ACP Chat Markdown and static file previews while preserving content safety, presentation, and measured Chat performance.
|
||||
touchedAreas:
|
||||
- harness/reference/markdown-rendering.md
|
||||
- harness/specs/tasks/replace-markdown-renderer-with-streamdown.md
|
||||
- harness/specs/rules/markdown-rendering-safety-and-performance.md
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/scenarios/chat-workspace-and-navigation.md
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- tailwind.config.js
|
||||
- src/main.tsx
|
||||
- src/components/markdown/**
|
||||
- src/components/file-preview/MarkdownPreview.tsx
|
||||
- src/pages/Chat/AcpTimeline.tsx
|
||||
- src/pages/Chat/AcpAssistantTurn.tsx
|
||||
- src/pages/Chat/AcpMessageSegment.tsx
|
||||
- src/pages/Chat/index.tsx
|
||||
- src/styles/globals.css
|
||||
- tests/unit/harness-specs.test.ts
|
||||
- tests/unit/streamdown-config.test.tsx
|
||||
- tests/unit/markdown-preview.test.tsx
|
||||
- tests/unit/file-preview-body.test.tsx
|
||||
- tests/unit/acp-chat-components.test.tsx
|
||||
- tests/unit/browser-link.test.tsx
|
||||
- tests/e2e/markdown-file-preview.spec.ts
|
||||
- tests/e2e/chat-streamdown-rendering.spec.ts
|
||||
- tests/e2e/chat-code-block-wrap.spec.ts
|
||||
- tests/e2e/chat-latex-rendering.spec.ts
|
||||
- tests/e2e/chat-assistant-markdown-plain.spec.ts
|
||||
- tests/e2e/chat-table-header-light.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Assistant and process Markdown renders incrementally during ACP Chat streaming, while completed blocks remain stable and only the active final Markdown part receives subtle word animation and a circle caret.
|
||||
- Markdown files render in static mode with highlighted fenced code, KaTeX math, CJK-aware parsing, and omitted YAML or TOML frontmatter; Mermaid fences remain code.
|
||||
- User messages and tool output remain literal, raw HTML remains visible literal text, links remain inert, and unsafe Markdown image sources remain rejected.
|
||||
- Chat and preview preserve prose spacing and compact lists; tables show only the cell grid; fenced code preserves source lines and soft-wraps with a compact right-aligned language header and a vertically centered localized copy action.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
requiredRules:
|
||||
- renderer-main-boundary
|
||||
- acp-compatibility-content-safety
|
||||
- attachment-access-safety
|
||||
- ui-i18n-design-tokens
|
||||
- markdown-rendering-safety-and-performance
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/harness-specs.test.ts
|
||||
- pnpm exec vitest run tests/unit/streamdown-config.test.tsx tests/unit/markdown-preview.test.tsx tests/unit/file-preview-body.test.tsx tests/unit/acp-chat-components.test.tsx tests/unit/browser-link.test.tsx
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/markdown-file-preview.spec.ts tests/e2e/chat-streamdown-rendering.spec.ts tests/e2e/chat-code-block-wrap.spec.ts tests/e2e/chat-latex-rendering.spec.ts tests/e2e/chat-assistant-markdown-plain.spec.ts tests/e2e/chat-table-header-light.spec.ts --workers=1
|
||||
- pnpm run perf:chat
|
||||
- pnpm exec vite build --sourcemap
|
||||
- pnpm harness validate --spec harness/specs/tasks/replace-markdown-renderer-with-streamdown.md
|
||||
- pnpm run harness:ci
|
||||
acceptance:
|
||||
- ACP assistant and process Markdown uses Streamdown streaming mode, Markdown file preview uses static mode, and user messages plus tool output stay outside Streamdown.
|
||||
- One module-scoped configuration enables code, math with single-dollar support, and CJK plugins without installing or configuring a Mermaid plugin.
|
||||
- Raw HTML is rendered as literal text, links remain inert through BrowserLink, Markdown images retain isSafeAcpImageSource validation, only localized code copy is enabled, and table, Mermaid, code-download, and line-number controls stay disabled.
|
||||
- YAML and TOML frontmatter is parsed and omitted without the previous custom metadata card.
|
||||
- Only the open assistant segment's final Markdown part animates, using word-level fadeIn with duration 140 and stagger 0 plus a circle caret; completed blocks do not restart animation.
|
||||
- Focused unit and Electron E2E checks cover incomplete streaming Markdown, code highlighting, wrapping and copying, prose spacing, compact lists, cell-only table borders, math, CJK punctuation, Mermaid-as-code, raw HTML, inert links, safe images, literal user and tool content, frontmatter omission, active-part animation, and retained visual contracts.
|
||||
- Production source-map review confirms the expected Streamdown and Shiki cost, no direct @streamdown/mermaid dependency, and no unexpected eager Mermaid renderer chunk.
|
||||
- Three before and three after runs of the same 80-turn and 300-chunk workload retain metrics and CPU profiles; median Renderer TaskDuration and ScriptDuration each regress by no more than 10 percent, and median ScriptDuration or sampled Markdown and React CPU time improves by at least 10 percent.
|
||||
- ReactMarkdown and obsolete direct Markdown plugin dependencies are absent after both renderers migrate, KaTeX remains direct with exactly one stylesheet import, and multilingual README documentation matches the delivered behavior without claiming Mermaid support.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task covers the shared renderer configuration, ACP Chat streaming presentation, static Markdown file preview, dependency cleanup, focused safety and presentation tests, Electron E2E coverage, bundle inspection, and measured before/after performance validation. It does not change ACP transport, timeline reduction, event batching, store update cadence, Renderer/Main APIs, or fallback policy.
|
||||
|
||||
The durable renderer, safety, and profiling contract is documented in `harness/reference/markdown-rendering.md` and enforced by `harness/specs/rules/markdown-rendering-safety-and-performance.md`.
|
||||
|
||||
## Validation Notes
|
||||
|
||||
Performance artifacts are generated fixture data and remain ignored under `test-results/`. Compare three-run medians from the same machine; do not encode machine-specific absolute timings in automated tests or weaken the percentage thresholds when a migration misses them.
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
id: restore-hardware-accelerated-rendering
|
||||
title: Restore hardware-accelerated Electron rendering
|
||||
scenario: acp-chat-experience
|
||||
taskType: runtime-bridge
|
||||
intent: Restore Chromium GPU compositing and rasterization by default, then retain a rich-Markdown interaction profile that catches sidebar and scroll regressions missed by streaming-only measurements.
|
||||
touchedAreas:
|
||||
- electron/main/index.ts
|
||||
- tests/unit/main-hardware-acceleration.test.ts
|
||||
- tests/e2e/hardware-acceleration.spec.ts
|
||||
- tests/e2e/renderer-performance.spec.ts
|
||||
- harness/reference/electron-rendering-performance.md
|
||||
- harness/specs/rules/electron-rendering-performance.md
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/scenarios/chat-workspace-and-navigation.md
|
||||
- harness/specs/tasks/restore-hardware-accelerated-rendering.md
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Long rich-Markdown conversations scroll smoothly on supported desktop GPUs.
|
||||
- Collapsing or expanding the sidebar preserves its existing animation without software-rasterization frame drops.
|
||||
- A user with a broken graphics driver can still opt into Chromium's native `--disable-gpu` fallback.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
requiredRules:
|
||||
- electron-rendering-performance
|
||||
- markdown-rendering-safety-and-performance
|
||||
- diagnostics-trace-safety
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/main-hardware-acceleration.test.ts
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/hardware-acceleration.spec.ts --workers=1
|
||||
- pnpm run perf:chat
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm harness validate --spec harness/specs/tasks/restore-hardware-accelerated-rendering.md
|
||||
acceptance:
|
||||
- Electron Main no longer disables hardware acceleration globally.
|
||||
- A desktop Electron E2E confirms hardware acceleration and GPU compositing are enabled where the runner provides a real GPU.
|
||||
- The existing sidebar animation and Markdown rendering behavior remain unchanged.
|
||||
- The permanent performance command records frame pacing and profiles for a generated rich static Markdown conversation during sidebar collapse and vertical scrolling.
|
||||
- Real-conversation before/after profiles on the same machine show the stable frame-pacing regression is removed without relying on machine-specific automated timing thresholds.
|
||||
- The native `--disable-gpu` troubleshooting path remains available without a new settings or compatibility layer.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task removes the application-owned global software-rendering policy, adds focused policy/runtime coverage, and extends the existing profiling harness to the idle interactions from the reported real conversation. It does not redesign the sidebar, virtualize Chat history, change Markdown output, or profile the GPU process directly.
|
||||
@@ -29,7 +29,8 @@ touchedAreas:
|
||||
- shared/i18n/locales/ru/chat.json
|
||||
- tests/unit/gateway-events.test.ts
|
||||
- tests/unit/gateway-event-dispatch.test.ts
|
||||
- tests/unit/chat-session-management.test.ts
|
||||
- tests/unit/gateway-bisection-0d794cd.test.ts
|
||||
- tests/unit/chat-store-history-retry.test.ts
|
||||
- tests/unit/chat-store-session-label-fetch.test.ts
|
||||
- tests/unit/session-label-hydration.test.ts
|
||||
- tests/unit/sidebar-session-buckets.test.ts
|
||||
@@ -58,7 +59,7 @@ requiredRules:
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/session-attention.test.ts tests/unit/session-status.test.ts tests/unit/session-catalog.test.ts tests/unit/session-label-hydration.test.ts tests/unit/gateway-events.test.ts tests/unit/chat-session-management.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
|
||||
- pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/session-attention.test.ts tests/unit/session-status.test.ts tests/unit/session-catalog.test.ts tests/unit/session-label-hydration.test.ts tests/unit/gateway-events.test.ts tests/unit/chat-store-history-retry.test.ts tests/unit/chat-store-session-label-fetch.test.ts tests/unit/sidebar-session-buckets.test.ts
|
||||
- pnpm exec vitest run tests/unit/i18n-locale-parity.test.ts tests/unit/gateway-event-dispatch.test.ts
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
id: use-openclaw-native-config-delivery
|
||||
title: Use OpenClaw-native config delivery and restart planning
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Make one Main-owned coordinator the only ClawX path for OpenClaw config mutation, using config.get and config.set while running and a file fallback before startup.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/use-openclaw-native-config-delivery.md
|
||||
- harness/specs/rules/openclaw-config-delivery.md
|
||||
- harness/specs/scenarios/gateway-backend-communication.md
|
||||
- harness/reference/openclaw-config-delivery.md
|
||||
- electron/gateway/config-delivery.ts
|
||||
- electron/gateway/manager.ts
|
||||
- electron/gateway/ws-trace.ts
|
||||
- electron/main/index.ts
|
||||
- electron/main/ipc-handlers.ts
|
||||
- electron/services/agents-api.ts
|
||||
- electron/services/channels-api.ts
|
||||
- electron/services/providers/provider-runtime-sync.ts
|
||||
- electron/utils/channel-config.ts
|
||||
- electron/utils/agent-config.ts
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- electron/utils/openclaw-image-generation.ts
|
||||
- electron/utils/plugin-install.ts
|
||||
- electron/utils/skill-config.ts
|
||||
- tests/unit/gateway-config-delivery.test.ts
|
||||
- tests/unit/gateway-ws-trace.test.ts
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/openclaw-image-generation.test.ts
|
||||
- tests/unit/provider-runtime-sync.test.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Saving providers, agents, channels, bindings, skills, or other hot-applicable OpenClaw configuration does not replace the Gateway process.
|
||||
- Every ClawX mutation of the active OpenClaw config goes through one coordinator-owned read-modify-write transaction.
|
||||
- A running Gateway mutation uses config.get as its baseline and config.set as its commit instead of writing the file directly.
|
||||
- Saving config while the Gateway is stopped does not start it.
|
||||
- Proxy and other process-launch environment changes still replace the running Gateway process.
|
||||
- Manual restart and health/crash recovery behavior remains available.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
requiredRules:
|
||||
- openclaw-config-delivery
|
||||
- gateway-readiness-policy
|
||||
- backend-communication-boundary
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/gateway-config-delivery.test.ts
|
||||
- tests/unit/gateway-ws-trace.test.ts
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/provider-runtime-sync.test.ts
|
||||
acceptance:
|
||||
- Electron Main owns config delivery; Renderer does not add direct Gateway transport calls.
|
||||
- A running Gateway mutation prefers the runtime-shaped config.get `config` object as its baseline, applies its mutator, and commits the serialized result with config.set and the returned hash as baseHash; source-shaped `raw` is only a compatibility fallback.
|
||||
- Successful config.set delivery does not send SIGUSR1 and does not call GatewayManager.restart.
|
||||
- The coordinator serializes mutations and retries one base-hash conflict from a fresh config.get snapshot.
|
||||
- When Gateway is stopped or starting, the same coordinator performs the mutation against the resolved file path and does not start the Gateway.
|
||||
- A non-conflict RPC mutation failure does not perform an out-of-band file write; it fails the mutation without silently diverging live and persisted config.
|
||||
- Provider deletion, OAuth completion, Agent deletion, Channel save/delete/binding, skill config, image config, and hidden self-heal writes do not carry independent blanket restart policy.
|
||||
- Deleting a custom default Channel account does not recreate its mirrored top-level credentials under a literal `default` account.
|
||||
- Channel account deletion removes matching credentials from plugin-backed account mirrors, including Agent deletion paths.
|
||||
- Gateway WebSocket traces redact serialized raw config-write payloads before logging them.
|
||||
- Proxy environment changes, manual restart, heartbeat recovery, process crash recovery, app update, and app shutdown/startup retain their lifecycle behavior.
|
||||
- No production helper outside the coordinator writes the active OpenClaw config file.
|
||||
- Coordinator file fallback uses the configured OpenClaw config path rather than a hardcoded home-directory path.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
Use this task spec for the OpenClaw 2026.7.1 config-delivery convergence change.
|
||||
+6
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawx",
|
||||
"version": "0.5.3",
|
||||
"version": "0.5.2",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@discordjs/opus",
|
||||
@@ -52,10 +52,8 @@
|
||||
"test": "vitest run",
|
||||
"test:e2e": "pnpm run build:vite && playwright test",
|
||||
"test:e2e:headed": "pnpm run build:vite && playwright test --headed",
|
||||
"perf:chat": "pnpm run build:vite && playwright test tests/e2e/renderer-performance.spec.ts --project=performance --no-deps --workers=1",
|
||||
"profile:main": "pnpm run build:vite && electron --inspect=9229 .",
|
||||
"harness": "pnpm --filter @clawx/harness start --",
|
||||
"harness:ci": "pnpm harness list && pnpm harness validate --spec harness/specs/scenarios/gateway-backend-communication.md && pnpm harness validate --spec harness/specs/tasks/maintain-session-catalog-reconciliation.example.md --no-diff && pnpm harness run --spec harness/specs/scenarios/gateway-backend-communication.md --dry-run && pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/harness-git.test.ts",
|
||||
"harness:ci": "pnpm harness list && pnpm harness validate --spec harness/specs/scenarios/gateway-backend-communication.md && pnpm harness validate --spec harness/specs/tasks/fix-chat-history-gateway-timeout.example.md --no-diff && pnpm harness run --spec harness/specs/scenarios/gateway-backend-communication.md --dry-run && pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/harness-git.test.ts",
|
||||
"comms:replay": "node scripts/comms/replay.mjs",
|
||||
"comms:baseline": "node scripts/comms/baseline.mjs",
|
||||
"comms:compare": "node scripts/comms/compare.mjs",
|
||||
@@ -93,7 +91,6 @@
|
||||
"@agentclientprotocol/sdk": "1.1.0",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"json5": "2.2.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"posthog-node": "^5.28.0",
|
||||
"tar": "^6.2.1",
|
||||
@@ -130,9 +127,6 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"@soimy/dingtalk": "3.6.6",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@tencent-connect/qqbot-connector": "^1.1.0",
|
||||
"@tencent-weixin/openclaw-weixin": "^2.4.6",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -187,12 +181,15 @@
|
||||
"react-arborist": "^3.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
+1
-39
@@ -1,29 +1,9 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
import {
|
||||
DEFAULT_E2E_WORKERS,
|
||||
E2E_EXCLUSIVE_TAG,
|
||||
E2E_PERFORMANCE_TAG,
|
||||
} from './tests/e2e/parallel-policy';
|
||||
|
||||
function e2eWorkers(): number {
|
||||
const configured = process.env.CLAWX_E2E_WORKERS?.trim();
|
||||
if (!configured) return DEFAULT_E2E_WORKERS;
|
||||
|
||||
const workers = Number(configured);
|
||||
if (!Number.isInteger(workers) || workers < 1) {
|
||||
throw new Error('CLAWX_E2E_WORKERS must be a positive integer');
|
||||
}
|
||||
return workers;
|
||||
}
|
||||
|
||||
const exclusivePattern = new RegExp(E2E_EXCLUSIVE_TAG);
|
||||
const performancePattern = new RegExp(E2E_PERFORMANCE_TAG);
|
||||
const nonParallelPattern = new RegExp(`${E2E_EXCLUSIVE_TAG}|${E2E_PERFORMANCE_TAG}`);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
fullyParallel: false,
|
||||
workers: e2eWorkers(),
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
timeout: 90_000,
|
||||
@@ -39,22 +19,4 @@ export default defineConfig({
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'exclusive',
|
||||
grep: exclusivePattern,
|
||||
workers: 1,
|
||||
},
|
||||
{
|
||||
name: 'parallel',
|
||||
grepInvert: nonParallelPattern,
|
||||
dependencies: ['exclusive'],
|
||||
},
|
||||
{
|
||||
name: 'performance',
|
||||
grep: performancePattern,
|
||||
dependencies: ['parallel'],
|
||||
workers: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Generated
+36
-1211
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import type { HostEventContract } from '../host-events/contract';
|
||||
import type { AcpSessionUpdateEnvelope } from './types';
|
||||
|
||||
export const acpChatHostApiActions = [
|
||||
'sendWithMedia',
|
||||
'loadAcpSession',
|
||||
'sendAcpPrompt',
|
||||
'cancelAcpSession',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export type CronLiveRunStatus = 'running';
|
||||
|
||||
export type CronLiveRunItem =
|
||||
| {
|
||||
kind: 'tool';
|
||||
id: string;
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
inputText?: string;
|
||||
outputText?: string;
|
||||
error?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'command';
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
output: string;
|
||||
exitCode?: number;
|
||||
}
|
||||
| {
|
||||
kind: 'patch';
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
added?: number;
|
||||
modified?: number;
|
||||
deleted?: number;
|
||||
}
|
||||
| {
|
||||
kind: 'approval';
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface CronLiveRunOverlaySnapshot {
|
||||
canonicalSessionKey: string;
|
||||
sourceSessionKey: string;
|
||||
runSessionId: string;
|
||||
runId: string;
|
||||
revision: number;
|
||||
status: CronLiveRunStatus;
|
||||
startedAt?: number;
|
||||
updatedAt: number;
|
||||
lastSeq?: number;
|
||||
assistantText: string;
|
||||
thinking: boolean;
|
||||
items: CronLiveRunItem[];
|
||||
}
|
||||
|
||||
export interface CronLiveRunOverlaySnapshotSet {
|
||||
revision: number;
|
||||
snapshots: CronLiveRunOverlaySnapshot[];
|
||||
}
|
||||
|
||||
export type CronLiveRunOverlayChange =
|
||||
| {
|
||||
kind: 'upsert';
|
||||
revision: number;
|
||||
snapshot: CronLiveRunOverlaySnapshot;
|
||||
}
|
||||
| {
|
||||
kind: 'remove';
|
||||
revision: number;
|
||||
canonicalSessionKey: string;
|
||||
sourceSessionKey: string;
|
||||
runId: string;
|
||||
reason: 'ended' | 'evicted' | 'gateway-reset';
|
||||
terminalStatus?: 'completed' | 'error' | 'aborted';
|
||||
terminalError?: string;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface CronSessionKeyParts {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
}
|
||||
|
||||
export function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts[0] !== 'agent' || parts[2] !== 'cron') return null;
|
||||
|
||||
const agentId = parts[1];
|
||||
const jobId = parts[3];
|
||||
if (!agentId?.trim() || !jobId?.trim()) return null;
|
||||
|
||||
if (parts.length === 4) return { agentId, jobId };
|
||||
if (parts.length !== 6 || parts[4] !== 'run') return null;
|
||||
|
||||
const runSessionId = parts[5];
|
||||
return runSessionId?.trim() ? { agentId, jobId, runSessionId } : null;
|
||||
}
|
||||
|
||||
export function isCronSessionKey(sessionKey: string): boolean {
|
||||
return parseCronSessionKey(sessionKey) != null;
|
||||
}
|
||||
|
||||
export function isRunScopedCronSessionKey(sessionKey: string): boolean {
|
||||
return parseCronSessionKey(sessionKey)?.runSessionId != null;
|
||||
}
|
||||
|
||||
export function getCronSessionBaseKey(sessionKey: string): string {
|
||||
const parts = parseCronSessionKey(sessionKey);
|
||||
if (!parts) return sessionKey;
|
||||
return `agent:${parts.agentId}:cron:${parts.jobId}`;
|
||||
}
|
||||
|
||||
export function sessionKeysAreEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
if (a == null || b == null) return false;
|
||||
if (a === b) return true;
|
||||
const parsedA = parseCronSessionKey(a);
|
||||
const parsedB = parseCronSessionKey(b);
|
||||
if (!parsedA || !parsedB) return false;
|
||||
return parsedA.agentId === parsedB.agentId && parsedA.jobId === parsedB.jobId;
|
||||
}
|
||||
+73
-8
@@ -1,4 +1,6 @@
|
||||
/** Metadata for files attached to ACP prompts or projected by bounded ACP media compatibility. */
|
||||
import type { ChatRuntimeEvent } from '../chat-runtime-events';
|
||||
|
||||
/** Metadata for locally-attached files (not from Gateway) */
|
||||
export interface AttachedFileMeta {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
@@ -19,7 +21,7 @@ export interface AttachedFileMeta {
|
||||
gatewayUrl?: string;
|
||||
}
|
||||
|
||||
/** Structured OpenClaw transcript message used by bounded ACP supplements. */
|
||||
/** Raw message from OpenClaw chat.history */
|
||||
export interface RawMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'toolresult';
|
||||
content: unknown; // string | ContentBlock[]
|
||||
@@ -46,7 +48,7 @@ export interface RawMessage {
|
||||
workspaceDir?: string;
|
||||
}>;
|
||||
};
|
||||
/** Renderer metadata for user-selected files included in an ACP prompt. */
|
||||
/** Local-only: file metadata for user-uploaded attachments (not sent to/from Gateway) */
|
||||
_attachedFiles?: AttachedFileMeta[];
|
||||
}
|
||||
|
||||
@@ -120,16 +122,56 @@ export type LoadSessionsOptions = {
|
||||
gatewayGeneration?: number;
|
||||
};
|
||||
|
||||
export interface ToolStatus {
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
durationMs?: number;
|
||||
summary?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ChatRuntimeRunState {
|
||||
runId: string;
|
||||
sessionKey?: string;
|
||||
status: 'running' | 'completed' | 'error' | 'aborted';
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
assistantText: string;
|
||||
thinkingText: string;
|
||||
events: ChatRuntimeEvent[];
|
||||
}
|
||||
|
||||
export interface DeleteSessionsResult {
|
||||
deletedKeys: string[];
|
||||
failedKeys: string[];
|
||||
}
|
||||
|
||||
export type DeleteSessionResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string };
|
||||
|
||||
export interface ChatState {
|
||||
// Messages
|
||||
messages: RawMessage[];
|
||||
loading: boolean;
|
||||
loadingMoreHistory: boolean;
|
||||
hasMoreHistory: boolean;
|
||||
error: string | null;
|
||||
runError: string | null;
|
||||
/** Per-session runError text dismissed by the user (sessionKey -> error message). */
|
||||
dismissedRunErrors: Record<string, string>;
|
||||
|
||||
// Streaming
|
||||
sending: boolean;
|
||||
activeRunId: string | null;
|
||||
streamingText: string;
|
||||
streamingMessage: unknown | null;
|
||||
streamingTools: ToolStatus[];
|
||||
pendingFinal: boolean;
|
||||
lastUserMessageAt: number | null;
|
||||
/** Images collected from tool results, attached to the next assistant message */
|
||||
pendingToolImages: AttachedFileMeta[];
|
||||
runtimeRuns: Record<string, ChatRuntimeRunState>;
|
||||
|
||||
// Sessions
|
||||
sessions: ChatSession[];
|
||||
currentSessionKey: string;
|
||||
currentAgentId: string;
|
||||
@@ -138,15 +180,38 @@ export interface ChatState {
|
||||
/** Last message timestamp (ms) per session key, used for sorting */
|
||||
sessionLastActivity: Record<string, number>;
|
||||
|
||||
// Thinking
|
||||
thinkingLevel: string | null;
|
||||
|
||||
// Actions
|
||||
loadSessions: (options?: LoadSessionsOptions) => Promise<void>;
|
||||
handleSessionsChanged: (payload: GatewaySessionsChangedPayload) => void;
|
||||
switchSession: (key: string) => void;
|
||||
selectAcpSession: (key: string, workspacePath?: string) => void;
|
||||
newSession: () => void;
|
||||
acknowledgeAcpSessionCreated: (key: string, workspacePath?: string, initialPrompt?: string) => void;
|
||||
deleteSession: (key: string) => Promise<DeleteSessionResult>;
|
||||
deleteSession: (key: string) => Promise<void>;
|
||||
deleteSessions: (keys: string[]) => Promise<DeleteSessionsResult>;
|
||||
renameSession: (key: string, label: string) => Promise<void>;
|
||||
cleanupEmptySession: () => void;
|
||||
loadHistory: (quiet?: boolean) => Promise<void>;
|
||||
loadMoreHistory: () => Promise<void>;
|
||||
sendMessage: (
|
||||
text: string,
|
||||
attachments?: Array<{
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
stagedPath: string;
|
||||
preview: string | null;
|
||||
}>,
|
||||
targetAgentId?: string | null,
|
||||
) => Promise<void>;
|
||||
abortRun: () => Promise<void>;
|
||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||
handleRuntimeEvent: (event: ChatRuntimeEvent) => void;
|
||||
refresh: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const DEFAULT_CANONICAL_PREFIX = 'agent:main';
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AcpChatRespondPermissionPayload,
|
||||
} from '../acp-chat/types';
|
||||
import type { RawMessage } from '../chat/types';
|
||||
import type { CronLiveRunOverlaySnapshotSet } from '../chat/cron-live-run';
|
||||
import type { AgentsSnapshot } from '../types/agent';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||
import type { GatewayHealth, GatewayStatus } from '../types/gateway';
|
||||
@@ -717,6 +718,18 @@ export type SessionSummariesResult = HostSuccess & {
|
||||
export type SessionDeletePayload = { id: string };
|
||||
export type SessionRenamePayload = { id: string; title: string };
|
||||
|
||||
export type ChatMediaItem = { filePath: string; mimeType?: string; fileName?: string };
|
||||
export type ChatSendWithMediaPayload = {
|
||||
sessionKey: string;
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
media?: ChatMediaItem[];
|
||||
};
|
||||
export type ChatSendWithMediaResult = HostSuccess & {
|
||||
result?: { runId?: string };
|
||||
};
|
||||
|
||||
export type CronUpdatePayload = { id: string; input: CronJobUpdateInput };
|
||||
export type CronIdPayload = { id: string };
|
||||
export type CronTogglePayload = CronIdPayload & { enabled: boolean };
|
||||
@@ -975,12 +988,14 @@ export type HostApiContract = {
|
||||
turnTimings: (payload: SessionTurnTimingsPayload) => SessionTurnTimingsResult;
|
||||
};
|
||||
chat: {
|
||||
sendWithMedia: (payload: ChatSendWithMediaPayload) => ChatSendWithMediaResult;
|
||||
loadAcpSession: (payload: AcpChatLoadPayload) => AcpChatOperationResult;
|
||||
sendAcpPrompt: (payload: AcpChatPromptPayload) => AcpChatOperationResult;
|
||||
cancelAcpSession: (payload: AcpChatCancelPayload) => AcpChatOperationResult;
|
||||
respondAcpPermission: (payload: AcpChatRespondPermissionPayload) => AcpChatOperationResult;
|
||||
};
|
||||
cron: {
|
||||
liveRunOverlays: () => CronLiveRunOverlaySnapshotSet;
|
||||
list: () => CronJob[];
|
||||
create: (payload: CronJobCreateInput) => CronJob;
|
||||
update: (payload: CronUpdatePayload) => CronJob;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '../acp-chat/types';
|
||||
import type { UpdateStatusSnapshot } from '../host-api/contract';
|
||||
import type { ChatRuntimeEvent } from '../chat-runtime-events';
|
||||
import type { CronLiveRunOverlayChange } from '../chat/cron-live-run';
|
||||
import type {
|
||||
GatewayNotification,
|
||||
GatewayRuntimePayload,
|
||||
@@ -82,6 +83,9 @@ export type HostEventContract = {
|
||||
acpSessionUpdate: (payload: AcpSessionUpdateEnvelope) => void;
|
||||
acpPermissionRequest: (payload: AcpPermissionRequestEnvelope) => void;
|
||||
};
|
||||
cron: {
|
||||
liveRunOverlayChanged: (payload: CronLiveRunOverlayChange) => void;
|
||||
};
|
||||
oauth: {
|
||||
code: (payload: OAuthCodeEvent) => void;
|
||||
success: (payload: OAuthSuccessEvent) => void;
|
||||
@@ -131,6 +135,9 @@ export const HOST_EVENT_CHANNELS = {
|
||||
acpSessionUpdate: 'chat:acp-session-update',
|
||||
acpPermissionRequest: 'chat:acp-permission-request',
|
||||
},
|
||||
cron: {
|
||||
liveRunOverlayChanged: 'cron:live-run-overlay-changed',
|
||||
},
|
||||
oauth: {
|
||||
code: 'oauth:code',
|
||||
success: 'oauth:success',
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
},
|
||||
"noLogs": "(No logs available yet)",
|
||||
"scrollToLatest": "Back to bottom",
|
||||
"loadMoreHistory": "Load earlier messages",
|
||||
"loadingMoreHistory": "Loading more…",
|
||||
"toolbar": {
|
||||
"refresh": "Refresh chat",
|
||||
"currentAgent": "{{agent}}",
|
||||
"currentAgent": "Talking to {{agent}}",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"generatedFiles": {
|
||||
"openFolder": "Open folder"
|
||||
"title": "File changes ({{count}})",
|
||||
"created": "Created",
|
||||
"modified": "Modified",
|
||||
"openFolder": "Open folder",
|
||||
"viewAll": "View all changes"
|
||||
},
|
||||
"fileActivity": {
|
||||
"created": "Created",
|
||||
@@ -105,7 +111,9 @@
|
||||
"filePreview": {
|
||||
"tabs": {
|
||||
"source": "Source",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"changes": "Changes",
|
||||
"info": "Info"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
@@ -119,6 +127,11 @@
|
||||
"openHtmlExternally": "Open HTML in system browser",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"info": {
|
||||
"path": "Path",
|
||||
"size": "Size",
|
||||
"type": "Type"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "Saved to disk",
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
@@ -133,8 +146,8 @@
|
||||
"notFound": "File not found",
|
||||
"loadFailed": "Load failed: {{error}}",
|
||||
"noPreview": "No preview available for this file",
|
||||
"unsupportedFormatTitle": "This file format is not supported for built-in preview",
|
||||
"unsupportedFormatHint": "Built-in preview currently supports directly readable text/Markdown-style files only. Open the file in the file manager instead.",
|
||||
"unsupportedFormatTitle": "This file format is not supported for built-in preview or diff",
|
||||
"unsupportedFormatHint": "Built-in preview and diff currently support directly readable text/Markdown-style files only. Open the file in the file manager instead.",
|
||||
"largeBinaryOpenTitle": "This file is too large for built-in preview",
|
||||
"largeBinaryOpenHint": "This file is {{size}}. ClawX cannot preview it inline, but you can confirm and open it with the system default app.",
|
||||
"openInFinderFailed": "Could not reveal file",
|
||||
@@ -145,6 +158,13 @@
|
||||
"html": {
|
||||
"crashed": "HTML preview stopped"
|
||||
},
|
||||
"diff": {
|
||||
"noChanges": "No changes to display",
|
||||
"newFile": "New file — no original to compare",
|
||||
"tooLarge": "File too large — diff view disabled, switch to the Source tab to view the full content",
|
||||
"unavailable": "The current chat did not capture a precise baseline for this file, so no diff is available.",
|
||||
"unavailableHint": "Use the Preview tab for the current file; use Git or another VCS for an exact diff."
|
||||
},
|
||||
"confirmOpen": {
|
||||
"title": "Open file",
|
||||
"message": "Open “{{fileName}}” with the system default app?",
|
||||
@@ -200,12 +220,68 @@
|
||||
"openRootInFinder": "Reveal workspace in file manager"
|
||||
}
|
||||
},
|
||||
"taskPanel": {
|
||||
"eyebrow": "Run View",
|
||||
"title": "Task Outline",
|
||||
"emptyTitle": "No structured steps yet",
|
||||
"emptyBody": "Once a run starts, ClawX will surface thinking, tool calls, and handoff states here.",
|
||||
"status": {
|
||||
"idle": "Idle",
|
||||
"running_one": "1 active step",
|
||||
"running_other": "{{count}} active steps"
|
||||
},
|
||||
"stepStatus": {
|
||||
"running": "Running",
|
||||
"completed": "Done",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"executionGraph": {
|
||||
"title": "Execution Graph",
|
||||
"branchLabel": "branch",
|
||||
"thinkingLabel": "Thinking",
|
||||
"errorLabel": "Error",
|
||||
"imageGenerateLabel": "Image generation",
|
||||
"agentRun": "{{agent}} execution",
|
||||
"collapsedSummary": "{{toolCount}} tool calls · {{processCount}} process messages",
|
||||
"collapseAction": "Collapse execution graph"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "Generating image, please wait…",
|
||||
"previewLoading": "Loading image preview…",
|
||||
"previewUnavailable": "Image generation completed, but the preview could not be loaded.",
|
||||
"generatedReady": "Generated image is ready.",
|
||||
"generatedReadyWithMissing": "Generated image is ready. Some images could not be loaded."
|
||||
},
|
||||
"cronLiveRun": {
|
||||
"title": "Live scheduled run",
|
||||
"transient": "Transient, read-only progress. Final content appears in chat after the run finishes.",
|
||||
"running": "Running",
|
||||
"thinking": "Thinking",
|
||||
"item": {
|
||||
"tool": "Tool",
|
||||
"command": "Command",
|
||||
"patch": "Patch",
|
||||
"approval": "Approval"
|
||||
},
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"detail": {
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"error": "Error",
|
||||
"exitCode": "Exit code: {{code}}"
|
||||
},
|
||||
"patch": {
|
||||
"added": "Added: {{count}}",
|
||||
"modified": "Modified: {{count}}",
|
||||
"deleted": "Deleted: {{count}}"
|
||||
},
|
||||
"approvalReadOnly": "Read-only status. Respond in the originating client."
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "Attach files",
|
||||
"pickSkill": "Choose skill",
|
||||
@@ -227,7 +303,7 @@
|
||||
"thinking": "Thinking…",
|
||||
"gatewayConnected": "connected",
|
||||
"gatewayStarting": "starting",
|
||||
"gatewayStatus": "gateway {{state}}",
|
||||
"gatewayStatus": "gateway {{state}} | port: {{port}} | pid: {{pid}}",
|
||||
"retryFailedAttachments": "Retry failed attachments",
|
||||
"workspacePrefix": "{{workspace}}",
|
||||
"workspacePickerTitle": "Select workspace folder",
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
"hide": "Hide",
|
||||
"clear": "Clear"
|
||||
},
|
||||
"markdown": {
|
||||
"copyCode": "Copy code"
|
||||
},
|
||||
"status": {
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
},
|
||||
"noLogs": "(ログはまだありません)",
|
||||
"scrollToLatest": "最下部に戻る",
|
||||
"loadMoreHistory": "以前のメッセージを読み込む",
|
||||
"loadingMoreHistory": "読み込み中…",
|
||||
"toolbar": {
|
||||
"refresh": "チャットを更新",
|
||||
"currentAgent": "{{agent}}",
|
||||
"currentAgent": "現在の会話相手: {{agent}}",
|
||||
"workspace": "ワークスペース"
|
||||
},
|
||||
"generatedFiles": {
|
||||
"openFolder": "フォルダーを開く"
|
||||
"title": "ファイルの変更({{count}} 件)",
|
||||
"created": "新規",
|
||||
"modified": "更新",
|
||||
"openFolder": "フォルダーを開く",
|
||||
"viewAll": "ファイル変更を見る"
|
||||
},
|
||||
"fileActivity": {
|
||||
"created": "作成済み",
|
||||
@@ -105,7 +111,9 @@
|
||||
"filePreview": {
|
||||
"tabs": {
|
||||
"source": "ソース",
|
||||
"preview": "プレビュー"
|
||||
"preview": "プレビュー",
|
||||
"changes": "変更",
|
||||
"info": "情報"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
@@ -119,6 +127,11 @@
|
||||
"openHtmlExternally": "HTML をシステムブラウザで開く",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"info": {
|
||||
"path": "パス",
|
||||
"size": "サイズ",
|
||||
"type": "種類"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "ディスクに保存しました",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
@@ -133,8 +146,8 @@
|
||||
"notFound": "ファイルが見つかりません",
|
||||
"loadFailed": "読み込みに失敗しました: {{error}}",
|
||||
"noPreview": "このファイルにはプレビューがありません",
|
||||
"unsupportedFormatTitle": "このファイル形式は内蔵プレビューに未対応です",
|
||||
"unsupportedFormatHint": "現在、内蔵プレビューは直接読み取れるテキスト/Markdown 系ファイルのみ対応しています。ファイルマネージャーで開いてください。",
|
||||
"unsupportedFormatTitle": "このファイル形式は内蔵プレビューまたは差分表示に未対応です",
|
||||
"unsupportedFormatHint": "現在、内蔵プレビューと差分表示は直接読み取れるテキスト/Markdown 系ファイルのみ対応しています。ファイルマネージャーで開いてください。",
|
||||
"largeBinaryOpenTitle": "このファイルは大きいため内蔵プレビューできません",
|
||||
"largeBinaryOpenHint": "このファイルは {{size}} です。ClawX では内蔵プレビューできませんが、確認後にシステム既定のアプリで開けます。",
|
||||
"openInFinderFailed": "ファイルマネージャーで表示できません",
|
||||
@@ -145,6 +158,13 @@
|
||||
"html": {
|
||||
"crashed": "HTML プレビューが停止しました"
|
||||
},
|
||||
"diff": {
|
||||
"noChanges": "表示する変更はありません",
|
||||
"newFile": "新規ファイルのため比較対象がありません",
|
||||
"tooLarge": "ファイルが大きすぎるため diff ビューは無効化されました。完全な内容は「ソース」タブで確認してください",
|
||||
"unavailable": "この会話ではこのファイルの正確な変更ベースラインを取得できなかったため、差分を生成できません。",
|
||||
"unavailableHint": "「プレビュー」で現在の内容を確認するか、Git などで正確な差分を参照してください。"
|
||||
},
|
||||
"confirmOpen": {
|
||||
"title": "ファイルを開く",
|
||||
"message": "「{{fileName}}」をシステム既定のアプリで開きますか?",
|
||||
@@ -200,12 +220,68 @@
|
||||
"openRootInFinder": "ルートをファイルマネージャーで表示"
|
||||
}
|
||||
},
|
||||
"taskPanel": {
|
||||
"eyebrow": "実行ビュー",
|
||||
"title": "タスクの流れ",
|
||||
"emptyTitle": "まだ構造化されたステップはありません",
|
||||
"emptyBody": "実行が始まると、ClawX は思考・ツール呼び出し・最終化の状態をここに表示します。",
|
||||
"status": {
|
||||
"idle": "待機中",
|
||||
"running_one": "進行中 1 件",
|
||||
"running_other": "進行中 {{count}} 件"
|
||||
},
|
||||
"stepStatus": {
|
||||
"running": "実行中",
|
||||
"completed": "完了",
|
||||
"error": "エラー"
|
||||
}
|
||||
},
|
||||
"executionGraph": {
|
||||
"title": "実行グラフ",
|
||||
"branchLabel": "branch",
|
||||
"thinkingLabel": "考え中",
|
||||
"errorLabel": "エラー",
|
||||
"imageGenerateLabel": "画像生成",
|
||||
"agentRun": "{{agent}} の実行",
|
||||
"collapsedSummary": "ツール呼び出し {{toolCount}} 件 · プロセスメッセージ {{processCount}} 件",
|
||||
"collapseAction": "実行グラフを折りたたむ"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "画像を生成しています。しばらくお待ちください…",
|
||||
"previewLoading": "画像プレビューを読み込み中…",
|
||||
"previewUnavailable": "画像生成は完了しましたが、プレビューを読み込めませんでした。",
|
||||
"generatedReady": "生成された画像の準備ができました。",
|
||||
"generatedReadyWithMissing": "生成された画像の準備ができましたが、一部の画像を読み込めませんでした。"
|
||||
},
|
||||
"cronLiveRun": {
|
||||
"title": "スケジュール実行のライブ状況",
|
||||
"transient": "一時的な読み取り専用の進行状況です。最終内容は実行終了後にチャットへ表示されます。",
|
||||
"running": "実行中",
|
||||
"thinking": "考え中",
|
||||
"item": {
|
||||
"tool": "ツール",
|
||||
"command": "コマンド",
|
||||
"patch": "パッチ",
|
||||
"approval": "承認"
|
||||
},
|
||||
"status": {
|
||||
"running": "実行中",
|
||||
"completed": "完了",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"detail": {
|
||||
"input": "入力",
|
||||
"output": "出力",
|
||||
"error": "エラー",
|
||||
"exitCode": "終了コード: {{code}}"
|
||||
},
|
||||
"patch": {
|
||||
"added": "追加: {{count}}",
|
||||
"modified": "変更: {{count}}",
|
||||
"deleted": "削除: {{count}}"
|
||||
},
|
||||
"approvalReadOnly": "この状態は読み取り専用です。要求元のクライアントで応答してください。"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "ファイルを添付",
|
||||
"pickSkill": "Skill を選択",
|
||||
@@ -227,7 +303,7 @@
|
||||
"thinking": "考え中…",
|
||||
"gatewayConnected": "接続済み",
|
||||
"gatewayStarting": "起動中",
|
||||
"gatewayStatus": "ゲートウェイ {{state}}",
|
||||
"gatewayStatus": "ゲートウェイ {{state}} | ポート: {{port}} | PID: {{pid}}",
|
||||
"retryFailedAttachments": "失敗した添付を再試行",
|
||||
"workspacePrefix": "{{workspace}}",
|
||||
"workspacePickerTitle": "ワークスペースフォルダーを選択",
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
"hide": "非表示",
|
||||
"clear": "クリア"
|
||||
},
|
||||
"markdown": {
|
||||
"copyCode": "コードをコピー"
|
||||
},
|
||||
"status": {
|
||||
"running": "実行中",
|
||||
"stopped": "停止",
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
},
|
||||
"noLogs": "(Журналы ещё недоступны)",
|
||||
"scrollToLatest": "Вернуться вниз",
|
||||
"loadMoreHistory": "Загрузить предыдущие сообщения",
|
||||
"loadingMoreHistory": "Загрузка…",
|
||||
"toolbar": {
|
||||
"refresh": "Обновить чат",
|
||||
"currentAgent": "{{agent}}",
|
||||
"currentAgent": "Общение с {{agent}}",
|
||||
"workspace": "Рабочая область"
|
||||
},
|
||||
"generatedFiles": {
|
||||
"openFolder": "Открыть папку"
|
||||
"title": "Изменения файлов ({{count}})",
|
||||
"created": "Создано",
|
||||
"modified": "Изменено",
|
||||
"openFolder": "Открыть папку",
|
||||
"viewAll": "Все изменения"
|
||||
},
|
||||
"fileActivity": {
|
||||
"created": "Создан",
|
||||
@@ -105,7 +111,9 @@
|
||||
"filePreview": {
|
||||
"tabs": {
|
||||
"source": "Исходник",
|
||||
"preview": "Предпросмотр"
|
||||
"preview": "Предпросмотр",
|
||||
"changes": "Изменения",
|
||||
"info": "Информация"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить",
|
||||
@@ -119,6 +127,11 @@
|
||||
"openHtmlExternally": "Открыть HTML в системном браузере",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"info": {
|
||||
"path": "Путь",
|
||||
"size": "Размер",
|
||||
"type": "Тип"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "Сохранено на диск",
|
||||
"saveFailed": "Ошибка сохранения: {{error}}"
|
||||
@@ -133,8 +146,8 @@
|
||||
"notFound": "Файл не найден",
|
||||
"loadFailed": "Ошибка загрузки: {{error}}",
|
||||
"noPreview": "Для этого файла предпросмотр недоступен",
|
||||
"unsupportedFormatTitle": "Этот формат файла не поддерживает встроенный предпросмотр",
|
||||
"unsupportedFormatHint": "Встроенный предпросмотр сейчас поддерживает только напрямую читаемые текстовые/Markdown-файлы. Откройте файл через файловый менеджер.",
|
||||
"unsupportedFormatTitle": "Этот формат файла не поддерживает встроенный предпросмотр или diff",
|
||||
"unsupportedFormatHint": "Встроенный предпросмотр и diff сейчас поддерживают только напрямую читаемые текстовые/Markdown-файлы. Откройте файл через файловый менеджер.",
|
||||
"largeBinaryOpenTitle": "Этот файл слишком большой для встроенного предпросмотра",
|
||||
"largeBinaryOpenHint": "Размер файла — {{size}}. ClawX не может показать его встроенно, но вы можете подтвердить открытие в системном приложении по умолчанию.",
|
||||
"openInFinderFailed": "Не удалось показать файл",
|
||||
@@ -145,6 +158,13 @@
|
||||
"html": {
|
||||
"crashed": "Предпросмотр HTML остановлен"
|
||||
},
|
||||
"diff": {
|
||||
"noChanges": "Нет изменений для отображения",
|
||||
"newFile": "Новый файл — нет оригинала для сравнения",
|
||||
"tooLarge": "Файл слишком большой — режим diff отключён, откройте вкладку «Источник» для просмотра содержимого",
|
||||
"unavailable": "В этом чате не удалось зафиксировать точную базовую версию этого файла, поэтому diff недоступен.",
|
||||
"unavailableHint": "Откройте вкладку «Просмотр» для текущего содержимого; точное сравнение — в Git или другой системе."
|
||||
},
|
||||
"confirmOpen": {
|
||||
"title": "Открыть файл",
|
||||
"message": "Открыть «{{fileName}}» в системном приложении по умолчанию?",
|
||||
@@ -200,12 +220,68 @@
|
||||
"openRootInFinder": "Показать корень в файловом менеджере"
|
||||
}
|
||||
},
|
||||
"taskPanel": {
|
||||
"eyebrow": "Вид выполнения",
|
||||
"title": "Структура задачи",
|
||||
"emptyTitle": "Пока нет структурированных шагов",
|
||||
"emptyBody": "Когда начнётся выполнение, ClawX покажет размышления, вызовы инструментов и состояния передачи здесь.",
|
||||
"status": {
|
||||
"idle": "Бездействует",
|
||||
"running_one": "1 активный шаг",
|
||||
"running_other": "{{count}} активных шагов"
|
||||
},
|
||||
"stepStatus": {
|
||||
"running": "Выполняется",
|
||||
"completed": "Готово",
|
||||
"error": "Ошибка"
|
||||
}
|
||||
},
|
||||
"executionGraph": {
|
||||
"title": "Граф выполнения",
|
||||
"branchLabel": "ветвь",
|
||||
"thinkingLabel": "Думаю",
|
||||
"errorLabel": "Ошибка модели",
|
||||
"imageGenerateLabel": "Генерация изображения",
|
||||
"agentRun": "Выполнение {{agent}}",
|
||||
"collapsedSummary": "Вызовов инструментов: {{toolCount}} · Промежуточных сообщений: {{processCount}}",
|
||||
"collapseAction": "Свернуть граф выполнения"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "Изображение генерируется, подождите…",
|
||||
"previewLoading": "Загрузка предпросмотра изображения…",
|
||||
"previewUnavailable": "Создание изображения завершено, но предпросмотр загрузить не удалось.",
|
||||
"generatedReady": "Сгенерированное изображение готово.",
|
||||
"generatedReadyWithMissing": "Сгенерированное изображение готово, но некоторые изображения не удалось загрузить."
|
||||
},
|
||||
"cronLiveRun": {
|
||||
"title": "Выполнение задачи по расписанию",
|
||||
"transient": "Временный прогресс только для чтения. Итог появится в чате после завершения выполнения.",
|
||||
"running": "Выполняется",
|
||||
"thinking": "Обдумывание",
|
||||
"item": {
|
||||
"tool": "Инструмент",
|
||||
"command": "Команда",
|
||||
"patch": "Изменения",
|
||||
"approval": "Подтверждение"
|
||||
},
|
||||
"status": {
|
||||
"running": "Выполняется",
|
||||
"completed": "Завершено",
|
||||
"failed": "Ошибка"
|
||||
},
|
||||
"detail": {
|
||||
"input": "Входные данные",
|
||||
"output": "Результат",
|
||||
"error": "Ошибка",
|
||||
"exitCode": "Код завершения: {{code}}"
|
||||
},
|
||||
"patch": {
|
||||
"added": "Добавлено: {{count}}",
|
||||
"modified": "Изменено: {{count}}",
|
||||
"deleted": "Удалено: {{count}}"
|
||||
},
|
||||
"approvalReadOnly": "Статус только для чтения. Ответьте в исходном клиенте."
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "Прикрепить файлы",
|
||||
"pickSkill": "Выбрать Skill",
|
||||
@@ -227,7 +303,7 @@
|
||||
"thinking": "Думаю…",
|
||||
"gatewayConnected": "подключён",
|
||||
"gatewayStarting": "запуск",
|
||||
"gatewayStatus": "шлюз {{state}}",
|
||||
"gatewayStatus": "шлюз {{state}} | порт: {{port}} | PID: {{pid}}",
|
||||
"retryFailedAttachments": "Повторить неудавшиеся вложения",
|
||||
"workspacePrefix": "{{workspace}}",
|
||||
"workspacePickerTitle": "Выберите папку рабочей области",
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
"hide": "Скрыть",
|
||||
"clear": "Очистить"
|
||||
},
|
||||
"markdown": {
|
||||
"copyCode": "Копировать код"
|
||||
},
|
||||
"status": {
|
||||
"running": "Запущен",
|
||||
"stopped": "Остановлен",
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
},
|
||||
"noLogs": "(暂无日志)",
|
||||
"scrollToLatest": "回到底部",
|
||||
"loadMoreHistory": "加载更早的消息",
|
||||
"loadingMoreHistory": "加载更多中…",
|
||||
"toolbar": {
|
||||
"refresh": "刷新聊天",
|
||||
"currentAgent": "{{agent}}",
|
||||
"currentAgent": "当前对话对象:{{agent}}",
|
||||
"workspace": "工作空间"
|
||||
},
|
||||
"generatedFiles": {
|
||||
"openFolder": "打开文件夹"
|
||||
"title": "文件变更({{count}} 个)",
|
||||
"created": "新增",
|
||||
"modified": "修改",
|
||||
"openFolder": "打开文件夹",
|
||||
"viewAll": "查看文件变更"
|
||||
},
|
||||
"fileActivity": {
|
||||
"created": "已创建",
|
||||
@@ -105,7 +111,9 @@
|
||||
"filePreview": {
|
||||
"tabs": {
|
||||
"source": "源码",
|
||||
"preview": "预览"
|
||||
"preview": "预览",
|
||||
"changes": "变更",
|
||||
"info": "信息"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存",
|
||||
@@ -119,6 +127,11 @@
|
||||
"openHtmlExternally": "在系统浏览器中打开 HTML",
|
||||
"retry": "重试"
|
||||
},
|
||||
"info": {
|
||||
"path": "路径",
|
||||
"size": "大小",
|
||||
"type": "类型"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "已保存到磁盘",
|
||||
"saveFailed": "保存失败:{{error}}"
|
||||
@@ -133,8 +146,8 @@
|
||||
"notFound": "文件不存在",
|
||||
"loadFailed": "加载失败:{{error}}",
|
||||
"noPreview": "该文件没有预览",
|
||||
"unsupportedFormatTitle": "此文件格式暂不支持内置预览",
|
||||
"unsupportedFormatHint": "当前仅支持文本/Markdown 等可直接读取的文件进行内置预览。请在文件管理器中打开该文件。",
|
||||
"unsupportedFormatTitle": "此文件格式暂不支持内置预览或变更",
|
||||
"unsupportedFormatHint": "当前仅支持文本/Markdown 等可直接读取的文件进行内置预览与变更对比。请在文件管理器中打开该文件。",
|
||||
"largeBinaryOpenTitle": "该文件较大,暂不支持内置预览",
|
||||
"largeBinaryOpenHint": "当前文件为 {{size}},ClawX 不提供内置预览。你可以确认后直接使用系统默认应用打开。",
|
||||
"openInFinderFailed": "无法在文件管理器中显示",
|
||||
@@ -145,6 +158,13 @@
|
||||
"html": {
|
||||
"crashed": "HTML 预览已停止运行"
|
||||
},
|
||||
"diff": {
|
||||
"noChanges": "没有可显示的变更",
|
||||
"newFile": "这是新增文件,无对比内容",
|
||||
"tooLarge": "文件过大,已禁用 diff 视图,请到「源码」标签查看完整内容",
|
||||
"unavailable": "本会话没有抓到这个文件的精确变更基线,无法生成 diff。",
|
||||
"unavailableHint": "可点击顶部「预览」查看当前文件内容;若需精确差异,请在 Git 等工具中对比版本。"
|
||||
},
|
||||
"confirmOpen": {
|
||||
"title": "打开文件",
|
||||
"message": "确认直接打开「{{fileName}}」?",
|
||||
@@ -200,12 +220,68 @@
|
||||
"openRootInFinder": "在文件管理器中显示根目录"
|
||||
}
|
||||
},
|
||||
"taskPanel": {
|
||||
"eyebrow": "运行视图",
|
||||
"title": "任务脉络",
|
||||
"emptyTitle": "还没有结构化步骤",
|
||||
"emptyBody": "当一次运行开始后,ClawX 会在这里展示思考、工具调用和收尾状态。",
|
||||
"status": {
|
||||
"idle": "空闲",
|
||||
"running_one": "1 个活动步骤",
|
||||
"running_other": "{{count}} 个活动步骤"
|
||||
},
|
||||
"stepStatus": {
|
||||
"running": "进行中",
|
||||
"completed": "完成",
|
||||
"error": "错误"
|
||||
}
|
||||
},
|
||||
"executionGraph": {
|
||||
"title": "执行关系图",
|
||||
"branchLabel": "分支",
|
||||
"thinkingLabel": "思考中",
|
||||
"errorLabel": "模型调用失败",
|
||||
"imageGenerateLabel": "图片生成",
|
||||
"agentRun": "{{agent}} 执行",
|
||||
"collapsedSummary": "{{toolCount}} 个工具调用,{{processCount}} 条过程消息",
|
||||
"collapseAction": "收起执行关系图"
|
||||
},
|
||||
"imageGeneration": {
|
||||
"generating": "图片生成中,请稍候…",
|
||||
"previewLoading": "正在加载图片预览…",
|
||||
"previewUnavailable": "图片生成已完成,但无法加载预览。",
|
||||
"generatedReady": "生成的图片已准备好。",
|
||||
"generatedReadyWithMissing": "生成的图片已准备好,但有部分图片无法加载。"
|
||||
},
|
||||
"cronLiveRun": {
|
||||
"title": "计划任务实时运行",
|
||||
"transient": "此处仅显示临时的只读进度。运行结束后,最终内容将显示在聊天记录中。",
|
||||
"running": "运行中",
|
||||
"thinking": "思考中",
|
||||
"item": {
|
||||
"tool": "工具",
|
||||
"command": "命令",
|
||||
"patch": "补丁",
|
||||
"approval": "审批"
|
||||
},
|
||||
"status": {
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"failed": "失败"
|
||||
},
|
||||
"detail": {
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"error": "错误",
|
||||
"exitCode": "退出代码:{{code}}"
|
||||
},
|
||||
"patch": {
|
||||
"added": "新增:{{count}}",
|
||||
"modified": "修改:{{count}}",
|
||||
"deleted": "删除:{{count}}"
|
||||
},
|
||||
"approvalReadOnly": "此状态为只读。请在发起请求的客户端中响应。"
|
||||
},
|
||||
"composer": {
|
||||
"attachFiles": "添加文件",
|
||||
"pickSkill": "选择技能",
|
||||
@@ -227,7 +303,7 @@
|
||||
"thinking": "思考中…",
|
||||
"gatewayConnected": "已连接",
|
||||
"gatewayStarting": "启动中",
|
||||
"gatewayStatus": "网关{{state}}",
|
||||
"gatewayStatus": "网关{{state}} | 端口:{{port}} | 进程 ID:{{pid}}",
|
||||
"retryFailedAttachments": "重试失败的附件",
|
||||
"workspacePrefix": "{{workspace}}",
|
||||
"workspacePickerTitle": "选择工作空间文件夹",
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
"hide": "隐藏",
|
||||
"clear": "清空"
|
||||
},
|
||||
"markdown": {
|
||||
"copyCode": "复制代码"
|
||||
},
|
||||
"status": {
|
||||
"running": "运行中",
|
||||
"stopped": "已停止",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user