Compare commits

..

4 Commits

Author SHA1 Message Date
Peter Steinberger
7ec621ab58 chore: polish qa lab follow-ups 2026-04-05 09:13:50 +01:00
Peter Steinberger
8762df9bb4 feat: add qa lab extension 2026-04-05 09:13:49 +01:00
Peter Steinberger
dce0467826 refactor: hide qa channels with exposure metadata 2026-04-05 09:12:00 +01:00
Peter Steinberger
258484854b feat: add qa channel foundation 2026-04-05 09:12:00 +01:00
5785 changed files with 180954 additions and 295883 deletions

View File

@@ -17,7 +17,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
- If `main` is moving under active multi-agent work, prefer a detached worktree pinned to one commit for long Parallels suites. The smoke scripts now verify the packed tgz commit instead of live `git rev-parse HEAD`, but a pinned worktree still avoids noisy rebuild/version drift during reruns.
- For `openclaw update --channel dev` lanes, remember the guest clones GitHub `main`, not your local worktree. If a local fix exists but the rerun still fails inside the cloned dev checkout, do not treat that as disproof of the fix until the branch has been pushed.
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
- If the workflow installs OpenClaw from a repo checkout instead of the site installer/npm release, finish by installing a real guest CLI shim and verifying it in a fresh guest shell. `pnpm openclaw ...` inside the repo is not enough for handoff parity.
- On macOS guests, prefer a user-global install plus a stable PATH-visible shim:
@@ -33,7 +32,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
- On macOS same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; launchd can otherwise report a loaded service while the old process has exited and the fresh process is not RPC-ready yet.
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
- After that Windows restart, do not trust one `gateway status --deep --require-rpc` call after a fixed sleep. Retry the RPC-ready probe for roughly 30 seconds and log each attempt; current guests can keep port `18789` bound while the fresh RPC endpoint is still coming up.
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
- The Windows same-guest update helper should write stage markers to its log before long steps like tgz download and `npm install -g` so the outer progress monitor does not sit on `waiting for first log line` during healthy but quiet installs.
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
@@ -47,17 +45,10 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
## macOS flow
- Preferred entrypoint: `pnpm test:parallels:macos`
- Default upgrade coverage on macOS should now include: fresh snapshot -> site installer pinned to the latest stable tag -> `openclaw update --channel dev` on the guest. Treat this as part of the default Tahoe regression plan, not an optional side quest.
- `parallels-macos-smoke.sh --mode upgrade` should run that release-to-dev lane by default. Keep the older host-tgz upgrade path only when the caller explicitly passes `--target-package-spec`.
- Because the default upgrade lane no longer needs a host tgz, skip `npm pack` + host HTTP server startup for `--mode upgrade` unless `--target-package-spec` is set. Keep the pack/server path for `fresh` and `both`.
- If that release-to-dev lane fails with `reason=preflight-no-good-commit` and repeated `sh: pnpm: command not found` tails from `preflight build`, treat it as an updater regression first. The fix belongs in the git/dev updater bootstrap path, not in Parallels retry logic.
- Until the public stable train includes that updater bootstrap fix, the macOS release-to-dev lane may seed a temporary guest-local `pnpm` shim immediately before `openclaw update --channel dev`. Keep that workaround scoped to the smoke harness and remove it once the latest stable no longer needs it.
- In Tahoe `prlctl exec --current-user` runs, prefer explicit `node .../openclaw.mjs ...` invocations for the release->dev handoff itself and for post-update verification. The shebanged global `openclaw` wrapper can fail with `env: node: No such file or directory`, and self-updating through the wrapper is a weaker lane than invoking the entrypoint under a fixed `node`.
- Default to the snapshot closest to `macOS 26.3.1 latest`.
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
- `parallels-macos-smoke.sh` now retries `snapshot-switch` once after force-stopping a stuck running/suspended guest. If Tahoe still times out after that recovery path, then treat it as a real Parallels/host issue and rerun manually.
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
- If a Tahoe lane times out in `fresh.first-agent-turn` and the phase log stops right after `__OPENCLAW_RC__:0` from `models set`, suspect the `prlctl enter` / `expect` wrapper before blaming auth or the model lane. That pattern means the first guest command finished but the transport never released for the next `guest_current_user_cli` call.
- If a packaged install regresses with `500` on `/`, `/healthz`, or `__openclaw/control-ui-config.json` after `fresh.install-main` or `upgrade.install-main`, suspect bundled plugin runtime deps resolving from the package root `node_modules` rather than `dist/extensions/*/node_modules`. Repro quickly with a real `npm pack`/global install lane before blaming dashboard auth or Safari.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
@@ -70,25 +61,14 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Preferred entrypoint: `pnpm test:parallels:windows`
- Use the snapshot closest to `pre-openclaw-native-e2e-2026-03-12`.
- Default upgrade coverage on Windows should now include: fresh snapshot -> site installer pinned to the requested stable tag -> `openclaw update --channel dev` on the guest. Keep the older host-tgz upgrade path only when the caller explicitly passes `--target-package-spec`.
- Optional exact npm-tag baseline on Windows: `bash scripts/e2e/parallels-windows-smoke.sh --mode upgrade --target-package-spec openclaw@<tag> --json`. That lane installs the published npm tarball as baseline, then runs `openclaw update --channel dev`.
- Optional forward-fix Windows validation: `bash scripts/e2e/parallels-windows-smoke.sh --mode upgrade --upgrade-from-packed-main --json`. That lane installs the packed current-main npm tgz as baseline, then runs `openclaw update --channel dev`.
- Always use `prlctl exec --current-user`; plain `prlctl exec` lands in `NT AUTHORITY\\SYSTEM`.
- Prefer explicit `npm.cmd` and `openclaw.cmd`.
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
- Current Windows Node installs expose `corepack` as a `.cmd` shim. If a release-to-dev lane sees `corepack` on PATH but `openclaw update --channel dev` still behaves as if corepack is missing, treat that as an exec-shim regression first.
- If an exact published-tag Windows lane fails during preflight with `npm run build` and `'pnpm' is not recognized`, remember that the guest is still executing the old published updater. Validate the fix with `--upgrade-from-packed-main`, then wait for the next tagged npm release before expecting the historical tag lane to pass.
- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`.
- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe.
- If a Windows retry sees the VM become `suspended` or `stopped`, resume/start it before the next `prlctl exec`; otherwise the second attempt just repeats the same `rc=255`.
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
- When those Windows global installs stay quiet, the useful progress often lives in the guest npm debug log, not the helper phase log. The smoke script now streams incremental `npm-cache/_logs/*-debug-0.log` deltas into the phase log during long baseline/package installs; read those lines before assuming the lane is stalled.
- The Windows baseline-package helpers now auto-dump the latest guest `npm-cache/_logs/*-debug-0.log` tail on timeout or nonzero completion. Read that tail in the phase log before opening a second guest shell.
- The same incremental npm-debug streaming also applies to `--upgrade-from-packed-main` / packaged-install baseline phases. A phase log that still says only `install.start`, `install.download-tgz`, `install.install-tgz` can still be healthy if the streamed npm-debug section shows registry fetches or bundled-plugin postinstall work.
- Fresh Windows tgz install phases should also use the background PowerShell runner plus done-file/log-drain pattern; do not rely on one long-lived `prlctl exec ... powershell ... npm install -g` transport for package installs.
- Windows release-to-dev helpers should log `where pnpm` before and after the update and require `where pnpm` to succeed post-update. That proves the updater installed or enabled `pnpm` itself instead of depending on a smoke-only bootstrap.
- Fresh Windows ref-mode onboard should use the same background PowerShell runner plus done-file/log-drain pattern as the npm-update helper, including startup materialization checks, host-side timeouts on short poll `prlctl exec` calls, and retry-on-poll-failure behavior for transient transport flakes.
- Fresh Windows daemon-health reachability should use a hello-only gateway probe and a longer per-probe timeout than the default local attach path; full health RPCs are too eager during initial startup on current main.
- Fresh Windows ref-mode agent verification should set `OPENAI_API_KEY` in the PowerShell environment before invoking `openclaw.cmd agent`, for the same pairing-required fallback reason as macOS.
- The standalone Windows upgrade smoke lane should stop the managed gateway after `upgrade.install-main` and before `upgrade.onboard-ref`. Restarting before onboard can leave the old process alive on the pre-onboard token while onboard rewrites `~/.openclaw/openclaw.json`, which then fails `gateway-health` with `unauthorized: gateway token mismatch`.
- If standalone Windows upgrade fails with a gateway token mismatch but `pnpm test:parallels:npm-update` passes, trust the mismatch as a standalone ref-onboard ordering bug first; the npm-update helper does not re-run ref-mode onboard on the same guest.

View File

@@ -1,85 +0,0 @@
---
name: openclaw-qa-testing
description: Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.
---
# OpenClaw QA Testing
Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
## Read first
- `docs/concepts/qa-e2e-automation.md`
- `docs/help/testing.md`
- `docs/channels/qa-channel.md`
- `qa/QA_KICKOFF_TASK.md`
- `qa/seed-scenarios.json`
- `extensions/qa-lab/src/suite.ts`
## Model policy
- Live OpenAI lane: `openai/gpt-5.4`
- Fast mode: on
- Do not use:
- `openai/gpt-5.4-pro`
- `openai/gpt-5.4-mini`
- Only change model policy if the user explicitly asks.
## Default workflow
1. Read the seed plan and current suite implementation.
2. Decide lane:
- mock/dev: `mock-openai`
- real validation: `live-openai`
3. For live OpenAI, use:
```bash
OPENCLAW_LIVE_OPENAI_KEY="${OPENAI_API_KEY}" \
pnpm openclaw qa suite \
--provider-mode live-openai \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--output-dir .artifacts/qa-e2e/run-all-live-openai-<tag>
```
4. Watch outputs:
- summary: `.artifacts/qa-e2e/run-all-live-openai-<tag>/qa-suite-summary.json`
- report: `.artifacts/qa-e2e/run-all-live-openai-<tag>/qa-suite-report.md`
5. If the user wants to watch the live UI, find the current `openclaw-qa` listen port and report `http://127.0.0.1:<port>`.
6. If a scenario fails, fix the product or harness root cause, then rerun the full lane.
## Repo facts
- Seed scenarios live in `qa/`.
- Main live runner: `extensions/qa-lab/src/suite.ts`
- QA lab server: `extensions/qa-lab/src/lab-server.ts`
- Child gateway harness: `extensions/qa-lab/src/gateway-child.ts`
- Synthetic channel: `extensions/qa-channel/`
## What “done” looks like
- Full suite green for the requested lane.
- User gets:
- watch URL if applicable
- pass/fail counts
- artifact paths
- concise note on what was fixed
## Common failure patterns
- Live timeout too short:
- widen live waits in `extensions/qa-lab/src/suite.ts`
- Discovery cannot find repo files:
- point prompts at `repo/...` inside seeded workspace
- Subagent proof too brittle:
- prefer stable final reply evidence over transient child-session listing
- Harness “rebuild” delay:
- dirty tree can trigger a pre-run build; expect that before ports appear
## When adding scenarios
- Add scenario metadata to `qa/seed-scenarios.json`
- Keep kickoff expectations in `qa/QA_KICKOFF_TASK.md` aligned
- Add executable coverage in `extensions/qa-lab/src/suite.ts`
- Prefer end-to-end assertions over mock-only checks
- Save outputs under `.artifacts/qa-e2e/`

View File

@@ -1,4 +0,0 @@
interface:
display_name: "QA Test OpenClaw"
short_description: "Run and debug qa-lab and qa-channel scenarios"
default_prompt: "Use $openclaw-qa-testing to run or extend the OpenClaw QA suite with qa-lab and qa-channel, using regular openai/gpt-5.4 in fast mode for live OpenAI runs."

12
.github/labeler.yml vendored
View File

@@ -233,18 +233,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/memory-lancedb/**"
"extensions: memory-wiki":
- changed-files:
- any-glob-to-any-file:
- "extensions/memory-wiki/**"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:
- "extensions/open-prose/**"
"extensions: webhooks":
- changed-files:
- any-glob-to-any-file:
- "extensions/webhooks/**"
"extensions: device-pair":
- changed-files:
- any-glob-to-any-file:
@@ -257,10 +249,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/acpx/**"
"extensions: arcee":
- changed-files:
- any-glob-to-any-file:
- "extensions/arcee/**"
"extensions: byteplus":
- changed-files:
- any-glob-to-any-file:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -46,7 +46,6 @@ jobs:
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
checks_windows_matrix: ${{ steps.manifest.outputs.checks_windows_matrix }}
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
@@ -129,7 +128,6 @@ jobs:
OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
run: |
@@ -167,8 +165,6 @@ jobs:
const runAndroid = parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly;
const runWindows = parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly;
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
const runControlUiI18n =
parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly;
const hasChangedExtensions =
parseBoolean(process.env.OPENCLAW_CI_HAS_CHANGED_EXTENSIONS) && !docsOnly;
const changedExtensionsMatrix = hasChangedExtensions
@@ -245,7 +241,6 @@ jobs:
run_check_additional: runNode,
run_build_smoke: runNode,
run_check_docs: docsChanged,
run_control_ui_i18n: runControlUiI18n,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
checks_windows_matrix: createMatrix(
@@ -743,32 +738,11 @@ jobs:
continue-on-error: true
run: pnpm run lint:extensions:no-relative-outside-package
- name: Run extension channel lint
id: extension_channel_lint
continue-on-error: true
run: pnpm run lint:extensions:channels
- name: Run bundled extension lint
id: extension_bundled_lint
continue-on-error: true
run: pnpm run lint:extensions:bundled
- name: Run extension package boundary TypeScript check
id: extension_package_boundary_tsc
continue-on-error: true
run: pnpm run test:extensions:package-boundary
- name: Enforce safe external URL opening policy
id: no_raw_window_open
continue-on-error: true
run: pnpm lint:ui:no-raw-window-open
- name: Check control UI locale sync
id: control_ui_i18n
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
continue-on-error: true
run: pnpm ui:i18n:check
- name: Run gateway watch regression harness
id: gateway_watch_regression
continue-on-error: true
@@ -800,11 +774,7 @@ jobs:
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
EXTENSION_CHANNEL_LINT_OUTCOME: ${{ steps.extension_channel_lint.outcome }}
EXTENSION_BUNDLED_LINT_OUTCOME: ${{ steps.extension_bundled_lint.outcome }}
EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME: ${{ steps.extension_package_boundary_tsc.outcome }}
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome == 'skipped' && 'success' || steps.control_ui_i18n.outcome }}
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
run: |
failures=0
@@ -824,11 +794,7 @@ jobs:
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
"lint:extensions:channels|$EXTENSION_CHANNEL_LINT_OUTCOME" \
"lint:extensions:bundled|$EXTENSION_BUNDLED_LINT_OUTCOME" \
"test:extensions:package-boundary|$EXTENSION_PACKAGE_BOUNDARY_TSC_OUTCOME" \
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
name="${result%%|*}"
outcome="${result#*|}"

View File

@@ -1,172 +0,0 @@
name: Control UI Locale Refresh
on:
push:
branches:
- main
paths:
- ui/src/i18n/locales/en.ts
- ui/src/i18n/locales/*.ts
- ui/src/i18n/.i18n/*
- ui/src/i18n/lib/types.ts
- ui/src/i18n/lib/registry.ts
- scripts/control-ui-i18n.ts
- .github/workflows/control-ui-locale-refresh.yml
release:
types:
- published
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: control-ui-locale-refresh
cancel-in-progress: false
jobs:
plan:
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
runs-on: ubuntu-latest
outputs:
has_locales: ${{ steps.plan.outputs.has_locales }}
locales_json: ${{ steps.plan.outputs.locales_json }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
submodules: false
- name: Plan locale matrix
id: plan
env:
BEFORE_SHA: ${{ github.event.before }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
all_locales_json='["zh-CN","zh-TW","pt-BR","de","es","ja-JP","ko","fr","tr","uk","id","pl"]'
if [ "$EVENT_NAME" != "push" ]; then
echo "has_locales=true" >> "$GITHUB_OUTPUT"
echo "locales_json=$all_locales_json" >> "$GITHUB_OUTPUT"
exit 0
fi
before_ref="$BEFORE_SHA"
if [ -z "$before_ref" ] || [ "$before_ref" = "0000000000000000000000000000000000000000" ]; then
before_ref="$(git rev-parse HEAD^)"
fi
changed_files="$(git diff --name-only "$before_ref" HEAD)"
echo "changed files:"
printf '%s\n' "$changed_files"
if printf '%s\n' "$changed_files" | grep -Eq '^(ui/src/i18n/locales/en\.ts|ui/src/i18n/lib/types\.ts|ui/src/i18n/lib/registry\.ts|scripts/control-ui-i18n\.ts|\.github/workflows/control-ui-locale-refresh\.yml)$'; then
echo "has_locales=true" >> "$GITHUB_OUTPUT"
echo "locales_json=$all_locales_json" >> "$GITHUB_OUTPUT"
exit 0
fi
locales_json="$(printf '%s\n' "$changed_files" | node <<'EOF'
const fs = require("node:fs");
const changed = fs.readFileSync(0, "utf8").split(/\r?\n/).filter(Boolean);
const locales = new Set();
for (const file of changed) {
let match = file.match(/^ui\/src\/i18n\/locales\/(.+)\.ts$/);
if (match && match[1] !== "en") {
locales.add(match[1]);
continue;
}
match = file.match(/^ui\/src\/i18n\/\.i18n\/(.+)\.(?:meta\.json|tm\.jsonl)$/);
if (match) {
locales.add(match[1]);
}
}
process.stdout.write(JSON.stringify([...locales]));
EOF
)"
if [ "$locales_json" = "[]" ]; then
echo "has_locales=false" >> "$GITHUB_OUTPUT"
echo "locales_json=[]" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "has_locales=true" >> "$GITHUB_OUTPUT"
echo "locales_json=$locales_json" >> "$GITHUB_OUTPUT"
refresh:
needs: plan
if: github.repository == 'openclaw/openclaw' && needs.plan.outputs.has_locales == 'true'
strategy:
fail-fast: false
max-parallel: 4
matrix:
locale: ${{ fromJson(needs.plan.outputs.locales_json) }}
runs-on: ubuntu-latest
name: Refresh ${{ matrix.locale }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: true
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Ensure translation provider secrets exist
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
set -euo pipefail
if [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo "Missing OPENCLAW_DOCS_I18N_OPENAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY secret."
exit 1
fi
- name: Refresh control UI locale files
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_MODEL: gpt-5.4
OPENCLAW_CONTROL_UI_I18N_THINKING: low
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${{ matrix.locale }}" --write
- name: Commit and push locale updates
env:
LOCALE: ${{ matrix.locale }}
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if git diff --quiet -- ui/src/i18n; then
echo "No control UI locale changes for ${LOCALE}."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A ui/src/i18n
git commit --no-verify -m "chore(ui): refresh ${LOCALE} control ui locale"
for attempt in 1 2 3 4 5; do
git fetch origin "${TARGET_BRANCH}"
git rebase --autostash "origin/${TARGET_BRANCH}"
if git push origin HEAD:"${TARGET_BRANCH}"; then
exit 0
fi
echo "Push attempt ${attempt} for ${LOCALE} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to push ${LOCALE} locale update after retries."
exit 1

View File

@@ -1,70 +0,0 @@
name: Docs Sync Publish Repo
on:
push:
branches:
- main
paths:
- docs/**
- scripts/docs-sync-publish.mjs
- .github/workflows/docs-sync-publish.yml
workflow_dispatch:
permissions:
contents: read
jobs:
sync-publish-repo:
runs-on: ubuntu-latest
steps:
- name: Checkout source repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Clone publish repo
env:
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
run: |
set -euo pipefail
git clone \
"https://x-access-token:${OPENCLAW_DOCS_SYNC_TOKEN}@github.com/openclaw/docs.git" \
publish
- name: Sync docs into publish repo
run: |
node scripts/docs-sync-publish.mjs \
--target "$GITHUB_WORKSPACE/publish" \
--source-repo "$GITHUB_REPOSITORY" \
--source-sha "$GITHUB_SHA"
- name: Commit publish repo sync
working-directory: publish
run: |
set -euo pipefail
if git diff --quiet -- docs .openclaw-sync; then
echo "No publish-repo changes."
exit 0
fi
git config user.name "openclaw-docs-sync[bot]"
git config user.email "openclaw-docs-sync[bot]@users.noreply.github.com"
git add docs .openclaw-sync
git commit -m "chore(sync): mirror docs from $GITHUB_REPOSITORY@$GITHUB_SHA"
for attempt in 1 2 3 4 5; do
git fetch origin main
git rebase origin/main
if git push origin HEAD:main; then
exit 0
fi
echo "Push attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to push publish-repo sync after retries."
exit 1

View File

@@ -1,42 +0,0 @@
name: Docs Trigger Locale Translate On Release
on:
release:
types:
- published
permissions:
contents: read
jobs:
dispatch-translate:
runs-on: ubuntu-latest
steps:
- name: Trigger locale translates in publish repo
env:
GH_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
for event_type in \
translate-zh-cn-release \
translate-ja-jp-release \
translate-es-release \
translate-pt-br-release \
translate-ko-release \
translate-de-release \
translate-fr-release \
translate-ar-release \
translate-it-release \
translate-tr-release \
translate-uk-release \
translate-id-release \
translate-pl-release
do
gh api repos/openclaw/docs/dispatches \
--method POST \
-f event_type="${event_type}" \
-f client_payload[release_tag]="${RELEASE_TAG}" \
-f client_payload[source_repository]="${GITHUB_REPOSITORY}" \
-f client_payload[source_sha]="${GITHUB_SHA}"
done

View File

@@ -78,7 +78,7 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
install-bun: "false"
use-sticky-disk: "false"
- name: Ensure version is not already published

1
.gitignore vendored
View File

@@ -145,7 +145,6 @@ changelog/fragments/
# Local scratch workspace
.tmp/
.artifacts/
test/fixtures/openclaw-vitest-unit-report.json
analysis/
.artifacts/qa-e2e/

View File

@@ -25,8 +25,8 @@
"ignorePatterns": [
"assets/",
"dist/",
"dist-runtime/",
"docs/_layouts/",
"extensions/",
"node_modules/",
"patches/",
"pnpm-lock.yaml",
@@ -34,36 +34,6 @@
"src/auto-reply/reply/export-html/template.js",
"src/canvas-host/a2ui/a2ui.bundle.js",
"Swabble/",
"vendor/",
"**/.cache/**",
"**/build/**",
"**/coverage/**",
"**/dist/**",
"**/dist-runtime/**",
"**/node_modules/**"
],
"overrides": [
{
"files": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.e2e.test.ts",
"**/*.live.test.ts",
"**/*test-harness.ts",
"**/*test-helpers.ts",
"**/*test-support.ts"
],
"rules": {
"typescript/await-thenable": "off",
"typescript/no-base-to-string": "off",
"typescript/no-explicit-any": "off",
"typescript/no-floating-promises": "off",
"typescript/no-misused-spread": "off",
"typescript/no-redundant-type-constituents": "off",
"typescript/no-unnecessary-template-expression": "off",
"typescript/unbound-method": "off",
"eslint/no-unsafe-optional-chaining": "off"
}
}
"vendor/"
]
}

View File

@@ -38,14 +38,8 @@
- Plugin and extension boundary:
- Public docs: `docs/plugins/building-plugins.md`, `docs/plugins/architecture.md`, `docs/plugins/sdk-overview.md`, `docs/plugins/sdk-entrypoints.md`, `docs/plugins/sdk-runtime.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/sdk-provider-plugins.md`
- Definition files: `src/plugin-sdk/plugin-entry.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/channel-contract.ts`, `scripts/lib/plugin-sdk-entrypoints.json`, `package.json`
- Invariant: core must stay extension-agnostic. Adding a bundled or third-party extension should not require unrelated core edits just to teach core that the extension exists.
- Rule: extensions must cross into core only through `openclaw/plugin-sdk/*`, manifest metadata, and documented runtime helpers. Do not import `src/**` from extension production code.
- Rule: core code and tests must not deep-import bundled plugin internals such as a plugin's `src/**` files or `onboard.js`. If core needs a bundled plugin helper, expose it through that plugin's `api.ts` and, when it is a real cross-package contract, through `src/plugin-sdk/<id>.ts`.
- Rule: do not add hardcoded bundled extension/provider/channel/capability id lists, maps, or named special cases in core when a manifest, capability, registry, or plugin-owned contract can express the same behavior.
- Rule: extension-owned compatibility behavior belongs to the owning extension. Core may orchestrate generic doctor/config flows, but extension-specific legacy repairs, detection rules, onboarding, auth detection, and provider defaults should live in plugin-owned contracts.
- Rule: for legacy config specifically, prefer doctor-owned repair paths over startup/load-time core migrations. Do not add new plugin-specific legacy migration logic to shared core/runtime surfaces when `openclaw doctor --fix` can own it.
- Rule: when a test is asserting extension-specific behavior, keep that coverage in the owning extension when feasible. Core tests should assert generic contracts and registry/capability behavior, not extension internals.
- Refactor trigger: if you encounter core code or tests that name a specific extension/provider/channel for extension-owned behavior, refactor toward a generic registry/capability/plugin-owned seam instead of adding another special case.
- Compatibility: new plugin seams are allowed, but they must be added as documented, backwards-compatible, versioned contracts. We have third-party plugins in the wild and do not break them casually.
- Channel boundary:
- Public docs: `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/architecture.md`
@@ -73,7 +67,6 @@
- Extension test boundary:
- Keep extension-owned onboarding/config/provider coverage under the owning bundled plugin package when feasible.
- If core tests need bundled plugin behavior, consume it through public `src/plugin-sdk/<id>.ts` facades or the plugin's `api.ts`, not private extension modules.
- If a core test is asserting extension-specific behavior instead of a generic contract, move it to the owning extension package.
## Docs Linking (Mintlify)
@@ -88,24 +81,16 @@
- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub.
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## Docs i18n (generated publish locales)
## Docs i18n (zh-CN)
- Foreign-language docs are not maintained in this repo. The generated publish output lives in the separate `openclaw/docs` repo (often cloned locally as the sibling `openclaw-docs` directory); do not add or edit localized docs under `docs/<locale>/**` here.
- Those localized docs are autogenerated. Treat this repo's English docs plus glossary files as the source of truth, and let the publish/translation pipeline update `openclaw/docs`.
- Pipeline: update English docs here → adjust the matching `docs/.i18n/glossary.<locale>.json` entries → let the publish-repo sync + `scripts/docs-i18n` run in `openclaw/docs` / local `openclaw-docs` clone → apply targeted fixes only if instructed.
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`).
- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns.
- Translation memory lives in generated `docs/.i18n/*.tm.jsonl` files in the publish repo.
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
- See `docs/.i18n/README.md`.
- The pipeline can be slow/inefficient; if its dragging, ping @jospalmbier on Discord instead of hacking around it.
## Control UI i18n (generated in repo)
- Control UI foreign-language locale bundles are generated in this repo; do not hand-edit `ui/src/i18n/locales/*.ts` for non-English locales or `ui/src/i18n/.i18n/*` unless a targeted generated-output fix is explicitly requested.
- Source of truth is `ui/src/i18n/locales/en.ts` plus the generator/runtime wiring in `scripts/control-ui-i18n.ts`, `ui/src/i18n/lib/types.ts`, and `ui/src/i18n/lib/registry.ts`.
- Pipeline: update English control UI strings and locale wiring here → run `pnpm ui:i18n:sync` (or let `Control UI Locale Refresh` do it) → commit the regenerated locale bundles and `.i18n` metadata.
- If the control UI locale outputs drift, regenerate them; do not manually translate or hand-maintain the generated locale files by default.
## exe.dev VM ops (general)
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
@@ -132,7 +117,7 @@
- Type-check/build: `pnpm build`
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Local agent/dev shells default to host-aware `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK_MODE=throttled` to force the lower-memory profile, `OPENCLAW_LOCAL_CHECK_MODE=full` to keep lock-only behavior, or `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
- Local agent/dev shells default to lower-memory `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Terminology:
@@ -296,7 +281,7 @@
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Carbon: prefer latest published beta over stable when possible; do not switch to stable casually.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.

View File

@@ -4,429 +4,133 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Changes
- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory subagent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, and opt-in transcript persistence for debugging.
- CLI/infer: add a first-class `openclaw infer ...` hub for provider-backed inference workflows across model, media, web, and embedding tasks. Thanks @Takhoffman.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. (#61892) Thanks @mbelinky.
- Tools/media generation: preserve intent across auth-backed image, music, and video provider fallback, remap size, aspect ratio, resolution, and duration hints to the closest supported option, and surface explicit provider capabilities plus mode-aware video-to-video support.
- Memory/wiki: restore the bundled `memory-wiki` stack with plugin, CLI, sync/query/apply tooling, and memory-host integration for wiki-backed memory workflows.
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with Trinity catalog entries, OpenRouter support, and updated onboarding/auth guidance. (#62068) Thanks @arthurbr11.
- Providers/Google: add Gemma 4 model support and keep Google fallback resolution on the requested provider path so native Google Gemma routes work again. (#61507) Thanks @eyjohn.
- Providers/Google: preserve explicit thinking-off semantics for Gemma 4 while still enabling Gemma reasoning support in the Google compatibility wrappers. (#62127) Thanks @romgenie, co-authored with BunsDev.
- Providers/Anthropic: restore Claude CLI as the preferred local Anthropic path in onboarding, model-auth guidance, doctor flows, and Docker Claude CLI live lanes again.
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.5.1` so plugin-local installs and strict version checks pick up the latest published runtime release. (#62148) Thanks @onutc.
- Tools/media generation: auto-fallback across auth-backed image, music, and video providers by default, and remap fallback size, aspect ratio, resolution, and duration hints to the closest supported option instead of dropping intent on provider switches.
- Tools/media generation: report applied fallback geometry and duration settings consistently in tool results, add a shared normalization contract for image/music/video runtimes, and simplify the bundled image-generation-core runtime test to only verify the plugin-sdk re-export seam.
- Gateway/sessions: add persisted compaction checkpoints plus Sessions UI branch/restore actions so operators can inspect and recover pre-compaction session state. (#62146) Thanks @scoootscooob.
- Providers/Ollama: detect vision capability from the `/api/show` response and set image input on models that support it so Ollama vision models accept image attachments. (#62193) Thanks @BruceMacD.
- Memory/dreaming: ingest redacted session transcripts into the dreaming corpus with per-day session-corpus notes, cursor checkpointing, and promotion/doctor support. (#62227) Thanks @vignesh07.
- Plugins/memory: add a public memory-artifact export seam to the unified memory capability so companion plugins like `memory-wiki` can bridge the active memory plugin without reaching into `memory-core` internals. Thanks @vincentkoc.
- Memory/wiki: add structured claim/evidence fields plus compiled agent digest artifacts so `memory-wiki` behaves more like a persistent knowledge layer and less like markdown-only page storage. Thanks @vincentkoc.
- Memory/wiki: add claim-health linting, contradiction clustering, staleness-aware dashboards, and freshness-weighted wiki search so `memory-wiki` can act more like a maintained belief layer than a passive markdown dump. Thanks @vincentkoc.
- Memory/wiki: use compiled digest artifacts as the first-pass wiki index for search/get flows, and resolve claim ids back to owning pages so agents can retrieve knowledge by belief identity instead of only by file path. Thanks @vincentkoc.
- Memory/wiki: add an opt-in `context.includeCompiledDigestPrompt` flag so memory prompt supplements can append a compact compiled wiki snapshot for legacy prompt assembly and context engines that explicitly consume memory prompt sections. Thanks @vincentkoc.
- Plugin SDK/context engines: pass `availableTools` and `citationsMode` into `assemble()`, and expose `buildMemorySystemPromptAddition(...)` so non-legacy context engines can adopt the active memory prompt path without reimplementing it. Thanks @vincentkoc.
- Providers/inferrs: add string-content compatibility for stricter OpenAI-compatible chat backends, document `inferrs` setup with a full config example, and add troubleshooting guidance for local backends that pass direct probes but fail on full agent-runtime prompts.
- Agents/context engine: expose prompt-cache runtime context to context engines and keep current-turn prompt-cache usage aligned with the active attempt instead of stale prior-turn assistant state. (#62179) Thanks @jalehman.
- Tools/media: document per-provider music and video generation capabilities, and add shared live video-to-video sweep coverage for providers that support local reference clips.
- Compaction: add pluggable compaction provider registry so plugins can replace the built-in summarization pipeline. Configure via `agents.defaults.compaction.provider`; falls back to LLM summarization on provider failure. (#56224) Thanks @DhruvBhatia0.
- Discord/events: allow `event-create` to accept a cover image URL or local file path, load and validate PNG/JPG/GIF event cover media, and pass the encoded image payload through Discord admin action/runtime paths. (#60883) Thanks @bittoby.
### Fixes
- Plugins/media: when `plugins.allow` is set, capability fallback now merges bundled capability plugin ids into the allowlist (not only `plugins.entries`), so media understanding providers such as OpenAI-compatible STT load for voice transcription without requiring `openai` in `plugins.allow`. (#62205) Thanks @neeravmakwana.
- CLI/infer: keep provider-backed infer behavior aligned with actual runtime execution by fixing explicit TTS override handling, profile-aware gateway TTS prefs resolution, per-request transcription `prompt`/`language` overrides, image output MIME/extension mismatches, configured web-search fallback behavior, and agent-vs-CLI web-search execution drift.
- Auth/OpenAI Codex OAuth: reload fresh on-disk credentials inside the locked refresh path and retry once after `refresh_token_reused` rotates only the stored refresh token, so relogin/restart recovery stops getting stuck on stale cached auth state. Thanks @owen-ever.
- Agents/history and replies: buffer phaseless OpenAI WS text until a real assistant phase arrives, keep replay and SSE history sequence tracking aligned, hide commentary and leaked tool XML from user-visible history, and keep history-based follow-up replies on `final_answer` text only. (#61729, #61747, #61829, #61855, #61954) Thanks @100yenadmin, @afurm, and @openperf.
- Plugins/channels: keep bundled channel artifact and secret-contract loading stable under lazy loading, preserve plugin-schema defaults during install, and fix Windows `file://` plus native-Jiti plugin loader paths so onboarding, doctor, `openclaw secret`, and bundled plugin installs work again. (#61832, #61836, #61853, #61856) Thanks @Zeesejo and @SuperMarioYL.
- Plugins/ClawHub: verify downloaded plugin archives against version metadata SHA-256, fail closed when archive integrity metadata is missing or malformed, and tighten fallback ZIP verification so plugin installs cannot proceed on mismatched or incomplete ClawHub package metadata. (#60517) Thanks @mappel-nv.
- Auto-reply/media: allow managed generated-media `MEDIA:` paths from normal reply text again while still blocking arbitrary host-local media and document paths, so generated media keep delivering without reopening host-path injection holes.
- Runtime event trust: mark background `notifyOnExit` summaries, ACP parent-stream relays, and wake-hook payloads as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text. (#62003)
- Providers/Anthropic: preserve thinking blocks for Claude Opus 4.5+, Sonnet 4.5+, and newer Claude 4-family models so prompt-cache prefixes keep matching, and skip `service_tier` injection on OAuth-authenticated stream wrapper requests so Claude OAuth streaming stops failing with HTTP 401. (#60356, #61793)
- Control UI: show `/tts` audio replies in webchat, detect mistaken `?token=` auth links with the correct `#token=` hint, and keep Copy, Canvas, and mobile exec-approval UI from covering chat content on narrow screens. (#54842, #61514, #61598) Thanks @neeravmakwana.
- TUI: route `/status` through the shared session-status command, keep commentary hidden in history, strip raw envelope metadata from async command notices, preserve fallback streaming before per-attempt failures finalize, and restore Kitty keyboard state on exit or fatal crashes. (#49130, #59985, #60043, #61463) Thanks @biefan, @MoerAI, @jwchmodx, and @100yenadmin.
- Sessions/model selection: resolve the explicitly selected session model separately from runtime fallback resolution so session status and live model switching stay aligned with the chosen model.
- Discord/ACP bindings: canonicalize DM conversation identity across inbound messages, component interactions, native commands, and current-conversation binding resolution so `--bind here` in Discord DMs keeps routing follow-up replies to the bound agent instead of falling back to the default agent.
- iOS/Watch exec approvals: keep Apple Watch review and approval recovery working while the iPhone is locked or backgrounded, including reconnect recovery, pending approval persistence, notification cleanup, and APNs-backed watch refresh recovery. (#61757) Thanks @ngutman.
- Nodes/exec approvals: keep `host=node` POSIX transport shell wrappers (`/bin/sh -lc ...`) aligned with inner-command allowlist analysis so allowlisted scripts stop prompting unnecessarily, while Windows `cmd.exe` wrapper runs stay approval-gated. (#62401) Thanks @ngutman.
- Nodes/exec approvals: keep Windows `cmd.exe /c` wrapper runs approval-gated even when `env` carriers, including env-assignment carriers, wrap the shell invocation. (#62439) Thanks @ngutman.
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
- Host exec/env sanitization: block dangerous `JAVA_OPTS`, `RUSTFLAGS`, and `CARGO_HOME` inputs at the host-exec boundary so attacker-controlled env overrides can no longer inject JVM agents, compiler flags, or Cargo state pivots into host-run processes. (#62291) Thanks @pgondhi987.
- Commands/allowlist: require owner authorization for `/allowlist add` and `/allowlist remove` before channel resolution, so non-owner but command-authorized senders can no longer persistently rewrite allowlist policy state. (#62383) Thanks @pgondhi987.
- Feishu/docx uploads: honor `tools.fs.workspaceOnly` for local `upload_file` and `upload_image` paths by forwarding workspace-constrained `localRoots` into the media loader, so docx uploads can no longer read host-local files outside the workspace when workspace-only mode is active. (#62369) Thanks @pgondhi987.
- Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987.
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
- Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987.
- Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit.
- Gateway/auth: invalidate existing shared-token and password WebSocket sessions when the configured secret rotates, so stale authenticated sockets cannot stay attached after token or password changes. (#62350) Thanks @pgondhi987.
- Gateway/status and containers: auto-bind to `0.0.0.0` inside Docker and Podman environments, and probe local TLS gateways over `wss://` with self-signed fingerprint forwarding so container startup and loopback TLS status checks work again. (#61818, #61935) Thanks @openperf and @ThanhNguyxn07.
- macOS/gateway version: strip trailing commit metadata from CLI version output before semver parsing so the Mac app recognizes installed gateway versions like `OpenClaw 2026.4.2 (d74a122)` again. (#61111) Thanks @oliviareid-svg.
- Discord: recover forwarded referenced message text and attachments when snapshots are missing, use `ws://` again for gateway monitor sockets, stop forcing a hardcoded temperature for Codex-backed auto-thread titles, and harden voice receive recovery so rapid speaker restarts keep their next utterance. (#41536, #61670) Thanks @artwalker and @wit-oc.
- Slack/threading: keep legacy thread stickiness for real replies when older callers omit `isThreadReply`, while still honoring `replyToMode` for Slack's auto-created top-level `thread_ts`. (#61835) Thanks @kaonash.
- Plugins/loaders: centralize bundled `dist/**` Jiti native-load policy and keep channel, public-surface, facade, and config-metadata loader seams off native Jiti on Windows so onboarding and configure flows stop tripping `ERR_UNSUPPORTED_ESM_URL_SCHEME`. (#62286) Thanks @chen-zhang-cs-code.
- Providers/xAI: recognize `api.grok.x.ai` as an xAI-native endpoint again and keep legacy `x_search` auth resolution working so older xAI web-search configs continue to load. (#61377) Thanks @jjjojoj.
- Memory/vector recall: surface explicit warnings when `sqlite-vec` is unavailable or vector writes are degraded, and strip managed Light Sleep and REM blocks before daily-note ingestion so memory indexing and dreaming stop reporting false-success or re-ingesting staged output. (#61720) Thanks @MonkeyLeeT.
- Matrix/formatting: preserve multi-paragraph and loose-list rendering in Element so numbered and bulleted Markdown keeps their content attached to the correct list item. (#60997) Thanks @gucasbrg.
- MS Teams/security: validate file-consent upload URLs against HTTPS, Microsoft/SharePoint host allowlists, and private-IP DNS checks before uploading attachments, blocking SSRF-style consent-upload abuse. (#23596)
- QQ Bot/media: route gateway-side attachment and fallback downloads through guarded QQ/Tencent HTTPS fetches so QQ media handling no longer follows arbitrary remote hosts.
- Providers/Ollama: stop warning that Ollama could not be reached when discovery only sees empty default local stubs, while still keeping real explicit Ollama overrides loud when the endpoint is unreachable.
- Tools/web_fetch and web_search: fix `TypeError: fetch failed` caused by undici 8.0 enabling HTTP/2 by default; pinned SSRF-guard dispatchers now explicitly set `allowH2: false` to restore HTTP/1.1 behavior and keep the custom DNS-pinning lookup compatible. (#61738, #61777) Thanks @zozo123.
- Tools/web search/Exa: show Exa Search in onboarding and configure provider pickers again by marking the bundled Exa provider as setup-visible. Thanks @vincentkoc.
- Docs/i18n: relocalize final localized-page links after translation and remove the zh-CN homepage redirect override so localized Mintlify pages resolve to the correct language roots again. (#61796) Thanks @hxy91819.
- Plugins/provider hooks: stop recursive provider snapshot loads from overflowing the stack during plugin initialization, while still preserving cached nested provider-hook results. (#61922, #61938, #61946, #61951)
- Exec/runtime events: mark background `notifyOnExit` summaries and ACP parent-stream relays as untrusted system events so lower-trust runtime output no longer re-enters later turns as trusted `System:` text.
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
- Browser/node invoke: block persistent browser profile create, reset, and delete mutations through `browser.proxy` on both gateway-forwarded `node.invoke` and the node-host proxy path, even when no profile allowlist is configured. (#60489)
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
- UI/light mode: target both root and nested WebKit scrollbar thumbs in the light theme so page-level and container scrollbars stay visible on light backgrounds. (#61753) Thanks @chziyue.
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
- Telegram/doctor: keep top-level access-control fallback in place during multi-account normalization while still promoting legacy default auth into `accounts.default`, so existing named bots keep inherited allowlists without dropping the legacy default bot. (#62263) Thanks @obviyus.
- Agents/subagents: honor `sessions_spawn(lightContext: true)` for spawned subagent runs by preserving lightweight bootstrap context through the gateway and embedded runner instead of silently falling back to full workspace bootstrap injection. (#62264) Thanks @theSamPadilla.
- Slack/media: keep attachment downloads on the SSRF-guarded dispatcher path so Slack media fetching works on Node 22 without dropping pinned transport enforcement. (#62239) Thanks @openperf.
- Docker/plugins: stop forcing bundled plugin discovery to `/app/extensions` in runtime images so packaged installs use compiled `dist/extensions` artifacts again and Node 24 containers do not boot through source-only plugin entry paths. Fixes #62044. (#62316) Thanks @gumadeiras.
- Cron: load `jobId` into `id` when the on-disk store omits `id`, matching doctor migration and fixing `unknown cron job id` for hand-edited `jobs.json`. (#62246) Thanks @neeravmakwana.
- Agents/model fallback: classify minimal HTTP 404 API errors (for example `404 status code (no body)`) as `model_not_found` so assistant failures throw into the fallback chain instead of stopping at the first fallback candidate. (#62119) Thanks @neeravmakwana.
- Providers/Mistral: send `reasoning_effort` for `mistral/mistral-small-latest` (Mistral Small 4) with thinking-level mapping, and mark the catalog entry as reasoning-capable so adjustable reasoning matches Mistrals Chat Completions API. (#62162) Thanks @neeravmakwana.
- OpenAI TTS/Groq: send `wav` to Groq-compatible speech endpoints, honor explicit `responseFormat` overrides on OpenAI-compatible paths, and only mark voice-note output as voice-compatible when the actual format is `opus`. (#62233) Thanks @neeravmakwana.
- BlueBubbles/network: respect explicit private-network opt-out for loopback and private `serverUrl` values across account resolution, status probes, monitor startup, and attachment downloads, while keeping public-host attachment hostname pinning intact. (#59373) Thanks @jpreagan.
- Agents/heartbeat: keep heartbeat runs pinned to the main session so active subagent transcripts are not overwritten by heartbeat status messages. (#61803) Thanks @100yenadmin.
- Agents/compaction: stop compaction-wait aborts from re-entering prompt failover and replaying completed tool turns. (#62600) Thanks @i-dentifier.
- Approvals/runtime: move native approval lifecycle assembly into shared core bootstrap/runtime seams driven by channel capabilities and runtime contexts, and remove the legacy bundled approval fallback wiring. (#62135) Thanks @gumadeiras.
- Security/fetch-guard: stop rejecting operator-configured proxy hostnames against the target-scoped hostname allowlist in SSRF-guarded fetches, restoring proxy-based media downloads for Telegram and other channels. (#62312) Thanks @ademczuk.
- iOS/gateway: replace string-matched connection error UI with structured gateway connection problems, preserve actionable pairing/auth failures over later generic disconnect noise, and surface reusable problem banners and details across onboarding, settings, and root status surfaces. (#62650) Thanks @ngutman.
## 2026.4.5
### Breaking
- Config: remove legacy public config aliases such as `talk.voiceId` / `talk.apiKey`, `agents.*.sandbox.perSession`, `browser.ssrfPolicy.allowPrivateNetwork`, `hooks.internal.handlers`, and channel/group/room `allow` toggles in favor of the canonical public paths and `enabled`, while keeping load-time compatibility and `openclaw doctor --fix` migration support for existing configs. (#60726) Thanks @vincentkoc.
### Changes
- Agents/video generation: add the built-in `video_generate` tool so agents can create videos through configured providers and return the generated media directly in the reply.
- Agents/music generation: ignore unsupported optional hints such as `durationSeconds` with a warning instead of hard-failing requests on providers like Google Lyria.
- Providers/Arcee AI: add a bundled Arcee AI provider plugin with `ARCEEAI_API_KEY` onboarding, Trinity model catalog (mini, large-preview, large-thinking), OpenAI-compatible API support, and OpenRouter as an alternative auth path. (#62068) Thanks @arthurbr11.
- Providers/ComfyUI: add a bundled `comfy` workflow media plugin for local ComfyUI and Comfy Cloud workflows, including shared `image_generate`, `video_generate`, and workflow-backed `music_generate` support, with prompt injection, optional reference-image upload, live tests, and output download.
- Tools/music generation: add the built-in `music_generate` tool with bundled Google (Lyria) and MiniMax providers plus workflow-backed Comfy support, including async task tracking and follow-up delivery of finished audio.
- Providers: add bundled Qwen, Fireworks AI, and StepFun providers, plus MiniMax TTS, Ollama Web Search, and MiniMax Search integrations for chat, speech, and search workflows. (#60032, #55921, #59318, #54648)
- Providers/Amazon Bedrock: add bundled Mantle support plus inference-profile discovery and automatic request-region injection so Bedrock-hosted Claude, GPT-OSS, Qwen, Kimi, GLM, and similar routes work with less manual setup. (#61296, #61299) Thanks @wirjo.
- Control UI/multilingual: add localized control UI support for Simplified Chinese, Traditional Chinese, Brazilian Portuguese, German, Spanish, Japanese, Korean, French, Turkish, Indonesian, Polish, and Ukrainian. Thanks @vincentkoc.
- Plugins: add plugin-config TUI prompts to guided onboarding/setup flows, and add `openclaw plugins install --force` so existing plugin and hook-pack targets can be replaced without using the dangerous-code override flag. (#60590, #60544)
- Control UI/skills: add ClawHub search, detail, and install flows directly in the Skills panel. (#60134) Thanks @samzong.
- iOS/exec approvals: add generic APNs approval notifications that open an in-app exec approval modal, fetch command details only after authenticated operator reconnect, and clear stale notification state when the approval resolves. (#60239) Thanks @ngutman.
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
- Memory/dreaming (experimental): add weighted short-term recall promotion, managed dreaming modes (`off|core|rem|deep`), a `/dreaming` command, Dreams UI, multilingual conceptual tagging, and doctor/status repair support so durable memory promotion can run in the background with less manual setup. (#60569, #60697)
- Channels/context visibility: add configurable `contextVisibility` per channel (`all`, `allowlist`, `allowlist_quote`) so supplemental quote, thread, and fetched history context can be filtered by sender allowlists instead of always passing through as received.
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
- Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467.
- Providers/Fireworks: add a bundled Fireworks AI provider plugin with `FIREWORKS_API_KEY` onboarding, Fire Pass Kimi defaults, and dynamic Fireworks model-id support.
- Providers/config: add `models.providers.*.request` overrides for headers and auth on model-provider paths, and full request transport overrides for media provider HTTP paths.
- MiniMax/TTS: add a bundled MiniMax speech provider backed by the T2A v2 API so speech synthesis can run through MiniMax-native voices and auth. (#55921) Thanks @duncanita.
- Control UI/skills: add ClawHub search, detail, and install flows directly in the Skills panel. (#60134) Thanks @samzong.
- Providers/Ollama: add a bundled Ollama Web Search provider for key-free `web_search` via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD.
- Plugins/onboarding: add plugin config TUI prompts to onboard and configure wizards so more plugin setup can stay in the guided flow. (#60590)
- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras.
- Providers/Anthropic: remove setup-token from new onboarding and auth-command setup paths, keep existing configured legacy token profiles runnable, and steer new Anthropic setup to Claude CLI or API keys.
- Providers/OpenAI Codex: add forward-compat `openai-codex/gpt-5.4-mini` synthesis across provider runtime, model catalog, and model listing so Codex mini works before bundled Pi catalog updates land.
- Tools/web_search: add a bundled MiniMax Search provider backed by the Coding Plan search API, with region reuse from `MINIMAX_API_HOST` and plugin-owned credential config. (#54648) Thanks @fengmk2.
- Channels/context visibility: add configurable `contextVisibility` per channel (`all`, `allowlist`, `allowlist_quote`) so quoted, threaded, and fetched history context can be filtered by sender allowlists instead of always passing through as received.
- Providers/request overrides: add shared model and media request transport overrides across OpenAI-, Anthropic-, Google-, and compatible provider paths, including headers, auth, proxy, and TLS controls. (#60200)
- Providers/OpenAI: add forward-compat `openai-codex/gpt-5.4-mini`, an opt-in GPT personality, and provider-owned GPT-5 prompt contributions so Codex/GPT runs stay cache-stable and compatible with bundled catalog lag.
- Agents/Claude CLI: expose OpenClaw tools to background Claude CLI runs through a loopback MCP bridge and switch bundled runs to stdin + `stream-json` partial-message streaming so prompts stop riding argv, long replies show live progress, and final session/usage metadata still land cleanly. (#35676) Thanks @mylukin.
- ACPX/runtime: embed the ACP runtime directly in the bundled `acpx` plugin, remove the extra external ACP CLI hop, harden live ACP session binding and reuse, and add a generic `reply_dispatch` hook so bundled plugins like ACPX can own reply interception without hardcoded ACP paths in core auto-reply routing. (#61319)
- Agents/progress: add experimental structured plan updates and structured execution item events so compatible UIs can show clearer step-by-step progress during long-running runs.
- Providers/Anthropic: remove the Claude CLI backend and setup-token from new onboarding, keep existing configured legacy profiles runnable, and have `openclaw doctor` repair or remove stale `anthropic:claude-cli` state during migration.
- Tools/video generation: add bundled xAI (`grok-imagine-video`), Alibaba Model Studio Wan, and Runway video providers, plus live-test/default model wiring for all three.
- Memory/search: add Amazon Bedrock embeddings for Titan, Cohere, Nova, and TwelveLabs models, with AWS credential-chain auto-detection for `provider: "auto"` and provider-specific dimension controls. Thanks @wirjo.
- Providers/Amazon Bedrock Mantle: generate bearer tokens from the AWS credential chain so Mantle auto-discovery can use IAM auth without manually exporting `AWS_BEARER_TOKEN_BEDROCK`. Thanks @wirjo.
- Memory/dreaming (experimental): add weighted short-term recall promotion, a `/dreaming` command, Dreams UI, multilingual conceptual tagging, and doctor/status repair support, while refactoring dreaming from competing modes into three cooperative phases (light, deep, REM) with independent schedules and recovery behavior so durable memory promotion can run in the background with less manual setup. (#60569, #60697) Thanks @vignesh07.
- Memory/dreaming: add configurable aging controls (`recencyHalfLifeDays`, `maxAgeDays`) plus optional verbose logging so operators can tune recall decay and inspect promotion decisions more easily.
- Memory/dreaming: add REM preview tooling (`openclaw memory rem-harness`, `promote-explain`), surface possible lasting truths during REM staging, and make deep promotion replay-safe so reruns reconcile instead of duplicating `MEMORY.md` entries.
- Memory/dreaming: write dreaming trail content to top-level `dreams.md` instead of daily memory notes, update `/dreaming` help text to point there, and keep `dreams.md` available for explicit reads without pulling it into default recall. Thanks @davemorin.
- Memory/dreaming: add the Dream Diary surface in Dreams, simplify user-facing dreaming config to `enabled` plus optional `frequency`, treat phases as implementation detail in docs/UI, and keep the lobster animation visible above diary content. Thanks @vignesh07.
- Prompt caching: keep prompt prefixes more reusable across transport fallback, deterministic MCP tool ordering, compaction, embedded image history, normalized system-prompt fingerprints, `openclaw status --verbose` cache diagnostics, and the removal of duplicate in-band tool inventories from agent system prompts so follow-up turns hit cache more reliably. (#58036, #58037, #58038, #59054, #60603, #60691) Thanks @bcherny and @vincentkoc.
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
- Agents/Claude CLI: expose OpenClaw tools to background Claude CLI runs through a loopback MCP bridge that reuses gateway tool policy, honors session/account/channel scoping, and only advertises the bridge when the local runtime is actually live. (#35676) Thanks @mylukin.
- Agents/Claude CLI: switch bundled Claude CLI runs to stdin + `stream-json` partial-message streaming so prompts stop riding argv, long replies show live progress, and final session/usage metadata still land cleanly.
- Prompt caching: keep prompt prefixes more reusable across transport fallback, deterministic MCP tool ordering, compaction, and embedded image history so follow-up turns hit cache more reliably. (#58036, #58037, #58038, #59054, #60603, #60691) Thanks @bcherny.
- Agents/cache: diagnostics: add prompt-cache break diagnostics, trace live cache scenarios through embedded runner paths, and show cache reuse explicitly in `openclaw status --verbose`. Thanks @vincentkoc.
- Agents/cache: stabilize cache-relevant system prompt fingerprints by normalizing equivalent structured prompt whitespace, line endings, hook-added system context, and runtime capability ordering so semantically unchanged prompts reuse KV/cache more reliably. Thanks @vincentkoc.
- Agents/tool prompts: remove the duplicate in-band tool inventory from agent system prompts so tool-calling models rely on the structured tool definitions as the single source of truth, improving prompt stability and reducing stale tool guidance.
- Config/schema: enrich the exported `openclaw config schema` JSON Schema with field titles and descriptions so editors, agents, and other schema consumers receive the same config help metadata. (#60067) Thanks @solavrc.
- Matrix/exec approvals: clarify unavailable-approval replies so Matrix no longer claims chat approvals are unsupported when native exec approvals are merely unconfigured. (#61424) Thanks @gumadeiras.
- Providers/OpenAI Codex: add forward-compat `openai-codex/gpt-5.4-mini` synthesis across provider runtime, model catalog, and model listing so Codex mini works before bundled Pi catalog updates land.
- Providers/OpenAI: add an opt-in GPT personality and move GPT-5 prompt tuning onto provider-owned system-prompt contributions so cache-stable guidance stays above the prompt cache boundary and embedded runner paths reuse the same provider-specific prompt behavior.
- Docs/IRC: replace public IRC hostname examples with `irc.example.com` and recommend private servers for bot coordination while listing common public networks for intentional use.
- Memory/dreaming: group nearby daily-note lines into short coherent chunks before staging them for dreaming, so one-off context from recent notes reaches REM/deep with better evidence and less line-level noise.
- Memory/dreaming: drop generic date/day headings from daily-note chunk prefixes while keeping meaningful section labels, so staged snippets stay cleaner and more reusable. (#61597) Thanks @mbelinky.
- Plugins/Lobster: run bundled Lobster workflows in process instead of spawning the external CLI, reducing transport overhead and unblocking native runtime integration. (#61523) Thanks @mbelinky.
- Plugins/Lobster: harden managed resume validation so invalid TaskFlow resume calls fail earlier, and memoize embedded runtime loading per runner while keeping failed loads retryable. (#61566) Thanks @mbelinky.
- Agents/bootstrap: add opt-in `agents.defaults.contextInjection: "continuation-skip"` so safe continuation turns can skip workspace bootstrap re-injection, while heartbeat runs and post-compaction retries still rebuild context when needed. Fixes #9157. Thanks @cgdusek.
- Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467.
### Fixes
- Control UI/chat: show `/tts` and other local audio-only slash replies in webchat by embedding local audio in the assistant message and rendering `<audio>` controls instead of dropping empty-text finals. Fixes #61564. (#61598) Thanks @neeravmakwana.
- Security: preserve restrictive plugin-only tool allowlists, require owner access for `/allowlist add` and `/allowlist remove`, fail closed when `before_tool_call` hooks crash, block browser SSRF redirect bypasses earlier, and keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins. (#58476, #59836, #59822, #58771, #59120) Thanks @eleqtrizit and @pgondhi987.
- Providers/OpenAI: make GPT-5 and Codex runs act sooner with lower-verbosity defaults, visible progress during tool work, and a one-shot retry when a turn only narrates the plan instead of taking action.
- Providers/OpenAI and reply delivery: preserve native `reasoning.effort: "none"` and strict schemas where supported, add GPT-5.4 assistant `phase` metadata across replay and the Gateway `/v1/responses` layer, and keep commentary buffered until `final_answer` so web chat, session previews, embedded replies, and Telegram partials stop leaking planning text. Fixes #59150, #59643, #61282.
- Telegram: fix current-model checks in the model picker, HTML-format non-default `/model` confirmations, explicit topic replies, persisted reaction ownership across restarts, caption-media placeholder and `file_id` preservation on download failure, and upgraded-install inbound image reads. (#60384, #60042, #59634, #59207, #59948, #59971) Thanks @sfuminya, @GitZhangChi, @dashhuang, @samzong, @v1p0r, and @neeravmakwana.
- Telegram: restore DM voice-note preflight transcription so direct-message audio stops arriving as raw `<media:audio>` placeholders. (#61008) Thanks @manueltarouca.
- Telegram/reasoning: only create a Telegram reasoning preview lane when the session is explicitly `reasoning:stream`, so hidden `<think>` traces from streamed replies stop surfacing as chat previews on normal sessions. Thanks @vincentkoc.
- Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more `/` entries visible. (#61129) Thanks @neeravmakwana.
- Telegram/startup: bound `deleteWebhook`, `getMe`, and `setWebhook` startup requests while keeping the longer `getUpdates` poll timeout, so wedged Telegram control-plane calls stop hanging startup indefinitely. (#61601) Thanks @neeravmakwana.
- Agents/failover: classify Anthropic "extra usage" exhaustion as billing so same-turn model fallback still triggers when Claude blocks long-context requests on usage limits. (#61608) Thanks @neeravmakwana.
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
- Discord/reply tags: strip leaked `[[reply_to_current]]` control tags from preview text and honor explicit reply-tag threading during final delivery, so Discord replies stay attached to the triggering message instead of printing reply metadata into chat.
- Discord/replies: replace the unshipped `replyToOnlyWhenBatched` flag with `replyToMode: "batched"` so native reply references only attach on debounced multi-message turns while explicit reply tags still work.
- Discord/image generation: include the real generated `MEDIA:` paths in tool output, avoid duplicate plain-output media requeueing, and persist volatile workspace-generated media into durable outbound media before final reply delivery so generated image replies stop pointing at missing local files.
- Slack: route live DM replies back to the concrete inbound DM channel while keeping persisted routing metadata user-scoped, so normal assistant replies stop disappearing when pairing and system messages still arrive. (#59030) Thanks @afurm.
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
- Android/Talk Mode: cancel in-flight `talk.speak` playback when speech is explicitly stopped, and restore spoken replies on both node-scoped and gateway-backed sessions by keeping reply routing and embedded transport overrides aligned with the current playback path. (#60306, #61164, #61214)
- Voice-call/OpenAI: pass full plugin config into realtime transcription provider resolution so streaming calls can discover the bundled OpenAI realtime transcription provider again. Fixes #60936. Thanks @sliekens and @vincentkoc.
- Matrix/exec approvals: anchor seeded approval reactions to the primary Matrix prompt event, resolve them from event metadata instead of prompt text, and clean up chunked approval prompts correctly. (#60931) Thanks @gumadeiras.
- Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289) Thanks @al3mart, @emonty, and @efe-arv.
- Matrix/DM sessions: add `channels.matrix.dm.sessionScope`, shared-session collision notices, and aligned outbound session reuse so separate Matrix DM rooms can keep distinct context when configured. (#61373) Thanks @gumadeiras.
- Matrix: move legacy top-level `avatarUrl` into the default account during multi-account promotion and keep env-backed account setup avatar config persisted. (#61437) Thanks @gumadeiras.
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) Thanks @Ted-developer and @hyojin.
- MS Teams: replace the deprecated Teams SDK HttpPlugin stub with `httpServerAdapter` so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.
- Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.
- Sandbox/SSH: reject hardlinked files during cross-device rename fallback so EXDEV file copies preserve the same pinned file-boundary checks as direct reads.
- Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) Thanks @chziyue and @frankekn.
- Control UI/avatar: honor `ui.assistant.avatar` when serving `/avatar/:agentId` so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev.
- Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm.
- Control UI/Overview: prevent gateway access token/password visibility toggle buttons from overlapping their inputs at narrow widths. (#56924) Thanks @bbddbb1.
- Auto-reply: unify reply lifecycle ownership across preflight compaction, session rotation, CLI-backed runs, and gateway restart handling so `/stop` and same-session overlap checks target the right active turn and restart-interrupted turns return the restart notice instead of being silently dropped. (#61267) Thanks @dutifulbob.
- Reply delivery: prevent duplicate block replies on `text_end` channels so providers that emit explicit text-end boundaries no longer double-send the same final message. (#61530)
- Gateway/startup: default `gateway.mode` to `local` when unset, detect PID recycling in gateway lock files on Windows and macOS, and show startup progress so healthy restarts stop getting blocked by stale locks. (#54801, #60085, #59843) Thanks @BradGroux and @TonyDerek-dot.
- Gateway/macOS: let launchd `KeepAlive` own in-process gateway restarts again, adding a short supervised-exit delay so rapid restarts avoid launchd crash-loop unloads while `openclaw gateway restart` still reports real LaunchAgent errors synchronously.
- Gateway/macOS: re-bootstrap the LaunchAgent if `launchctl kickstart -k` unloads it during restart so failed restarts do not leave the gateway unmanaged until manual repair.
- Gateway/macOS: recover installed-but-unloaded LaunchAgents during `openclaw gateway start` and `restart`, while still preferring live unmanaged gateways during restart recovery. (#43766) Thanks @HenryC-3.
- Gateway/Windows scheduled tasks: preserve Task Scheduler settings on reinstall, fail loudly when `/Run` does not start, and report fast failed restarts accurately instead of pretending they timed out after 60 seconds. (#59335) Thanks @tmimmanuel.
- Windows/restart: fall back to the installed Startup-entry launcher when the scheduled task was never registered, so `/restart` can relaunch the gateway on Windows setups where `schtasks` install fell back during onboarding. (#58943) Thanks @imechZhangLY.
- Windows/restart: clean up stale gateway listeners before Windows self-restart and treat listener and argv probe failures as inconclusive, so scheduled-task relaunch no longer falls into an `EADDRINUSE` retry loop. (#60480) Thanks @arifahmedjoy.
- Update/npm: prefer the npm binary that owns the installed global OpenClaw prefix so mixed Homebrew-plus-nvm setups update the right install. (#60153) Thanks @jayeshp19.
- Agents/music and video generation: add `tools.media.asyncCompletion.directSend` as an opt-in direct-delivery path for finished async media tasks, while keeping the legacy requester-session wake/model-delivery flow as the default.
- CLI/skills JSON: route `skills list --json`, `skills info --json`, and `skills check --json` output to stdout instead of stderr so machine-readable consumers receive JSON on the expected stream again. (#60914; fixes #57599; landed from contributor PR #57611 by @Aftabbs) Thanks @Aftabbs.
- CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010.
- Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.
- Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker.
- Exec/remote skills: stop advertising `exec host=node` when the current exec policy cannot route to a node, and clarify blocked exec-host override errors with both the requested host and allowed config path.
- Agents/Claude CLI/security: clear inherited Claude Code config-root and plugin-root env overrides like `CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_PLUGIN_*`, so OpenClaw-launched Claude CLI runs cannot be silently pointed at an alternate Claude config/plugin tree with different hooks, plugins, or auth context. Thanks @vincentkoc.
- Agents/Claude CLI/security: clear inherited Claude Code provider-routing and managed-auth env overrides, and mark OpenClaw-launched Claude CLI runs as host-managed, so Claude CLI backdoor sessions cannot be silently redirected to proxy, Bedrock, Vertex, Foundry, or parent-managed token contexts. Thanks @vincentkoc.
- Agents/Claude CLI/security: force host-managed Claude CLI backdoor runs to `--setting-sources user`, even under custom backend arg overrides, so repo-local `.claude` project/local settings, hooks, and plugin discovery do not silently execute inside non-interactive OpenClaw sessions. Thanks @vincentkoc.
- Agents/Claude CLI: treat malformed bare `--permission-mode` backend overrides as missing and fail safe back to `bypassPermissions`, so custom `cliBackends.claude-cli.args` security config cannot accidentally consume the next flag as a bogus permission mode. Thanks @vincentkoc.
- Gateway/device pairing: require non-admin paired-device sessions to manage only their own device for token rotate/revoke and paired-device removal, blocking cross-device token theft inside pairing-scoped sessions. (#50627) Thanks @coygeek.
- Gateway/plugin routes: keep gateway-auth plugin runtime routes on write-only fallback scopes unless a trusted-proxy caller explicitly declares narrower `x-openclaw-scopes`, so plugin HTTP handlers no longer mint admin-level runtime scopes on missing or untrusted HTTP scope headers. (#59815) Thanks @pgondhi987.
- Build/types: fix the Node `createRequire(...)` helper typing so provider-runtime lazy loads compile cleanly again and `pnpm build` no longer fails in the Pi embedded provider error-pattern path.
- Gateway/security: scope loopback browser-origin auth throttling by normalized origin so one localhost Control UI tab cannot lock out a different localhost browser origin after repeated auth failures.
- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.
- Device pairing/security: keep non-operator device scope checks bound to the requested role prefix so bootstrap verification cannot redeem `operator.*` scopes through `node` auth. (#57258) Thanks @jlapenna.
- Device pairing: reject rotating device tokens into roles that were never approved during pairing, and keep reconnect role checks bounded to the paired device's approved role set. (#60462) Thanks @eleqtrizit.
- Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit `deviceToken` scope requests and empty-cache fallbacks intact so reconnects preserve `operator.read` without breaking explicit auth flows. (#46032) Thanks @caicongyang.
- Mobile pairing/security: fail closed for internal `/pair` setup-code issuance, cleanup, and approval paths when gateway pairing scopes are missing, and keep approval-time requested-scope enforcement on the internal command path. (#55996) Thanks @coygeek.
- Mobile pairing/bootstrap: keep QR bootstrap handoff tokens bounded to the mobile-safe contract so node handoff stays unscoped and operator handoff drops mixed `node.*`, `operator.admin`, and `operator.pairing` scopes.
- Mobile pairing/Android: tighten secure endpoint handling so Tailscale and public remote setup reject cleartext endpoints, private LAN pairing still works, merged-role approvals mint both node and operator device tokens, and bootstrap tokens survive node auto-pair until operator approval finishes. (#60128, #60208, #60221) Thanks @obviyus.
- Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation.
- Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out.
- Synology Chat/security: route webhook token comparison through the shared constant-time secret helper for consistency with other bundled plugins.
- Plugins/marketplace: block remote marketplace symlink escapes without breaking ordinary local marketplace install paths. (#60556) Thanks @eleqtrizit.
- Telegram/local Bot API: honor `channels.telegram.apiRoot` for buffered media downloads, add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP setups, and require `channels.telegram.trustedLocalFileRoots` before reading absolute Bot API `file_path` values. (#59544, #60705) Thanks @SARAMALI15792 and @obviyus.
- Outbound/sanitizer: strip leaked `<tool_call>`, `<function_calls>`, and model special tokens from shared user-visible assistant text, including truncated tool-call streams, so internal scaffolding no longer bleeds into replies across surfaces. (#60619) Thanks @oliviareid-svg.
- Agents/errors: surface an explicit disk-full message when local session or transcript writes fail with `ENOSPC`/`disk full`, so those runs stop degrading into opaque `NO_REPLY`-style failures. Thanks @vincentkoc.
- Exec approvals: remove heuristic command-obfuscation gating from host exec so gateway and node runs rely on explicit policy, allowlist, and strict inline-eval rules only.
- Agents/tool results: cap live tool-result persistence and overflow-recovery truncation at 40k characters so oversized tool output stays bounded without discarding recent context entirely.
- Discord/video replies: split text-plus-video deliveries into a text reply followed by a media-only send, and let live provider auth checks honor manifest-declared API key env vars like `MODELSTUDIO_API_KEY`.
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the rendered snapshot. (#28214) Thanks @solodmd.
- Plugin SDK/facades: back-fill bundled plugin facade sentinels before plugin-id tracking re-enters config loading, so CLI/provider startup no longer crashes with `shouldNormalizeGoogleProviderConfig is not a function` or other empty-facade reads during bundled plugin re-entry. Thanks @adam91holt.
- Plugins/facades: back-fill facade sentinels before tracked-plugin resolution re-enters config loading, so facade exports stay defined during circular provider normalization. (#61180) Thanks @adam91holt.
- QA lab: restore typed mock OpenAI gateway config wiring so QA-lab config helpers compile cleanly again and `pnpm check` / `pnpm build` stay green.
- Discord/image generation: include the real generated `MEDIA:` paths in tool output and avoid duplicate plain-output media requeueing so Discord image replies stop pointing at missing local files.
- Slack: route live DM replies back to the concrete inbound DM channel while keeping persisted routing metadata user-scoped, so normal assistant replies stop disappearing when pairing and system messages still arrive. (#59030) Thanks @afurm.
- Discord/reply tags: strip leaked `[[reply_to_current]]` control tags from preview text and honor explicit reply-tag threading during final delivery, so Discord replies stay attached to the triggering message instead of printing reply metadata into chat.
- Telegram: fix current-model checks in the model picker, HTML-format non-default `/model` confirmations, explicit topic replies, persisted reaction ownership across restarts, caption-media placeholder and `file_id` preservation on download failure, and upgraded-install inbound image reads. (#60384, #60042, #59634, #59207, #59948, #59971) Thanks @sfuminya, @GitZhangChi, @dashhuang, @samzong, @v1p0r, and @neeravmakwana.
- Telegram: restore DM voice-note preflight transcription so direct-message audio stops arriving as raw `<media:audio>` placeholders. (#61008) Thanks @manueltarouca.
- Telegram/reasoning: only create a Telegram reasoning preview lane when the session is explicitly `reasoning:stream`, so hidden `<think>` traces from streamed replies stop surfacing as chat previews on normal sessions. Thanks @vincentkoc.
- Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more `/` entries visible. (#61129) Thanks @neeravmakwana.
- Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly `reasoning:stream`, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
- Browser/security: re-run SSRF safety checks after interaction-driven navigations and before snapshot reads so click, submit, keyboard, and current-page snapshot flows fail closed on disallowed destinations. (#62023) Thanks @eleqtrizit.
- Memory: keep `memory-core` builtin embedding registration on the already-registered path so selecting `memory-core` no longer recurses through plugin discovery and crashes during startup. (#61402) Thanks @ngutman.
- Agents/tool results: keep large `read` outputs visible longer, preserve the latest `read` output when older tool output can absorb the overflow budget, and fall back to Pi's normal overflow compaction/retry path before replacing a fresh `read` with a compacted stub. Thanks @vincentkoc.
- Memory/QMD: prefer modern `qmd collection add --glob`, accept newer single-line JSON hit metadata while keeping legacy line fields, refresh QMD docs/doctor install guidance and model-override guidance, and keep older QMD releases working. Thanks @vincentkoc.
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) Thanks @Ted-developer and @hyojin.
- MS Teams: replace the deprecated Teams SDK HttpPlugin stub with `httpServerAdapter` so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.
- Matrix/exec approvals: anchor seeded approval reactions to the primary Matrix prompt event, resolve them from event metadata instead of prompt text, and clean up chunked approval prompts correctly. (#60931) Thanks @gumadeiras.
- Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289) Thanks @al3mart, @emonty, and @efe-arv.
- Android/Talk Mode: cancel in-flight `talk.speak` playback when speech is explicitly stopped, so stale replies stop starting after barge-in or manual stop. (#61164) Thanks @obviyus.
- Android/Talk Mode: restore spoken assistant replies on node-scoped sessions by keeping reply routing synced to the resolved node session key and pausing mic capture during reply playback. (#60306) Thanks @MKV21.
- Android/Talk Mode: restore voice replies on gateway-backed talk mode sessions by updating embedded runner transport overrides to the current agent transport API. (#61214) Thanks @obviyus.
- Voice-call/OpenAI: pass full plugin config into realtime transcription provider resolution so streaming calls can discover the bundled OpenAI realtime transcription provider again. Fixes #60936. Thanks @sliekens and @vincentkoc.
- Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.
- Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) Thanks @chziyue and @frankekn.
- Control UI/avatar: honor `ui.assistant.avatar` when serving `/avatar/:agentId` so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev.
- Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm.
- Control UI/Overview: prevent gateway access token/password visibility toggle buttons from overlapping their inputs at narrow widths. (#56924) Thanks @bbddbb1.
- CLI/skills JSON: route `skills list --json`, `skills info --json`, and `skills check --json` output to stdout instead of stderr so machine-readable consumers receive JSON on the expected stream again. (#60914; fixes #57599; landed from contributor PR #57611 by @Aftabbs) Thanks @Aftabbs.
- CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010.
- Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.
- Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker.
- Live model switching: only treat explicit user-driven model changes as pending live switches, so fallback rotation, heartbeat overrides, and compaction no longer trip `LiveSessionModelSwitchError` before making an API call. (#60266) Thanks @kiranvk-2011.
- Exec approvals: reuse durable exact-command `allow-always` approvals in allowlist mode so identical reruns stop prompting, and tighten Windows interpreter/path approval handling so wrapper and malformed-path cases fail closed more consistently. (#59880, #59780, #58040, #59182) Thanks @luoyanglang, @SnowSky1, and @pgondhi987.
- Node exec approvals: keep node-host `system.run` approvals bound to the prepared execution plan across async forwarding, so mutable script operands still get approval-time binding and drift revalidation instead of dropping back to unbound execution.
- Agents/exec approvals: let `exec-approvals.json` agent security override stricter gateway tool defaults so approved subagents can use `security: “full”` without falling back to allowlist enforcement again. (#60310) Thanks @lml2468.
- Agents/exec: restore `host=node` routing for node-pinned and `host=auto` sessions, while still blocking sandboxed `auto` sessions from jumping to gateway. (#60788) Thanks @openperf.
- Exec/heartbeat: use the canonical `exec-event` wake reason for `notifyOnExit` so background exec completions still trigger follow-up turns when `HEARTBEAT.md` is empty or comments-only. (#41479) Thanks @rstar327.
- Heartbeat: skip wake delivery when the target session lane is already busy so the pending event is retried instead of getting drained too early. (#40526) Thanks @lucky7323.
- Group chats/agent prompts: tell models to minimize empty lines and use normal chat-style spacing so group replies avoid document-style blank-line formatting.
- Providers/OpenAI GPT: treat short approval turns like `ok do it` and `go ahead` as immediate action turns, and trim overly memo-like GPT-5 chat confirmations so OpenAI replies stay shorter and more conversational by default.
- Providers/OpenAI Codex: split native `contextWindow` from runtime `contextTokens`, keep the default effective cap at `272000`, and expose a per-model `contextTokens` override on `models.providers.*.models[]`.
- Providers/OpenAI-compatible WS: compute fallback token totals from normalized usage when providers omit or zero `total_tokens`, so DashScope-compatible sessions stop storing zero totals after alias normalization. (#54940) Thanks @lyfuci.
- Agents/OpenAI: mark Claude-compatible file tool schemas as `additionalProperties: false` so direct OpenAI GPT-5 routes stop rejecting the `read` tool with invalid strict-schema errors.
- Agents/OpenAI: fall back to `strict: false` for native OpenAI tool calls when a tool schema is not strict-compatible, and normalize empty-object tool schemas to include `required: []`, so direct GPT-5 routes stop failing with invalid strict-schema errors like missing `path` in `required`.
- Agents/GPT: add explicit work-item lifecycle events for embedded runs, use them to surface real progress more reliably, and stop counting tool-started turns as planning-only retries.
- Plugins/OpenAI: enable `gpt-image-1` reference-image edits through `/images/edits` multipart uploads, and stop inferring unsupported resolution overrides when no explicit `size` or `resolution` is provided.
- Agents/replay: remove the malformed assistant-content canonicalization repair from replay history sanitization instead of extending that legacy repair path into replay validation.
- Plugins/OpenAI: tune the OpenAI prompt overlay for live-chat cadence so GPT replies stay shorter, more human, and less wall-of-text by default.
- Providers/compat: stop forcing OpenAI-only defaults on proxy and custom OpenAI-compatible routes, preserve native vendor-specific reasoning/tool/streaming behavior across Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, and Z.ai endpoints, and route GitHub Copilot Claude models through Anthropic Messages instead of OpenAI Responses.
- Providers/GitHub Copilot: send IDE identity headers on runtime model requests and GitHub token exchange so IDE-authenticated Copilot runs stop failing with missing `Editor-Version`. (#60641) Thanks @VACInc and @vincentkoc.
- Providers/OpenRouter failover: classify `403 “Key limit exceeded”` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
- Providers/Anthropic: keep `claude-cli/*` auth on live Claude CLI credentials at runtime, avoid persisting stale bearer-token profiles, and suppress macOS Keychain prompts during non-interactive Claude CLI setup. (#61234) Thanks @darkamenosa.
- Providers/Anthropic: when Claude CLI auth becomes the default, write a real `claude-cli` auth profile so local and gateway agent runs can use Claude CLI immediately without missing-API-key failures. Thanks @vincentkoc.
- Memory/dreaming: make Dreams config reads and writes respect the selected memory slot plugin (including `doctor.memory.status` and Control UI fallback state) instead of always targeting `memory-core`. (#62275) Thanks @SnowSky1.
- Providers/Anthropic Vertex: honor `cacheRetention: “long”` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
- Agents/Anthropic: preserve native `toolu_*` replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612)
- Providers/Google: add model-level `cacheRetention` support for direct Gemini system prompts by creating, reusing, and refreshing `cachedContents` automatically on Google AI Studio runs. (#51372) Thanks @rafaelmariano-glitch.
- Google Gemini CLI auth: detect bundled npm installs by scanning packaged bundle files for the Gemini OAuth client config, so `npm install -g @google/gemini-cli` layouts work again. (#60486) Thanks @wzfmini01.
- Google Gemini CLI auth: detect personal OAuth mode from local Gemini settings and skip Code Assist project discovery for those logins, so personal Google accounts stop failing with `loadCodeAssist 400 Bad Request`. (#49226) Thanks @bobworrall.
- Google Gemini CLI auth: improve OAuth credential discovery across Windows nvm and Homebrew libexec installs, and align Code Assist metadata so Gemini login stops failing on packaged CLI layouts. (#40729) Thanks @hughcube.
- Google Gemini CLI models: add forward-compat support for stable `gemini-2.5-*` model ids by letting the bundled CLI provider clone them from Google templates, so `gemini-2.5-flash-lite` and related configured models stop showing up as missing. (#35274) Thanks @mySebbe.
- Google image generation: disable pinned DNS for Gemini image requests and honor explicit `pinDns` overrides in shared provider HTTP helpers so proxy-backed image generation works again. (#59873) Thanks @luoyanglang.
- Providers/Microsoft Foundry: preserve explicit image capability on normalized Foundry deployments, repair stale GPT/o-series text-only model metadata across gateway and runtime paths, and keep unknown fallback models from borrowing unrelated image support.
- Providers/Model Studio: preserve native streaming usage reporting for DashScope-compatible endpoints even when they are configured under a generic provider key, so streamed token totals stop sticking at zero. (#52395) Thanks @IVY-AI-gif.
- Providers/Z.AI: preserve explicitly registered `glm-5-*` variants like `glm-5-turbo` instead of intercepting them with the generic GLM-5 forward-compat shim. (#48185) Thanks @haoyu-haoyu.
- Amazon Bedrock/aws-sdk auth: stop injecting the fake `AWS_PROFILE` apiKey marker when no AWS auth env vars exist, so instance-role and other default-chain setups keep working without poisoning provider config. (#61194) Thanks @wirjo.
- Agents/Kimi tool-call repair: preserve tool arguments that were already present on streamed tool calls when later malformed deltas fail reevaluation, while still dropping stale repair-only state before `toolcall_end`.
- Plugins/Kimi Coding: parse tagged tool calls and keep Anthropic-native tool payloads so Kimi coding endpoints execute tools instead of echoing raw markup. (#60051, #60391) Thanks @obviyus and @Eric-Guo.
- Media understanding: auto-register image-capable config providers for vision routing, so custom GLM-style provider ids with image models stop failing with “no media-understanding provider registered”. (#51418) Thanks @xydt-610.
- Plugins/media understanding: enable bundled Groq and Deepgram providers by default so configured transcription models work without extra plugin activation config. (#59982) Thanks @yxjsxy.
- MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch.
- MiniMax: advertise image input on bundled `MiniMax-M2.7` and `MiniMax-M2.7-highspeed` model definitions so image-capable flows can route through the M2.7 family correctly. (#54843) Thanks @MerlinMiao88888888.
- Models/MiniMax: honor `MINIMAX_API_HOST` for implicit bundled MiniMax provider catalogs so China-hosted API-key setups pick `api.minimaxi.com/anthropic` without manual provider config. (#34524) Thanks @caiqinghua.
- Usage/MiniMax: invert remaining-style `usage_percent` fields when MiniMax reports only remaining percentage data, so usage bars stop showing nearly-full remaining quota as nearly-exhausted usage. (#60254) Thanks @jwchmodx.
- Usage/MiniMax: let usage snapshots treat `minimax-portal` and MiniMax CN aliases as the same MiniMax quota surface, and prefer stored MiniMax OAuth before falling back to Coding Plan keys.
- Usage/MiniMax: prefer the chat-model `model_remains` entry and derive Coding Plan window labels from MiniMax interval timestamps so MiniMax usage snapshots stop picking zero-budget media rows and misreporting 4h windows as `5h`. (#52349) Thanks @IVY-AI-gif.
- Model picker/providers: treat bundled BytePlus and Volcengine plan aliases as their native providers during setup, and expose their bundled standard/coding catalogs before auth so setup can suggest the right models. (#58819) Thanks @Luckymingxuan.
- Tools/web_search (Kimi): when `tools.web.search.kimi.baseUrl` is unset, inherit native Moonshot chat `baseUrl` (`.ai` / `.cn`) so China console keys authenticate on the same host as chat. Fixes #44851. (#56769) Thanks @tonga54.
- Agents/Claude CLI: keep non-interactive `--permission-mode bypassPermissions` when custom `cliBackends.claude-cli.args` override defaults, including fallback resolution before the runtime plugin registry is active, so cron and heartbeat Claude CLI runs do not regress to interactive approval mode. (#61114) Thanks @cathrynlavery and @thewilloftheshadow.
- Agents/Claude CLI: persist explicit `openclaw agent --session-id` runs under a stable session key so follow-ups can reuse the stored CLI binding and resume the same underlying Claude session.
- Agents/Claude CLI: persist routed Claude session bindings, rotate them on `/new` and `/reset`, and keep live Claude CLI model switches moving across the configured Claude family so resumed sessions follow the real active thread and model. Thanks @vincentkoc.
- Agents/CLI backends: invalidate stored CLI session reuse when local CLI login state or the selected auth profile credential changes, so relogin and token rotation stop resuming stale sessions.
- Agents/Claude CLI/images: reuse stable hydrated image file paths and preserve shared media extensions like HEIC when passing image refs to local CLI runs, so Claude CLI image prompts stop thrashing KV cache prefixes and oddball image formats do not fall back to `.bin`. Thanks @vincentkoc.
- Agents/compaction: keep assistant tool calls and displaced tool results in the same compaction chunk so strict summarization providers stop rejecting orphaned tool pairs. (#58849) Thanks @openperf.
- Agents/failover: scope Anthropic `An unknown error occurred` failover matching by provider so generic internal unknown-error text no longer triggers retryable timeout fallback. (#59325) Thanks @aaron-he-zhu.
- Agents/subagents: honor allowlist validation, auth-profile handoff, and session override state when a subagent retries after `LiveSessionModelSwitchError`. (#58178) Thanks @openperf.
- Agents/runtime: make default subagent allowlists, inherited skills/workspaces, and duplicate session-id resolution behave more predictably, and include value-shape hints in missing-parameter tool errors. (#59944, #59992, #59858, #55317) Thanks @hclsys, @gumadeiras, @joelnishanth, and @priyansh19.
- Agents/pairing: merge completion announce delivery context with the requester session fallback so missing `to` still reaches the original channel, and include `operator.talk.secrets` in CLI default operator scopes for node-role device pairing approvals. (#56481) Thanks @maxpetrusenko.
- Agents/scheduling: steer background-now work toward automatic completion wake and treat `process` polling as on-demand inspection or intervention instead of default completion handling. (#60877) Thanks @vincentkoc.
- Agents/skills: skip `.git` and `node_modules` when mirroring skills into sandbox workspaces so read-only sandboxes do not copy repo history or dependency trees. (#61090) Thanks @joelnishanth.
- ACP/agents: inherit the target agent workspace for cross-agent ACP spawns and fall back safely when the inherited workspace no longer exists. (#58438) Thanks @zssggle-rgb.
- ACPX/Windows: preserve backslashes and absolute `.exe` paths in Claude CLI parsing, and fail fast on wrapper-script targets with guidance to use `cmd.exe /c`, `powershell.exe -File`, or `node <script>`. (#60689) Thanks @steipete.
- Auth/failover: persist selected fallback overrides before retrying, shorten `auth_permanent` lockouts, and refresh websocket/shared-auth sessions only when real auth changes occur so retries and secret rotations behave predictably. (#60404, #60323, #60387) Thanks @extrasmall0 and @mappel-nv.
- Gateway/channels: pin the initial startup channel registry before later plugin-registry churn so configured channels stay visible and `channels.status` stops falling back to empty `channelOrder` / `channels` payloads after runtime plugin loads.
- Prompt caching: order stable workspace project-context files before `HEARTBEAT.md` and keep `HEARTBEAT.md` below the system-prompt cache boundary so heartbeat churn does not invalidate the stable project-context prefix. (#58979) Thanks @yozu and @vincentkoc.
- Prompt caching: route Codex Responses and Anthropic Vertex through boundary-aware cache shaping, and report the actual outbound system prompt in cache traces so cache reuse and misses line up with what providers really receive. Thanks @vincentkoc.
- Agents/cache: preserve the full 3-turn prompt-cache image window across tool loops, keep colliding bundled MCP tool definitions deterministic, and reapply Anthropic Vertex cache shaping after payload hook replacements so KV/cache reuse stays stable. Thanks @vincentkoc.
- Status/cache: restore `cacheRead` and `cacheWrite` in transcript fallback so `/status` keeps showing cache hit percentages when session logs are the only complete usage source. (#59247) Thanks @stuartsy.
- MiniMax: advertise image input on bundled `MiniMax-M2.7` and `MiniMax-M2.7-highspeed` model definitions so image-capable flows can route through the M2.7 family correctly. (#54843) Thanks @MerlinMiao88888888.
- Media understanding: auto-register image-capable config providers for vision routing, so custom GLM-style provider ids with image models stop failing with “no media-understanding provider registered”. (#51418) Thanks @xydt-610.
- Providers/OpenAI: preserve native `reasoning.effort: "none"` and strict tool schemas on direct OpenAI-family endpoints, keep compat routes on compat shaping, fix Responses WebSocket warm-up behavior, keep stable session and turn metadata, and fall back more gracefully after early WebSocket failures.
- Providers/OpenAI Codex: split native `contextWindow` from runtime `contextTokens`, keep the default effective cap at `272000`, and expose a per-model `contextTokens` override on `models.providers.*.models[]`.
- Providers/compat: stop forcing OpenAI-only defaults on proxy and custom OpenAI-compatible routes, preserve native vendor-specific reasoning/tool/streaming behavior across Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, and Z.ai endpoints, and route GitHub Copilot Claude models through Anthropic Messages instead of OpenAI Responses.
- Providers/Model Studio: preserve native streaming usage reporting for DashScope-compatible endpoints even when they are configured under a generic provider key, so streamed token totals stop sticking at zero. (#52395) Thanks @IVY-AI-gif.
- Providers/OpenAI-compatible WS: compute fallback token totals from normalized usage when providers omit or zero `total_tokens`, so DashScope-compatible sessions stop storing zero totals after alias normalization. (#54940) Thanks @lyfuci.
- Status/usage: let `/status` and `session_status` fall back to transcript token totals when the session meta store stayed at zero, so LM Studio, Ollama, DashScope, and similar OpenAI-compatible providers stop showing `Context: 0/...`. (#55041) Thanks @jjjojoj.
- Mattermost/config schema: accept `groups.*.requireMention` again so existing Mattermost configs no longer fail strict validation after upgrade. (#58271) Thanks @MoerAI.
- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev.
- Anthropic CLI onboarding: rewrite migrated fallback model refs during non-interactive Claude CLI setup too, so onboarding and scripted setup no longer keep stale `anthropic/*` fallbacks after switching the primary model to `claude-cli/*`. Thanks @vincentkoc.
- Models/Anthropic CLI auth: replace migrated `agents.defaults.models` allowlists when `openclaw models auth login --provider anthropic --method cli --set-default` switches to `claude-cli/*`, so stale `anthropic/*` entries do not linger beside the migrated Claude CLI defaults. Thanks @vincentkoc.
- Doctor/Claude CLI: add dedicated Claude CLI health checks so `openclaw doctor` can spot missing local installs or broken auth before agent runs fail. Thanks @vincentkoc.
- Plugins/auth-choice: apply provider-owned auth config patches without recursively preserving replaced default-model maps, so Anthropic Claude CLI and similar migrations can intentionally swap model allowlists during onboarding and setup instead of accumulating stale entries. Thanks @vincentkoc.
- Plugins/onboarding: write dotted plugin uiHint paths like Brave `webSearch.mode` as nested plugin config so `llm-context` setup stops failing validation. (#61159) Thanks @obviyus.
- Providers/Z.AI: preserve explicitly registered `glm-5-*` variants like `glm-5-turbo` instead of intercepting them with the generic GLM-5 forward-compat shim. (#48185) Thanks @haoyu-haoyu.
- Live model switching: only treat explicit user-driven model changes as pending live switches, so fallback rotation, heartbeat overrides, and compaction no longer trip `LiveSessionModelSwitchError` before making an API call. (#60266) Thanks @kiranvk-2011.
- Voice-call/OpenAI: pass full plugin config into realtime transcription provider resolution so streaming calls can discover the bundled OpenAI realtime transcription provider again. Fixes #60936. Thanks @sliekens and @vincentkoc.
- Plugins/OpenAI: enable `gpt-image-1` reference-image edits through `/images/edits` multipart uploads, and stop inferring unsupported resolution overrides when no explicit `size` or `resolution` is provided.
- Gateway/startup: default `gateway.mode` to `local` when unset, detect PID recycling in gateway lock files on Windows and macOS, and show startup progress so healthy restarts stop getting blocked by stale locks. (#54801, #60085, #59843)
- Mobile pairing/Android: tighten secure endpoint handling so Tailscale and public remote setup reject cleartext endpoints, private LAN pairing still works, merged-role approvals mint both node and operator device tokens, and bootstrap tokens survive node auto-pair until operator approval finishes. (#60128, #60208, #60221)
- Android/Talk Mode: restore spoken assistant replies on node-scoped sessions by keeping reply routing synced to the resolved node session key and pausing mic capture during reply playback. (#60306) Thanks @MKV21.
- Telegram: fix current-model checks in the model picker, HTML-format non-default `/model` confirmations, explicit topic replies, persisted reaction ownership across restarts, caption-media placeholder and `file_id` preservation on download failure, and upgraded-install inbound image reads. (#60384, #60042, #59634, #59207, #59948, #59971)
- Telegram/local Bot API: honor `channels.telegram.apiRoot` for buffered media downloads, add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP setups, and require `channels.telegram.trustedLocalFileRoots` before reading absolute Bot API `file_path` values. (#59544, #60705)
- Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289)
- ACP/agents: inherit the target agent workspace for cross-agent ACP spawns and fall back safely when the inherited workspace no longer exists. (#58438) Thanks @zssggle-rgb.
- ACPX/Windows: preserve backslashes and absolute `.exe` paths in Claude CLI parsing, and fail fast on wrapper-script targets with guidance to use `cmd.exe /c`, `powershell.exe -File`, or `node <script>`. (#60689)
- Gateway/Windows scheduled tasks: preserve Task Scheduler settings on reinstall, fail loudly when `/Run` does not start, and report fast failed restarts accurately instead of pretending they timed out after 60 seconds. (#59335) Thanks @tmimmanuel.
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345)
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069)
- Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267)
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198)
- Agents/Claude CLI: persist explicit `openclaw agent --session-id` runs under a stable session key so follow-ups can reuse the stored CLI binding and resume the same underlying Claude session.
- Agents/CLI backends: invalidate stored CLI session reuse when local CLI login state or the selected auth profile credential changes, so relogin and token rotation stop resuming stale sessions.
- Gateway/macOS: recover installed-but-unloaded LaunchAgents during `openclaw gateway start` and `restart`, while still preferring live unmanaged gateways during restart recovery. (#43766) Thanks @HenryC-3.
- Auth/failover: persist selected fallback overrides before retrying, shorten `auth_permanent` lockouts, and refresh websocket/shared-auth sessions only when real auth changes occur so retries and secret rotations behave predictably. (#60404, #60323, #60387)
- Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.
- Plugins/media understanding: enable bundled Groq and Deepgram providers by default so configured transcription models work without extra plugin activation config. (#59982) Thanks @yxjsxy.
- Plugins/Kimi Coding: parse tagged tool calls and keep Anthropic-native tool payloads so Kimi coding endpoints execute tools instead of echoing raw markup. (#60051, #60391)
- Tools/web_search (Kimi): when `tools.web.search.kimi.baseUrl` is unset, inherit native Moonshot chat `baseUrl` (`.ai` / `.cn`) so China console keys authenticate on the same host as chat. Fixes #44851. (#56769) Thanks @tonga54.
- Plugins/marketplace: block remote marketplace symlink escapes without breaking ordinary local marketplace install paths. (#60556) Thanks @eleqtrizit.
- Plugins/install: preserve unsafe override flags across linked plugin and hook-pack probes so local `--link` installs honor the documented override behavior. (#60624) Thanks @JerrettDavis.
- Plugins/cache: inherit the active gateway workspace for provider, web-search, and web-fetch snapshot loads when callers omit `workspaceDir`, so compatible plugin registries and snapshot caches stop missing on gateway-owned runtime paths. (#61138) Thanks @jzakirov.
- Plugin SDK/context engines: export the missing context-engine result and subagent lifecycle types from `openclaw/plugin-sdk` so context engine plugins can type `ContextEngine` implementations without local workarounds. (#61251) Thanks @DaevMithran.
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the rendered snapshot. (#28214) Thanks @solodmd.
- Security: preserve restrictive plugin-only tool allowlists, require owner access for `/allowlist add` and `/allowlist remove`, fail closed when `before_tool_call` hooks crash, block browser SSRF redirect bypasses earlier, and keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins. (#58476, #59836, #59822, #58771, #59120)
- Exec approvals: reuse durable exact-command `allow-always` approvals in allowlist mode so identical reruns stop prompting, and tighten Windows interpreter/path approval handling so wrapper and malformed-path cases fail closed more consistently. (#59880, #59780, #58040, #59182)
- Agents/runtime: make default subagent allowlists, inherited skills/workspaces, and duplicate session-id resolution behave more predictably, and include value-shape hints in missing-parameter tool errors. (#59944, #59992, #59858, #55317)
- Update/npm: prefer the npm binary that owns the installed global OpenClaw prefix so mixed Homebrew-plus-nvm setups update the right install. (#60153) Thanks @jayeshp19.
- Gateway/plugin routes: keep gateway-auth plugin runtime routes on write-only fallback scopes unless a trusted-proxy caller explicitly declares narrower `x-openclaw-scopes`, so plugin HTTP handlers no longer mint admin-level runtime scopes on missing or untrusted HTTP scope headers. (#59815) Thanks @pgondhi987.
- Agents/exec approvals: let `exec-approvals.json` agent security override stricter gateway tool defaults so approved subagents can use `security: "full"` without falling back to allowlist enforcement again. (#60310) Thanks @lml2468.
- Tasks/maintenance: reconcile stale cron and chat-backed CLI task rows against live cron-job and agent-run ownership instead of treating any persisted session key as proof that the task is still running. (#60310) Thanks @lml2468.
- Plugins: suppress trust-warning noise during non-activating snapshot and CLI metadata loads. (#61427) Thanks @gumadeiras.
- Agents/video generation: accept `agents.defaults.videoGenerationModel` in strict config validation and `openclaw config set/get`, so gateways using `video_generate` no longer fail to boot after enabling a video model.
- Matrix/streaming: add a quiet preview mode for streamed Matrix replies, keep legacy `partial` preview-first behavior, and finalize quiet media captions correctly so previews stop notifying early without dropping final text semantics. (#61450) Thanks @gumadeiras.
- Agents/compaction: skip redundant partial summarization when no messages were oversized, so the same transcript is not summarized twice after a full summarization failure. Fixes #61465. (#61603) Thanks @neeravmakwana.
- Gateway/shutdown: bound websocket-server shutdown even when no tracked clients remain, so gateway restarts stop hanging until the watchdog kills the process. (#61565) Thanks @mbelinky.
- Control UI/multilingual: localize the remaining shared channel, instances, nodes, and gateway-confirmation strings so the dashboard stops mixing translated UI with hardcoded English labels. Thanks @vincentkoc.
- Discord/media: raise the default inbound and outbound media cap to `100MB` so Discord matches Telegram more closely and larger attachments stop failing on the old low default.
- Matrix: keep direct transport requests on the pinned dispatcher by routing them through undici runtime fetch, so Matrix clients resume syncing on newer runtimes without dropping the validated address binding. (#61595) Thanks @gumadeiras.
- Plugins/facades: resolve globally installed bundled-plugin runtime facades from registry roots so bundled channels like LINE still boot when the winning plugin install lives under the global extensions directory with an encoded scoped folder name. (#61297) Thanks @openperf.
- Matrix: avoid failing startup when token auth already knows the user ID but still needs optional device metadata, retry transient auth bootstrap requests, and backfill missing device IDs after startup while keeping unknown-device storage reuse conservative until metadata is repaired. (#61383) Thanks @gumadeiras.
- Agents/exec: stop streaming `tool_execution_update` events after an exec session backgrounds, preventing delayed background output from hitting a stale listener and crashing the gateway while keeping the output available through `process poll/log`. (#61627) Thanks @openperf.
- Matrix: pass configured `deviceId` through health probes and keep probe-only client setup out of durable Matrix storage, so health checks preserve the correct device identity without rewriting `storage-meta.json` or related probe state on disk. (#61581) Thanks @MoerAI.
||||||| parent of b4694a4ac7 (Telegram: add outbound chunker regression coverage)
- Image generation/build: write stable runtime alias files into `dist/` and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files.
- Config/runtime: pin the first successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing `openclaw.json` between watcher-driven swaps.
- Config/legacy cleanup: stop probing obsolete alternate legacy config names and service labels during local config/service detection, while keeping the active `~/.openclaw/openclaw.json` path canonical.
- ACP/sessions_spawn: register ACP child runs for completion tracking and lifecycle cleanup, and make registration-failure cleanup explicitly best-effort so callers do not assume an already-started ACP turn was fully aborted. (#40885) Thanks @xaeon2026 and @vincentkoc.
- ACP/tasks: mark cleanly exited ACP runs as blocked when they end on deterministic write or authorization blockers, and wake the parent session with a follow-up instead of falsely reporting success.
- ACPX/runtime: derive the bundled ACPX expected version from the extension package metadata instead of hardcoding a separate literal, so plugin-local ACPX installs stop drifting out of health-check parity after version bumps. (#49089) Thanks @jiejiesks and @vincentkoc.
- Gateway/auth: make local-direct `trusted-proxy` fallback require the configured shared token instead of silently authenticating same-host callers, while keeping same-host reverse proxy identity-header flows on the normal trusted-proxy path. Thanks @zhangning-agent and @vincentkoc.
- Memory/QMD: send MCP `query` collection filters as the upstream `collections` array instead of the legacy singular `collection` field, so mcporter-backed QMD 1.1+ searches still scope correctly after the unified `query` tool migration. (#54728) Thanks @armanddp and @vincentkoc.
- Memory/QMD: keep `qmd embed` active in `search` mode too, so BM25-first setups still build a complete index for later vector and hybrid retrieval. (#54509) Thanks @hnshah and @vincentkoc.
- Memory/QMD: point `QMD_CONFIG_DIR` at the nested `xdg-config/qmd` directory so per-agent collection config resolves correctly. (#39078) Thanks @smart-tinker and @vincentkoc.
- Memory/QMD: include deduplicated default plus per-agent `memorySearch.extraPaths` when building QMD custom collections, so shared and agent-specific extra roots both get indexed consistently. (#57315) Thanks @Vitalcheffe and @vincentkoc.
- Memory/session indexer: include `.jsonl.reset.*` and `.jsonl.deleted.*` transcripts in the memory host session scan while still excluding `.jsonl.bak.*` compaction backups and lock files, so memory search sees archived session history without duplicating stale snapshots. Thanks @hclsys and @vincentkoc.
- Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.
- LINE/ACP: add current-conversation binding and inbound binding-routing parity so `/acp spawn ... --thread here`, configured ACP bindings, and active conversation-bound ACP sessions work on LINE like the other conversation channels.
- LINE/markdown: preserve underscores inside Latin, Cyrillic, and CJK words when stripping markdown, while still removing standalone `_italic_` markers on the shared text-runtime path used by LINE and TTS. (#47465) Thanks @jackjin1997.
- TTS/Microsoft: auto-switch the default Edge voice to Chinese for CJK-dominant text without overriding explicitly selected Microsoft voices. (#52355) Thanks @extrasmall0.
- Agents/context pruning: count supplementary-plane CJK characters with the shared code-point-aware estimator so context pruning stops underestimating Japanese and Chinese text that uses Extension B ideographs. (#39985) Thanks @Edward-Qiang-2024.
- Slack/status reactions: add a reaction lifecycle for queued, thinking, tool, done, and error phases in Slack monitors, with safer cleanup so queued ack reactions stay correct across silent runs, pre-reply failures, and delayed transitions. (#56430) Thanks @hsiaoa.
- macOS/local gateway: stop OpenClaw.app from killing healthy local gateway listeners after startup by recognizing the current `openclaw-gateway` process title and using the current `openclaw gateway` launch shape.
- Gateway/OpenAI compatibility: accept flat Responses API function tool definitions on `/v1/responses` and preserve `strict` when normalizing hosted tools into the embedded runner, so spec-compliant clients like Codex no longer fail validation or silently lose strict tool enforcement. Thanks @malaiwah and @vincentkoc.
- Memory/QMD: resolve slugified `memory_search` file hints back to the indexed filesystem path before returning search hits, so `memory_get` works again for mixed-case and spaced paths. (#50313) Thanks @erra9x.
- OpenAI/Codex fast mode: map `/fast` to priority processing on native OpenAI and Codex Responses endpoints instead of rewriting reasoning settings, and document the exact endpoint and override behavior.
- Memory/QMD: weight CJK-heavy text correctly when estimating chunk sizes, preserve surrogate-pair characters during fine splits, and keep long Latin lines on the old chunk boundaries so memory indexing produces better-sized chunks for CJK notes. (#40271) Thanks @AaronLuo00.
- Security/LINE: make webhook signature validation run the timing-safe compare even when the supplied signature length is wrong, closing a small timing side-channel. (#55663) Thanks @gavyngong.
- LINE/status: stop `openclaw status` from warning about missing credentials when sanitized LINE snapshots are already configured, while still surfacing whether the missing field is the token or secret. (#45701) Thanks @tamaosamu.
- Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u.
- Agents/MCP: reuse bundled MCP runtimes across turns in the same session, while recreating them when MCP config changes and disposing stale runtimes cleanly on session rollover. (#55090) Thanks @allan0509.
- Memory/QMD: honor `memory.qmd.update.embedInterval` even when regular QMD update cadence is disabled or slower by arming a dedicated embed-cadence maintenance timer, while avoiding redundant timers when regular updates are already frequent enough. (#37326) Thanks @barronlroth.
- Memory/QMD: add `memory.qmd.searchTool` as an exact mcporter tool override, so custom QMD MCP tools such as `hybrid_search` can be used without weakening the validated `searchMode` config surface. (#27801) Thanks @keramblock.
- Memory/QMD: keep reset and deleted session transcripts in QMD session export so daily session resets do not silently drop most historical recall from `memory_search`. (#30220) Thanks @pushkarsingh32.
- Memory/QMD: rebind collections when QMD reports a changed pattern but omits path metadata, so config pattern changes stop being silently ignored on restart. (#49897) Thanks @Madruru.
- Memory/QMD: warn explicitly when `memory.backend=qmd` is configured but the `qmd` binary is missing, so doctor and runtime fallback no longer fail as a silent builtin downgrade. (#50439) Thanks @Jimmy-xuzimo and @vincentkoc.
- Memory/QMD: pass a direct-session key on `openclaw memory search` so CLI QMD searches no longer get denied as `session=<none>` under direct-only scope defaults. (#43517) Thanks @waynecc-at and @vincentkoc.
- Memory/QMD: keep `memory_search` session-hit paths roundtrip-safe when exported session markdown lives under the workspace `qmd/` directory, so `memory_get` can read the exact returned path instead of failing on the generic `qmd/sessions/...` alias. (#43519) Thanks @holgergruenhagen and @vincentkoc.
- Agents/memory flush: keep daily memory flush files append-only during embedded attempts so compaction writes do not overwrite earlier notes. (#53725) Thanks @HPluseven.
- Web UI/markdown: stop bare auto-links from swallowing adjacent CJK text while preserving valid mixed-script path and query characters in rendered links. (#48410) Thanks @jnuyao.
- BlueBubbles/iMessage: coalesce URL-only inbound messages with their link-preview balloon again so sharing a bare link no longer drops the URL from agent context. Thanks @vincentkoc.
- Sandbox/browser: install `fonts-noto-cjk` in the sandbox browser image so screenshots render Chinese, Japanese, and Korean text correctly instead of tofu boxes. Fixes #35597. Thanks @carrotRakko and @vincentkoc.
- Memory/FTS: add configurable trigram tokenization plus short-CJK substring fallback so memory search can find Chinese, Japanese, and Korean text without breaking mixed long-and-short queries. Thanks @carrotRakko.
- Hooks/config: accept runtime channel plugin ids in `hooks.mappings[].channel` (for example `feishu`) instead of rejecting non-core channels during config validation. (#56226) Thanks @AiKrai001.
- TUI/chat: keep optimistic outbound user messages visible during active runs by deferring local-run binding until the first gateway chat event reveals the real run id, preventing premature history reloads from wiping pending local sends. (#54722) Thanks @seanturner001.
- TUI/model picker: keep searchable `/model` and `/models` input mode from hijacking `j`/`k` as navigation keys, and harden width bounds under `m`-filtered model lists so search no longer crashes on long rows. (#30156) Thanks @briannicholls.
- Agents/Kimi: preserve already-valid Anthropic-compatible tool call argument objects while still clearing cached repairs when later trailing junk exceeds the repair allowance. (#54491) Thanks @yuanaichi.
- Docker/setup: force BuildKit for local image builds (including sandbox image builds) so `./docker-setup.sh` no longer fails on `RUN --mount=...` when hosts default to Docker's legacy builder. (#56681) Thanks @zhanghui-china.
- Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as `Not set`. (#56637) Thanks @dxsx84.
- Control UI/slash commands: make `/steer` and `/redirect` work from the chat command palette with visible pending state for active-run `/steer`, correct redirected-run tracking, and a single canonical `/steer` entry in the command menu. (#54625) Thanks @fuller-stack-dev.
- Exec/runtime: default implicit exec to `host=auto`, resolve that target to sandbox only when a sandbox runtime exists, keep explicit `host=sandbox` fail-closed without sandbox, and show `/exec` effective host state in runtime status/docs.
- Exec: fail closed when the implicit sandbox host has no sandbox runtime, and stop denied async approval followups from reusing prior command output from the same session. (#56800) Thanks @scoootscooob.
- Exec/approvals: infer Discord and Telegram exec approvers from existing owner config when `execApprovals.approvers` is unset, extend the default approval window to 30 minutes, and clarify approval-unavailable guidance so approvals do not appear to silently disappear.
- Exec/node: stop gateway-side workdir fallback from rewriting explicit `host=node` cwd values to the gateway filesystem, so remote node exec approval and runs keep using the intended node-local directory. (#50961) Thanks @openperf.
- Plugins/ClawHub: sanitize temporary archive filenames for scoped package names and slash-containing skill slugs so `openclaw plugins install @scope/name` no longer fails with `ENOENT` during archive download. (#56452) Thanks @soimy.
- Telegram/polling: keep the watchdog from aborting long-running reply delivery by treating recent non-polling API activity as bounded liveness instead of a hard stall. (#56343) Thanks @openperf.
- Memory/FTS: keep provider-less keyword hits visible at the default memory-search threshold, so FTS-only recall works without requiring `--min-score 0`. (#56473) Thanks @opriz.
- Memory/LanceDB: resolve runtime dependency manifest lookup from the bundled `extensions/memory-lancedb` path (including flattened dist chunks) so startup no longer fails with a missing `@lancedb/lancedb` dependency error. (#56623) Thanks @LUKSOAgent.
- Tools/web_search: localize the shared search cache to module scope so same-process global symbol lookups can no longer inspect or mutate cached web-search responses. Thanks @vincentkoc.
- Agents/silent turns: fail closed on silent memory-flush runs so narrated `NO_REPLY` self-talk cannot stream or finalize into external replies even when block streaming is enabled. (#52593)
- Browser/plugins: auto-enable the bundled browser plugin when browser config or browser tool policy already references it, and show a clearer CLI error when `plugins.allow` excludes `browser`.
- Matrix/plugin loading: ship and source-load the crypto bootstrap runtime sidecar correctly so current `main` stops warning about failed Matrix bootstrap loads and `matrix/index` plugin-id mismatches on every invocation. (#53298) thanks @keithce.
- iOS/Live Activities: mark the `ActivityKit` import in `LiveActivityManager.swift` as `@preconcurrency` so Xcode 26.4 / Swift 6 builds stop failing on strict concurrency checks. (#57180) Thanks @ngutman.
- Plugins/Matrix: mirror the Matrix crypto WASM runtime dependency into the root packaged install and enforce root/plugin dependency parity so bundled Matrix E2EE crypto resolves correctly in shipped builds. (#57163) Thanks @gumadeiras.
- Plugins/CLI: add descriptor-backed lazy plugin CLI registration so Matrix can keep its CLI module lazy-loaded without dropping `openclaw matrix ...` from parse-time command registration. (#57165) Thanks @gumadeiras.
- Plugins/CLI: collect root-help plugin descriptors through a dedicated non-activating CLI metadata path so enabled plugins keep validated config semantics without triggering runtime-only plugin registration work, while preserving runtime CLI command registration for legacy channel plugins that still wire commands from full registration. (#57294) thanks @gumadeiras.
- Anthropic/OAuth: inject `/fast` `service_tier` hints for direct `sk-ant-oat-*` requests so OAuth-authenticated Anthropic runs stop missing the same overload-routing signal as API-key traffic. Fixes #55758. Thanks @Cypherm and @vincentkoc.
- Anthropic/service tiers: support explicit `serviceTier` model params for direct Anthropic requests and let them override `/fast` defaults when both are set. (#45453) Thanks @vincentkoc.
- Auto-reply/fast: accept `/fast status` on the directive-only path, align help/status text with the documented `status|on|off` syntax, and keep current-state replies consistent across command surfaces. Fixes #46095. Thanks @weissfl and @vincentkoc.
- Telegram/native commands: prefix native command menu callback payloads and preserve `CommandSource: "native"` when Telegram replays them through callback queries, so `/fast` and other native command menus keep working even when text-command routing is disabled. Thanks @vincentkoc.
- Docs/anchors: fix broken English docs links and make Mint anchor audits run against the English-source docs tree. (#57039) thanks @velvet-shark.
- Cron/announce: preserve all deliverable text payloads for announce mode instead of collapsing to the last chunk, so multi-line cron reports deliver in full to Telegram forum topics.
- Harden async approval followup delivery in webchat-only sessions (#57359) Thanks @joshavant.
- Status: fix cache hit rate exceeding 100% by deriving denominator from prompt-side token fields instead of potentially undersized totalTokens. Fixes #26643.
- Config/update: stop `openclaw doctor` write-backs from persisting plugin-injected channel defaults, so `openclaw update` no longer seeds config keys that later break service refresh validation. (#56834) Thanks @openperf.
- Agents/Anthropic failover: treat Anthropic `api_error` payloads with `An unexpected error occurred while processing the response` as transient so retry/fallback can engage instead of surfacing a terminal failure. (#57441) Thanks @zijiess and @vincentkoc.
- Agents/compaction: keep late compaction-retry rejections handled after the aggregate timeout path wins without swallowing real pre-timeout wait failures, so timed-out retries no longer surface an unhandled rejection on later unsubscribe. (#57451) Thanks @mpz4life and @vincentkoc.
- Matrix/delivery recovery: treat Synapse `User not in room` replay failures as permanent during startup recovery so poisoned queued messages move to `failed/` instead of crash-looping Matrix after restart. (#57426) thanks @dlardo.
- Plugins/facades: guard bundled plugin facade loads with a cache-first sentinel so circular re-entry stops crashing `xai`, `sglang`, and `vllm` during gateway plugin startup. (#57508) Thanks @openperf.
- Agents/MCP: dispose bundled MCP runtimes after one-shot `openclaw agent --local` runs finish, while preserving bundled MCP state across in-run retries so local JSON runs exit cleanly without restarting stateful MCP tools mid-run.
- Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit `x-openclaw-scopes`, so headless `/v1/chat/completions` and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf.
- Gateway/attachments: offload large inbound images without leaking `media://` markers into text-only runs, preserve mixed attachment order for model input/transcripts, and fail closed when model image capability cannot be resolved. (#55513) Thanks @Syysean.
- Telegram/outbound chunking: use static markdown chunking when Telegram runtime state is unavailable so long outbound Telegram messages still split correctly after cold starts. (#57816) Thanks @ForestDengHK.
- Providers/GitHub Copilot: send IDE identity headers on runtime model requests and GitHub token exchange so IDE-authenticated Copilot runs stop failing with missing `Editor-Version`. (#60641) Thanks @VACInc and @vincentkoc.
- Model picker/providers: treat bundled BytePlus and Volcengine plan aliases as their native providers during setup, and expose their bundled standard/coding catalogs before auth so setup can suggest the right models. (#58819) Thanks @Luckymingxuan.
- Prompt caching: route Codex Responses and Anthropic Vertex through boundary-aware cache shaping, and report the actual outbound system prompt in cache traces so cache reuse and misses line up with what providers really receive. Thanks @vincentkoc.
- Prompt caching: order stable workspace project-context files before `HEARTBEAT.md` and keep `HEARTBEAT.md` below the system-prompt cache boundary so heartbeat churn does not invalidate the stable project-context prefix. (#58979) Thanks @yozu and @vincentkoc.
- Plugins/cache: inherit the active gateway workspace for provider, web-search, and web-fetch snapshot loads when callers omit `workspaceDir`, so compatible plugin registries and snapshot caches stop missing on gateway-owned runtime paths. (#61138) Thanks @jzakirov.
- Agents/Kimi tool-call repair: preserve tool arguments that were already present on streamed tool calls when later malformed deltas fail reevaluation, while still dropping stale repair-only state before `toolcall_end`.
- MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch.
- Agents/cache: preserve the full 3-turn prompt-cache image window across tool loops, keep colliding bundled MCP tool definitions deterministic, and reapply Anthropic Vertex cache shaping after payload hook replacements so KV/cache reuse stays stable. Thanks @vincentkoc.
- Device pairing: reject rotating device tokens into roles that were never approved during pairing, and keep reconnect role checks bounded to the paired device's approved role set. (#60462) Thanks @eleqtrizit.
- Mobile pairing/security: fail closed for internal `/pair` setup-code issuance, cleanup, and approval paths when gateway pairing scopes are missing, and keep approval-time requested-scope enforcement on the internal command path. (#55996) Thanks @coygeek.
- Status/cache: restore `cacheRead` and `cacheWrite` in transcript fallback so `/status` keeps showing cache hit percentages when session logs are the only complete usage source. (#59247) Thanks @stuartsy.
- Exec approvals/node host: forward prepared `system.run` approval plans on the async node invoke path so mutable script operands keep their approval-time binding and drift revalidation instead of dropping back to unbound execution.
- Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out.
- Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation.
- Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker.
- Mobile pairing/bootstrap: keep QR bootstrap handoff tokens bounded to the mobile-safe contract so node handoff stays unscoped and operator handoff drops mixed `node.*`, `operator.admin`, and `operator.pairing` scopes.
- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.
- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev.
- Providers/Anthropic Vertex: honor `cacheRetention: "long"` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
- Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit `deviceToken` scope requests and empty-cache fallbacks intact so reconnects preserve `operator.read` without breaking explicit auth flows. (#46032) Thanks @caicongyang.
- Agents/scheduling: steer background-now work toward automatic completion wake and treat `process` polling as on-demand inspection or intervention instead of default completion handling. (#60877) Thanks @vincentkoc.
- Google Gemini CLI auth: improve OAuth credential discovery across Windows nvm and Homebrew libexec installs, and align Code Assist metadata so Gemini login stops failing on packaged CLI layouts. (#40729) Thanks @hughcube.
- Google Gemini CLI auth: detect bundled npm installs by scanning packaged bundle files for the Gemini OAuth client config, so `npm install -g @google/gemini-cli` layouts work again. (#60486) Thanks @wzfmini01.
- Mattermost/config schema: accept `groups.*.requireMention` again so existing Mattermost configs no longer fail strict validation after upgrade. (#58271) Thanks @MoerAI.
- Agents/failover: scope Anthropic `An unknown error occurred` failover matching by provider so generic internal unknown-error text no longer triggers retryable timeout fallback. (#59325) Thanks @aaron-he-zhu.
- Providers/OpenRouter failover: classify `403 "Key limit exceeded"` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
- Device pairing/security: keep non-operator device scope checks bound to the requested role prefix so bootstrap verification cannot redeem `operator.*` scopes through `node` auth. (#57258) Thanks @jlapenna.
- Gateway/device pairing: require non-admin paired-device sessions to manage only their own device for token rotate/revoke and paired-device removal, blocking cross-device token theft inside pairing-scoped sessions. (#50627) Thanks @coygeek.
- CLI/skills JSON: route `skills list --json`, `skills info --json`, and `skills check --json` output to stdout instead of stderr so machine-readable consumers receive JSON on the expected stream again. (#60914; fixes #57599; landed from contributor PR #57611 by @Aftabbs) Thanks @Aftabbs.
- Agents/subagents: honor allowlist validation, auth-profile handoff, and session override state when a subagent retries after `LiveSessionModelSwitchError`. (#58178) Thanks @openperf.
- Google image generation: disable pinned DNS for Gemini image requests and honor explicit `pinDns` overrides in shared provider HTTP helpers so proxy-backed image generation works again. (#59873) Thanks @luoyanglang.
- Agents/exec: restore `host=node` routing for node-pinned and `host=auto` sessions, while still blocking sandboxed `auto` sessions from jumping to gateway. (#60788) Thanks @openperf.
- Agents/compaction: keep assistant tool calls and displaced tool results in the same compaction chunk so strict summarization providers stop rejecting orphaned tool pairs. (#58849) Thanks @openperf.
- Outbound/sanitizer: strip leaked `<tool_call>`, `<function_calls>`, and model special tokens from shared user-visible assistant text, including truncated tool-call streams, so internal scaffolding no longer bleeds into replies across surfaces. (#60619) Thanks @oliviareid-svg.
- Telegram: restore DM voice-note preflight transcription so direct-message audio stops arriving as raw `<media:audio>` placeholders. (#61008) Thanks @manueltarouca.
- Control UI/avatar: honor `ui.assistant.avatar` when serving `/avatar/:agentId` so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev.
- Control UI/Overview: prevent gateway access token/password visibility toggle buttons from overlapping their inputs at narrow widths. (#56924) Thanks @bbddbb1.
- Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm.
- MS Teams: replace the deprecated Teams SDK HttpPlugin stub with `httpServerAdapter` so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.
- CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010.
- Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more `/` entries visible. (#61129) Thanks @neeravmakwana.
- Agents/Claude CLI: keep non-interactive `--permission-mode bypassPermissions` when custom `cliBackends.claude-cli.args` override defaults, including fallback resolution before the runtime plugin registry is active, so cron and heartbeat Claude CLI runs do not regress to interactive approval mode. (#61114) Thanks @cathrynlavery and @thewilloftheshadow.
- Agents/skills: skip `.git` and `node_modules` when mirroring skills into sandbox workspaces so read-only sandboxes do not copy repo history or dependency trees. (#61090) Thanks @joelnishanth.
- Android/Talk Mode: cancel in-flight `talk.speak` playback when speech is explicitly stopped, so stale replies stop starting after barge-in or manual stop. (#61164) Thanks @obviyus.
- Plugins/onboarding: write dotted plugin uiHint paths like Brave `webSearch.mode` as nested plugin config so `llm-context` setup stops failing validation. (#61159) Thanks @obviyus.
- Android/Talk Mode: restore voice replies on gateway-backed talk mode sessions by updating embedded runner transport overrides to the current agent transport API. (#61214) Thanks @obviyus.
- Amazon Bedrock/aws-sdk auth: stop injecting the fake `AWS_PROFILE` apiKey marker when no AWS auth env vars exist, so instance-role and other default-chain setups keep working without poisoning provider config. (#61194) Thanks @wirjo.
## 2026.4.2
@@ -833,7 +537,6 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: surface safeguard-specific cancel reasons and relabel benign manual `/compact` no-op cases as skipped instead of failed. (#51072) Thanks @afurm.
- Docs: add `pnpm docs:check-links:anchors` for Mintlify anchor validation while keeping `scripts/docs-link-audit.mjs` as the stable link-audit entrypoint. (#55912) Thanks @velvet-shark.
- Tavily: mark outbound API requests with `X-Client-Source: openclaw` so Tavily can attribute OpenClaw-originated traffic. (#55335) Thanks @lakshyaag-tavily.
- Plugins/hooks: add async `requireApproval` to `before_tool_call` hooks, letting plugins pause tool execution and prompt the user for approval via the exec approval overlay, Telegram buttons, Discord interactions, or the `/approve` command on any channel. The `/approve` command now handles both exec and plugin approvals with automatic fallback. (#55339) Thanks @vaclavbelak and @joshavant.
### Fixes
@@ -993,7 +696,6 @@ Docs: https://docs.openclaw.ai
- Security/path resolution: prefer non-user-writable absolute helper binaries for OpenClaw CLI, ffmpeg, and OpenSSL resolution so PATH hijacks cannot replace trusted helpers with attacker-controlled executables.
- Security/gateway command scopes: require `operator.admin` before Telegram target writeback and Talk Voice `/voice set` config writes persist through gateway message flows.
- Security/OpenShell mirror: exclude workspace `hooks/` from mirror sync so untrusted sandbox files cannot become trusted host hooks on gateway startup.
- Exec env policy: block Mercurial config redirects, Rust compiler wrappers, and GNU make flag env vars in host exec sanitization so inherited env and request-scoped overrides cannot redirect build-tool execution.
## 2026.3.24-beta.2

View File

@@ -62,10 +62,9 @@ RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY openclaw.mjs ./
COPY ui/package.json ./ui/package.json
COPY patches ./patches
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
COPY scripts/postinstall-bundled-plugins.mjs scripts/npm-runner.mjs ./scripts/
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
@@ -98,24 +97,11 @@ RUN pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm ui:build
RUN pnpm qa:lab:build
# Prune dev dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# Keep the install layer frozen, but allow prune to run against the full copied
# workspace tree subset used during `pnpm install`. The build stage only copied
# the root, `ui`, and opted-in plugin manifests into the install layer, so
# prune must not rediscover unrelated workspaces from the later full source
# copy.
RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
for ext in $OPENCLAW_EXTENSIONS; do \
printf ' - %s/%s\n' "$OPENCLAW_BUNDLED_PLUGIN_DIR" "$ext" >> /tmp/pnpm-workspace.runtime.yaml; \
done && \
cp /tmp/pnpm-workspace.runtime.yaml pnpm-workspace.yaml && \
CI=true NPM_CONFIG_FROZEN_LOCKFILE=false pnpm prune --prod && \
RUN CI=true pnpm prune --prod && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
# ── Runtime base images ─────────────────────────────────────────
@@ -170,7 +156,10 @@ COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR}
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
COPY --from=runtime-assets --chown=node:node /app/qa ./qa
# In npm-installed Docker images, prefer the copied source extension tree for
# bundled discovery so package metadata that points at source entries stays valid.
ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/${OPENCLAW_BUNDLED_PLUGIN_DIR}
# Keep pnpm available in the runtime image for container-local workflows.
# Use a shared Corepack home so the non-root `node` user does not need a

View File

@@ -89,7 +89,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
Model note: while many providers and models are supported, prefer a current flagship model from the provider you trust and already use. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
## Models (selection + auth)
@@ -371,7 +371,7 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
```json5
{
agent: {
model: "<provider>/<model-id>",
model: "anthropic/claude-opus-4-6",
},
}
```

View File

@@ -97,7 +97,6 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
- Direct localhost/loopback Control UI and Gateway WebSocket sessions authenticated with the shared gateway secret (`token` / `password`) are in that same trusted-operator bucket. Local auto-paired device sessions on that path are expected to retain full localhost operator capability; they do not create a separate `operator.write` vs `operator.admin` security boundary.
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) and direct tool endpoint (`POST /tools/invoke`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
- Concretely, on the OpenAI-compatible HTTP surface:
- shared-secret bearer auth (`token` / `password`) authenticates possession of the gateway operator secret

View File

@@ -2,254 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.4.5</title>
<pubDate>Mon, 06 Apr 2026 04:55:17 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026040590</sparkle:version>
<sparkle:shortVersionString>2026.4.5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.5</h2>
<h3>Breaking</h3>
<ul>
<li>Config: remove legacy public config aliases such as <code>talk.voiceId</code> / <code>talk.apiKey</code>, <code>agents.*.sandbox.perSession</code>, <code>browser.ssrfPolicy.allowPrivateNetwork</code>, <code>hooks.internal.handlers</code>, and channel/group/room <code>allow</code> toggles in favor of the canonical public paths and <code>enabled</code>, while keeping load-time compatibility and <code>openclaw doctor --fix</code> migration support for existing configs. (#60726) Thanks @vincentkoc.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Agents/video generation: add the built-in <code>video_generate</code> tool so agents can create videos through configured providers and return the generated media directly in the reply.</li>
<li>Agents/music generation: ignore unsupported optional hints such as <code>durationSeconds</code> with a warning instead of hard-failing requests on providers like Google Lyria.</li>
<li>Providers/ComfyUI: add a bundled <code>comfy</code> workflow media plugin for local ComfyUI and Comfy Cloud workflows, including shared <code>image_generate</code>, <code>video_generate</code>, and workflow-backed <code>music_generate</code> support, with prompt injection, optional reference-image upload, live tests, and output download.</li>
<li>Tools/music generation: add the built-in <code>music_generate</code> tool with bundled Google (Lyria) and MiniMax providers plus workflow-backed Comfy support, including async task tracking and follow-up delivery of finished audio.</li>
<li>Providers: add bundled Qwen, Fireworks AI, and StepFun providers, plus MiniMax TTS, Ollama Web Search, and MiniMax Search integrations for chat, speech, and search workflows. (#60032, #55921, #59318, #54648)</li>
<li>Providers/Amazon Bedrock: add bundled Mantle support plus inference-profile discovery and automatic request-region injection so Bedrock-hosted Claude, GPT-OSS, Qwen, Kimi, GLM, and similar routes work with less manual setup. (#61296, #61299) Thanks @wirjo.</li>
<li>Control UI/multilingual: add localized control UI support for Simplified Chinese, Traditional Chinese, Brazilian Portuguese, German, Spanish, Japanese, Korean, French, Turkish, Indonesian, Polish, and Ukrainian. Thanks @vincentkoc.</li>
<li>Plugins: add plugin-config TUI prompts to guided onboarding/setup flows, and add <code>openclaw plugins install --force</code> so existing plugin and hook-pack targets can be replaced without using the dangerous-code override flag. (#60590, #60544)</li>
<li>Control UI/skills: add ClawHub search, detail, and install flows directly in the Skills panel. (#60134) Thanks @samzong.</li>
<li>iOS/exec approvals: add generic APNs approval notifications that open an in-app exec approval modal, fetch command details only after authenticated operator reconnect, and clear stale notification state when the approval resolves. (#60239) Thanks @ngutman.</li>
<li>Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.</li>
<li>Channels/context visibility: add configurable <code>contextVisibility</code> per channel (<code>all</code>, <code>allowlist</code>, <code>allowlist_quote</code>) so supplemental quote, thread, and fetched history context can be filtered by sender allowlists instead of always passing through as received.</li>
<li>Providers/request overrides: add shared model and media request transport overrides across OpenAI-, Anthropic-, Google-, and compatible provider paths, including headers, auth, proxy, and TLS controls. (#60200)</li>
<li>Providers/OpenAI: add forward-compat <code>openai-codex/gpt-5.4-mini</code>, an opt-in GPT personality, and provider-owned GPT-5 prompt contributions so Codex/GPT runs stay cache-stable and compatible with bundled catalog lag.</li>
<li>Agents/Claude CLI: expose OpenClaw tools to background Claude CLI runs through a loopback MCP bridge and switch bundled runs to stdin + <code>stream-json</code> partial-message streaming so prompts stop riding argv, long replies show live progress, and final session/usage metadata still land cleanly. (#35676) Thanks @mylukin.</li>
<li>ACPX/runtime: embed the ACP runtime directly in the bundled <code>acpx</code> plugin, remove the extra external ACP CLI hop, harden live ACP session binding and reuse, and add a generic <code>reply_dispatch</code> hook so bundled plugins like ACPX can own reply interception without hardcoded ACP paths in core auto-reply routing. (#61319)</li>
<li>Agents/progress: add experimental structured plan updates and structured execution item events so compatible UIs can show clearer step-by-step progress during long-running runs.</li>
<li>Providers/Anthropic: remove the Claude CLI backend and setup-token from new onboarding, keep existing configured legacy profiles runnable, and have <code>openclaw doctor</code> repair or remove stale <code>anthropic:claude-cli</code> state during migration.</li>
<li>Tools/video generation: add bundled xAI (<code>grok-imagine-video</code>), Alibaba Model Studio Wan, and Runway video providers, plus live-test/default model wiring for all three.</li>
<li>Memory/search: add Amazon Bedrock embeddings for Titan, Cohere, Nova, and TwelveLabs models, with AWS credential-chain auto-detection for <code>provider: "auto"</code> and provider-specific dimension controls. Thanks @wirjo.</li>
<li>Providers/Amazon Bedrock Mantle: generate bearer tokens from the AWS credential chain so Mantle auto-discovery can use IAM auth without manually exporting <code>AWS_BEARER_TOKEN_BEDROCK</code>. Thanks @wirjo.</li>
<li>Memory/dreaming (experimental): add weighted short-term recall promotion, a <code>/dreaming</code> command, Dreams UI, multilingual conceptual tagging, and doctor/status repair support, while refactoring dreaming from competing modes into three cooperative phases (light, deep, REM) with independent schedules and recovery behavior so durable memory promotion can run in the background with less manual setup. (#60569, #60697) Thanks @vignesh07.</li>
<li>Memory/dreaming: add configurable aging controls (<code>recencyHalfLifeDays</code>, <code>maxAgeDays</code>) plus optional verbose logging so operators can tune recall decay and inspect promotion decisions more easily.</li>
<li>Memory/dreaming: add REM preview tooling (<code>openclaw memory rem-harness</code>, <code>promote-explain</code>), surface possible lasting truths during REM staging, and make deep promotion replay-safe so reruns reconcile instead of duplicating <code>MEMORY.md</code> entries.</li>
<li>Memory/dreaming: write dreaming trail content to top-level <code>dreams.md</code> instead of daily memory notes, update <code>/dreaming</code> help text to point there, and keep <code>dreams.md</code> available for explicit reads without pulling it into default recall. Thanks @davemorin.</li>
<li>Memory/dreaming: add the Dream Diary surface in Dreams, simplify user-facing dreaming config to <code>enabled</code> plus optional <code>frequency</code>, treat phases as implementation detail in docs/UI, and keep the lobster animation visible above diary content. Thanks @vignesh07.</li>
<li>Prompt caching: keep prompt prefixes more reusable across transport fallback, deterministic MCP tool ordering, compaction, embedded image history, normalized system-prompt fingerprints, <code>openclaw status --verbose</code> cache diagnostics, and the removal of duplicate in-band tool inventories from agent system prompts so follow-up turns hit cache more reliably. (#58036, #58037, #58038, #59054, #60603, #60691) Thanks @bcherny and @vincentkoc.</li>
<li>Agents/cache: diagnostics: add prompt-cache break diagnostics, trace live cache scenarios through embedded runner paths, and show cache reuse explicitly in <code>openclaw status --verbose</code>. Thanks @vincentkoc.</li>
<li>Agents/cache: stabilize cache-relevant system prompt fingerprints by normalizing equivalent structured prompt whitespace, line endings, hook-added system context, and runtime capability ordering so semantically unchanged prompts reuse KV/cache more reliably. Thanks @vincentkoc.</li>
<li>Agents/tool prompts: remove the duplicate in-band tool inventory from agent system prompts so tool-calling models rely on the structured tool definitions as the single source of truth, improving prompt stability and reducing stale tool guidance.</li>
<li>Config/schema: enrich the exported <code>openclaw config schema</code> JSON Schema with field titles and descriptions so editors, agents, and other schema consumers receive the same config help metadata. (#60067) Thanks @solavrc.</li>
<li>Providers/CLI: remove bundled CLI text-provider backends and the <code>agents.defaults.cliBackends</code> surface, while keeping ACP harness sessions and Gemini media understanding on the native bundled providers.</li>
<li>Matrix/exec approvals: clarify unavailable-approval replies so Matrix no longer claims chat approvals are unsupported when native exec approvals are merely unconfigured. (#61424) Thanks @gumadeiras.</li>
<li>Docs/IRC: replace public IRC hostname examples with <code>irc.example.com</code> and recommend private servers for bot coordination while listing common public networks for intentional use.</li>
<li>Memory/dreaming: group nearby daily-note lines into short coherent chunks before staging them for dreaming, so one-off context from recent notes reaches REM/deep with better evidence and less line-level noise.</li>
<li>Memory/dreaming: drop generic date/day headings from daily-note chunk prefixes while keeping meaningful section labels, so staged snippets stay cleaner and more reusable. (#61597) Thanks @mbelinky.</li>
<li>Plugins/Lobster: run bundled Lobster workflows in process instead of spawning the external CLI, reducing transport overhead and unblocking native runtime integration. (#61523) Thanks @mbelinky.</li>
<li>Plugins/Lobster: harden managed resume validation so invalid TaskFlow resume calls fail earlier, and memoize embedded runtime loading per runner while keeping failed loads retryable. (#61566) Thanks @mbelinky.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security: preserve restrictive plugin-only tool allowlists, require owner access for <code>/allowlist add</code> and <code>/allowlist remove</code>, fail closed when <code>before_tool_call</code> hooks crash, block browser SSRF redirect bypasses earlier, and keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins. (#58476, #59836, #59822, #58771, #59120) Thanks @eleqtrizit and @pgondhi987.</li>
<li>Providers/OpenAI: make GPT-5 and Codex runs act sooner with lower-verbosity defaults, visible progress during tool work, and a one-shot retry when a turn only narrates the plan instead of taking action.</li>
<li>Providers/OpenAI and reply delivery: preserve native <code>reasoning.effort: "none"</code> and strict schemas where supported, add GPT-5.4 assistant <code>phase</code> metadata across replay and the Gateway <code>/v1/responses</code> layer, and keep commentary buffered until <code>final_answer</code> so web chat, session previews, embedded replies, and Telegram partials stop leaking planning text. Fixes #59150, #59643, #61282.</li>
<li>Telegram: fix current-model checks in the model picker, HTML-format non-default <code>/model</code> confirmations, explicit topic replies, persisted reaction ownership across restarts, caption-media placeholder and <code>file_id</code> preservation on download failure, and upgraded-install inbound image reads. (#60384, #60042, #59634, #59207, #59948, #59971) Thanks @sfuminya, @GitZhangChi, @dashhuang, @samzong, @v1p0r, and @neeravmakwana.</li>
<li>Telegram: restore DM voice-note preflight transcription so direct-message audio stops arriving as raw <code><media:audio></code> placeholders. (#61008) Thanks @manueltarouca.</li>
<li>Telegram/reasoning: only create a Telegram reasoning preview lane when the session is explicitly <code>reasoning:stream</code>, so hidden <code><think></code> traces from streamed replies stop surfacing as chat previews on normal sessions. Thanks @vincentkoc.</li>
<li>Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more <code>/</code> entries visible. (#61129) Thanks @neeravmakwana.</li>
<li>Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor <code>@everyone</code> and <code>@here</code> mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.</li>
<li>Discord/reply tags: strip leaked <code>[[reply_to_current]]</code> control tags from preview text and honor explicit reply-tag threading during final delivery, so Discord replies stay attached to the triggering message instead of printing reply metadata into chat.</li>
<li>Discord/replies: replace the unshipped <code>replyToOnlyWhenBatched</code> flag with <code>replyToMode: "batched"</code> so native reply references only attach on debounced multi-message turns while explicit reply tags still work.</li>
<li>Discord/image generation: include the real generated <code>MEDIA:</code> paths in tool output, avoid duplicate plain-output media requeueing, and persist volatile workspace-generated media into durable outbound media before final reply delivery so generated image replies stop pointing at missing local files.</li>
<li>Slack: route live DM replies back to the concrete inbound DM channel while keeping persisted routing metadata user-scoped, so normal assistant replies stop disappearing when pairing and system messages still arrive. (#59030) Thanks @afurm.</li>
<li>WhatsApp: restore <code>channels.whatsapp.blockStreaming</code> and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.</li>
<li>Android/Talk Mode: cancel in-flight <code>talk.speak</code> playback when speech is explicitly stopped, and restore spoken replies on both node-scoped and gateway-backed sessions by keeping reply routing and embedded transport overrides aligned with the current playback path. (#60306, #61164, #61214)</li>
<li>Voice-call/OpenAI: pass full plugin config into realtime transcription provider resolution so streaming calls can discover the bundled OpenAI realtime transcription provider again. Fixes #60936. Thanks @sliekens and @vincentkoc.</li>
<li>Matrix/exec approvals: anchor seeded approval reactions to the primary Matrix prompt event, resolve them from event metadata instead of prompt text, and clean up chunked approval prompts correctly. (#60931) Thanks @gumadeiras.</li>
<li>Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289) Thanks @al3mart, @emonty, and @efe-arv.</li>
<li>Matrix/DM sessions: add <code>channels.matrix.dm.sessionScope</code>, shared-session collision notices, and aligned outbound session reuse so separate Matrix DM rooms can keep distinct context when configured. (#61373) Thanks @gumadeiras.</li>
<li>Matrix: move legacy top-level <code>avatarUrl</code> into the default account during multi-account promotion and keep env-backed account setup avatar config persisted. (#61437) Thanks @gumadeiras.</li>
<li>MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) Thanks @Ted-developer and @hyojin.</li>
<li>MS Teams: replace the deprecated Teams SDK HttpPlugin stub with <code>httpServerAdapter</code> so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.</li>
<li>Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.</li>
<li>Sandbox/SSH: reject hardlinked files during cross-device rename fallback so EXDEV file copies preserve the same pinned file-boundary checks as direct reads.</li>
<li>Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) Thanks @chziyue and @frankekn.</li>
<li>Control UI/avatar: honor <code>ui.assistant.avatar</code> when serving <code>/avatar/:agentId</code> so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev.</li>
<li>Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm.</li>
<li>Control UI/Overview: prevent gateway access token/password visibility toggle buttons from overlapping their inputs at narrow widths. (#56924) Thanks @bbddbb1.</li>
<li>Auto-reply: unify reply lifecycle ownership across preflight compaction, session rotation, CLI-backed runs, and gateway restart handling so <code>/stop</code> and same-session overlap checks target the right active turn and restart-interrupted turns return the restart notice instead of being silently dropped. (#61267) Thanks @dutifulbob.</li>
<li>Reply delivery: prevent duplicate block replies on <code>text_end</code> channels so providers that emit explicit text-end boundaries no longer double-send the same final message. (#61530)</li>
<li>Gateway/startup: default <code>gateway.mode</code> to <code>local</code> when unset, detect PID recycling in gateway lock files on Windows and macOS, and show startup progress so healthy restarts stop getting blocked by stale locks. (#54801, #60085, #59843) Thanks @BradGroux and @TonyDerek-dot.</li>
<li>Gateway/macOS: let launchd <code>KeepAlive</code> own in-process gateway restarts again, adding a short supervised-exit delay so rapid restarts avoid launchd crash-loop unloads while <code>openclaw gateway restart</code> still reports real LaunchAgent errors synchronously.</li>
<li>Gateway/macOS: re-bootstrap the LaunchAgent if <code>launchctl kickstart -k</code> unloads it during restart so failed restarts do not leave the gateway unmanaged until manual repair.</li>
<li>Gateway/macOS: recover installed-but-unloaded LaunchAgents during <code>openclaw gateway start</code> and <code>restart</code>, while still preferring live unmanaged gateways during restart recovery. (#43766) Thanks @HenryC-3.</li>
<li>Gateway/Windows scheduled tasks: preserve Task Scheduler settings on reinstall, fail loudly when <code>/Run</code> does not start, and report fast failed restarts accurately instead of pretending they timed out after 60 seconds. (#59335) Thanks @tmimmanuel.</li>
<li>Windows/restart: fall back to the installed Startup-entry launcher when the scheduled task was never registered, so <code>/restart</code> can relaunch the gateway on Windows setups where <code>schtasks</code> install fell back during onboarding. (#58943) Thanks @imechZhangLY.</li>
<li>Windows/restart: clean up stale gateway listeners before Windows self-restart and treat listener and argv probe failures as inconclusive, so scheduled-task relaunch no longer falls into an <code>EADDRINUSE</code> retry loop. (#60480) Thanks @arifahmedjoy.</li>
<li>Update/npm: prefer the npm binary that owns the installed global OpenClaw prefix so mixed Homebrew-plus-nvm setups update the right install. (#60153) Thanks @jayeshp19.</li>
<li>Agents/music and video generation: add <code>tools.media.asyncCompletion.directSend</code> as an opt-in direct-delivery path for finished async media tasks, while keeping the legacy requester-session wake/model-delivery flow as the default.</li>
<li>CLI/skills JSON: route <code>skills list --json</code>, <code>skills info --json</code>, and <code>skills check --json</code> output to stdout instead of stderr so machine-readable consumers receive JSON on the expected stream again. (#60914; fixes #57599; landed from contributor PR #57611 by @Aftabbs) Thanks @Aftabbs.</li>
<li>CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010.</li>
<li>Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.</li>
<li>Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit <code>failureDestination</code> is configured. (#60622) Thanks @artwalker.</li>
<li>Exec/remote skills: stop advertising <code>exec host=node</code> when the current exec policy cannot route to a node, and clarify blocked exec-host override errors with both the requested host and allowed config path.</li>
<li>Agents/Claude CLI/security: clear inherited Claude Code config-root and plugin-root env overrides like <code>CLAUDE_CONFIG_DIR</code> and <code>CLAUDE_CODE_PLUGIN_*</code>, so OpenClaw-launched Claude CLI runs cannot be silently pointed at an alternate Claude config/plugin tree with different hooks, plugins, or auth context. Thanks @vincentkoc.</li>
<li>Agents/Claude CLI/security: clear inherited Claude Code provider-routing and managed-auth env overrides, and mark OpenClaw-launched Claude CLI runs as host-managed, so Claude CLI backdoor sessions cannot be silently redirected to proxy, Bedrock, Vertex, Foundry, or parent-managed token contexts. Thanks @vincentkoc.</li>
<li>Agents/Claude CLI/security: force host-managed Claude CLI backdoor runs to <code>--setting-sources user</code>, even under custom backend arg overrides, so repo-local <code>.claude</code> project/local settings, hooks, and plugin discovery do not silently execute inside non-interactive OpenClaw sessions. Thanks @vincentkoc.</li>
<li>Agents/Claude CLI: treat malformed bare <code>--permission-mode</code> backend overrides as missing and fail safe back to <code>bypassPermissions</code>, so custom <code>cliBackends.claude-cli.args</code> security config cannot accidentally consume the next flag as a bogus permission mode. Thanks @vincentkoc.</li>
<li>Gateway/device pairing: require non-admin paired-device sessions to manage only their own device for token rotate/revoke and paired-device removal, blocking cross-device token theft inside pairing-scoped sessions. (#50627) Thanks @coygeek.</li>
<li>Gateway/plugin routes: keep gateway-auth plugin runtime routes on write-only fallback scopes unless a trusted-proxy caller explicitly declares narrower <code>x-openclaw-scopes</code>, so plugin HTTP handlers no longer mint admin-level runtime scopes on missing or untrusted HTTP scope headers. (#59815) Thanks @pgondhi987.</li>
<li>Build/types: fix the Node <code>createRequire(...)</code> helper typing so provider-runtime lazy loads compile cleanly again and <code>pnpm build</code> no longer fails in the Pi embedded provider error-pattern path.</li>
<li>Gateway/security: scope loopback browser-origin auth throttling by normalized origin so one localhost Control UI tab cannot lock out a different localhost browser origin after repeated auth failures.</li>
<li>Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.</li>
<li>Device pairing/security: keep non-operator device scope checks bound to the requested role prefix so bootstrap verification cannot redeem <code>operator.*</code> scopes through <code>node</code> auth. (#57258) Thanks @jlapenna.</li>
<li>Device pairing: reject rotating device tokens into roles that were never approved during pairing, and keep reconnect role checks bounded to the paired device's approved role set. (#60462) Thanks @eleqtrizit.</li>
<li>Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit <code>deviceToken</code> scope requests and empty-cache fallbacks intact so reconnects preserve <code>operator.read</code> without breaking explicit auth flows. (#46032) Thanks @caicongyang.</li>
<li>Mobile pairing/security: fail closed for internal <code>/pair</code> setup-code issuance, cleanup, and approval paths when gateway pairing scopes are missing, and keep approval-time requested-scope enforcement on the internal command path. (#55996) Thanks @coygeek.</li>
<li>Mobile pairing/bootstrap: keep QR bootstrap handoff tokens bounded to the mobile-safe contract so node handoff stays unscoped and operator handoff drops mixed <code>node.*</code>, <code>operator.admin</code>, and <code>operator.pairing</code> scopes.</li>
<li>Mobile pairing/Android: tighten secure endpoint handling so Tailscale and public remote setup reject cleartext endpoints, private LAN pairing still works, merged-role approvals mint both node and operator device tokens, and bootstrap tokens survive node auto-pair until operator approval finishes. (#60128, #60208, #60221) Thanks @obviyus.</li>
<li>Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation.</li>
<li>Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit <code>allowInsecureSsl: true</code> opts out.</li>
<li>Synology Chat/security: route webhook token comparison through the shared constant-time secret helper for consistency with other bundled plugins.</li>
<li>Plugins/marketplace: block remote marketplace symlink escapes without breaking ordinary local marketplace install paths. (#60556) Thanks @eleqtrizit.</li>
<li>Telegram/local Bot API: honor <code>channels.telegram.apiRoot</code> for buffered media downloads, add <code>channels.telegram.network.dangerouslyAllowPrivateNetwork</code> for trusted fake-IP setups, and require <code>channels.telegram.trustedLocalFileRoots</code> before reading absolute Bot API <code>file_path</code> values. (#59544, #60705) Thanks @SARAMALI15792 and @obviyus.</li>
<li>Outbound/sanitizer: strip leaked <code><tool_call></code>, <code><function_calls></code>, and model special tokens from shared user-visible assistant text, including truncated tool-call streams, so internal scaffolding no longer bleeds into replies across surfaces. (#60619) Thanks @oliviareid-svg.</li>
<li>Agents/errors: surface an explicit disk-full message when local session or transcript writes fail with <code>ENOSPC</code>/<code>disk full</code>, so those runs stop degrading into opaque <code>NO_REPLY</code>-style failures. Thanks @vincentkoc.</li>
<li>Exec approvals: remove heuristic command-obfuscation gating from host exec so gateway and node runs rely on explicit policy, allowlist, and strict inline-eval rules only.</li>
<li>Agents/tool results: cap live tool-result persistence and overflow-recovery truncation at 40k characters so oversized tool output stays bounded without discarding recent context entirely.</li>
<li>Discord/video replies: split text-plus-video deliveries into a text reply followed by a media-only send, and let live provider auth checks honor manifest-declared API key env vars like <code>MODELSTUDIO_API_KEY</code>.</li>
<li>Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the rendered snapshot. (#28214) Thanks @solodmd.</li>
<li>Plugin SDK/facades: back-fill bundled plugin facade sentinels before plugin-id tracking re-enters config loading, so CLI/provider startup no longer crashes with <code>shouldNormalizeGoogleProviderConfig is not a function</code> or other empty-facade reads during bundled plugin re-entry. Thanks @adam91holt.</li>
<li>Plugins/facades: back-fill facade sentinels before tracked-plugin resolution re-enters config loading, so facade exports stay defined during circular provider normalization. (#61180) Thanks @adam91holt.</li>
<li>QA lab: restore typed mock OpenAI gateway config wiring so QA-lab config helpers compile cleanly again and <code>pnpm check</code> / <code>pnpm build</code> stay green.</li>
<li>Discord/image generation: include the real generated <code>MEDIA:</code> paths in tool output and avoid duplicate plain-output media requeueing so Discord image replies stop pointing at missing local files.</li>
<li>Slack: route live DM replies back to the concrete inbound DM channel while keeping persisted routing metadata user-scoped, so normal assistant replies stop disappearing when pairing and system messages still arrive. (#59030) Thanks @afurm.</li>
<li>Discord/reply tags: strip leaked <code>[[reply_to_current]]</code> control tags from preview text and honor explicit reply-tag threading during final delivery, so Discord replies stay attached to the triggering message instead of printing reply metadata into chat.</li>
<li>Telegram: fix current-model checks in the model picker, HTML-format non-default <code>/model</code> confirmations, explicit topic replies, persisted reaction ownership across restarts, caption-media placeholder and <code>file_id</code> preservation on download failure, and upgraded-install inbound image reads. (#60384, #60042, #59634, #59207, #59948, #59971) Thanks @sfuminya, @GitZhangChi, @dashhuang, @samzong, @v1p0r, and @neeravmakwana.</li>
<li>Telegram: restore DM voice-note preflight transcription so direct-message audio stops arriving as raw <code><media:audio></code> placeholders. (#61008) Thanks @manueltarouca.</li>
<li>Telegram/reasoning: only create a Telegram reasoning preview lane when the session is explicitly <code>reasoning:stream</code>, so hidden <code><think></code> traces from streamed replies stop surfacing as chat previews on normal sessions. Thanks @vincentkoc.</li>
<li>Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more <code>/</code> entries visible. (#61129) Thanks @neeravmakwana.</li>
<li>Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly <code>reasoning:stream</code>, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.</li>
<li>Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor <code>@everyone</code> and <code>@here</code> mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.</li>
<li>WhatsApp: restore <code>channels.whatsapp.blockStreaming</code> and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.</li>
<li>Memory: keep <code>memory-core</code> builtin embedding registration on the already-registered path so selecting <code>memory-core</code> no longer recurses through plugin discovery and crashes during startup. (#61402) Thanks @ngutman.</li>
<li>Agents/tool results: keep large <code>read</code> outputs visible longer, preserve the latest <code>read</code> output when older tool output can absorb the overflow budget, and fall back to Pi's normal overflow compaction/retry path before replacing a fresh <code>read</code> with a compacted stub. Thanks @vincentkoc.</li>
<li>Memory/QMD: prefer modern <code>qmd collection add --glob</code>, accept newer single-line JSON hit metadata while keeping legacy line fields, refresh QMD docs/doctor install guidance and model-override guidance, and keep older QMD releases working. Thanks @vincentkoc.</li>
<li>MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) Thanks @Ted-developer and @hyojin.</li>
<li>MS Teams: replace the deprecated Teams SDK HttpPlugin stub with <code>httpServerAdapter</code> so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.</li>
<li>Matrix/exec approvals: anchor seeded approval reactions to the primary Matrix prompt event, resolve them from event metadata instead of prompt text, and clean up chunked approval prompts correctly. (#60931) Thanks @gumadeiras.</li>
<li>Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289) Thanks @al3mart, @emonty, and @efe-arv.</li>
<li>Android/Talk Mode: cancel in-flight <code>talk.speak</code> playback when speech is explicitly stopped, so stale replies stop starting after barge-in or manual stop. (#61164) Thanks @obviyus.</li>
<li>Android/Talk Mode: restore spoken assistant replies on node-scoped sessions by keeping reply routing synced to the resolved node session key and pausing mic capture during reply playback. (#60306) Thanks @MKV21.</li>
<li>Android/Talk Mode: restore voice replies on gateway-backed talk mode sessions by updating embedded runner transport overrides to the current agent transport API. (#61214) Thanks @obviyus.</li>
<li>Voice-call/OpenAI: pass full plugin config into realtime transcription provider resolution so streaming calls can discover the bundled OpenAI realtime transcription provider again. Fixes #60936. Thanks @sliekens and @vincentkoc.</li>
<li>Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.</li>
<li>Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) Thanks @chziyue and @frankekn.</li>
<li>Control UI/avatar: honor <code>ui.assistant.avatar</code> when serving <code>/avatar/:agentId</code> so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev.</li>
<li>Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm.</li>
<li>Control UI/Overview: prevent gateway access token/password visibility toggle buttons from overlapping their inputs at narrow widths. (#56924) Thanks @bbddbb1.</li>
<li>CLI/skills JSON: route <code>skills list --json</code>, <code>skills info --json</code>, and <code>skills check --json</code> output to stdout instead of stderr so machine-readable consumers receive JSON on the expected stream again. (#60914; fixes #57599; landed from contributor PR #57611 by @Aftabbs) Thanks @Aftabbs.</li>
<li>CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010.</li>
<li>Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.</li>
<li>Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit <code>failureDestination</code> is configured. (#60622) Thanks @artwalker.</li>
<li>Live model switching: only treat explicit user-driven model changes as pending live switches, so fallback rotation, heartbeat overrides, and compaction no longer trip <code>LiveSessionModelSwitchError</code> before making an API call. (#60266) Thanks @kiranvk-2011.</li>
<li>Exec approvals: reuse durable exact-command <code>allow-always</code> approvals in allowlist mode so identical reruns stop prompting, and tighten Windows interpreter/path approval handling so wrapper and malformed-path cases fail closed more consistently. (#59880, #59780, #58040, #59182) Thanks @luoyanglang, @SnowSky1, and @pgondhi987.</li>
<li>Node exec approvals: keep node-host <code>system.run</code> approvals bound to the prepared execution plan across async forwarding, so mutable script operands still get approval-time binding and drift revalidation instead of dropping back to unbound execution.</li>
<li>Agents/exec approvals: let <code>exec-approvals.json</code> agent security override stricter gateway tool defaults so approved subagents can use <code>security: “full”</code> without falling back to allowlist enforcement again. (#60310) Thanks @lml2468.</li>
<li>Agents/exec: restore <code>host=node</code> routing for node-pinned and <code>host=auto</code> sessions, while still blocking sandboxed <code>auto</code> sessions from jumping to gateway. (#60788) Thanks @openperf.</li>
<li>Exec/heartbeat: use the canonical <code>exec-event</code> wake reason for <code>notifyOnExit</code> so background exec completions still trigger follow-up turns when <code>HEARTBEAT.md</code> is empty or comments-only. (#41479) Thanks @rstar327.</li>
<li>Heartbeat: skip wake delivery when the target session lane is already busy so the pending event is retried instead of getting drained too early. (#40526) Thanks @lucky7323.</li>
<li>Group chats/agent prompts: tell models to minimize empty lines and use normal chat-style spacing so group replies avoid document-style blank-line formatting.</li>
<li>Providers/OpenAI GPT: treat short approval turns like <code>ok do it</code> and <code>go ahead</code> as immediate action turns, and trim overly memo-like GPT-5 chat confirmations so OpenAI replies stay shorter and more conversational by default.</li>
<li>Providers/OpenAI Codex: split native <code>contextWindow</code> from runtime <code>contextTokens</code>, keep the default effective cap at <code>272000</code>, and expose a per-model <code>contextTokens</code> override on <code>models.providers.*.models[]</code>.</li>
<li>Providers/OpenAI-compatible WS: compute fallback token totals from normalized usage when providers omit or zero <code>total_tokens</code>, so DashScope-compatible sessions stop storing zero totals after alias normalization. (#54940) Thanks @lyfuci.</li>
<li>Agents/OpenAI: mark Claude-compatible file tool schemas as <code>additionalProperties: false</code> so direct OpenAI GPT-5 routes stop rejecting the <code>read</code> tool with invalid strict-schema errors.</li>
<li>Agents/OpenAI: fall back to <code>strict: false</code> for native OpenAI tool calls when a tool schema is not strict-compatible, and normalize empty-object tool schemas to include <code>required: []</code>, so direct GPT-5 routes stop failing with invalid strict-schema errors like missing <code>path</code> in <code>required</code>.</li>
<li>Agents/GPT: add explicit work-item lifecycle events for embedded runs, use them to surface real progress more reliably, and stop counting tool-started turns as planning-only retries.</li>
<li>Plugins/OpenAI: enable <code>gpt-image-1</code> reference-image edits through <code>/images/edits</code> multipart uploads, and stop inferring unsupported resolution overrides when no explicit <code>size</code> or <code>resolution</code> is provided.</li>
<li>Agents/replay: remove the malformed assistant-content canonicalization repair from replay history sanitization instead of extending that legacy repair path into replay validation.</li>
<li>Plugins/OpenAI: tune the OpenAI prompt overlay for live-chat cadence so GPT replies stay shorter, more human, and less wall-of-text by default.</li>
<li>Providers/compat: stop forcing OpenAI-only defaults on proxy and custom OpenAI-compatible routes, preserve native vendor-specific reasoning/tool/streaming behavior across Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, and Z.ai endpoints, and route GitHub Copilot Claude models through Anthropic Messages instead of OpenAI Responses.</li>
<li>Providers/GitHub Copilot: send IDE identity headers on runtime model requests and GitHub token exchange so IDE-authenticated Copilot runs stop failing with missing <code>Editor-Version</code>. (#60641) Thanks @VACInc and @vincentkoc.</li>
<li>Providers/OpenRouter failover: classify <code>403 “Key limit exceeded”</code> spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.</li>
<li>Providers/Anthropic: keep <code>claude-cli/*</code> auth on live Claude CLI credentials at runtime, avoid persisting stale bearer-token profiles, and suppress macOS Keychain prompts during non-interactive Claude CLI setup. (#61234) Thanks @darkamenosa.</li>
<li>Providers/Anthropic: when Claude CLI auth becomes the default, write a real <code>claude-cli</code> auth profile so local and gateway agent runs can use Claude CLI immediately without missing-API-key failures. Thanks @vincentkoc.</li>
<li>Providers/Anthropic Vertex: honor <code>cacheRetention: “long”</code> with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default <code>anthropic-vertex</code> cache retention like direct Anthropic. (#60888) Thanks @affsantos.</li>
<li>Agents/Anthropic: preserve native <code>toolu_*</code> replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612)</li>
<li>Providers/Google: add model-level <code>cacheRetention</code> support for direct Gemini system prompts by creating, reusing, and refreshing <code>cachedContents</code> automatically on Google AI Studio runs. (#51372) Thanks @rafaelmariano-glitch.</li>
<li>Google Gemini CLI auth: detect bundled npm installs by scanning packaged bundle files for the Gemini OAuth client config, so <code>npm install -g @google/gemini-cli</code> layouts work again. (#60486) Thanks @wzfmini01.</li>
<li>Google Gemini CLI auth: detect personal OAuth mode from local Gemini settings and skip Code Assist project discovery for those logins, so personal Google accounts stop failing with <code>loadCodeAssist 400 Bad Request</code>. (#49226) Thanks @bobworrall.</li>
<li>Google Gemini CLI auth: improve OAuth credential discovery across Windows nvm and Homebrew libexec installs, and align Code Assist metadata so Gemini login stops failing on packaged CLI layouts. (#40729) Thanks @hughcube.</li>
<li>Google Gemini CLI models: add forward-compat support for stable <code>gemini-2.5-*</code> model ids by letting the bundled CLI provider clone them from Google templates, so <code>gemini-2.5-flash-lite</code> and related configured models stop showing up as missing. (#35274) Thanks @mySebbe.</li>
<li>Google image generation: disable pinned DNS for Gemini image requests and honor explicit <code>pinDns</code> overrides in shared provider HTTP helpers so proxy-backed image generation works again. (#59873) Thanks @luoyanglang.</li>
<li>Providers/Microsoft Foundry: preserve explicit image capability on normalized Foundry deployments, repair stale GPT/o-series text-only model metadata across gateway and runtime paths, and keep unknown fallback models from borrowing unrelated image support.</li>
<li>Providers/Model Studio: preserve native streaming usage reporting for DashScope-compatible endpoints even when they are configured under a generic provider key, so streamed token totals stop sticking at zero. (#52395) Thanks @IVY-AI-gif.</li>
<li>Providers/Z.AI: preserve explicitly registered <code>glm-5-*</code> variants like <code>glm-5-turbo</code> instead of intercepting them with the generic GLM-5 forward-compat shim. (#48185) Thanks @haoyu-haoyu.</li>
<li>Amazon Bedrock/aws-sdk auth: stop injecting the fake <code>AWS_PROFILE</code> apiKey marker when no AWS auth env vars exist, so instance-role and other default-chain setups keep working without poisoning provider config. (#61194) Thanks @wirjo.</li>
<li>Agents/Kimi tool-call repair: preserve tool arguments that were already present on streamed tool calls when later malformed deltas fail reevaluation, while still dropping stale repair-only state before <code>toolcall_end</code>.</li>
<li>Plugins/Kimi Coding: parse tagged tool calls and keep Anthropic-native tool payloads so Kimi coding endpoints execute tools instead of echoing raw markup. (#60051, #60391) Thanks @obviyus and @Eric-Guo.</li>
<li>Media understanding: auto-register image-capable config providers for vision routing, so custom GLM-style provider ids with image models stop failing with “no media-understanding provider registered”. (#51418) Thanks @xydt-610.</li>
<li>Plugins/media understanding: enable bundled Groq and Deepgram providers by default so configured transcription models work without extra plugin activation config. (#59982) Thanks @yxjsxy.</li>
<li>MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch.</li>
<li>MiniMax: advertise image input on bundled <code>MiniMax-M2.7</code> and <code>MiniMax-M2.7-highspeed</code> model definitions so image-capable flows can route through the M2.7 family correctly. (#54843) Thanks @MerlinMiao88888888.</li>
<li>Models/MiniMax: honor <code>MINIMAX_API_HOST</code> for implicit bundled MiniMax provider catalogs so China-hosted API-key setups pick <code>api.minimaxi.com/anthropic</code> without manual provider config. (#34524) Thanks @caiqinghua.</li>
<li>Usage/MiniMax: invert remaining-style <code>usage_percent</code> fields when MiniMax reports only remaining percentage data, so usage bars stop showing nearly-full remaining quota as nearly-exhausted usage. (#60254) Thanks @jwchmodx.</li>
<li>Usage/MiniMax: let usage snapshots treat <code>minimax-portal</code> and MiniMax CN aliases as the same MiniMax quota surface, and prefer stored MiniMax OAuth before falling back to Coding Plan keys.</li>
<li>Usage/MiniMax: prefer the chat-model <code>model_remains</code> entry and derive Coding Plan window labels from MiniMax interval timestamps so MiniMax usage snapshots stop picking zero-budget media rows and misreporting 4h windows as <code>5h</code>. (#52349) Thanks @IVY-AI-gif.</li>
<li>Model picker/providers: treat bundled BytePlus and Volcengine plan aliases as their native providers during setup, and expose their bundled standard/coding catalogs before auth so setup can suggest the right models. (#58819) Thanks @Luckymingxuan.</li>
<li>Tools/web_search (Kimi): when <code>tools.web.search.kimi.baseUrl</code> is unset, inherit native Moonshot chat <code>baseUrl</code> (<code>.ai</code> / <code>.cn</code>) so China console keys authenticate on the same host as chat. Fixes #44851. (#56769) Thanks @tonga54.</li>
<li>Agents/Claude CLI: keep non-interactive <code>--permission-mode bypassPermissions</code> when custom <code>cliBackends.claude-cli.args</code> override defaults, including fallback resolution before the runtime plugin registry is active, so cron and heartbeat Claude CLI runs do not regress to interactive approval mode. (#61114) Thanks @cathrynlavery and @thewilloftheshadow.</li>
<li>Agents/Claude CLI: persist explicit <code>openclaw agent --session-id</code> runs under a stable session key so follow-ups can reuse the stored CLI binding and resume the same underlying Claude session.</li>
<li>Agents/Claude CLI: persist routed Claude session bindings, rotate them on <code>/new</code> and <code>/reset</code>, and keep live Claude CLI model switches moving across the configured Claude family so resumed sessions follow the real active thread and model. Thanks @vincentkoc.</li>
<li>Agents/CLI backends: invalidate stored CLI session reuse when local CLI login state or the selected auth profile credential changes, so relogin and token rotation stop resuming stale sessions.</li>
<li>Agents/Claude CLI/images: reuse stable hydrated image file paths and preserve shared media extensions like HEIC when passing image refs to local CLI runs, so Claude CLI image prompts stop thrashing KV cache prefixes and oddball image formats do not fall back to <code>.bin</code>. Thanks @vincentkoc.</li>
<li>Agents/compaction: keep assistant tool calls and displaced tool results in the same compaction chunk so strict summarization providers stop rejecting orphaned tool pairs. (#58849) Thanks @openperf.</li>
<li>Agents/failover: scope Anthropic <code>An unknown error occurred</code> failover matching by provider so generic internal unknown-error text no longer triggers retryable timeout fallback. (#59325) Thanks @aaron-he-zhu.</li>
<li>Agents/subagents: honor allowlist validation, auth-profile handoff, and session override state when a subagent retries after <code>LiveSessionModelSwitchError</code>. (#58178) Thanks @openperf.</li>
<li>Agents/runtime: make default subagent allowlists, inherited skills/workspaces, and duplicate session-id resolution behave more predictably, and include value-shape hints in missing-parameter tool errors. (#59944, #59992, #59858, #55317) Thanks @hclsys, @gumadeiras, @joelnishanth, and @priyansh19.</li>
<li>Agents/pairing: merge completion announce delivery context with the requester session fallback so missing <code>to</code> still reaches the original channel, and include <code>operator.talk.secrets</code> in CLI default operator scopes for node-role device pairing approvals. (#56481) Thanks @maxpetrusenko.</li>
<li>Agents/scheduling: steer background-now work toward automatic completion wake and treat <code>process</code> polling as on-demand inspection or intervention instead of default completion handling. (#60877) Thanks @vincentkoc.</li>
<li>Agents/skills: skip <code>.git</code> and <code>node_modules</code> when mirroring skills into sandbox workspaces so read-only sandboxes do not copy repo history or dependency trees. (#61090) Thanks @joelnishanth.</li>
<li>ACP/agents: inherit the target agent workspace for cross-agent ACP spawns and fall back safely when the inherited workspace no longer exists. (#58438) Thanks @zssggle-rgb.</li>
<li>ACPX/Windows: preserve backslashes and absolute <code>.exe</code> paths in Claude CLI parsing, and fail fast on wrapper-script targets with guidance to use <code>cmd.exe /c</code>, <code>powershell.exe -File</code>, or <code>node <script></code>. (#60689) Thanks @steipete.</li>
<li>Auth/failover: persist selected fallback overrides before retrying, shorten <code>auth_permanent</code> lockouts, and refresh websocket/shared-auth sessions only when real auth changes occur so retries and secret rotations behave predictably. (#60404, #60323, #60387) Thanks @extrasmall0 and @mappel-nv.</li>
<li>Gateway/channels: pin the initial startup channel registry before later plugin-registry churn so configured channels stay visible and <code>channels.status</code> stops falling back to empty <code>channelOrder</code> / <code>channels</code> payloads after runtime plugin loads.</li>
<li>Prompt caching: order stable workspace project-context files before <code>HEARTBEAT.md</code> and keep <code>HEARTBEAT.md</code> below the system-prompt cache boundary so heartbeat churn does not invalidate the stable project-context prefix. (#58979) Thanks @yozu and @vincentkoc.</li>
<li>Prompt caching: route Codex Responses and Anthropic Vertex through boundary-aware cache shaping, and report the actual outbound system prompt in cache traces so cache reuse and misses line up with what providers really receive. Thanks @vincentkoc.</li>
<li>Agents/cache: preserve the full 3-turn prompt-cache image window across tool loops, keep colliding bundled MCP tool definitions deterministic, and reapply Anthropic Vertex cache shaping after payload hook replacements so KV/cache reuse stays stable. Thanks @vincentkoc.</li>
<li>Status/cache: restore <code>cacheRead</code> and <code>cacheWrite</code> in transcript fallback so <code>/status</code> keeps showing cache hit percentages when session logs are the only complete usage source. (#59247) Thanks @stuartsy.</li>
<li>Status/usage: let <code>/status</code> and <code>session_status</code> fall back to transcript token totals when the session meta store stayed at zero, so LM Studio, Ollama, DashScope, and similar OpenAI-compatible providers stop showing <code>Context: 0/...</code>. (#55041) Thanks @jjjojoj.</li>
<li>Mattermost/config schema: accept <code>groups.*.requireMention</code> again so existing Mattermost configs no longer fail strict validation after upgrade. (#58271) Thanks @MoerAI.</li>
<li>Doctor/config: compare normalized <code>talk</code> configs by deep structural equality instead of key-order-sensitive serialization so <code>openclaw doctor --fix</code> stops repeatedly reporting/applying no-op <code>talk.provider/providers</code> normalization. (#59911) Thanks @ejames-dev.</li>
<li>Anthropic CLI onboarding: rewrite migrated fallback model refs during non-interactive Claude CLI setup too, so onboarding and scripted setup no longer keep stale <code>anthropic/*</code> fallbacks after switching the primary model to <code>claude-cli/*</code>. Thanks @vincentkoc.</li>
<li>Models/Anthropic CLI auth: replace migrated <code>agents.defaults.models</code> allowlists when <code>openclaw models auth login --provider anthropic --method cli --set-default</code> switches to <code>claude-cli/*</code>, so stale <code>anthropic/*</code> entries do not linger beside the migrated Claude CLI defaults. Thanks @vincentkoc.</li>
<li>Doctor/Claude CLI: add dedicated Claude CLI health checks so <code>openclaw doctor</code> can spot missing local installs or broken auth before agent runs fail. Thanks @vincentkoc.</li>
<li>Plugins/auth-choice: apply provider-owned auth config patches without recursively preserving replaced default-model maps, so Anthropic Claude CLI and similar migrations can intentionally swap model allowlists during onboarding and setup instead of accumulating stale entries. Thanks @vincentkoc.</li>
<li>Plugins/onboarding: write dotted plugin uiHint paths like Brave <code>webSearch.mode</code> as nested plugin config so <code>llm-context</code> setup stops failing validation. (#61159) Thanks @obviyus.</li>
<li>Plugins/install: preserve unsafe override flags across linked plugin and hook-pack probes so local <code>--link</code> installs honor the documented override behavior. (#60624) Thanks @JerrettDavis.</li>
<li>Plugins/cache: inherit the active gateway workspace for provider, web-search, and web-fetch snapshot loads when callers omit <code>workspaceDir</code>, so compatible plugin registries and snapshot caches stop missing on gateway-owned runtime paths. (#61138) Thanks @jzakirov.</li>
<li>Plugin SDK/context engines: export the missing context-engine result and subagent lifecycle types from <code>openclaw/plugin-sdk</code> so context engine plugins can type <code>ContextEngine</code> implementations without local workarounds. (#61251) Thanks @DaevMithran.</li>
<li>Tasks/maintenance: reconcile stale cron and chat-backed CLI task rows against live cron-job and agent-run ownership instead of treating any persisted session key as proof that the task is still running. (#60310) Thanks @lml2468.</li>
<li>Plugins: suppress trust-warning noise during non-activating snapshot and CLI metadata loads. (#61427) Thanks @gumadeiras.</li>
<li>Agents/video generation: accept <code>agents.defaults.videoGenerationModel</code> in strict config validation and <code>openclaw config set/get</code>, so gateways using <code>video_generate</code> no longer fail to boot after enabling a video model.</li>
<li>Matrix/streaming: add a quiet preview mode for streamed Matrix replies, keep legacy <code>partial</code> preview-first behavior, and finalize quiet media captions correctly so previews stop notifying early without dropping final text semantics. (#61450) Thanks @gumadeiras.</li>
<li>Gateway/shutdown: bound websocket-server shutdown even when no tracked clients remain, so gateway restarts stop hanging until the watchdog kills the process. (#61565) Thanks @mbelinky.</li>
<li>Control UI/multilingual: localize the remaining shared channel, instances, nodes, and gateway-confirmation strings so the dashboard stops mixing translated UI with hardcoded English labels. Thanks @vincentkoc.</li>
<li>Discord/media: raise the default inbound and outbound media cap to <code>100MB</code> so Discord matches Telegram more closely and larger attachments stop failing on the old low default.</li>
<li>Matrix: keep direct transport requests on the pinned dispatcher by routing them through undici runtime fetch, so Matrix clients resume syncing on newer runtimes without dropping the validated address binding. (#61595) Thanks @gumadeiras.</li>
<li>Plugins/facades: resolve globally installed bundled-plugin runtime facades from registry roots so bundled channels like LINE still boot when the winning plugin install lives under the global extensions directory with an encoded scoped folder name. (#61297) Thanks @openperf.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.5/OpenClaw-2026.4.5.zip" length="25050620" type="application/octet-stream" sparkle:edSignature="gVbB/73byllY0utwGIi3P5t0FyvLldeR0Uq2pAa6LTBr8VyZlwNCZ2xPlt2zDFshSUBFKxicYzohOmfJ28ACBg=="/>
</item>
<item>
<title>2026.4.2</title>
<pubDate>Thu, 02 Apr 2026 18:57:54 +0000</pubDate>
@@ -435,5 +187,121 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.1/OpenClaw-2026.4.1.zip" length="25841903" type="application/octet-stream" sparkle:edSignature="0TPiyshScmwDbgs626JU08NOUUFJmIsVFa5g0xmizfl64Fr+IoT4l/dkXarFqbZAJidtj5WN7Bff7fG8ye/7AA=="/>
</item>
<item>
<title>2026.3.31</title>
<pubDate>Tue, 31 Mar 2026 21:47:15 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026033190</sparkle:version>
<sparkle:shortVersionString>2026.3.31</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.3.31</h2>
<h3>Breaking</h3>
<ul>
<li>Nodes/exec: remove the duplicated <code>nodes.run</code> shell wrapper from the CLI and agent <code>nodes</code> tool so node shell execution always goes through <code>exec host=node</code>, keeping node-specific capabilities on <code>nodes invoke</code> and the dedicated media/location/notify actions.</li>
<li>Plugin SDK: deprecate the legacy provider compat subpaths plus the older bundled provider setup and channel-runtime compatibility shims, emit migration warnings, and keep the current documented <code>openclaw/plugin-sdk/*</code> entrypoints plus local <code>api.ts</code> / <code>runtime-api.ts</code> barrels as the forward path ahead of a future major-release removal.</li>
<li>Skills/install and Plugins/install: built-in dangerous-code <code>critical</code> findings and install-time scan failures now fail closed by default, so plugin installs and gateway-backed skill dependency installs that previously succeeded may now require an explicit dangerous override such as <code>--dangerously-force-unsafe-install</code> to proceed.</li>
<li>Gateway/auth: <code>trusted-proxy</code> now rejects mixed shared-token configs, and local-direct fallback requires the configured token instead of implicitly authenticating same-host callers. Thanks @zhangning-agent, @jacobtomlinson, and @vincentkoc.</li>
<li>Gateway/node commands: node commands now stay disabled until node pairing is approved, so device pairing alone is no longer enough to expose declared node commands. (#57777) Thanks @jacobtomlinson.</li>
<li>Gateway/node events: node-originated runs now stay on a reduced trusted surface, so notification-driven or node-triggered flows that previously relied on broader host/session tool access may need adjustment. (#57691) Thanks @jacobtomlinson.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643.</li>
<li>Agents/LLM: add a configurable idle-stream timeout for embedded runner requests so stalled model streams abort cleanly instead of hanging until the broader run timeout fires. (#55072) Thanks @liuy.</li>
<li>Agents/MCP: materialize bundle MCP tools with provider-safe names (<code>serverName__toolName</code>), support optional <code>streamable-http</code> transport selection plus per-server connection timeouts, and preserve real tool results from aborted/error turns unless truncation explicitly drops them. (#49505) Thanks @ziomancer.</li>
<li>Android/notifications: add notification-forwarding controls with package filtering, quiet hours, rate limiting, and safer picker behavior for forwarded notification events. (#40175) Thanks @nimbleenigma.</li>
<li>Background tasks: turn tasks into a real shared background-run control plane instead of ACP-only bookkeeping by unifying ACP, subagent, cron, and background CLI execution under one SQLite-backed ledger, routing detached lifecycle updates through the executor seam, adding audit/maintenance/status visibility, tightening auto-cleanup and lost-run recovery, improving task awareness in internal status/tool surfaces, and clarifying the split between heartbeat/main-session automation and detached scheduled runs. Thanks @mbelinky and @vincentkoc.</li>
<li>Background tasks: add the first linear task flow control surface with <code>openclaw flows list|show|cancel</code>, keep manual multi-task flows separate from one-task auto-sync flows, and surface doctor recovery hints for obviously orphaned or broken flow/task linkage. Thanks @mbelinky and @vincentkoc.</li>
<li>Channels/QQ Bot: add QQ Bot as a bundled channel plugin with multi-account setup, SecretRef-aware credentials, slash commands, reminders, and media send/receive support. (#52986) Thanks @sliverp.</li>
<li>Diffs: skip unused viewer-versus-file SSR preload work so <code>diffs</code> view-only and file-only runs do less render work while keeping mode outputs aligned. (#57909) thanks @gumadeiras.</li>
<li>Tasks: add a minimal SQLite-backed task flow registry plus task-to-flow linkage scaffolding, so orchestrated work can start gaining a first-class parent record without changing current task delivery behavior. Thanks @mbelinky and @vincentkoc.</li>
<li>Tasks: persist blocked state on one-task task flows and let the same flow reopen cleanly on retry, so blocked detached work can carry a parent-level reason and continue without fragmenting into a new job. Thanks @mbelinky and @vincentkoc.</li>
<li>Tasks: route one-task ACP and subagent updates through a parent task-flow owner context, so detached work can emerge back through the intended parent thread/session instead of speaking only as a raw child task. Thanks @mbelinky and @vincentkoc.</li>
<li>LINE/outbound media: add LINE image, video, and audio outbound sends on the LINE-specific delivery path, including explicit preview/tracking handling for videos while keeping generic media sends on the existing image-only route. (#45826) Thanks @masatohoshino.</li>
<li>Matrix/history: add optional room history context for Matrix group triggers via <code>channels.matrix.historyLimit</code>, with per-agent watermarks and retry-safe snapshots so failed trigger retries do not drift into newer room messages. (#57022) thanks @chain710.</li>
<li>Matrix/network: add explicit <code>channels.matrix.proxy</code> config for routing Matrix traffic through an HTTP(S) proxy, including account-level overrides and matching probe/runtime behavior. (#56931) thanks @patrick-yingxi-pan.</li>
<li>Matrix/streaming: add draft streaming so partial Matrix replies update the same message in place instead of sending a new message for each chunk. (#56387) Thanks @jrusz.</li>
<li>Matrix/threads: add per-DM <code>threadReplies</code> overrides and keep thread session isolation aligned with the effective room or DM thread policy from the triggering message onward. (#57995) thanks @teconomix.</li>
<li>MCP: add remote HTTP/SSE server support for <code>mcp.servers</code> URL configs, including auth headers and safer config redaction for MCP credentials. (#50396) Thanks @dhananjai1729.</li>
<li>Memory/QMD: add per-agent <code>memorySearch.qmd.extraCollections</code> so agents can opt into cross-agent session search without flattening every transcript collection into one shared QMD namespace. Thanks @vincentkoc.</li>
<li>Microsoft Teams/member info: add a Graph-backed member info action so Teams automations and tools can resolve channel member details directly from Microsoft Graph. (#57528) Thanks @sudie-codes.</li>
<li>Nostr/inbound DMs: verify inbound event signatures before pairing or sender-authorization side effects, so forged DM events no longer create pairing requests or trigger reply attempts. Thanks @smaeljaish771 and @vincentkoc.</li>
<li>OpenAI/Responses: forward configured <code>text.verbosity</code> across Responses HTTP and WebSocket transports, surface it in <code>/status</code>, and keep per-agent verbosity precedence aligned with runtime behavior. (#47106) Thanks @merc1305 and @vincentkoc.</li>
<li>Pi/Codex: add native Codex web search support for embedded Pi runs, including config/docs/wizard coverage and managed-tool suppression when native Codex search is active. (#46579) Thanks @Evizero.</li>
<li>Slack/exec approvals: add native Slack approval routing and approver authorization so exec approval prompts can stay in Slack instead of falling back to the Web UI or terminal. Thanks @vincentkoc.</li>
<li>TTS: Add structured provider diagnostics and fallback attempt analytics. (#57954) Thanks @joshavant.</li>
<li>WhatsApp/reactions: agents can now react with emoji on incoming WhatsApp messages, enabling more natural conversational interactions like acknowledging a photo with ❤️ instead of typing a reply. Thanks @mcaxtr.</li>
<li>Agents/BTW: force <code>/btw</code> side questions to disable provider reasoning so Anthropic adaptive-thinking sessions stop failing with <code>No BTW response generated</code>. Fixes #55376. Thanks @Catteres and @vincentkoc.</li>
<li>CLI/onboarding: reset the remote gateway URL prompt to the safe loopback default after declining a discovered endpoint, so onboarding does not keep a previously rejected remote URL. (#57828)</li>
<li>Agents/exec defaults: honor per-agent <code>tools.exec</code> defaults when no inline directive or session override is present, so configured exec host, security, ask, and node settings actually apply. (#57689)</li>
<li>Sandbox/networking: sanitize SSH subprocess env vars through the shared sandbox policy and route marketplace archive downloads plus Ollama discovery, auth, and pull requests through the guarded fetch path so sandboxed execution and remote fetches follow the repo's trust boundaries. (#57848, #57850)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Slack: stop retry-driven duplicate replies when draft-finalization edits fail ambiguously, and log configured allowlisted users/channels by readable name instead of raw IDs.</li>
<li>Agents/OpenAI Responses: normalize raw bundled MCP tool schemas on the WebSocket/Responses path so bare-object, object-ish, and top-level union MCP tools no longer get rejected by OpenAI during tool registration. (#58299) Thanks @yelog.</li>
<li>ACP/security: replace ACP's dangerous-tool name override with semantic approval classes, so only narrow readonly reads/searches can auto-approve while indirect exec-capable and control-plane tools always require explicit prompt approval. Thanks @vincentkoc.</li>
<li>ACP/sessions_spawn: register ACP child runs for completion tracking and lifecycle cleanup, and make registration-failure cleanup explicitly best-effort so callers do not assume an already-started ACP turn was fully aborted. (#40885) Thanks @xaeon2026 and @vincentkoc.</li>
<li>ACP/tasks: mark cleanly exited ACP runs as blocked when they end on deterministic write or authorization blockers, and wake the parent session with a follow-up instead of falsely reporting success.</li>
<li>ACPX/runtime: derive the bundled ACPX expected version from the extension package metadata instead of hardcoding a separate literal, so plugin-local ACPX installs stop drifting out of health-check parity after version bumps. (#49089) Thanks @jiejiesks and @vincentkoc.</li>
<li>Agents/Anthropic failover: treat Anthropic <code>api_error</code> payloads with <code>An unexpected error occurred while processing the response</code> as transient so retry/fallback can engage instead of surfacing a terminal failure. (#57441) Thanks @zijiess and @vincentkoc.</li>
<li>Agents/compaction: keep late compaction-retry completions from double-resolving finished compaction futures, so interrupted or timed-out compactions stop surfacing spurious second-completion races. (#57796) Thanks @joshavant.</li>
<li>Agents/disabled providers: make disabled providers disappear from default model selection and embedded provider fallback, while letting explicitly pinned disabled providers fail with a clear config error instead of silently taking traffic. (#57735) Thanks @rileybrown-dev and @vincentkoc.</li>
<li>Agents/OAuth output: force exec-host OAuth output readers through the gateway fs policy so embedded gateway runs stop crashing when provider auth writes land outside the current sandbox workspace. (#58249) Thanks @joshavant.</li>
<li>Agents/system prompt: fix <code>agent.name</code> interpolation in the embedded runtime system prompt and make provider/model fallback text reflect the effective runtime selection after start. (#57625) Thanks @StllrSvr and @vincentkoc.</li>
<li>Android/device info: read the app's version metadata from the package manager instead of hidden APIs so Android 15+ onboarding and device info no longer fail to compile or report placeholder values. (#58126) Thanks @L3ER0Y.</li>
<li>Android/pairing: stop appending duplicate push receiver entries to <code>gateway-service.conf</code> on repeated QR pairing and keep push registration bounded to the current successful pairing, so Android push delivery stays healthy across re-pair and token rotation. (#58256) Thanks @surrealroad.</li>
<li>App install smoke: pin the latest-release lookup to <code>latest</code>, cache the first stable install version across the rerun, and relax prerelease package assertions so the Parallels smoke lane can validate stable-to-main upgrades even when <code>beta</code> moves ahead or the guest starts from an older stable. (#58177) Thanks @vincentkoc.</li>
<li>Auth/profiles: keep the last successful config load in memory for the running process and refresh that snapshot on successful writes/reloads, so hot paths stop reparsing <code>openclaw.json</code> between watcher-driven swaps.</li>
<li>Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant.</li>
<li>Config/Telegram: migrate removed <code>channels.telegram.groupMentionsOnly</code> into <code>channels.telegram.groups[\"*\"].requireMention</code> on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.</li>
<li>Config/update: stop <code>openclaw doctor</code> write-backs from persisting plugin-injected channel defaults, so <code>openclaw update</code> no longer seeds config keys that later break service refresh validation. (#56834) Thanks @openperf.</li>
<li>Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as <code>Not set</code>. (#56637) Thanks @dxsx84.</li>
<li>Control UI/slash commands: make <code>/steer</code> and <code>/redirect</code> work from the chat command palette with visible pending state for active-run <code>/steer</code>, correct redirected-run tracking, and a single canonical <code>/steer</code> entry in the command menu. (#54625) Thanks @fuller-stack-dev.</li>
<li>Cron/announce: preserve all deliverable text payloads for announce mode instead of collapsing to the last chunk, so multi-line cron reports deliver in full to Telegram forum topics.</li>
<li>Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1.</li>
<li>Diffs/config: preserve schema-shaped plugin config parsing from <code>diffsPluginConfigSchema.safeParse()</code>, so direct callers keep <code>defaults</code> and <code>security</code> sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras.</li>
<li>Diffs: fall back to plain text when <code>lang</code> hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras.</li>
<li>Discord/voice: enforce the same guild channel and member allowlist checks on spoken voice ingress before transcription, so joined voice channels no longer accept speech from users outside the configured Discord access policy. Thanks @cyjhhh and @vincentkoc.</li>
<li>Docker/setup: force BuildKit for local image builds (including sandbox image builds) so <code>./docker-setup.sh</code> no longer fails on <code>RUN --mount=...</code> when hosts default to Docker's legacy builder. (#56681) Thanks @zhanghui-china.</li>
<li>Docs/anchors: fix broken English docs links and make Mint anchor audits run against the English-source docs tree. (#57039) thanks @velvet-shark.</li>
<li>Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled <code>enabledByDefault</code> plugins in the gateway startup set. (#57931) Thanks @dinakars777.</li>
<li>Exec approvals/macOS: unwrap <code>arch</code> and <code>xcrun</code> before deriving shell payloads and allow-always patterns, so wrapper approvals stay bound to the carried command instead of the outer carrier. Thanks @tdjackey and @vincentkoc.</li>
<li>Exec approvals: unwrap <code>caffeinate</code> and <code>sandbox-exec</code> before persisting allow-always trust so later shell payload changes still require a fresh approval. Thanks @tdjackey and @vincentkoc.</li>
<li>Exec/approvals: infer Discord and Telegram exec approvers from existing owner config when <code>execApprovals.approvers</code> is unset, extend the default approval window to 30 minutes, and clarify approval-unavailable guidance so approvals do not appear to silently disappear.</li>
<li>Pi/TUI: flush message-boundary replies at <code>message_end</code> so turns stop looking stuck until the next nudge when the final reply was already ready. Thanks @vincentkoc.</li>
<li>Exec/approvals: keep <code>awk</code> and <code>sed</code> family binaries out of the low-risk <code>safeBins</code> fast path, and stop doctor profile scaffolding from treating them like ordinary custom filters. Thanks @vincentkoc.</li>
<li>Exec/env: block proxy, TLS, and Docker endpoint env overrides in host execution so request-scoped commands cannot silently reroute outbound traffic or trust attacker-supplied certificate settings. Thanks @AntAISecurityLab.</li>
<li>Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc.</li>
<li>Exec/node: stop gateway-side workdir fallback from rewriting explicit <code>host=node</code> cwd values to the gateway filesystem, so remote node exec approval and runs keep using the intended node-local directory. (#50961) Thanks @openperf.</li>
<li>Exec/runtime: default implicit exec to <code>host=auto</code>, resolve that target to sandbox only when a sandbox runtime exists, keep explicit <code>host=sandbox</code> fail-closed without sandbox, and show <code>/exec</code> effective host state in runtime status/docs.</li>
<li>Exec: fail closed when the implicit sandbox host has no sandbox runtime, and stop denied async approval followups from reusing prior command output from the same session. (#56800) Thanks @scoootscooob.</li>
<li>Feishu/groups: keep quoted replies and topic bootstrap context aligned with group sender allowlists so only allowlisted thread messages seed agent context. Thanks @AntAISecurityLab and @vincentkoc.</li>
<li>Gateway/attachments: offload large inbound images without leaking <code>media://</code> markers into text-only runs, preserve mixed attachment order for model input/transcripts, and fail closed when model image capability cannot be resolved. (#55513) Thanks @Syysean.</li>
<li>Gateway/auth: keep shared-auth rate limiting active during WebSocket handshake attempts even when callers also send device-token candidates, so bogus device-token fields no longer suppress shared-secret brute-force tracking. Thanks @kexinoh and @vincentkoc.</li>
<li>Gateway/auth: reject mismatched browser <code>Origin</code> headers on trusted-proxy HTTP operator requests while keeping origin-less headless proxy clients working. Thanks @AntAISecurityLab and @vincentkoc.</li>
<li>Gateway/device tokens: disconnect active device sessions after token rotation so newly rotated credentials revoke existing live connections immediately instead of waiting for those sockets to close naturally. Thanks @zsxsoft and @vincentkoc.</li>
<li>Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u.</li>
<li>Gateway/pairing: restore QR bootstrap onboarding handoff so fresh <code>/pair qr</code> iPhone setup can auto-approve the initial node pairing, receive a reusable node device token, and stop retrying with spent bootstrap auth. (#58382) Thanks @ngutman.</li>
<li>Gateway/OpenAI compatibility: accept flat Responses API function tool definitions on <code>/v1/responses</code> and preserve <code>strict</code> when normalizing hosted tools into the embedded runner, so spec-compliant clients like Codex no longer fail validation or silently lose strict tool enforcement. Thanks @malaiwah and @vincentkoc.</li>
<li>Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit <code>x-openclaw-scopes</code>, so headless <code>/v1/chat/completions</code> and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf.</li>
<li>Gateway/plugins: scope plugin-auth HTTP route runtime clients to read-only access and keep gateway-authenticated plugin routes on write scope, so plugin-owned webhook handlers do not inherit write-capable runtime access by default. Thanks @davidluzsilva and @vincentkoc.</li>
<li>Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.</li>
<li>Gateway/tools HTTP: tighten HTTP tool-invoke authorization so owner-only tools stay off HTTP invoke paths. (#57773) Thanks @jacobtomlinson.</li>
<li>Harden async approval followup delivery in webchat-only sessions (#57359) Thanks @joshavant.</li>
<li>Heartbeat/auth: prevent exec-event heartbeat runs from inheriting owner-only tool access from the session delivery target, so node exec output stays on the non-owner tool surface even when the target session belongs to the owner. Thanks @AntAISecurityLab and @vincentkoc.</li>
<li>Hooks/config: accept runtime channel plugin ids in <code>hooks.mappings[].channel</code> (for example <code>feishu</code>) instead of rejecting non-core channels during config validation. (#56226) Thanks @AiKrai001.</li>
<li>Hooks/session routing: rebind hook-triggered <code>agent:</code> session keys to the actual target agent before isolated dispatch so dedicated hook agents keep their own session-scoped tool and plugin identity. Thanks @kexinoh and @vincentkoc.</li>
<li>Host exec/env: block additional request-scoped env overrides that can redirect Docker endpoints, trust roots, compiler include paths, package resolution, or Python environment roots during approved host runs. Thanks @tdjackey and @vincentkoc.</li>
<li>Image generation/build: write stable runtime alias files into <code>dist/</code> and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files.</li>
<li>iOS/Live Activities: mark the <code>ActivityKit</code> import in <code>LiveActivityManager.swift</code> as <code>@preconcurrency</code> so Xcode 26.4 / Swift 6 builds stop failing on strict concurrency checks. (#57180) Thanks @ngutman.</li>
<li>LINE/ACP: add current-conversation binding and inbound binding-routing parity so <code>/acp spawn ... --thread here</code>, configured ACP bindings, and active conversation-bound ACP sessions work on LINE like the other conversation channels.</li>
<li>LINE/markdown: preserve underscores inside Latin, Cyrillic, and CJK words when stripping markdown, while still removing standalone <code>_italic_</code> markers on the shared text-runtime path used by LINE and TTS. (#47465) Thanks @jackjin1997.</li>
<li>Agents/failover: make overloaded same-provider retry count and retry delay configurable via <code>auth.cooldowns</code>, default to one retry with no delay, and document the model-fallback behavior.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.31/OpenClaw-2026.3.31.zip" length="25820093" type="application/octet-stream" sparkle:edSignature="NjpuH/j7OaNASEatBTpQ4uQy6+oUNq/lIwjrY69rJfkgGSk3/kU8vgxo9osjSgx034m7TpuZvWyulu57OBsQCg=="/>
</item>
</channel>
</rss>
</rss>

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026040601
versionName = "2026.4.6"
versionCode = 2026040401
versionName = "2026.4.4"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -239,52 +239,44 @@ tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
val stripReleaseDnsjavaServiceDescriptor =
tasks.register("stripReleaseDnsjavaServiceDescriptor") {
val mergedJar =
layout.buildDirectory.file(
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
"intermediates/merged_java_res/release/mergeReleaseJavaResource/base.jar",
)
val stripTask =
tasks.register(stripTaskName) {
inputs.file(mergedJar)
outputs.file(mergedJar)
inputs.file(mergedJar)
outputs.file(mergedJar)
doLast {
val jarFile = mergedJar.get().asFile
if (!jarFile.exists()) {
return@doLast
}
val unpackDir = temporaryDir.resolve("merged-java-res")
delete(unpackDir)
copy {
from(zipTree(jarFile))
into(unpackDir)
exclude(dnsjavaInetAddressResolverService)
}
delete(jarFile)
ant.invokeMethod(
"zip",
mapOf(
"destfile" to jarFile.absolutePath,
"basedir" to unpackDir.absolutePath,
),
)
}
doLast {
val jarFile = mergedJar.get().asFile
if (!jarFile.exists()) {
return@doLast
}
tasks.matching { it.name == mergeTaskName }.configureEach {
finalizedBy(stripTask)
}
tasks.matching { it.name == minifyTaskName }.configureEach {
dependsOn(stripTask)
val unpackDir = temporaryDir.resolve("merged-java-res")
delete(unpackDir)
copy {
from(zipTree(jarFile))
into(unpackDir)
exclude(dnsjavaInetAddressResolverService)
}
delete(jarFile)
ant.invokeMethod(
"zip",
mapOf(
"destfile" to jarFile.absolutePath,
"basedir" to unpackDir.absolutePath,
),
)
}
}
tasks.matching { it.name == "stripReleaseDnsjavaServiceDescriptor" }.configureEach {
dependsOn("mergeReleaseJavaResource")
}
tasks.matching { it.name == "minifyReleaseWithR8" }.configureEach {
dependsOn(stripReleaseDnsjavaServiceDescriptor)
}

View File

@@ -10,7 +10,7 @@
<parameter
android:name="prompt"
android:key="prompt"
android:mimeType="https://schema.org/Text"
android:mimeType="text/*"
android:required="true" />
</intent>
</capability>

View File

@@ -3,23 +3,19 @@
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
#include? "../.local-signing.xcconfig"
#include? "../LocalSigning.xcconfig"
CODE_SIGN_STYLE = $(OPENCLAW_CODE_SIGN_STYLE)
CODE_SIGN_STYLE = Automatic
CODE_SIGN_IDENTITY = Apple Development
DEVELOPMENT_TEAM = $(OPENCLAW_DEVELOPMENT_TEAM)
DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
// Let Xcode manage provisioning for the selected local team unless a local override pins one.
// Let Xcode manage provisioning for the selected local team.
PROVISIONING_PROFILE_SPECIFIER =

View File

@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.4.6
OPENCLAW_MARKETING_VERSION = 2026.4.6
OPENCLAW_BUILD_VERSION = 2026040601
OPENCLAW_GATEWAY_VERSION = 2026.4.4
OPENCLAW_MARKETING_VERSION = 2026.4.4
OPENCLAW_BUILD_VERSION = 2026040401
#include? "../build/Version.xcconfig"

View File

@@ -13,5 +13,3 @@ OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -92,54 +92,6 @@ If you need to force a specific build number:
pnpm ios:beta -- --build-number 7
```
### Maintainer Quick Release Checklist
Use this when a clone is missing local iOS release setup and you want the shortest path to a TestFlight upload.
1. Confirm Fastlane auth is set up:
```bash
cd apps/ios
fastlane ios auth_check
```
2. If auth is missing, bootstrap it once on this Mac:
```bash
scripts/ios-asc-keychain-setup.sh \
--key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \
--issuer-id YOUR_ISSUER_ID \
--write-env
```
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
3. Set the official/TestFlight relay URL for the build:
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
```
4. Upload the beta:
```bash
pnpm ios:beta
```
5. Expected behavior:
- Fastlane reads `package.json.version`
- resolves the next TestFlight build number for that short version
- generates `apps/ios/build/BetaRelease.xcconfig`
- archives `OpenClaw`
- uploads the IPA to TestFlight
6. Expected outputs after a successful run:
- `apps/ios/build/beta/OpenClaw-<version>.ipa`
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<build>`
7. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
## APNs Expectations For Local/Manual Builds
- The app calls `registerForRemoteNotifications()` at launch.
@@ -148,9 +100,6 @@ pnpm ios:beta
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
## APNs Expectations For Official Builds

View File

@@ -1,196 +0,0 @@
import SwiftUI
private struct ExecApprovalPromptDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
.overlay {
if let prompt = self.appModel.pendingExecApprovalPrompt {
ZStack {
Color.black.opacity(0.38)
.ignoresSafeArea()
ExecApprovalPromptCard(
prompt: prompt,
isResolving: self.appModel.pendingExecApprovalPromptResolving,
errorText: self.appModel.pendingExecApprovalPromptErrorText,
brighten: self.colorScheme == .light,
onAllowOnce: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
}
},
onAllowAlways: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
},
onDeny: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny")
}
},
onCancel: {
self.appModel.dismissPendingExecApprovalPrompt()
})
.padding(.horizontal, 20)
.frame(maxWidth: 460)
.transition(.scale(scale: 0.98).combined(with: .opacity))
}
.zIndex(1)
}
}
.animation(.easeInOut(duration: 0.18), value: self.appModel.pendingExecApprovalPrompt?.id)
}
}
private struct ExecApprovalPromptCard: View {
let prompt: NodeAppModel.ExecApprovalPrompt
let isResolving: Bool
let errorText: String?
let brighten: Bool
let onAllowOnce: () -> Void
let onAllowAlways: () -> Void
let onDeny: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Exec approval required")
.font(.headline)
Text("OpenClaw opened from a notification. Review this exec request before continuing.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text(self.prompt.commandText)
.font(.system(size: 15, weight: .regular, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.black.opacity(0.14), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
VStack(alignment: .leading, spacing: 8) {
if let host = self.normalized(self.prompt.host) {
ExecApprovalPromptMetadataRow(label: "Host", value: host)
}
if let nodeId = self.normalized(self.prompt.nodeId) {
ExecApprovalPromptMetadataRow(label: "Node", value: nodeId)
}
if let agentId = self.normalized(self.prompt.agentId) {
ExecApprovalPromptMetadataRow(label: "Agent", value: agentId)
}
if let expiresText = self.expiresText(self.prompt.expiresAtMs) {
ExecApprovalPromptMetadataRow(label: "Expires", value: expiresText)
}
}
if let errorText = self.normalized(self.errorText) {
Text(errorText)
.font(.footnote)
.foregroundStyle(.red)
}
if self.isResolving {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Resolving…")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
VStack(spacing: 10) {
Button {
self.onAllowOnce()
} label: {
Text("Allow Once")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(self.isResolving)
if self.prompt.allowsAllowAlways {
Button {
self.onAllowAlways()
} label: {
Text("Allow Always")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
HStack(spacing: 10) {
Button(role: .destructive) {
self.onDeny()
} label: {
Text("Deny")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
Button(role: .cancel) {
self.onCancel()
} label: {
Text("Cancel")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(self.isResolving)
}
}
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
}
private func normalized(_ value: String?) -> String? {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func expiresText(_ expiresAtMs: Int?) -> String? {
guard let expiresAtMs else { return nil }
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
if remainingSeconds <= 0 {
return "expired"
}
if remainingSeconds < 60 {
return "under a minute"
}
if remainingSeconds < 3600 {
let minutes = Int(ceil(Double(remainingSeconds) / 60.0))
return minutes == 1 ? "about 1 minute" : "about \(minutes) minutes"
}
let hours = Int(ceil(Double(remainingSeconds) / 3600.0))
return hours == 1 ? "about 1 hour" : "about \(hours) hours"
}
}
private struct ExecApprovalPromptMetadataRow: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(self.label)
.font(.caption)
.foregroundStyle(.secondary)
Text(self.value)
.font(.footnote)
.textSelection(.enabled)
}
}
}
extension View {
func execApprovalPromptDialog() -> some View {
self.modifier(ExecApprovalPromptDialogModifier())
}
}

View File

@@ -1,5 +1,4 @@
import Foundation
import OpenClawKit
enum GatewayConnectionIssue: Equatable {
case none
@@ -30,37 +29,6 @@ enum GatewayConnectionIssue: Equatable {
return false
}
static func detect(problem: GatewayConnectionProblem?) -> Self {
guard let problem else { return .none }
if problem.needsPairingApproval {
return .pairingRequired(requestId: problem.requestId)
}
if problem.needsCredentialUpdate {
return problem.kind == .gatewayAuthTokenMissing ? .tokenMissing : .unauthorized
}
switch problem.kind {
case .deviceIdentityRequired,
.deviceSignatureExpired,
.deviceNonceRequired,
.deviceNonceMismatch,
.deviceSignatureInvalid,
.devicePublicKeyInvalid,
.deviceIdMismatch,
.tailscaleIdentityMissing,
.tailscaleProxyMissing,
.tailscaleWhoisFailed,
.tailscaleIdentityMismatch,
.authRateLimited:
return .unauthorized
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .network
case .unknown:
return .unknown(problem.message)
default:
return .none
}
}
static func detect(from statusText: String) -> Self {
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .none }

View File

@@ -1,232 +0,0 @@
import OpenClawKit
import SwiftUI
import UIKit
struct GatewayProblemBanner: View {
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
var onShowDetails: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: self.iconName)
.font(.headline.weight(.semibold))
.foregroundStyle(self.tint)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.problem.title)
.font(.subheadline.weight(.semibold))
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
Text(self.ownerLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(self.problem.message)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let requestId = self.problem.requestId {
Text("Request ID: \(requestId)")
.font(.system(.caption, design: .monospaced).weight(.medium))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
HStack(spacing: 10) {
if let primaryActionTitle, let onPrimaryAction {
Button(primaryActionTitle, action: onPrimaryAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
if let onShowDetails {
Button("Details", action: onShowDetails)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(
.thinMaterial,
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
)
}
private var iconName: String {
switch self.problem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return "person.crop.circle.badge.clock"
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return "wifi.exclamationmark"
case .deviceIdentityRequired,
.deviceSignatureExpired,
.deviceNonceRequired,
.deviceNonceMismatch,
.deviceSignatureInvalid,
.devicePublicKeyInvalid,
.deviceIdMismatch:
return "lock.shield"
default:
return "exclamationmark.triangle.fill"
}
}
private var tint: Color {
switch self.problem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return .orange
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return .yellow
default:
return .red
}
}
private var ownerLabel: String {
switch self.problem.owner {
case .gateway:
return "Fix on gateway"
case .iphone:
return "Fix on iPhone"
case .both:
return "Check both"
case .network:
return "Check network"
case .unknown:
return "Needs attention"
}
}
}
struct GatewayProblemDetailsSheet: View {
@Environment(\.dismiss) private var dismiss
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
@State private var copyFeedback: String?
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(self.problem.title)
.font(.title3.weight(.semibold))
Text(self.problem.message)
.font(.body)
.foregroundStyle(.secondary)
Text(self.ownerSummary)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
if let requestId = self.problem.requestId {
Section("Request") {
Text(verbatim: requestId)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Button("Copy request ID") {
UIPasteboard.general.string = requestId
self.copyFeedback = "Copied request ID"
}
}
}
if let actionCommand = self.problem.actionCommand {
Section("Gateway command") {
Text(verbatim: actionCommand)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Button("Copy command") {
UIPasteboard.general.string = actionCommand
self.copyFeedback = "Copied command"
}
}
}
if let docsURL = self.problem.docsURL {
Section("Help") {
Link(destination: docsURL) {
Label("Open docs", systemImage: "book")
}
Text(verbatim: docsURL.absoluteString)
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if let technicalDetails = self.problem.technicalDetails {
Section("Technical details") {
Text(verbatim: technicalDetails)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
if let copyFeedback {
Section {
Text(copyFeedback)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Connection problem")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
if let primaryActionTitle, let onPrimaryAction {
Button(primaryActionTitle) {
self.dismiss()
onPrimaryAction()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
self.dismiss()
}
}
}
}
}
private var ownerSummary: String {
switch self.problem.owner {
case .gateway:
return "Primary fix: gateway"
case .iphone:
return "Primary fix: this iPhone"
case .both:
return "Primary fix: check both this iPhone and the gateway"
case .network:
return "Primary fix: network or remote access"
case .unknown:
return "Primary fix: review details and retry"
}
}
}

View File

@@ -8,7 +8,6 @@ struct GatewayQuickSetupSheet: View {
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var connecting: Bool = false
@State private var connectError: String?
@State private var showGatewayProblemDetails: Bool = false
var body: some View {
NavigationStack {
@@ -16,14 +15,6 @@ struct GatewayQuickSetupSheet: View {
Text("Connect to a Gateway?")
.font(.title2.bold())
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemBanner(
problem: gatewayProblem,
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if let candidate = self.bestCandidate {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: candidate.name)
@@ -36,7 +27,7 @@ struct GatewayQuickSetupSheet: View {
// Use verbatim strings so Bonjour-provided values can't be interpreted as
// localized format strings (which can crash with Objective-C exceptions).
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayDisplayStatusText)")
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
}
@@ -113,11 +104,6 @@ struct GatewayQuickSetupSheet: View {
}
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(problem: gatewayProblem)
}
}
}
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {

File diff suppressed because it is too large Load Diff

View File

@@ -376,7 +376,7 @@ private struct ConnectionStatusBox: View {
gatewayController: GatewayConnectionController
) -> [String] {
var lines: [String] = [
"gateway: \(appModel.gatewayDisplayStatusText)",
"gateway: \(appModel.gatewayStatusText)",
"discovery: \(gatewayController.discoveryStatusText)",
]
lines.append("server: \(appModel.gatewayServerName ?? "")")

View File

@@ -69,7 +69,6 @@ struct OnboardingWizardView: View {
@State private var showQRScanner: Bool = false
@State private var scannerError: String?
@State private var selectedPhoto: PhotosPickerItem?
@State private var showGatewayProblemDetails: Bool = false
@State private var lastPairingAutoResumeAttemptAt: Date?
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
@@ -87,10 +86,6 @@ struct OnboardingWizardView: View {
self.step == .intro || self.step == .welcome || self.step == .success
}
private var currentProblem: GatewayConnectionProblem? {
self.appModel.lastGatewayProblem
}
var body: some View {
NavigationStack {
Group {
@@ -221,16 +216,6 @@ struct OnboardingWizardView: View {
}
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let currentProblem = self.currentProblem {
GatewayProblemDetailsSheet(
problem: currentProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {
Task { await self.retryLastAttempt() }
})
}
}
.onAppear {
self.initializeState()
}
@@ -265,11 +250,39 @@ struct OnboardingWizardView: View {
.onChange(of: self.gatewayPassword) { _, newValue in
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
}
.onChange(of: self.appModel.lastGatewayProblem) { _, newValue in
self.updateConnectionIssue(problem: newValue, statusText: self.appModel.gatewayStatusText)
}
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
self.updateConnectionIssue(problem: self.appModel.lastGatewayProblem, statusText: newValue)
let next = GatewayConnectionIssue.detect(from: newValue)
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
if self.issue.needsPairing, next.needsPairing {
// Keep the requestId sticky even if the status line omits it after we pause.
let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
self.issue = .pairingRequired(requestId: mergedRequestId)
} else if self.issue.needsPairing, !next.needsPairing {
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
} else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
// the user retries/scans again or we successfully connect.
} else {
self.issue = next
}
if let requestId = next.requestId, !requestId.isEmpty {
self.pairingRequestId = requestId
}
// If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
if next.needsAuthToken {
self.appModel.gatewayAutoReconnectEnabled = false
}
if self.issue.needsAuthToken || self.issue.needsPairing {
self.step = .auth
}
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.connectMessage = newValue
self.statusLine = newValue
}
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
guard newValue != nil else { return }
@@ -496,7 +509,7 @@ struct OnboardingWizardView: View {
Section {
LabeledContent("Mode", value: selectedMode.title)
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
LabeledContent("Progress", value: self.statusLine)
} header: {
Text("Status")
@@ -599,17 +612,7 @@ struct OnboardingWizardView: View {
.autocorrectionDisabled()
SecureField("Gateway Password", text: self.$gatewayPassword)
if let problem = self.currentProblem {
GatewayProblemBanner(
problem: problem,
primaryActionTitle: "Retry connection",
onPrimaryAction: {
Task { await self.retryLastAttempt() }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
} else if self.issue.needsAuthToken {
if self.issue.needsAuthToken {
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -632,15 +635,14 @@ struct OnboardingWizardView: View {
Text("Pairing Approval")
} footer: {
let requestLine: String = {
if let id = self.currentProblem?.requestId ?? self.issue.requestId, !id.isEmpty {
if let id = self.issue.requestId, !id.isEmpty {
return "Request ID: \(id)"
}
return "Request ID: check `openclaw devices list`."
}()
let commandLine = self.currentProblem?.actionCommand ?? "openclaw devices approve <requestId>"
Text(
"Approve this device on the gateway.\n"
+ "1) `\(commandLine)`\n"
+ "1) `openclaw devices approve` (or `openclaw devices approve <requestId>`)\n"
+ "2) `/pair approve` in your OpenClaw chat\n"
+ "\(requestLine)\n"
+ "OpenClaw will also retry automatically when you return to this app.")
@@ -822,45 +824,6 @@ struct OnboardingWizardView: View {
self.resumeAfterPairingApprovalInBackground()
}
private func updateConnectionIssue(problem: GatewayConnectionProblem?, statusText: String) {
let next = GatewayConnectionIssue.detect(problem: problem)
let fallback = next == .none ? GatewayConnectionIssue.detect(from: statusText) : next
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
if self.issue.needsPairing, fallback.needsPairing {
let mergedRequestId = fallback.requestId ?? self.issue.requestId ?? self.pairingRequestId
self.issue = .pairingRequired(requestId: mergedRequestId)
} else if self.issue.needsPairing, !fallback.needsPairing {
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
} else if self.issue.needsAuthToken, !fallback.needsAuthToken, !fallback.needsPairing {
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
// the user retries/scans again or we successfully connect.
} else {
self.issue = fallback
}
if let requestId = problem?.requestId ?? fallback.requestId, !requestId.isEmpty {
self.pairingRequestId = requestId
}
if self.issue.needsAuthToken || self.issue.needsPairing || problem?.pauseReconnect == true {
self.step = .auth
}
if let problem {
self.connectMessage = problem.message
self.statusLine = problem.message
return
}
let trimmedStatus = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedStatus.isEmpty {
self.connectMessage = trimmedStatus
self.statusLine = trimmedStatus
}
}
private func detectQRCode(from data: Data) -> String? {
guard let ciImage = CIImage(data: data) else { return nil }
let detector = CIDetector(

View File

@@ -13,13 +13,6 @@ private struct PendingWatchPromptAction {
var sessionKey: String?
}
private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt
@MainActor
enum OpenClawAppModelRegistry {
static var appModel: NodeAppModel?
}
@MainActor
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
@@ -28,13 +21,10 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
private var backgroundWakeTask: Task<Bool, Never>?
private var pendingAPNsDeviceToken: Data?
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
private var pendingExecApprovalRequestedPushIDs: [String] = []
private var pendingExecApprovalResolvedPushIDs: [String] = []
weak var appModel: NodeAppModel? {
didSet {
guard let model = self.resolvedAppModel() else { return }
guard let model = self.appModel else { return }
if let token = self.pendingAPNsDeviceToken {
self.pendingAPNsDeviceToken = nil
Task { @MainActor in
@@ -54,65 +44,22 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
}
}
if !self.pendingExecApprovalPrompts.isEmpty {
let pending = self.pendingExecApprovalPrompts
self.pendingExecApprovalPrompts.removeAll()
Task { @MainActor in
for prompt in pending {
await model.presentExecApprovalNotificationPrompt(prompt)
}
}
}
if !self.pendingExecApprovalRequestedPushIDs.isEmpty {
let pending = self.pendingExecApprovalRequestedPushIDs
self.pendingExecApprovalRequestedPushIDs.removeAll()
Task { @MainActor in
for approvalId in pending {
_ = await model.handleExecApprovalRequestedRemotePush(approvalId: approvalId)
}
}
}
if !self.pendingExecApprovalResolvedPushIDs.isEmpty {
let pending = self.pendingExecApprovalResolvedPushIDs
self.pendingExecApprovalResolvedPushIDs.removeAll()
Task { @MainActor in
for approvalId in pending {
await model.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
}
}
}
}
}
private func resolvedAppModel() -> NodeAppModel? {
self.appModel ?? OpenClawAppModelRegistry.appModel
}
#if DEBUG
func _test_resolvedAppModel() -> NodeAppModel? {
self.resolvedAppModel()
}
#endif
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool
{
GatewayDiagnostics.log("app delegate: didFinishLaunching")
if self.appModel == nil {
self.appModel = OpenClawAppModelRegistry.appModel
}
self.registerBackgroundWakeRefreshTask()
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.delegate = self
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
UNUserNotificationCenter.current().delegate = self
application.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if let appModel = self.resolvedAppModel() {
if let appModel = self.appModel {
Task { @MainActor in
appModel.updateAPNsDeviceToken(deviceToken)
}
@@ -133,28 +80,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
{
self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)")
Task { @MainActor in
let notificationCenter = LiveNotificationCenter()
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
userInfo: userInfo,
notificationCenter: notificationCenter)
{
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
if let appModel = self.resolvedAppModel() {
await appModel.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
} else {
self.pendingExecApprovalResolvedPushIDs.append(approvalId)
}
}
completionHandler(.newData)
return
}
guard let appModel = self.resolvedAppModel() else {
if ExecApprovalNotificationBridge.payloadKind(userInfo: userInfo)
== ExecApprovalNotificationBridge.requestedKind,
let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo)
{
self.pendingExecApprovalRequestedPushIDs.append(approvalId)
}
guard let appModel = self.appModel else {
self.logger.info("APNs wake skipped: appModel unavailable")
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model")
completionHandler(.noData)
@@ -170,7 +96,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
}
func scenePhaseChanged(_ phase: ScenePhase) {
GatewayDiagnostics.log("app delegate: scene phase changed=\(String(describing: phase))")
if phase == .background {
self.scheduleBackgroundWakeRefresh(afterSeconds: 120, reason: "scene_background")
}
@@ -215,7 +140,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
self.backgroundWakeTask?.cancel()
let wakeTask = Task { @MainActor [weak self] in
guard let self, let appModel = self.resolvedAppModel() else { return false }
guard let self, let appModel = self.appModel else { return false }
return await appModel.handleBackgroundRefreshWake(trigger: "bg_app_refresh")
}
self.backgroundWakeTask = wakeTask
@@ -291,16 +216,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
sessionKey: sessionKey)
}
private static func parseExecApprovalPrompt(
from response: UNNotificationResponse) -> PendingExecApprovalPrompt?
{
ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: response.actionIdentifier,
userInfo: response.notification.request.content.userInfo)
}
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
guard let appModel = self.resolvedAppModel() else {
guard let appModel = self.appModel else {
self.pendingWatchPromptActions.append(action)
return
}
@@ -312,25 +229,13 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
_ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action")
}
private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) {
guard let appModel = self.resolvedAppModel() else {
self.pendingExecApprovalPrompts.append(prompt)
return
}
Task { @MainActor in
await appModel.presentExecApprovalNotificationPrompt(prompt)
}
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
{
let userInfo = notification.request.content.userInfo
if Self.isWatchPromptNotification(userInfo)
|| ExecApprovalNotificationBridge.shouldPresentNotification(userInfo: userInfo)
{
if Self.isWatchPromptNotification(userInfo) {
completionHandler([.banner, .list, .sound])
return
}
@@ -342,29 +247,18 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void)
{
if let action = Self.parseWatchPromptAction(from: response) {
Task { @MainActor [weak self] in
guard let self else {
completionHandler()
return
}
await self.routeWatchPromptAction(action)
completionHandler()
}
guard let action = Self.parseWatchPromptAction(from: response) else {
completionHandler()
return
}
if let prompt = Self.parseExecApprovalPrompt(from: response) {
Task { @MainActor [weak self] in
guard let self else {
completionHandler()
return
}
self.routeExecApprovalPrompt(prompt)
Task { @MainActor [weak self] in
guard let self else {
completionHandler()
return
}
return
await self.routeWatchPromptAction(action)
completionHandler()
}
completionHandler()
}
}
@@ -613,7 +507,6 @@ struct OpenClawApp: App {
Self.installUncaughtExceptionLogger()
GatewaySettingsStore.bootstrapPersistence()
let appModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = appModel
_appModel = State(initialValue: appModel)
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
}

View File

@@ -1,117 +0,0 @@
import Foundation
import UserNotifications
struct ExecApprovalNotificationPrompt: Sendable, Equatable {
let approvalId: String
}
enum ExecApprovalNotificationBridge {
static let requestedKind = "exec.approval.requested"
static let resolvedKind = "exec.approval.resolved"
static let categoryIdentifier = "openclaw.exec-approval"
static let reviewActionIdentifier = "openclaw.exec-approval.review"
private static let localRequestPrefix = "exec.approval."
static func registerCategory(center: UNUserNotificationCenter = .current()) {
let category = UNNotificationCategory(
identifier: self.categoryIdentifier,
actions: [
UNNotificationAction(
identifier: self.reviewActionIdentifier,
title: "Review",
options: [.foreground]),
],
intentIdentifiers: [],
options: [])
center.getNotificationCategories { categories in
var updated = categories
updated.update(with: category)
center.setNotificationCategories(updated)
}
}
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
self.payloadKind(userInfo: userInfo) == self.requestedKind
}
static func parsePrompt(
actionIdentifier: String,
userInfo: [AnyHashable: Any]
) -> ExecApprovalNotificationPrompt?
{
guard actionIdentifier == UNNotificationDefaultActionIdentifier
|| actionIdentifier == self.reviewActionIdentifier
else {
return nil
}
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
return ExecApprovalNotificationPrompt(approvalId: approvalId)
}
@MainActor
static func handleResolvedPushIfNeeded(
userInfo: [AnyHashable: Any],
notificationCenter: NotificationCentering
) async -> Bool
{
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
let approvalId = self.approvalID(from: userInfo)
else {
return false
}
await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter)
return true
}
@MainActor
static func removeNotifications(
forApprovalID approvalId: String,
notificationCenter: NotificationCentering
) async {
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
await notificationCenter.removePendingNotificationRequests(
withIdentifiers: [self.localRequestIdentifier(for: normalizedID)])
let delivered = await notificationCenter.deliveredNotifications()
let identifiers = delivered.compactMap { snapshot -> String? in
guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil }
return snapshot.identifier
}
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
}
static func approvalID(from userInfo: [AnyHashable: Any]) -> String? {
let raw = self.openClawPayload(userInfo: userInfo)?["approvalId"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func localRequestIdentifier(for approvalId: String) -> String {
"\(self.localRequestPrefix)\(approvalId)"
}
static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
let raw = self.openClawPayload(userInfo: userInfo)?["kind"] as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "unknown" : trimmed
}
private static func openClawPayload(userInfo: [AnyHashable: Any]) -> [String: Any]? {
if let payload = userInfo["openclaw"] as? [String: Any] {
return payload
}
if let payload = userInfo["openclaw"] as? [AnyHashable: Any] {
return payload.reduce(into: [String: Any]()) { partialResult, pair in
guard let key = pair.key as? String else { return }
partialResult[key] = pair.value
}
}
return nil
}
}

View File

@@ -98,9 +98,6 @@ struct RootCanvas: View {
},
openSettings: {
self.presentedSheet = .settings
},
retryGatewayConnection: {
Task { await self.gatewayController.connectLastKnown() }
})
.preferredColorScheme(.dark)
@@ -110,7 +107,6 @@ struct RootCanvas: View {
}
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog()
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .settings:
@@ -232,7 +228,7 @@ struct RootCanvas: View {
private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
@@ -457,7 +453,6 @@ private struct CanvasContent: View {
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
var systemColorScheme: ColorScheme
var gatewayStatus: StatusPill.GatewayState
var voiceWakeEnabled: Bool
@@ -466,7 +461,6 @@ private struct CanvasContent: View {
var cameraHUDKind: NodeAppModel.CameraHUDKind?
var openChat: () -> Void
var openSettings: () -> Void
var retryGatewayConnection: () -> Void
private var brightenButtons: Bool { self.systemColorScheme == .light }
private var talkActive: Bool { self.appModel.talkMode.isEnabled || self.talkEnabled }
@@ -493,8 +487,6 @@ private struct CanvasContent: View {
onStatusTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else if self.appModel.lastGatewayProblem != nil {
self.showGatewayProblemDetails = true
} else {
self.openSettings()
}
@@ -511,35 +503,13 @@ private struct CanvasContent: View {
self.openSettings()
})
}
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: gatewayProblem.retryable ? "Retry" : "Open Settings",
onPrimaryAction: {
if gatewayProblem.retryable {
self.retryGatewayConnection()
} else {
self.openSettings()
}
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(
command: voiceWakeToastText,
brighten: self.brightenButtons)
.padding(.leading, 10)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.safeAreaPadding(.top, 58)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
@@ -547,16 +517,6 @@ private struct CanvasContent: View {
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.openSettings() })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.openSettings()
})
}
}
.onAppear {
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
if self.talkEnabled != self.appModel.talkMode.isEnabled {

View File

@@ -9,7 +9,6 @@ struct RootTabs: View {
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
var body: some View {
TabView(selection: self.$selectedTab) {
@@ -33,8 +32,6 @@ struct RootTabs: View {
onTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else if self.appModel.lastGatewayProblem != nil {
self.showGatewayProblemDetails = true
} else {
self.selectedTab = 2
}
@@ -42,29 +39,11 @@ struct RootTabs: View {
.padding(.leading, 10)
.safeAreaPadding(.top, 10)
}
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.selectedTab = 2
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.safeAreaPadding(.top, 58)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
@@ -95,16 +74,6 @@ struct RootTabs: View {
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.selectedTab = 2 })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Open Settings",
onPrimaryAction: {
self.selectedTab = 2
})
}
}
}
private var gatewayStatus: StatusPill.GatewayState {

View File

@@ -88,20 +88,6 @@ struct WatchQuickReplyEvent: Sendable, Equatable {
var transport: String
}
struct WatchExecApprovalResolveEvent: Sendable, Equatable {
var replyId: String
var approvalId: String
var decision: OpenClawWatchExecApprovalDecision
var sentAtMs: Int?
var transport: String
}
struct WatchExecApprovalSnapshotRequestEvent: Sendable, Equatable {
var requestId: String
var sentAtMs: Int?
var transport: String
}
struct WatchNotificationSendResult: Sendable, Equatable {
var deliveredImmediately: Bool
var queuedForDelivery: Bool
@@ -110,22 +96,10 @@ struct WatchNotificationSendResult: Sendable, Equatable {
protocol WatchMessagingServicing: AnyObject, Sendable {
func status() async -> WatchMessagingStatus
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?)
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?)
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?)
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
func sendExecApprovalResolved(
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
func sendExecApprovalExpired(
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
func syncExecApprovalSnapshot(
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
}
extension CameraController: CameraServicing {}

View File

@@ -1,11 +1,6 @@
import Foundation
import UserNotifications
struct NotificationSnapshot: @unchecked Sendable {
let identifier: String
let userInfo: [AnyHashable: Any]
}
enum NotificationAuthorizationStatus: Sendable {
case notDetermined
case denied
@@ -18,9 +13,6 @@ protocol NotificationCentering: Sendable {
func authorizationStatus() async -> NotificationAuthorizationStatus
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func add(_ request: UNNotificationRequest) async throws
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async
func deliveredNotifications() async -> [NotificationSnapshot]
}
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
@@ -63,27 +55,4 @@ struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
}
}
}
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
guard !identifiers.isEmpty else { return }
self.center.removePendingNotificationRequests(withIdentifiers: identifiers)
}
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
guard !identifiers.isEmpty else { return }
self.center.removeDeliveredNotifications(withIdentifiers: identifiers)
}
func deliveredNotifications() async -> [NotificationSnapshot] {
await withCheckedContinuation { continuation in
self.center.getDeliveredNotifications { notifications in
continuation.resume(
returning: notifications.map { notification in
NotificationSnapshot(
identifier: notification.request.identifier,
userInfo: notification.request.content.userInfo)
})
}
}
}
}

View File

@@ -1,363 +0,0 @@
import Foundation
import OSLog
@preconcurrency import WatchConnectivity
private struct WatchConnectivityTransportCallbacks {
var statusUpdateHandler: (@Sendable (WatchMessagingStatus) -> Void)?
var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
}
private func sendReachableWatchMessage(_ payload: [String: Any], with session: WCSession) async throws {
// WatchConnectivity replies arrive on its own queue. Keep this continuation explicitly
// nonisolated so Swift 6 does not inherit a caller actor (for example MainActor) into the
// Objective-C callback boundary and trap on the reply callback executor check.
try await withCheckedThrowingContinuation(isolation: nil) {
(continuation: CheckedContinuation<Void, Error>) in
session.sendMessage(
payload,
replyHandler: { _ in
continuation.resume(returning: ())
},
errorHandler: { error in
continuation.resume(throwing: error)
}
)
}
}
final class WatchConnectivityTransport: NSObject, @unchecked Sendable {
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
private let session: WCSession?
private let callbacksLock = NSLock()
private var callbacks = WatchConnectivityTransportCallbacks()
override init() {
if WCSession.isSupported() {
self.session = WCSession.default
} else {
self.session = nil
}
super.init()
if let session = self.session {
session.delegate = self
session.activate()
}
}
nonisolated static func isSupportedOnDevice() -> Bool {
WCSession.isSupported()
}
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
guard WCSession.isSupported() else {
return WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "unsupported")
}
return self.status(for: WCSession.default)
}
func status() async -> WatchMessagingStatus {
await self.ensureActivated()
return self.currentStatusSnapshot()
}
func currentStatusSnapshot() -> WatchMessagingStatus {
guard let session = self.session else {
return WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "unsupported")
}
return Self.status(for: session)
}
func setStatusUpdateHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
self.updateCallbacks { $0.statusUpdateHandler = handler }
}
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
self.updateCallbacks { $0.replyHandler = handler }
}
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
self.updateCallbacks { $0.execApprovalResolveHandler = handler }
}
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
{
self.updateCallbacks { $0.execApprovalSnapshotRequestHandler = handler }
}
func sendPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
await self.ensureActivated()
let session = try self.requireReadySession()
if session.isReachable {
do {
try await sendReachableWatchMessage(payload, with: session)
return WatchNotificationSendResult(
deliveredImmediately: true,
queuedForDelivery: false,
transport: "sendMessage")
} catch {
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
}
}
_ = session.transferUserInfo(payload)
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
transport: "transferUserInfo")
}
func sendSnapshotPayload(_ payload: [String: Any]) async throws -> WatchNotificationSendResult {
await self.ensureActivated()
let session = try self.requireReadySession()
if session.isReachable {
do {
try await sendReachableWatchMessage(payload, with: session)
return WatchNotificationSendResult(
deliveredImmediately: true,
queuedForDelivery: false,
transport: "sendMessage")
} catch {
Self.logger.error(
"watch snapshot sendMessage failed: \(error.localizedDescription, privacy: .public)")
}
}
do {
try session.updateApplicationContext(payload)
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
transport: "applicationContext")
} catch {
Self.logger.error(
"watch updateApplicationContext failed: \(error.localizedDescription, privacy: .public)")
_ = session.transferUserInfo(payload)
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
transport: "transferUserInfo")
}
}
private func updateCallbacks(_ update: (inout WatchConnectivityTransportCallbacks) -> Void) {
self.callbacksLock.lock()
defer { self.callbacksLock.unlock() }
update(&self.callbacks)
}
private func callbacksSnapshot() -> WatchConnectivityTransportCallbacks {
self.callbacksLock.lock()
defer { self.callbacksLock.unlock() }
return self.callbacks
}
private func requireReadySession() throws -> WCSession {
guard let session = self.session else {
throw WatchMessagingError.unsupported
}
let snapshot = Self.status(for: session)
guard snapshot.paired else {
throw WatchMessagingError.notPaired
}
guard snapshot.appInstalled else {
throw WatchMessagingError.watchAppNotInstalled
}
return session
}
private func ensureActivated() async {
guard let session = self.session else { return }
if session.activationState == .activated {
return
}
session.activate()
for _ in 0..<8 {
if session.activationState == .activated {
return
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
private func emitStatusUpdate(_ snapshot: WatchMessagingStatus) {
guard let handler = self.callbacksSnapshot().statusUpdateHandler else {
return
}
Task { @MainActor in
handler(snapshot)
}
}
private func emitReply(_ event: WatchQuickReplyEvent) {
guard let handler = self.callbacksSnapshot().replyHandler else {
return
}
Task { @MainActor in
handler(event)
}
}
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
guard let handler = self.callbacksSnapshot().execApprovalResolveHandler else {
return
}
Task { @MainActor in
handler(event)
}
}
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
guard let handler = self.callbacksSnapshot().execApprovalSnapshotRequestHandler else {
return
}
Task { @MainActor in
handler(event)
}
}
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
WatchMessagingStatus(
supported: true,
paired: session.isPaired,
appInstalled: session.isWatchAppInstalled,
reachable: session.isReachable,
activationState: self.activationStateLabel(session.activationState))
}
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
switch state {
case .notActivated:
"notActivated"
case .inactive:
"inactive"
case .activated:
"activated"
@unknown default:
"unknown"
}
}
}
extension WatchConnectivityTransport: WCSessionDelegate {
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: (any Error)?)
{
GatewayDiagnostics.log(
"watch messaging: activation complete state=\(Self.activationStateLabel(activationState)) error=\(error?.localizedDescription ?? "none")")
if let error {
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
} else {
Self.logger.debug(
"watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
}
self.emitStatusUpdate(Self.status(for: session))
}
func sessionDidBecomeInactive(_: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
GatewayDiagnostics.log("watch messaging: session did deactivate; reactivating")
session.activate()
self.emitStatusUpdate(Self.status(for: session))
}
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
let type = (message["type"] as? String) ?? "unknown"
GatewayDiagnostics.log("watch messaging: didReceiveMessage type=\(type)")
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
self.emitReply(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
message,
transport: "sendMessage")
{
self.emitExecApprovalResolve(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
message,
transport: "sendMessage")
{
self.emitExecApprovalSnapshotRequest(event)
}
}
func session(
_: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void)
{
let type = (message["type"] as? String) ?? "unknown"
GatewayDiagnostics.log("watch messaging: didReceiveMessageWithReply type=\(type)")
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(message, transport: "sendMessage") {
replyHandler(["ok": true])
self.emitReply(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
message,
transport: "sendMessage")
{
replyHandler(["ok": true])
self.emitExecApprovalResolve(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
message,
transport: "sendMessage")
{
replyHandler(["ok": true])
self.emitExecApprovalSnapshotRequest(event)
return
}
replyHandler(["ok": false, "error": "unsupported_payload"])
}
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
let type = (userInfo["type"] as? String) ?? "unknown"
GatewayDiagnostics.log("watch messaging: didReceiveUserInfo type=\(type)")
if let event = WatchMessagingPayloadCodec.parseQuickReplyPayload(
userInfo,
transport: "transferUserInfo")
{
self.emitReply(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalResolvePayload(
userInfo,
transport: "transferUserInfo")
{
self.emitExecApprovalResolve(event)
return
}
if let event = WatchMessagingPayloadCodec.parseExecApprovalSnapshotRequestPayload(
userInfo,
transport: "transferUserInfo")
{
self.emitExecApprovalSnapshotRequest(event)
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
GatewayDiagnostics.log(
"watch messaging: reachability changed reachable=\(session.isReachable) paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
self.emitStatusUpdate(Self.status(for: session))
}
}

View File

@@ -1,219 +0,0 @@
import Foundation
import OpenClawKit
enum WatchMessagingPayloadCodec {
static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
}
static func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
static func encodeNotificationPayload(
id: String,
params: OpenClawWatchNotifyParams) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.notify.rawValue,
"id": id,
"title": params.title,
"body": params.body,
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
"sentAtMs": nowMs(),
]
if let promptId = nonEmpty(params.promptId) {
payload["promptId"] = promptId
}
if let sessionKey = nonEmpty(params.sessionKey) {
payload["sessionKey"] = sessionKey
}
if let kind = nonEmpty(params.kind) {
payload["kind"] = kind
}
if let details = nonEmpty(params.details) {
payload["details"] = details
}
if let expiresAtMs = params.expiresAtMs {
payload["expiresAtMs"] = expiresAtMs
}
if let risk = params.risk {
payload["risk"] = risk.rawValue
}
if let actions = params.actions, !actions.isEmpty {
payload["actions"] = actions.map { action in
var encoded: [String: Any] = [
"id": action.id,
"label": action.label,
]
if let style = nonEmpty(action.style) {
encoded["style"] = style
}
return encoded
}
}
return payload
}
static func encodeExecApprovalItem(_ item: OpenClawWatchExecApprovalItem) -> [String: Any] {
var payload: [String: Any] = [
"id": item.id,
"commandText": item.commandText,
"allowedDecisions": item.allowedDecisions.map(\.rawValue),
]
if let commandPreview = nonEmpty(item.commandPreview) {
payload["commandPreview"] = commandPreview
}
if let host = nonEmpty(item.host) {
payload["host"] = host
}
if let nodeId = nonEmpty(item.nodeId) {
payload["nodeId"] = nodeId
}
if let agentId = nonEmpty(item.agentId) {
payload["agentId"] = agentId
}
if let expiresAtMs = item.expiresAtMs {
payload["expiresAtMs"] = expiresAtMs
}
if let risk = item.risk {
payload["risk"] = risk.rawValue
}
return payload
}
static func encodeExecApprovalPromptPayload(
_ message: OpenClawWatchExecApprovalPromptMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalPrompt.rawValue,
"approval": encodeExecApprovalItem(message.approval),
]
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
if let deliveryId = nonEmpty(message.deliveryId) {
payload["deliveryId"] = deliveryId
}
if message.resetResolvingState == true {
payload["resetResolvingState"] = true
}
return payload
}
static func encodeExecApprovalResolvedPayload(
_ message: OpenClawWatchExecApprovalResolvedMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalResolved.rawValue,
"approvalId": message.approvalId,
]
if let decision = message.decision {
payload["decision"] = decision.rawValue
}
if let resolvedAtMs = message.resolvedAtMs {
payload["resolvedAtMs"] = resolvedAtMs
}
if let source = nonEmpty(message.source) {
payload["source"] = source
}
return payload
}
static func encodeExecApprovalExpiredPayload(
_ message: OpenClawWatchExecApprovalExpiredMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalExpired.rawValue,
"approvalId": message.approvalId,
"reason": message.reason.rawValue,
]
if let expiredAtMs = message.expiredAtMs {
payload["expiredAtMs"] = expiredAtMs
}
return payload
}
static func encodeExecApprovalSnapshotPayload(
_ message: OpenClawWatchExecApprovalSnapshotMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": OpenClawWatchPayloadType.execApprovalSnapshot.rawValue,
"approvals": message.approvals.map(encodeExecApprovalItem),
]
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
if let snapshotId = nonEmpty(message.snapshotId) {
payload["snapshotId"] = snapshotId
}
return payload
}
static func parseQuickReplyPayload(
_ payload: [String: Any],
transport: String) -> WatchQuickReplyEvent?
{
guard (payload["type"] as? String) == OpenClawWatchPayloadType.reply.rawValue else {
return nil
}
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
return nil
}
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
let note = nonEmpty(payload["note"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchQuickReplyEvent(
replyId: replyId,
promptId: promptId,
actionId: actionId,
actionLabel: actionLabel,
sessionKey: sessionKey,
note: note,
sentAtMs: sentAtMs,
transport: transport)
}
static func parseExecApprovalResolvePayload(
_ payload: [String: Any],
transport: String) -> WatchExecApprovalResolveEvent?
{
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalResolve.rawValue else {
return nil
}
guard let approvalId = nonEmpty(payload["approvalId"] as? String),
let rawDecision = nonEmpty(payload["decision"] as? String),
let decision = OpenClawWatchExecApprovalDecision(rawValue: rawDecision)
else {
return nil
}
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalResolveEvent(
replyId: replyId,
approvalId: approvalId,
decision: decision,
sentAtMs: sentAtMs,
transport: transport)
}
static func parseExecApprovalSnapshotRequestPayload(
_ payload: [String: Any],
transport: String) -> WatchExecApprovalSnapshotRequestEvent?
{
guard (payload["type"] as? String) == OpenClawWatchPayloadType.execApprovalSnapshotRequest.rawValue else {
return nil
}
let requestId = nonEmpty(payload["requestId"] as? String) ?? UUID().uuidString
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalSnapshotRequestEvent(
requestId: requestId,
sentAtMs: sentAtMs,
transport: transport)
}
}

View File

@@ -1,5 +1,7 @@
import Foundation
import OpenClawKit
import OSLog
@preconcurrency import WatchConnectivity
enum WatchMessagingError: LocalizedError {
case unsupported
@@ -19,136 +21,272 @@ enum WatchMessagingError: LocalizedError {
}
@MainActor
final class WatchMessagingService: @preconcurrency WatchMessagingServicing {
private let transport: WatchConnectivityTransport
private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)?
private var lastEmittedStatus: WatchMessagingStatus?
final class WatchMessagingService: NSObject, @preconcurrency WatchMessagingServicing {
nonisolated private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging")
private let session: WCSession?
private var pendingActivationContinuations: [CheckedContinuation<Void, Never>] = []
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
private var execApprovalSnapshotRequestHandler: (
@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
init(transport: WatchConnectivityTransport = WatchConnectivityTransport()) {
self.transport = transport
self.transport.setStatusUpdateHandler { [weak self] snapshot in
Task { @MainActor [weak self] in
self?.emitStatusIfChanged(snapshot)
}
override init() {
if WCSession.isSupported() {
self.session = WCSession.default
} else {
self.session = nil
}
self.transport.setReplyHandler { [weak self] event in
Task { @MainActor [weak self] in
self?.emitReply(event)
}
}
self.transport.setExecApprovalResolveHandler { [weak self] event in
Task { @MainActor [weak self] in
self?.emitExecApprovalResolve(event)
}
}
self.transport.setExecApprovalSnapshotRequestHandler { [weak self] event in
Task { @MainActor [weak self] in
self?.emitExecApprovalSnapshotRequest(event)
}
super.init()
if let session = self.session {
session.delegate = self
session.activate()
}
}
nonisolated static func isSupportedOnDevice() -> Bool {
WatchConnectivityTransport.isSupportedOnDevice()
WCSession.isSupported()
}
nonisolated static func currentStatusSnapshot() -> WatchMessagingStatus {
WatchConnectivityTransport.currentStatusSnapshot()
guard WCSession.isSupported() else {
return WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "unsupported")
}
let session = WCSession.default
return status(for: session)
}
func status() async -> WatchMessagingStatus {
await self.transport.status()
}
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
self.statusHandler = handler
guard let handler else {
self.lastEmittedStatus = nil
GatewayDiagnostics.log("watch messaging: cleared status handler")
return
await self.ensureActivated()
guard let session = self.session else {
return WatchMessagingStatus(
supported: false,
paired: false,
appInstalled: false,
reachable: false,
activationState: "unsupported")
}
let snapshot = self.transport.currentStatusSnapshot()
self.lastEmittedStatus = snapshot
GatewayDiagnostics.log(
"watch messaging: set status handler supported=\(snapshot.supported) paired=\(snapshot.paired) appInstalled=\(snapshot.appInstalled) reachable=\(snapshot.reachable) activation=\(snapshot.activationState)")
handler(snapshot)
return Self.status(for: session)
}
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
self.replyHandler = handler
}
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
self.execApprovalResolveHandler = handler
}
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
{
self.execApprovalSnapshotRequestHandler = handler
}
func sendNotification(
id: String,
params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult
{
let payload = WatchMessagingPayloadCodec.encodeNotificationPayload(id: id, params: params)
return try await self.transport.sendPayload(payload)
}
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendPayload(
WatchMessagingPayloadCodec.encodeExecApprovalPromptPayload(message))
}
func sendExecApprovalResolved(
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendPayload(
WatchMessagingPayloadCodec.encodeExecApprovalResolvedPayload(message))
}
func sendExecApprovalExpired(
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendPayload(
WatchMessagingPayloadCodec.encodeExecApprovalExpiredPayload(message))
}
func syncExecApprovalSnapshot(
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
{
try await self.transport.sendSnapshotPayload(
WatchMessagingPayloadCodec.encodeExecApprovalSnapshotPayload(message))
}
private func emitStatusIfChanged(_ snapshot: WatchMessagingStatus) {
guard snapshot != self.lastEmittedStatus else {
return
await self.ensureActivated()
guard let session = self.session else {
throw WatchMessagingError.unsupported
}
let snapshot = Self.status(for: session)
guard snapshot.paired else { throw WatchMessagingError.notPaired }
guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled }
var payload: [String: Any] = [
"type": "watch.notify",
"id": id,
"title": params.title,
"body": params.body,
"priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue,
"sentAtMs": Int(Date().timeIntervalSince1970 * 1000),
]
if let promptId = Self.nonEmpty(params.promptId) {
payload["promptId"] = promptId
}
if let sessionKey = Self.nonEmpty(params.sessionKey) {
payload["sessionKey"] = sessionKey
}
if let kind = Self.nonEmpty(params.kind) {
payload["kind"] = kind
}
if let details = Self.nonEmpty(params.details) {
payload["details"] = details
}
if let expiresAtMs = params.expiresAtMs {
payload["expiresAtMs"] = expiresAtMs
}
if let risk = params.risk {
payload["risk"] = risk.rawValue
}
if let actions = params.actions, !actions.isEmpty {
payload["actions"] = actions.map { action in
var encoded: [String: Any] = [
"id": action.id,
"label": action.label,
]
if let style = Self.nonEmpty(action.style) {
encoded["style"] = style
}
return encoded
}
}
if snapshot.reachable {
do {
try await self.sendReachableMessage(payload, with: session)
return WatchNotificationSendResult(
deliveredImmediately: true,
queuedForDelivery: false,
transport: "sendMessage")
} catch {
Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)")
}
}
_ = session.transferUserInfo(payload)
return WatchNotificationSendResult(
deliveredImmediately: false,
queuedForDelivery: true,
transport: "transferUserInfo")
}
private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws {
try await withCheckedThrowingContinuation { continuation in
session.sendMessage(
payload,
replyHandler: { _ in
continuation.resume()
},
errorHandler: { error in
continuation.resume(throwing: error)
}
)
}
self.lastEmittedStatus = snapshot
GatewayDiagnostics.log(
"watch messaging: status supported=\(snapshot.supported) paired=\(snapshot.paired) appInstalled=\(snapshot.appInstalled) reachable=\(snapshot.reachable) activation=\(snapshot.activationState)")
self.statusHandler?(snapshot)
}
private func emitReply(_ event: WatchQuickReplyEvent) {
self.replyHandler?(event)
}
private func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
self.execApprovalResolveHandler?(event)
nonisolated private static func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
GatewayDiagnostics.log(
"watch messaging: snapshot request id=\(event.requestId) transport=\(event.transport) sentAtMs=\(event.sentAtMs ?? -1)")
self.execApprovalSnapshotRequestHandler?(event)
nonisolated private static func parseQuickReplyPayload(
_ payload: [String: Any],
transport: String) -> WatchQuickReplyEvent?
{
guard (payload["type"] as? String) == "watch.reply" else {
return nil
}
guard let actionId = nonEmpty(payload["actionId"] as? String) else {
return nil
}
let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown"
let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString
let actionLabel = nonEmpty(payload["actionLabel"] as? String)
let sessionKey = nonEmpty(payload["sessionKey"] as? String)
let note = nonEmpty(payload["note"] as? String)
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
return WatchQuickReplyEvent(
replyId: replyId,
promptId: promptId,
actionId: actionId,
actionLabel: actionLabel,
sessionKey: sessionKey,
note: note,
sentAtMs: sentAtMs,
transport: transport)
}
private func ensureActivated() async {
guard let session = self.session else { return }
if session.activationState == .activated { return }
session.activate()
await withCheckedContinuation { continuation in
self.pendingActivationContinuations.append(continuation)
}
}
nonisolated private static func status(for session: WCSession) -> WatchMessagingStatus {
WatchMessagingStatus(
supported: true,
paired: session.isPaired,
appInstalled: session.isWatchAppInstalled,
reachable: session.isReachable,
activationState: activationStateLabel(session.activationState))
}
nonisolated private static func activationStateLabel(_ state: WCSessionActivationState) -> String {
switch state {
case .notActivated:
"notActivated"
case .inactive:
"inactive"
case .activated:
"activated"
@unknown default:
"unknown"
}
}
}
extension WatchMessagingService: WCSessionDelegate {
nonisolated func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: (any Error)?)
{
if let error {
Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)")
} else {
Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)")
}
// Always resume all waiters so callers never hang, even on error.
Task { @MainActor in
let waiters = self.pendingActivationContinuations
self.pendingActivationContinuations.removeAll()
for continuation in waiters {
continuation.resume()
}
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) {
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
return
}
Task { @MainActor in
self.emitReply(event)
}
}
nonisolated func session(
_: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void)
{
guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else {
replyHandler(["ok": false, "error": "unsupported_payload"])
return
}
replyHandler(["ok": true])
Task { @MainActor in
self.emitReply(event)
}
}
nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else {
return
}
Task { @MainActor in
self.emitReply(event)
}
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {}
}

View File

@@ -53,7 +53,6 @@ struct SettingsTab: View {
@State private var selectedAgentPickerId: String = ""
@State private var showResetOnboardingAlert: Bool = false
@State private var showGatewayProblemDetails: Bool = false
@State private var activeFeatureHelp: FeatureHelp?
@State private var suppressCredentialPersist: Bool = false
@@ -64,20 +63,6 @@ struct SettingsTab: View {
Form {
Section {
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
!self.isGatewayConnected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: "Retry connection",
onPrimaryAction: {
Task { await self.retryGatewayConnectionFromProblem() }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
if !self.isGatewayConnected {
Text(
"1. Open a chat with your OpenClaw agent and send /pair\n"
@@ -138,7 +123,7 @@ struct SettingsTab: View {
if self.appModel.gatewayServerName == nil {
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
}
LabeledContent("Status", value: self.appModel.gatewayDisplayStatusText)
LabeledContent("Status", value: self.appModel.gatewayStatusText)
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
if let serverName = self.appModel.gatewayServerName {
@@ -417,16 +402,6 @@ struct SettingsTab: View {
.accessibilityLabel("Close")
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {
Task { await self.retryGatewayConnectionFromProblem() }
})
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
@@ -618,9 +593,6 @@ struct SettingsTab: View {
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
return server
}
if let problem = self.appModel.lastGatewayProblem {
return problem.statusText
}
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Not connected" : trimmed
}
@@ -670,7 +642,7 @@ struct SettingsTab: View {
private func gatewayDebugText() -> String {
var lines: [String] = [
"gateway: \(self.appModel.gatewayDisplayStatusText)",
"gateway: \(self.appModel.gatewayStatusText)",
"discovery: \(self.gatewayController.discoveryStatusText)",
]
lines.append("server: \(self.appModel.gatewayServerName ?? "")")
@@ -917,9 +889,6 @@ struct SettingsTab: View {
}
private var setupStatusLine: String? {
if let problem = self.appModel.lastGatewayProblem {
return problem.message
}
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
@@ -1018,14 +987,6 @@ struct SettingsTab: View {
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
}
private func retryGatewayConnectionFromProblem() async {
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
await self.connectManual()
return
}
await self.connectLastKnown()
}
private func resetOnboarding() {
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
self.appModel.disconnectGateway()

View File

@@ -1,24 +1,11 @@
import Foundation
import OpenClawKit
enum GatewayStatusBuilder {
@MainActor
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
self.build(
gatewayServerName: appModel.gatewayServerName,
lastGatewayProblem: appModel.lastGatewayProblem,
gatewayStatusText: appModel.gatewayStatusText)
}
if appModel.gatewayServerName != nil { return .connected }
static func build(
gatewayServerName: String?,
lastGatewayProblem: GatewayConnectionProblem?,
gatewayStatusText: String) -> StatusPill.GatewayState
{
if gatewayServerName != nil { return .connected }
if let lastGatewayProblem, lastGatewayProblem.pauseReconnect { return .error }
let text = gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{

View File

@@ -16,31 +16,6 @@ enum StatusActivityBuilder {
tint: .orange)
}
if let gatewayProblem = appModel.lastGatewayProblem {
switch gatewayProblem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return StatusPill.Activity(
title: "Approval pending",
systemImage: "person.crop.circle.badge.clock",
tint: .orange)
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return StatusPill.Activity(
title: "Check network",
systemImage: "wifi.exclamationmark",
tint: .orange)
default:
if gatewayProblem.pauseReconnect {
return StatusPill.Activity(
title: "Action required",
systemImage: "exclamationmark.triangle.fill",
tint: .orange)
}
}
}
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {

View File

@@ -1,112 +0,0 @@
import Foundation
import Testing
import UserNotifications
@testable import OpenClaw
private final class MockNotificationCenter: NotificationCentering, @unchecked Sendable {
var authorization: NotificationAuthorizationStatus = .authorized
var addedRequests: [UNNotificationRequest] = []
var pendingRemovedIdentifiers: [[String]] = []
var deliveredRemovedIdentifiers: [[String]] = []
var delivered: [NotificationSnapshot] = []
func authorizationStatus() async -> NotificationAuthorizationStatus {
self.authorization
}
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
true
}
func add(_ request: UNNotificationRequest) async throws {
self.addedRequests.append(request)
}
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
self.pendingRemovedIdentifiers.append(identifiers)
}
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
self.deliveredRemovedIdentifiers.append(identifiers)
}
func deliveredNotifications() async -> [NotificationSnapshot] {
self.delivered
}
}
@Suite(.serialized) struct ExecApprovalNotificationBridgeTests {
@Test func parsePromptMapsDefaultNotificationTap() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-123",
],
])
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
}
@Test func parsePromptMapsReviewAction() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: ExecApprovalNotificationBridge.reviewActionIdentifier,
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-456",
],
])
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-456"))
}
@Test func parsePromptIgnoresUnexpectedActionIdentifiers() {
let prompt = ExecApprovalNotificationBridge.parsePrompt(
actionIdentifier: "openclaw.exec-approval.allow-once",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-789",
],
])
#expect(prompt == nil)
}
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
let center = MockNotificationCenter()
center.delivered = [
NotificationSnapshot(
identifier: "remote-approval-1",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-123",
],
]),
NotificationSnapshot(
identifier: "remote-other",
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.requestedKind,
"approvalId": "approval-999",
],
]),
]
let handled = await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
userInfo: [
"openclaw": [
"kind": ExecApprovalNotificationBridge.resolvedKind,
"approvalId": "approval-123",
],
],
notificationCenter: center)
#expect(handled)
#expect(center.pendingRemovedIdentifiers == [["exec.approval.approval-123"]])
#expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]])
}
}

View File

@@ -1,36 +0,0 @@
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite struct GatewayStatusBuilderTests {
@Test func pausedProblemKeepsErrorStatus() {
let state = GatewayStatusBuilder.build(
gatewayServerName: nil,
lastGatewayProblem: GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
title: "Pairing required",
message: "Approve this device before reconnecting.",
requestId: "req-123",
retryable: false,
pauseReconnect: true),
gatewayStatusText: "Reconnecting…")
#expect(state == .error)
}
@Test func transientProblemAllowsConnectingStatus() {
let state = GatewayStatusBuilder.build(
gatewayServerName: nil,
lastGatewayProblem: GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
retryable: true,
pauseReconnect: false),
gatewayStatusText: "Reconnecting…")
#expect(state == .connecting)
}
}

View File

@@ -46,37 +46,16 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
transport: "sendMessage")
var sendError: Error?
var lastSent: (id: String, params: OpenClawWatchNotifyParams)?
var lastSentExecApprovalPrompt: OpenClawWatchExecApprovalPromptMessage?
var lastSentExecApprovalResolved: OpenClawWatchExecApprovalResolvedMessage?
var lastSentExecApprovalExpired: OpenClawWatchExecApprovalExpiredMessage?
var lastSentExecApprovalSnapshot: OpenClawWatchExecApprovalSnapshotMessage?
private var statusHandler: (@Sendable (WatchMessagingStatus) -> Void)?
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
private var execApprovalResolveHandler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?
private var execApprovalSnapshotRequestHandler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?
func status() async -> WatchMessagingStatus {
self.currentStatus
}
func setStatusHandler(_ handler: (@Sendable (WatchMessagingStatus) -> Void)?) {
self.statusHandler = handler
}
func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) {
self.replyHandler = handler
}
func setExecApprovalResolveHandler(_ handler: (@Sendable (WatchExecApprovalResolveEvent) -> Void)?) {
self.execApprovalResolveHandler = handler
}
func setExecApprovalSnapshotRequestHandler(
_ handler: (@Sendable (WatchExecApprovalSnapshotRequestEvent) -> Void)?)
{
self.execApprovalSnapshotRequestHandler = handler
}
func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult {
self.lastSent = (id: id, params: params)
if let sendError = self.sendError {
@@ -85,57 +64,9 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
return self.nextSendResult
}
func sendExecApprovalPrompt(
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
{
self.lastSentExecApprovalPrompt = message
if let sendError = self.sendError {
throw sendError
}
return self.nextSendResult
}
func sendExecApprovalResolved(
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
{
self.lastSentExecApprovalResolved = message
if let sendError = self.sendError {
throw sendError
}
return self.nextSendResult
}
func sendExecApprovalExpired(
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
{
self.lastSentExecApprovalExpired = message
if let sendError = self.sendError {
throw sendError
}
return self.nextSendResult
}
func syncExecApprovalSnapshot(
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
{
self.lastSentExecApprovalSnapshot = message
if let sendError = self.sendError {
throw sendError
}
return self.nextSendResult
}
func emitReply(_ event: WatchQuickReplyEvent) {
self.replyHandler?(event)
}
func emitExecApprovalResolve(_ event: WatchExecApprovalResolveEvent) {
self.execApprovalResolveHandler?(event)
}
func emitExecApprovalSnapshotRequest(_ event: WatchExecApprovalSnapshotRequestEvent) {
self.execApprovalSnapshotRequestHandler?(event)
}
}
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
@@ -158,14 +89,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
}
func add(_: UNNotificationRequest) async throws {}
func removePendingNotificationRequests(withIdentifiers _: [String]) async {}
func removeDeliveredNotifications(withIdentifiers _: [String]) async {}
func deliveredNotifications() async -> [NotificationSnapshot] {
[]
}
}
@Suite(.serialized) struct NodeAppModelInvokeTests {
@@ -196,233 +119,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(appModel.mainSessionKey == "agent:agent-123:main")
}
@Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws {
let appModel = NodeAppModel()
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-1",
commandText: "echo first",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: "main",
expiresAtMs: 1)))
let firstPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
#expect(firstPrompt.id == "approval-1")
#expect(firstPrompt.commandText == "echo first")
#expect(firstPrompt.allowsAllowAlways == false)
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-2",
commandText: "echo second",
allowedDecisions: ["allow-once", "allow-always", "deny"],
host: "gateway",
nodeId: "node-2",
agentId: nil,
expiresAtMs: 2)))
let secondPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
#expect(secondPrompt.id == "approval-2")
#expect(secondPrompt.commandText == "echo second")
#expect(secondPrompt.allowsAllowAlways)
appModel._test_dismissPendingExecApprovalPrompt()
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
}
@Test @MainActor func dismissPendingExecApprovalPromptByIdLeavesDifferentPromptVisible() throws {
let appModel = NodeAppModel()
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-active",
commandText: "echo keep",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: 1)))
appModel.dismissPendingExecApprovalPrompt(approvalId: "approval-stale")
let prompt = try #require(appModel._test_pendingExecApprovalPrompt())
#expect(prompt.id == "approval-active")
}
@Test @MainActor func presentingExecApprovalPromptSyncsWatchPrompt() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let prompt = try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-sync",
commandText: "npm publish",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: "node-1",
agentId: "main",
expiresAtMs: 1234))
appModel._test_presentExecApprovalPrompt(prompt)
await Task.yield()
let sent = try #require(watchService.lastSentExecApprovalPrompt)
#expect(sent.approval.id == "approval-watch-sync")
#expect(sent.approval.allowedDecisions == [.allowOnce, .deny])
#expect(sent.approval.host == "gateway")
#expect(sent.approval.risk == nil)
#expect(sent.resetResolvingState != true)
}
@Test @MainActor func watchExecApprovalSnapshotRequestPublishesCachedApprovalsInBackground() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60_000
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-snapshot",
commandText: "echo from watch",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: futureExpiryMs)))
await Task.yield()
appModel.setScenePhase(.background)
watchService.emitExecApprovalSnapshotRequest(
WatchExecApprovalSnapshotRequestEvent(
requestId: "snapshot-1",
sentAtMs: 111,
transport: "sendMessage"))
await Task.yield()
let snapshot = try #require(watchService.lastSentExecApprovalSnapshot)
#expect(snapshot.approvals.map(\.id) == ["approval-watch-snapshot"])
}
@Test @MainActor func watchExecApprovalSnapshotRequestSkipsForegroundRecovery() async throws {
let watchService = MockWatchMessagingService()
let appModel = NodeAppModel(watchMessagingService: watchService)
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60_000
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-foreground-skip",
commandText: "echo foreground",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: futureExpiryMs)))
await Task.yield()
watchService.lastSentExecApprovalSnapshot = nil
watchService.emitExecApprovalSnapshotRequest(
WatchExecApprovalSnapshotRequestEvent(
requestId: "snapshot-foreground",
sentAtMs: 222,
transport: "sendMessage"))
await Task.yield()
#expect(watchService.lastSentExecApprovalSnapshot == nil)
}
@Test @MainActor func pendingWatchRecoveryIDsAreIncludedWithoutDeliveredNotifications() async {
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter())
appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-recovery")
let ids = await appModel._test_pendingExecApprovalIDsForWatchRecovery()
#expect(ids == ["approval-watch-recovery"])
}
@Test @MainActor func presentingExecApprovalPromptClearsPendingWatchRecoveryID() throws {
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
let appModel = NodeAppModel(notificationCenter: MockBootstrapNotificationCenter())
appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-clear")
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"])
appModel._test_presentExecApprovalPrompt(
try #require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-watch-clear",
commandText: "echo clear",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60_000)))
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs().isEmpty)
}
@Test func approvalNotificationErrorClassificationPrefersStructuredDetails() {
let staleError = GatewayResponseError(
method: "exec.approval.get",
code: "INVALID_REQUEST",
message: "gateway error",
details: ["reason": AnyCodable("APPROVAL_NOT_FOUND")])
let unavailableError = GatewayResponseError(
method: "exec.approval.resolve",
code: "INVALID_REQUEST",
message: "gateway error",
details: ["reason": AnyCodable("APPROVAL_ALLOW_ALWAYS_UNAVAILABLE")])
#expect(NodeAppModel._test_isApprovalNotificationStaleError(staleError))
#expect(NodeAppModel._test_isApprovalNotificationUnavailableError(unavailableError))
}
@Test func backgroundAwareExecApprovalReconnectCoversWatchAndPushPaths() {
#expect(
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "watch_request",
isBackgrounded: true)
)
#expect(
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "push_request",
isBackgrounded: true)
)
#expect(
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "watch_resolve",
isBackgrounded: true)
)
#expect(
!NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "direct",
isBackgrounded: true)
)
#expect(
!NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
sourceReason: "watch_request",
isBackgrounded: false)
)
}
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],
cachedApprovalIDs: ["cached", "also-cached"])
#expect(idsToFetch == ["pending", "other"])
}
@Test func watchExecApprovalRetryPromptResetsResolvingStateOnlyForRetryReason() {
#expect(NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "resolve_retry"))
#expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "push_request"))
#expect(!NodeAppModel._test_shouldResetWatchExecApprovalResolvingStateOnPrompt(reason: "present_prompt"))
}
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
#expect(
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
@@ -813,7 +509,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
note: nil,
sentAtMs: 1234,
transport: "transferUserInfo"))
await Task.yield()
#expect(appModel._test_queuedWatchReplyCount() == 1)
}

View File

@@ -1,26 +0,0 @@
import Testing
@testable import OpenClaw
@Suite(.serialized) struct OpenClawAppDelegateTests {
@Test @MainActor func resolvesRegistryModelBeforeViewTaskAssignsDelegateModel() {
let registryModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = registryModel
defer { OpenClawAppModelRegistry.appModel = nil }
let delegate = OpenClawAppDelegate()
#expect(delegate._test_resolvedAppModel() === registryModel)
}
@Test @MainActor func prefersExplicitDelegateModelOverRegistryFallback() {
let registryModel = NodeAppModel()
let explicitModel = NodeAppModel()
OpenClawAppModelRegistry.appModel = registryModel
defer { OpenClawAppModelRegistry.appModel = nil }
let delegate = OpenClawAppDelegate()
delegate.appModel = explicitModel
#expect(delegate._test_resolvedAppModel() === explicitModel)
}
}

View File

@@ -2,79 +2,27 @@ import SwiftUI
@main
struct OpenClawWatchApp: App {
@Environment(\.scenePhase) private var scenePhase
@State private var inboxStore = WatchInboxStore()
@State private var receiver: WatchConnectivityReceiver?
@State private var execApprovalRefreshTask: Task<Void, Never>?
var body: some Scene {
WindowGroup {
WatchInboxView(
store: self.inboxStore,
onAction: { action in
guard let receiver = self.receiver else { return }
let draft = self.inboxStore.makeReplyDraft(action: action)
self.inboxStore.markReplySending(actionLabel: action.label)
Task { @MainActor in
let result = await receiver.sendReply(draft)
self.inboxStore.markReplyResult(result, actionLabel: action.label)
}
},
onExecApprovalDecision: { approvalId, decision in
guard let receiver = self.receiver else { return }
self.inboxStore.markExecApprovalSending(approvalId: approvalId, decision: decision)
Task { @MainActor in
let result = await receiver.sendExecApprovalResolve(
approvalId: approvalId,
decision: decision)
self.inboxStore.markExecApprovalSendResult(
approvalId: approvalId,
decision: decision,
result: result)
}
},
onRefreshExecApprovalReview: {
self.refreshExecApprovalReview(force: true)
})
WatchInboxView(store: self.inboxStore) { action in
guard let receiver = self.receiver else { return }
let draft = self.inboxStore.makeReplyDraft(action: action)
self.inboxStore.markReplySending(actionLabel: action.label)
Task { @MainActor in
let result = await receiver.sendReply(draft)
self.inboxStore.markReplyResult(result, actionLabel: action.label)
}
}
.task {
if self.receiver == nil {
let receiver = WatchConnectivityReceiver(store: self.inboxStore)
receiver.activate()
self.receiver = receiver
}
self.refreshExecApprovalReview()
}
.onChange(of: self.scenePhase) { _, newPhase in
guard newPhase == .active else { return }
self.refreshExecApprovalReview()
}
}
}
private func refreshExecApprovalReview(force: Bool = false) {
guard let receiver = self.receiver else { return }
guard force || self.inboxStore.shouldAutoRequestExecApprovalSnapshot else { return }
self.execApprovalRefreshTask?.cancel()
self.execApprovalRefreshTask = Task { @MainActor in
self.inboxStore.beginExecApprovalReviewLoading()
for attempt in 0..<5 {
if Task.isCancelled { return }
await receiver.requestExecApprovalSnapshot()
if !self.inboxStore.execApprovals.isEmpty
|| self.inboxStore.hasCompletedExecApprovalSnapshotRefresh
{
self.inboxStore.markExecApprovalReviewLoaded()
return
}
if attempt < 4 {
try? await Task.sleep(nanoseconds: 700_000_000)
}
}
if self.inboxStore.execApprovals.isEmpty {
self.inboxStore.markExecApprovalReviewUnavailable(
"Couldn't load approval from your iPhone yet.")
}
}
}
}

View File

@@ -52,31 +52,6 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
}
func requestExecApprovalSnapshot() async {
await self.ensureActivated()
guard let session = self.session else { return }
let request = WatchExecApprovalSnapshotRequestMessage(
requestId: UUID().uuidString,
sentAtMs: Self.nowMs())
let payload = Self.encodeSnapshotRequestPayload(request)
if session.isReachable {
do {
try await withCheckedThrowingContinuation(isolation: nil) {
(continuation: CheckedContinuation<Void, Error>) in
session.sendMessage(payload, replyHandler: { _ in
continuation.resume(returning: ())
}, errorHandler: { error in
continuation.resume(throwing: error)
})
}
return
} catch {
// Fall through to queued delivery.
}
}
_ = session.transferUserInfo(payload)
}
func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult {
await self.ensureActivated()
guard let session = self.session else {
@@ -88,7 +63,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
var payload: [String: Any] = [
"type": WatchPayloadType.reply.rawValue,
"type": "watch.reply",
"replyId": draft.replyId,
"promptId": draft.promptId,
"actionId": draft.actionId,
@@ -108,38 +83,11 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
payload["note"] = note
}
return await self.sendPayload(payload, session: session)
}
func sendExecApprovalResolve(
approvalId: String,
decision: WatchExecApprovalDecision) async -> WatchReplySendResult
{
await self.ensureActivated()
guard let session = self.session else {
return WatchReplySendResult(
deliveredImmediately: false,
queuedForDelivery: false,
transport: "none",
errorMessage: "watch session unavailable")
}
let payload = Self.encodeExecApprovalResolvePayload(
WatchExecApprovalResolveMessage(
approvalId: approvalId,
decision: decision,
replyId: UUID().uuidString,
sentAtMs: Self.nowMs()))
return await self.sendPayload(payload, session: session)
}
private func sendPayload(_ payload: [String: Any], session: WCSession) async -> WatchReplySendResult {
if session.isReachable {
do {
try await withCheckedThrowingContinuation(isolation: nil) {
(continuation: CheckedContinuation<Void, Error>) in
try await withCheckedThrowingContinuation { continuation in
session.sendMessage(payload, replyHandler: { _ in
continuation.resume(returning: ())
continuation.resume()
}, errorHandler: { error in
continuation.resume(throwing: error)
})
@@ -162,10 +110,6 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
errorMessage: nil)
}
private static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
}
private static func normalizeObject(_ value: Any) -> [String: Any]? {
if let object = value as? [String: Any] {
return object
@@ -203,9 +147,7 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
}
private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? {
guard let type = payload["type"] as? String,
type == WatchPayloadType.notify.rawValue
else {
guard let type = payload["type"] as? String, type == "watch.notify" else {
return nil
}
@@ -247,153 +189,6 @@ final class WatchConnectivityReceiver: NSObject, @unchecked Sendable {
risk: risk,
actions: actions)
}
private static func parseExecApprovalDecision(_ value: Any?) -> WatchExecApprovalDecision? {
let raw = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return WatchExecApprovalDecision(rawValue: raw)
}
private static func parseExecApprovalItem(_ value: Any?) -> WatchExecApprovalItem? {
guard let payload = value.flatMap(Self.normalizeObject) else {
return nil
}
let id = (payload["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let commandText = (payload["commandText"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !id.isEmpty, !commandText.isEmpty else {
return nil
}
let commandPreview = (payload["commandPreview"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let host = (payload["host"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let nodeId = (payload["nodeId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let agentId = (payload["agentId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue
let riskRaw = (payload["risk"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let risk = WatchRiskLevel(rawValue: riskRaw)
let allowedDecisions = (payload["allowedDecisions"] as? [Any] ?? []).compactMap {
Self.parseExecApprovalDecision($0)
}
return WatchExecApprovalItem(
id: id,
commandText: commandText,
commandPreview: commandPreview,
host: host,
nodeId: nodeId,
agentId: agentId,
expiresAtMs: expiresAtMs,
allowedDecisions: allowedDecisions,
risk: risk)
}
private static func parseExecApprovalPromptPayload(
_ payload: [String: Any]) -> WatchExecApprovalPromptMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalPrompt.rawValue,
let approval = Self.parseExecApprovalItem(payload["approval"])
else {
return nil
}
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let deliveryId = (payload["deliveryId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let resetResolvingState = payload["resetResolvingState"] as? Bool
return WatchExecApprovalPromptMessage(
approval: approval,
sentAtMs: sentAtMs,
deliveryId: deliveryId,
resetResolvingState: resetResolvingState)
}
private static func parseExecApprovalResolvedPayload(
_ payload: [String: Any]) -> WatchExecApprovalResolvedMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalResolved.rawValue
else {
return nil
}
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !approvalId.isEmpty else { return nil }
let decision = Self.parseExecApprovalDecision(payload["decision"])
let resolvedAtMs = (payload["resolvedAtMs"] as? Int)
?? (payload["resolvedAtMs"] as? NSNumber)?.intValue
let source = (payload["source"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalResolvedMessage(
approvalId: approvalId,
decision: decision,
resolvedAtMs: resolvedAtMs,
source: source)
}
private static func parseExecApprovalExpiredPayload(
_ payload: [String: Any]) -> WatchExecApprovalExpiredMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalExpired.rawValue
else {
return nil
}
let approvalId = (payload["approvalId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let rawReason = (payload["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !approvalId.isEmpty,
let reason = WatchExecApprovalCloseReason(rawValue: rawReason)
else {
return nil
}
let expiredAtMs = (payload["expiredAtMs"] as? Int) ?? (payload["expiredAtMs"] as? NSNumber)?.intValue
return WatchExecApprovalExpiredMessage(
approvalId: approvalId,
reason: reason,
expiredAtMs: expiredAtMs)
}
private static func parseExecApprovalSnapshotPayload(
_ payload: [String: Any]) -> WatchExecApprovalSnapshotMessage?
{
guard let type = payload["type"] as? String,
type == WatchPayloadType.execApprovalSnapshot.rawValue
else {
return nil
}
let approvals = (payload["approvals"] as? [Any] ?? []).compactMap { item in
Self.parseExecApprovalItem(item)
}
let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue
let snapshotId = (payload["snapshotId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
return WatchExecApprovalSnapshotMessage(
approvals: approvals,
sentAtMs: sentAtMs,
snapshotId: snapshotId)
}
private static func encodeSnapshotRequestPayload(
_ request: WatchExecApprovalSnapshotRequestMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": WatchPayloadType.execApprovalSnapshotRequest.rawValue,
"requestId": request.requestId,
]
if let sentAtMs = request.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
private static func encodeExecApprovalResolvePayload(
_ message: WatchExecApprovalResolveMessage) -> [String: Any]
{
var payload: [String: Any] = [
"type": WatchPayloadType.execApprovalResolve.rawValue,
"approvalId": message.approvalId,
"decision": message.decision.rawValue,
"replyId": message.replyId,
]
if let sentAtMs = message.sentAtMs {
payload["sentAtMs"] = sentAtMs
}
return payload
}
}
extension WatchConnectivityReceiver: WCSessionDelegate {
@@ -401,14 +196,13 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
_: WCSession,
activationDidCompleteWith _: WCSessionActivationState,
error _: (any Error)?)
{
Task {
await self.requestExecApprovalSnapshot()
}
}
{}
func session(_: WCSession, didReceiveMessage message: [String: Any]) {
self.consumeIncomingPayload(message, transport: "sendMessage")
guard let incoming = Self.parseNotificationPayload(message) else { return }
Task { @MainActor in
self.store.consume(message: incoming, transport: "sendMessage")
}
}
func session(
@@ -416,47 +210,27 @@ extension WatchConnectivityReceiver: WCSessionDelegate {
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void)
{
guard let incoming = Self.parseNotificationPayload(message) else {
replyHandler(["ok": false])
return
}
replyHandler(["ok": true])
self.consumeIncomingPayload(message, transport: "sendMessage")
Task { @MainActor in
self.store.consume(message: incoming, transport: "sendMessage")
}
}
func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
self.consumeIncomingPayload(userInfo, transport: "transferUserInfo")
guard let incoming = Self.parseNotificationPayload(userInfo) else { return }
Task { @MainActor in
self.store.consume(message: incoming, transport: "transferUserInfo")
}
}
func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
self.consumeIncomingPayload(applicationContext, transport: "applicationContext")
}
private func consumeIncomingPayload(_ payload: [String: Any], transport: String) {
if let incoming = Self.parseNotificationPayload(payload) {
Task { @MainActor in
self.store.consume(message: incoming, transport: transport)
}
return
}
if let prompt = Self.parseExecApprovalPromptPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalPrompt: prompt, transport: transport)
}
return
}
if let resolved = Self.parseExecApprovalResolvedPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalResolved: resolved)
}
return
}
if let expired = Self.parseExecApprovalExpiredPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalExpired: expired)
}
return
}
if let snapshot = Self.parseExecApprovalSnapshotPayload(payload) {
Task { @MainActor in
self.store.consume(execApprovalSnapshot: snapshot, transport: transport)
}
guard let incoming = Self.parseNotificationPayload(applicationContext) else { return }
Task { @MainActor in
self.store.consume(message: incoming, transport: "applicationContext")
}
}
}

View File

@@ -3,86 +3,6 @@ import Observation
import UserNotifications
import WatchKit
enum WatchPayloadType: String, Codable, Sendable, Equatable {
case notify = "watch.notify"
case reply = "watch.reply"
case execApprovalPrompt = "watch.execApproval.prompt"
case execApprovalResolve = "watch.execApproval.resolve"
case execApprovalResolved = "watch.execApproval.resolved"
case execApprovalExpired = "watch.execApproval.expired"
case execApprovalSnapshot = "watch.execApproval.snapshot"
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
}
enum WatchRiskLevel: String, Codable, Sendable, Equatable {
case low
case medium
case high
}
enum WatchExecApprovalDecision: String, Codable, Sendable, Equatable {
case allowOnce = "allow-once"
case deny
}
enum WatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
case expired
case notFound = "not-found"
case unavailable
case replaced
case resolved
}
struct WatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
var id: String
var commandText: String
var commandPreview: String?
var host: String?
var nodeId: String?
var agentId: String?
var expiresAtMs: Int?
var allowedDecisions: [WatchExecApprovalDecision]
var risk: WatchRiskLevel?
}
struct WatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
var approval: WatchExecApprovalItem
var sentAtMs: Int?
var deliveryId: String?
var resetResolvingState: Bool?
}
struct WatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
var approvalId: String
var decision: WatchExecApprovalDecision?
var resolvedAtMs: Int?
var source: String?
}
struct WatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
var approvalId: String
var reason: WatchExecApprovalCloseReason
var expiredAtMs: Int?
}
struct WatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
var approvals: [WatchExecApprovalItem]
var sentAtMs: Int?
var snapshotId: String?
}
struct WatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
var requestId: String
var sentAtMs: Int?
}
struct WatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
var approvalId: String
var decision: WatchExecApprovalDecision
var replyId: String
var sentAtMs: Int?
}
struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable {
var id: String
var label: String
@@ -103,18 +23,6 @@ struct WatchNotifyMessage: Sendable {
var actions: [WatchPromptAction]
}
struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
var approval: WatchExecApprovalItem
var transport: String
var updatedAt: Date
var isResolving: Bool
var pendingDecision: WatchExecApprovalDecision?
var statusText: String?
var statusAt: Date?
var id: String { self.approval.id }
}
@MainActor @Observable final class WatchInboxStore {
private struct PersistedState: Codable {
var title: String
@@ -131,20 +39,13 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
var actions: [WatchPromptAction]?
var replyStatusText: String?
var replyStatusAt: Date?
var execApprovals: [WatchExecApprovalRecord]
var selectedExecApprovalID: String?
var lastExecApprovalSnapshotID: String?
var lastExecApprovalOutcomeText: String?
var lastExecApprovalOutcomeAt: Date?
}
private static let persistedStateKey = "watch.inbox.state.v2"
private static let defaultTitle = "OpenClaw"
private static let defaultBody = "Waiting for messages from your iPhone."
private static let persistedStateKey = "watch.inbox.state.v1"
private let defaults: UserDefaults
var title = WatchInboxStore.defaultTitle
var body = WatchInboxStore.defaultBody
var title = "OpenClaw"
var body = "Waiting for messages from your iPhone."
var transport = "none"
var updatedAt: Date?
var promptId: String?
@@ -157,88 +58,16 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
var replyStatusText: String?
var replyStatusAt: Date?
var isReplySending = false
var execApprovals: [WatchExecApprovalRecord] = []
var selectedExecApprovalID: String?
var lastExecApprovalOutcomeText: String?
var lastExecApprovalOutcomeAt: Date?
var isExecApprovalReviewLoading = false
var execApprovalReviewStatusText: String?
var execApprovalReviewStatusAt: Date?
private var lastExecApprovalSnapshotID: String?
private var hasCompletedExecApprovalSnapshotRefreshInSession = false
private var lastDeliveryKey: String?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.restorePersistedState()
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
Task {
await self.ensureNotificationAuthorization()
}
}
var sortedExecApprovals: [WatchExecApprovalRecord] {
self.execApprovals.sorted { lhs, rhs in
let lhsExpires = lhs.approval.expiresAtMs ?? Int.max
let rhsExpires = rhs.approval.expiresAtMs ?? Int.max
if lhsExpires != rhsExpires {
return lhsExpires < rhsExpires
}
return lhs.updatedAt > rhs.updatedAt
}
}
var activeExecApproval: WatchExecApprovalRecord? {
if let selectedExecApprovalID,
let selected = self.execApprovals.first(where: { $0.id == selectedExecApprovalID })
{
return selected
}
return self.sortedExecApprovals.first
}
var shouldAutoRequestExecApprovalSnapshot: Bool {
self.execApprovals.isEmpty
&& self.actions.isEmpty
&& self.title == Self.defaultTitle
&& self.body == Self.defaultBody
&& !self.hasCompletedExecApprovalSnapshotRefreshInSession
}
var hasCompletedExecApprovalSnapshotRefresh: Bool {
self.hasCompletedExecApprovalSnapshotRefreshInSession
}
var shouldShowExecApprovalReviewStatus: Bool {
self.execApprovals.isEmpty && !(self.execApprovalReviewStatusText?.isEmpty ?? true)
}
func beginExecApprovalReviewLoading() {
guard self.execApprovals.isEmpty else {
self.markExecApprovalReviewLoaded()
return
}
self.isExecApprovalReviewLoading = true
self.execApprovalReviewStatusText = "Loading approval from iPhone…"
self.execApprovalReviewStatusAt = Date()
}
func markExecApprovalReviewLoaded() {
self.isExecApprovalReviewLoading = false
self.execApprovalReviewStatusText = nil
self.execApprovalReviewStatusAt = nil
}
func markExecApprovalReviewUnavailable(_ message: String) {
guard self.execApprovals.isEmpty else {
self.markExecApprovalReviewLoaded()
return
}
self.isExecApprovalReviewLoading = false
self.execApprovalReviewStatusText = message
self.execApprovalReviewStatusAt = Date()
}
func consume(message: WatchNotifyMessage, transport: String) {
let messageID = message.id?
.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -253,7 +82,6 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
self.title = normalizedTitle
self.body = message.body
self.transport = transport
self.markExecApprovalReviewLoaded()
self.updatedAt = Date()
self.promptId = message.promptId
self.sessionKey = message.sessionKey
@@ -277,209 +105,6 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
}
}
func consume(
execApprovalPrompt message: WatchExecApprovalPromptMessage,
transport: String)
{
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
self.upsertExecApproval(
message.approval,
transport: transport,
keepSelectionIfPossible: true,
resetResolvingState: message.resetResolvingState == true)
self.markExecApprovalReviewLoaded()
self.lastExecApprovalOutcomeText = nil
self.lastExecApprovalOutcomeAt = nil
Task {
await self.postLocalNotification(
identifier: "watch.execApproval.\(message.approval.id)",
title: "Exec approval required",
body: message.approval.commandPreview ?? message.approval.commandText,
risk: message.approval.risk?.rawValue)
}
}
func consume(
execApprovalSnapshot message: WatchExecApprovalSnapshotMessage,
transport: String)
{
let snapshotID = message.snapshotId?.trimmingCharacters(in: .whitespacesAndNewlines)
if let snapshotID, !snapshotID.isEmpty, snapshotID == self.lastExecApprovalSnapshotID {
return
}
let existingRecordsByID = Dictionary(
uniqueKeysWithValues: self.execApprovals.map { ($0.id, $0) })
self.execApprovals = message.approvals.map { approval in
self.mergedExecApprovalRecord(
approval: approval,
transport: transport,
existingRecord: existingRecordsByID[approval.id])
}
self.lastExecApprovalSnapshotID = snapshotID
self.hasCompletedExecApprovalSnapshotRefreshInSession = true
if let selectedExecApprovalID,
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
{
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
} else if self.selectedExecApprovalID == nil {
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.pruneExpiredExecApprovals(nowMs: Self.nowMs())
self.markExecApprovalReviewLoaded()
self.persistState()
}
func consume(execApprovalResolved message: WatchExecApprovalResolvedMessage) {
self.removeExecApproval(id: message.approvalId)
let statusText: String
switch message.decision {
case .allowOnce:
statusText = "Allowed once"
case .deny:
statusText = "Denied"
case nil:
statusText = "Approval resolved"
}
self.lastExecApprovalOutcomeText = statusText
self.lastExecApprovalOutcomeAt = Date()
self.persistState()
}
func consume(execApprovalExpired message: WatchExecApprovalExpiredMessage) {
self.removeExecApproval(id: message.approvalId)
let statusText: String
switch message.reason {
case .expired:
statusText = "Approval expired"
case .notFound:
statusText = "Approval no longer available"
case .resolved:
statusText = "Approval resolved elsewhere"
case .replaced:
statusText = "Approval replaced"
case .unavailable:
statusText = "Approval unavailable"
}
self.lastExecApprovalOutcomeText = statusText
self.lastExecApprovalOutcomeAt = Date()
self.persistState()
}
func selectExecApproval(id: String) {
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
guard self.execApprovals.contains(where: { $0.id == normalizedID }) else { return }
self.selectedExecApprovalID = normalizedID
self.persistState()
}
func markExecApprovalSending(approvalId: String, decision: WatchExecApprovalDecision) {
guard let index = self.execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
self.execApprovals[index].isResolving = true
self.execApprovals[index].pendingDecision = decision
self.execApprovals[index].statusText = "Sending \(Self.decisionLabel(decision))"
self.execApprovals[index].statusAt = Date()
self.persistState()
}
func markExecApprovalSendResult(
approvalId: String,
decision: WatchExecApprovalDecision,
result: WatchReplySendResult)
{
guard let index = self.execApprovals.firstIndex(where: { $0.id == approvalId }) else { return }
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
self.execApprovals[index].isResolving = false
self.execApprovals[index].statusText = "Failed: \(errorMessage)"
} else if result.deliveredImmediately {
self.execApprovals[index].isResolving = true
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent"
} else if result.queuedForDelivery {
self.execApprovals[index].isResolving = true
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): queued"
} else {
self.execApprovals[index].isResolving = true
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent"
}
self.execApprovals[index].pendingDecision = result.errorMessage == nil ? decision : nil
self.execApprovals[index].statusAt = Date()
self.persistState()
}
private func upsertExecApproval(
_ approval: WatchExecApprovalItem,
transport: String,
keepSelectionIfPossible: Bool,
resetResolvingState: Bool = false)
{
if let index = self.execApprovals.firstIndex(where: { $0.id == approval.id }) {
self.execApprovals[index] = self.mergedExecApprovalRecord(
approval: approval,
transport: transport,
existingRecord: self.execApprovals[index],
resetResolvingState: resetResolvingState)
} else {
self.execApprovals.append(
self.mergedExecApprovalRecord(
approval: approval,
transport: transport,
existingRecord: nil,
resetResolvingState: resetResolvingState))
}
if !keepSelectionIfPossible || self.selectedExecApprovalID == nil {
self.selectedExecApprovalID = approval.id
}
self.persistState()
}
private func mergedExecApprovalRecord(
approval: WatchExecApprovalItem,
transport: String,
existingRecord: WatchExecApprovalRecord?,
resetResolvingState: Bool = false) -> WatchExecApprovalRecord
{
// Preserve in-flight state across ordinary snapshot/prompt refreshes so duplicate
// submissions stay disabled, but clear it when the iPhone explicitly republishes a
// prompt after a failed resolve so the watch can retry.
let isResolving = resetResolvingState ? false : (existingRecord?.isResolving ?? false)
let pendingDecision = resetResolvingState ? nil : existingRecord?.pendingDecision
let statusText = resetResolvingState ? nil : existingRecord?.statusText
let statusAt = resetResolvingState ? nil : existingRecord?.statusAt
return WatchExecApprovalRecord(
approval: approval,
transport: transport,
updatedAt: Date(),
isResolving: isResolving,
pendingDecision: pendingDecision,
statusText: statusText,
statusAt: statusAt)
}
private func removeExecApproval(id: String) {
let normalizedID = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedID.isEmpty else { return }
self.execApprovals.removeAll { $0.id == normalizedID }
if self.selectedExecApprovalID == normalizedID {
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.persistState()
}
private func pruneExpiredExecApprovals(nowMs: Int) {
self.execApprovals.removeAll { record in
guard let expiresAtMs = record.approval.expiresAtMs else { return false }
return expiresAtMs <= nowMs
}
if let selectedExecApprovalID,
!self.execApprovals.contains(where: { $0.id == selectedExecApprovalID })
{
self.selectedExecApprovalID = self.sortedExecApprovals.first?.id
}
self.persistState()
}
private func restorePersistedState() {
guard let data = self.defaults.data(forKey: Self.persistedStateKey),
let state = try? JSONDecoder().decode(PersistedState.self, from: data)
@@ -501,15 +126,10 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
self.actions = state.actions ?? []
self.replyStatusText = state.replyStatusText
self.replyStatusAt = state.replyStatusAt
self.execApprovals = state.execApprovals
self.selectedExecApprovalID = state.selectedExecApprovalID
self.lastExecApprovalSnapshotID = state.lastExecApprovalSnapshotID
self.lastExecApprovalOutcomeText = state.lastExecApprovalOutcomeText
self.lastExecApprovalOutcomeAt = state.lastExecApprovalOutcomeAt
}
private func persistState() {
let updatedAt = self.updatedAt ?? self.lastExecApprovalOutcomeAt ?? Date()
guard let updatedAt = self.updatedAt else { return }
let state = PersistedState(
title: self.title,
body: self.body,
@@ -524,12 +144,7 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
risk: self.risk,
actions: self.actions,
replyStatusText: self.replyStatusText,
replyStatusAt: self.replyStatusAt,
execApprovals: self.execApprovals,
selectedExecApprovalID: self.selectedExecApprovalID,
lastExecApprovalSnapshotID: self.lastExecApprovalSnapshotID,
lastExecApprovalOutcomeText: self.lastExecApprovalOutcomeText,
lastExecApprovalOutcomeAt: self.lastExecApprovalOutcomeAt)
replyStatusAt: self.replyStatusAt)
guard let data = try? JSONEncoder().encode(state) else { return }
self.defaults.set(data, forKey: Self.persistedStateKey)
}
@@ -572,7 +187,7 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
actionLabel: action.label,
sessionKey: self.sessionKey,
note: nil,
sentAtMs: Self.nowMs())
sentAtMs: Int(Date().timeIntervalSince1970 * 1000))
}
func markReplySending(actionLabel: String) {
@@ -612,17 +227,4 @@ struct WatchExecApprovalRecord: Codable, Sendable, Equatable, Identifiable {
_ = try? await UNUserNotificationCenter.current().add(request)
WKInterfaceDevice.current().play(self.mapHapticRisk(risk))
}
private static func decisionLabel(_ decision: WatchExecApprovalDecision) -> String {
switch decision {
case .allowOnce:
"Allow Once"
case .deny:
"Deny"
}
}
private static func nowMs() -> Int {
Int(Date().timeIntervalSince1970 * 1000)
}
}

View File

@@ -1,246 +1,7 @@
import SwiftUI
struct WatchInboxView: View {
var store: WatchInboxStore
var onAction: ((WatchPromptAction) -> Void)?
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
var onRefreshExecApprovalReview: (() -> Void)?
var body: some View {
NavigationStack {
if self.store.sortedExecApprovals.count == 1,
let record = self.store.activeExecApproval
{
WatchExecApprovalDetailView(
store: self.store,
record: record,
onDecision: self.onExecApprovalDecision)
} else if !self.store.sortedExecApprovals.isEmpty {
WatchExecApprovalListView(
store: self.store,
onDecision: self.onExecApprovalDecision)
} else if self.store.shouldShowExecApprovalReviewStatus {
WatchExecApprovalLoadingView(
store: self.store,
onRetry: self.onRefreshExecApprovalReview)
} else {
WatchGenericInboxView(store: self.store, onAction: self.onAction)
}
}
}
}
private struct WatchExecApprovalLoadingView: View {
var store: WatchInboxStore
var onRetry: (() -> Void)?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Text("Exec approval")
.font(.headline)
if self.store.isExecApprovalReviewLoading {
ProgressView()
.frame(maxWidth: .infinity, alignment: .leading)
}
if let statusText = self.store.execApprovalReviewStatusText, !statusText.isEmpty {
Text(statusText)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
if !self.store.isExecApprovalReviewLoading {
Button("Retry") {
self.onRetry?()
}
}
Text("Keep your iPhone nearby and unlocked if review details take a moment to appear.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("Exec approval")
}
}
private struct WatchExecApprovalListView: View {
var store: WatchInboxStore
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
var body: some View {
List {
Section("Exec approvals") {
ForEach(self.store.sortedExecApprovals) { record in
NavigationLink {
WatchExecApprovalDetailView(
store: self.store,
record: record,
onDecision: self.onDecision)
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(record.approval.commandPreview ?? record.approval.commandText)
.font(.headline)
.lineLimit(2)
Text(self.metadataLine(for: record))
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
if let statusText = record.statusText, !statusText.isEmpty {
Text(statusText)
.font(.footnote)
.foregroundStyle(record.isResolving ? Color.secondary : Color.red)
.lineLimit(2)
}
}
}
}
}
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
Section("Last result") {
Text(outcome)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Approvals")
}
private func metadataLine(for record: WatchExecApprovalRecord) -> String {
var parts: [String] = []
if let host = record.approval.host, !host.isEmpty {
parts.append(host)
}
if let nodeId = record.approval.nodeId, !nodeId.isEmpty {
parts.append(nodeId)
}
if let expiresText = Self.expiresText(record.approval.expiresAtMs) {
parts.append(expiresText)
}
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
}
private static func expiresText(_ expiresAtMs: Int?) -> String? {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "Expires in <1m"
}
return "Expires in \(deltaSeconds / 60)m"
}
}
private struct WatchExecApprovalDetailView: View {
var store: WatchInboxStore
let record: WatchExecApprovalRecord
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
Text(self.record.approval.commandText)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
if let host = self.record.approval.host, !host.isEmpty {
self.metadataRow(label: "Host", value: host)
}
if let nodeId = self.record.approval.nodeId, !nodeId.isEmpty {
self.metadataRow(label: "Node", value: nodeId)
}
if let agentId = self.record.approval.agentId, !agentId.isEmpty {
self.metadataRow(label: "Agent", value: agentId)
}
if let expiresText = Self.expiresText(self.record.approval.expiresAtMs) {
self.metadataRow(label: "Expires", value: expiresText)
}
if let riskText = self.riskText(self.record.approval.risk) {
self.metadataRow(label: "Risk", value: riskText)
}
if let statusText = self.currentRecord?.statusText, !statusText.isEmpty {
Text(statusText)
.font(.footnote)
.foregroundStyle((self.currentRecord?.isResolving ?? false) ? Color.secondary : Color.red)
}
if let currentRecord,
currentRecord.approval.allowedDecisions.contains(.allowOnce)
{
Button("Allow Once") {
self.onDecision?(currentRecord.id, .allowOnce)
}
.disabled(currentRecord.isResolving)
}
if let currentRecord,
currentRecord.approval.allowedDecisions.contains(.deny)
{
Button(role: .destructive) {
self.onDecision?(currentRecord.id, .deny)
} label: {
Text("Deny")
.frame(maxWidth: .infinity)
}
.disabled(currentRecord.isResolving)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("Exec approval")
.onAppear {
self.store.selectExecApproval(id: self.record.id)
}
}
private var currentRecord: WatchExecApprovalRecord? {
self.store.execApprovals.first(where: { $0.id == self.record.id })
}
private func metadataRow(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.footnote)
.fixedSize(horizontal: false, vertical: true)
}
}
private func riskText(_ risk: WatchRiskLevel?) -> String? {
switch risk {
case .high:
return "High"
case .medium:
return "Medium"
case .low:
return "Low"
case nil:
return nil
}
}
private static func expiresText(_ expiresAtMs: Int?) -> String? {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "<1 minute"
}
return "\(deltaSeconds / 60) minutes"
}
}
private struct WatchGenericInboxView: View {
var store: WatchInboxStore
@Bindable var store: WatchInboxStore
var onAction: ((WatchPromptAction) -> Void)?
private func role(for action: WatchPromptAction) -> ButtonRole? {
@@ -257,46 +18,40 @@ private struct WatchGenericInboxView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
Text(self.store.title)
Text(store.title)
.font(.headline)
.lineLimit(2)
Text(self.store.body)
Text(store.body)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
if let details = self.store.details, !details.isEmpty {
if let details = store.details, !details.isEmpty {
Text(details)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
Text(outcome)
.font(.footnote)
.foregroundStyle(.secondary)
}
if !self.store.actions.isEmpty {
ForEach(self.store.actions) { action in
if !store.actions.isEmpty {
ForEach(store.actions) { action in
Button(role: self.role(for: action)) {
self.onAction?(action)
} label: {
Text(action.label)
.frame(maxWidth: .infinity)
}
.disabled(self.store.isReplySending)
.disabled(store.isReplySending)
}
}
if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty {
if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty {
Text(replyStatusText)
.font(.footnote)
.foregroundStyle(.secondary)
}
if let updatedAt = self.store.updatedAt {
if let updatedAt = store.updatedAt {
Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))")
.font(.footnote)
.foregroundStyle(.secondary)
@@ -305,6 +60,5 @@ private struct WatchGenericInboxView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("OpenClaw")
}
}

View File

@@ -29,8 +29,6 @@ ASC_KEYCHAIN_SERVICE=openclaw-asc-key
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
```
Important: `apps/ios/fastlane/.env` is only for Fastlane/App Store Connect auth and optional beta-archive settings. It does **not** configure gateway-side direct APNs push delivery for local iOS builds.
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
```bash
@@ -55,8 +53,6 @@ IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
Tip: run `scripts/ios-team-id.sh` from repo root to print a Team ID for `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing.
For local/manual iOS builds that stay on direct APNs, configure the gateway host separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`. Those gateway runtime env vars are separate from Fastlane's `.env`.
Validate auth:
```bash
@@ -90,43 +86,6 @@ cd apps/ios
fastlane ios beta
```
Maintainer recovery path for a fresh clone on the same Mac:
1. Reuse the existing Keychain-backed ASC key on that machine.
2. Restore or recreate `apps/ios/fastlane/.env` so it contains the non-secret variables:
```bash
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEYCHAIN_SERVICE=openclaw-asc-key
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
```
3. Re-run auth validation:
```bash
cd apps/ios
fastlane ios auth_check
```
4. Set the official/TestFlight relay URL before release:
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
```
5. Upload:
```bash
pnpm ios:beta
```
Quick verification after upload:
- confirm `apps/ios/build/beta/OpenClaw-<version>.ipa` exists
- confirm Fastlane prints `Uploaded iOS beta: version=<version> short=<short> build=<build>`
- remember that TestFlight processing can take a few minutes after the upload succeeds
Versioning rules:
- Root `package.json.version` is the single source of truth for iOS

View File

@@ -237,19 +237,12 @@ targets:
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
info:
path: WatchApp/Info.plist
properties:
@@ -272,16 +265,9 @@ targets:
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
info:
path: WatchExtension/Info.plist
properties:

View File

@@ -299,10 +299,6 @@ enum GatewayEnvironment {
if normalized.lowercased().hasPrefix("openclaw ") {
normalized = String(normalized.dropFirst("openclaw ".count))
}
// Strip trailing commit metadata, e.g. "2026.4.2 (d74a122)" "2026.4.2"
if let parenRange = normalized.range(of: #"\s*\([0-9a-fA-F]+\)\s*$"#, options: .regularExpression) {
normalized = String(normalized[normalized.startIndex..<parenRange.lowerBound])
}
return normalized
}

View File

@@ -28,8 +28,6 @@ enum HostEnvSecurityPolicy {
"CC",
"CXX",
"CARGO_BUILD_RUSTC",
"CARGO_BUILD_RUSTC_WRAPPER",
"RUSTC_WRAPPER",
"CMAKE_C_COMPILER",
"CMAKE_CXX_COMPILER",
"SHELL",
@@ -38,7 +36,6 @@ enum HostEnvSecurityPolicy {
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
"JAVA_OPTS",
"JAVA_TOOL_OPTIONS",
"_JAVA_OPTIONS",
"JDK_JAVA_OPTIONS",
@@ -47,12 +44,9 @@ enum HostEnvSecurityPolicy {
"DOTNET_ADDITIONAL_DEPS",
"GLIBC_TUNABLES",
"MAVEN_OPTS",
"MAKEFLAGS",
"MFLAGS",
"SBT_OPTS",
"GRADLE_OPTS",
"ANT_OPTS",
"HGRCPATH"
"ANT_OPTS"
]
static let blockedOverrideKeys: Set<String> = [
@@ -89,8 +83,6 @@ enum HostEnvSecurityPolicy {
"CGO_CFLAGS",
"CGO_LDFLAGS",
"GOFLAGS",
"MAKEFLAGS",
"MFLAGS",
"CORECLR_PROFILER_PATH",
"PHPRC",
"PHP_INI_SCAN_DIR",
@@ -142,11 +134,7 @@ enum HostEnvSecurityPolicy {
"GOPRIVATE",
"GOENV",
"GOPATH",
"HGRCPATH",
"PYTHONUSERBASE",
"RUSTC_WRAPPER",
"RUSTFLAGS",
"CARGO_HOME",
"VIRTUAL_ENV",
"LUA_PATH",
"LUA_CPATH",
@@ -154,7 +142,6 @@ enum HostEnvSecurityPolicy {
"GEM_PATH",
"BUNDLE_GEMFILE",
"COMPOSER_HOME",
"CARGO_BUILD_RUSTC_WRAPPER",
"XDG_CONFIG_HOME",
"AWS_CONFIG_FILE"
]

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.6</string>
<string>2026.4.4</string>
<key>CFBundleVersion</key>
<string>2026040601</string>
<string>2026040401</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -537,8 +537,6 @@ public struct AgentParams: Codable, Sendable {
public let besteffortdeliver: Bool?
public let lane: String?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
@@ -568,8 +566,6 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
@@ -598,8 +594,6 @@ public struct AgentParams: Codable, Sendable {
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
@@ -630,8 +624,6 @@ public struct AgentParams: Codable, Sendable {
case besteffortdeliver = "bestEffortDeliver"
case lane
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
@@ -1335,236 +1327,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionCompactionCheckpoint: Codable, Sendable {
public let checkpointid: String
public let sessionkey: String
public let sessionid: String
public let createdat: Int
public let reason: AnyCodable
public let tokensbefore: Int?
public let tokensafter: Int?
public let summary: String?
public let firstkeptentryid: String?
public let precompaction: [String: AnyCodable]
public let postcompaction: [String: AnyCodable]
public init(
checkpointid: String,
sessionkey: String,
sessionid: String,
createdat: Int,
reason: AnyCodable,
tokensbefore: Int?,
tokensafter: Int?,
summary: String?,
firstkeptentryid: String?,
precompaction: [String: AnyCodable],
postcompaction: [String: AnyCodable])
{
self.checkpointid = checkpointid
self.sessionkey = sessionkey
self.sessionid = sessionid
self.createdat = createdat
self.reason = reason
self.tokensbefore = tokensbefore
self.tokensafter = tokensafter
self.summary = summary
self.firstkeptentryid = firstkeptentryid
self.precompaction = precompaction
self.postcompaction = postcompaction
}
private enum CodingKeys: String, CodingKey {
case checkpointid = "checkpointId"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case createdat = "createdAt"
case reason
case tokensbefore = "tokensBefore"
case tokensafter = "tokensAfter"
case summary
case firstkeptentryid = "firstKeptEntryId"
case precompaction = "preCompaction"
case postcompaction = "postCompaction"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionRestoreParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionListResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoints: [SessionCompactionCheckpoint]
public init(
ok: Bool,
key: String,
checkpoints: [SessionCompactionCheckpoint])
{
self.ok = ok
self.key = key
self.checkpoints = checkpoints
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoints
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
sourcekey: String,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.sourcekey = sourcekey
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case sourcekey = "sourceKey"
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCompactionRestoreResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?
@@ -3657,20 +3419,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
}
}
public struct ExecApprovalGetParams: Codable, Sendable {
public let id: String
public init(
id: String)
{
self.id = id
}
private enum CodingKeys: String, CodingKey {
case id
}
}
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String?

View File

@@ -30,17 +30,6 @@ struct GatewayEnvironmentTests {
#expect(Semver.parse(normalized) == Semver(major: 2026, minor: 3, patch: 23))
}
@Test func `gateway version output strips trailing commit hash`() {
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput("OpenClaw 2026.4.2 (d74a122)")
#expect(normalized == "2026.4.2")
#expect(Semver.parse(normalized) == Semver(major: 2026, minor: 4, patch: 2))
// Pre-release suffix + commit hash combined
let normalized2 = GatewayEnvironment.normalizeGatewayVersionOutput("OpenClaw 2026.4.2-1 (d74a122)")
#expect(normalized2 == "2026.4.2-1")
#expect(Semver.parse(normalized2) == Semver(major: 2026, minor: 4, patch: 2))
}
@Test func `semver compatibility requires same major and not older`() {
let required = Semver(major: 2, minor: 1, patch: 0)
#expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required))

View File

@@ -624,31 +624,11 @@ public actor GatewayChannelActor {
let detailCode = details?["code"]?.value as? String
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
let requestId = details?["requestId"]?.value as? String
let reason = details?["reason"]?.value as? String
let owner = details?["owner"]?.value as? String
let title = details?["title"]?.value as? String
let userMessage = details?["userMessage"]?.value as? String
let actionLabel = details?["actionLabel"]?.value as? String
let actionCommand = details?["actionCommand"]?.value as? String
let docsURLString = details?["docsUrl"]?.value as? String
let retryableOverride = details?["retryable"]?.value as? Bool
let pauseReconnectOverride = details?["pauseReconnect"]?.value as? Bool
throw GatewayConnectAuthError(
message: msg,
detailCodeRaw: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStepRaw: recommendedNextStep,
requestId: requestId,
detailsReason: reason,
ownerRaw: owner,
titleOverride: title,
userMessageOverride: userMessage,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURLString: docsURLString,
retryableOverride: retryableOverride,
pauseReconnectOverride: pauseReconnectOverride)
recommendedNextStepRaw: recommendedNextStep)
}
guard let payload = res.payload else {
throw NSError(

View File

@@ -1,761 +0,0 @@
import Foundation
public struct GatewayConnectionProblem: Equatable, Sendable {
public enum Kind: String, Equatable, Sendable {
case gatewayAuthTokenMissing
case gatewayAuthTokenMismatch
case gatewayAuthTokenNotConfigured
case gatewayAuthPasswordMissing
case gatewayAuthPasswordMismatch
case gatewayAuthPasswordNotConfigured
case bootstrapTokenInvalid
case deviceTokenMismatch
case pairingRequired
case pairingRoleUpgradeRequired
case pairingScopeUpgradeRequired
case pairingMetadataUpgradeRequired
case deviceIdentityRequired
case deviceSignatureExpired
case deviceNonceRequired
case deviceNonceMismatch
case deviceSignatureInvalid
case devicePublicKeyInvalid
case deviceIdMismatch
case tailscaleIdentityMissing
case tailscaleProxyMissing
case tailscaleWhoisFailed
case tailscaleIdentityMismatch
case authRateLimited
case timeout
case connectionRefused
case reachabilityFailed
case websocketCancelled
case unknown
}
public enum Owner: String, Equatable, Sendable {
case gateway
case iphone
case both
case network
case unknown
}
public let kind: Kind
public let owner: Owner
public let title: String
public let message: String
public let actionLabel: String?
public let actionCommand: String?
public let docsURL: URL?
public let requestId: String?
public let retryable: Bool
public let pauseReconnect: Bool
public let technicalDetails: String?
public init(
kind: Kind,
owner: Owner,
title: String,
message: String,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURL: URL? = nil,
requestId: String? = nil,
retryable: Bool,
pauseReconnect: Bool,
technicalDetails: String? = nil)
{
self.kind = kind
self.owner = owner
self.title = title
self.message = message
self.actionLabel = Self.trimmedOrNil(actionLabel)
self.actionCommand = Self.trimmedOrNil(actionCommand)
self.docsURL = docsURL
self.requestId = Self.trimmedOrNil(requestId)
self.retryable = retryable
self.pauseReconnect = pauseReconnect
self.technicalDetails = Self.trimmedOrNil(technicalDetails)
}
public var needsPairingApproval: Bool {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
return true
default:
return false
}
}
public var needsCredentialUpdate: Bool {
switch self.kind {
case .gatewayAuthTokenMissing,
.gatewayAuthTokenMismatch,
.gatewayAuthTokenNotConfigured,
.gatewayAuthPasswordMissing,
.gatewayAuthPasswordMismatch,
.gatewayAuthPasswordNotConfigured,
.bootstrapTokenInvalid,
.deviceTokenMismatch:
return true
default:
return false
}
}
public var statusText: String {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
if let requestId {
return "\(self.title) (request ID: \(requestId))"
}
return self.title
default:
return self.title
}
}
private static func trimmedOrNil(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}
public enum GatewayConnectionProblemMapper {
public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? {
guard let nextProblem = self.rawMap(error) else {
return nil
}
guard let previousProblem else {
return nextProblem
}
if self.shouldPreserve(previousProblem: previousProblem, over: nextProblem) {
return previousProblem
}
return nextProblem
}
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool {
if nextProblem.kind == .websocketCancelled {
return previousProblem.pauseReconnect || previousProblem.requestId != nil
}
return false
}
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool {
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return false }
if normalized.contains("cancelled") || normalized.contains("canceled") {
return previousProblem.pauseReconnect || previousProblem.requestId != nil
}
return false
}
private static func rawMap(_ error: Error) -> GatewayConnectionProblem? {
if let authError = error as? GatewayConnectAuthError {
return self.map(authError)
}
if let responseError = error as? GatewayResponseError {
return self.map(responseError)
}
return self.mapTransportError(error)
}
private static func map(_ authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
let pairingCommand = self.approvalCommand(requestId: authError.requestId)
switch authError.detail {
case .authTokenMissing:
return self.problem(
kind: .gatewayAuthTokenMissing,
owner: .both,
title: authError.titleOverride ?? "Gateway token required",
message: authError.userMessageOverride
?? "This gateway requires an auth token, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTokenMismatch:
return self.problem(
kind: .gatewayAuthTokenMismatch,
owner: .both,
title: authError.titleOverride ?? "Gateway token is out of date",
message: authError.userMessageOverride
?? "The token on this iPhone does not match the gateway token.",
actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
authError: authError)
case .authTokenNotConfigured:
return self.problem(
kind: .gatewayAuthTokenNotConfigured,
owner: .gateway,
title: authError.titleOverride ?? "Gateway token is not configured",
message: authError.userMessageOverride
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordMissing:
return self.problem(
kind: .gatewayAuthPasswordMissing,
owner: .both,
title: authError.titleOverride ?? "Gateway password required",
message: authError.userMessageOverride
?? "This gateway requires a password, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordMismatch:
return self.problem(
kind: .gatewayAuthPasswordMismatch,
owner: .both,
title: authError.titleOverride ?? "Gateway password is out of date",
message: authError.userMessageOverride
?? "The saved password on this iPhone does not match the gateway password.",
actionLabel: authError.actionLabel ?? "Update password",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authPasswordNotConfigured:
return self.problem(
kind: .gatewayAuthPasswordNotConfigured,
owner: .gateway,
title: authError.titleOverride ?? "Gateway password is not configured",
message: authError.userMessageOverride
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authBootstrapTokenInvalid:
return self.problem(
kind: .bootstrapTokenInvalid,
owner: .iphone,
title: authError.titleOverride ?? "Setup code expired",
message: authError.userMessageOverride
?? "The setup QR or bootstrap token is no longer valid.",
actionLabel: authError.actionLabel ?? "Scan QR again",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authDeviceTokenMismatch:
return self.problem(
kind: .deviceTokenMismatch,
owner: .both,
title: authError.titleOverride ?? "This iPhone's saved device token is no longer valid",
message: authError.userMessageOverride
?? "The gateway rejected the stored device token for this role.",
actionLabel: authError.actionLabel ?? "Repair pairing",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .pairingRequired:
return self.pairingProblem(for: authError)
case .controlUiDeviceIdentityRequired, .deviceIdentityRequired:
return self.problem(
kind: .deviceIdentityRequired,
owner: .iphone,
title: authError.titleOverride ?? "Secure device identity is required",
message: authError.userMessageOverride
?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
actionLabel: authError.actionLabel ?? "Retry from the app",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthSignatureExpired:
return self.problem(
kind: .deviceSignatureExpired,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake expired",
message: authError.userMessageOverride ?? "The device signature is too old to use.",
actionLabel: authError.actionLabel ?? "Check iPhone time",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthNonceRequired:
return self.problem(
kind: .deviceNonceRequired,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake is incomplete",
message: authError.userMessageOverride
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthNonceMismatch:
return self.problem(
kind: .deviceNonceMismatch,
owner: .iphone,
title: authError.titleOverride ?? "Secure handshake did not match",
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
authError: authError)
case .deviceAuthSignatureInvalid, .deviceAuthInvalid:
return self.problem(
kind: .deviceSignatureInvalid,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the identity this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthPublicKeyInvalid:
return self.problem(
kind: .devicePublicKeyInvalid,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway could not verify the public key this iPhone presented.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .deviceAuthDeviceIdMismatch:
return self.problem(
kind: .deviceIdMismatch,
owner: .iphone,
title: authError.titleOverride ?? "This device identity could not be verified",
message: authError.userMessageOverride
?? "The gateway rejected the device identity because the device ID did not match.",
actionLabel: authError.actionLabel ?? "Re-pair this iPhone",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleIdentityMissing:
return self.problem(
kind: .tailscaleIdentityMissing,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "This connection expected Tailscale identity headers, but they were not available.",
actionLabel: authError.actionLabel ?? "Turn on Tailscale",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleProxyMissing:
return self.problem(
kind: .tailscaleProxyMissing,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The gateway expected a Tailscale auth proxy, but it was not configured.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleWhoisFailed:
return self.problem(
kind: .tailscaleWhoisFailed,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The gateway could not verify this Tailscale client identity.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authTailscaleIdentityMismatch:
return self.problem(
kind: .tailscaleIdentityMismatch,
owner: .network,
title: authError.titleOverride ?? "Tailscale identity check failed",
message: authError.userMessageOverride
?? "The forwarded Tailscale identity did not match the verified identity.",
actionLabel: authError.actionLabel ?? "Review Tailscale setup",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/tailscale"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authRateLimited:
return self.problem(
kind: .authRateLimited,
owner: .gateway,
title: authError.titleOverride ?? "Too many failed attempts",
message: authError.userMessageOverride
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
actionLabel: authError.actionLabel ?? "Wait and retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case .authRequired, .authUnauthorized, .none:
return self.problem(
kind: .unknown,
owner: authError.ownerRaw.flatMap { self.owner(from: $0) } ?? .unknown,
title: authError.titleOverride ?? "Gateway rejected the connection",
message: authError.userMessageOverride ?? authError.message,
actionLabel: authError.actionLabel,
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: nil),
requestId: authError.requestId,
retryable: authError.retryableOverride ?? false,
pauseReconnect: authError.pauseReconnectOverride ?? authError.isNonRecoverable,
authError: authError)
}
}
private static func map(_ responseError: GatewayResponseError) -> GatewayConnectionProblem? {
let code = responseError.code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if code == "NOT_PAIRED" || responseError.detailsReason == "not-paired" {
let authError = GatewayConnectAuthError(
message: responseError.message,
detailCodeRaw: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
recommendedNextStepRaw: nil,
requestId: self.stringValue(responseError.details["requestId"]?.value),
detailsReason: responseError.detailsReason,
ownerRaw: nil,
titleOverride: nil,
userMessageOverride: nil,
actionLabel: nil,
actionCommand: nil,
docsURLString: nil,
retryableOverride: nil,
pauseReconnectOverride: nil)
return self.map(authError)
}
return nil
}
private static func mapTransportError(_ error: Error) -> GatewayConnectionProblem? {
let nsError = error as NSError
let rawMessage = nsError.userInfo[NSLocalizedDescriptionKey] as? String ?? nsError.localizedDescription
let lower = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if lower.isEmpty {
return nil
}
let urlErrorCode = URLError.Code(rawValue: nsError.code)
if nsError.domain == URLError.errorDomain {
switch urlErrorCode {
case .timedOut:
return GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotConnectToHost:
return GatewayConnectionProblem(
kind: .connectionRefused,
owner: .network,
title: "Gateway refused the connection",
message: "The gateway host was reachable, but it refused the connection.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed:
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
title: "Gateway is not reachable",
message: "OpenClaw could not reach the gateway over the current network.",
actionLabel: "Check network",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cancelled:
return GatewayConnectionProblem(
kind: .websocketCancelled,
owner: .network,
title: "Connection interrupted",
message: "The connection to the gateway was interrupted before setup completed.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
default:
break
}
}
if lower.contains("timed out") {
return GatewayConnectionProblem(
kind: .timeout,
owner: .network,
title: "Connection timed out",
message: "The gateway did not respond before the connection timed out.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("connection refused") || lower.contains("refused") {
return GatewayConnectionProblem(
kind: .connectionRefused,
owner: .network,
title: "Gateway refused the connection",
message: "The gateway host was reachable, but it refused the connection.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") {
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
title: "Gateway is not reachable",
message: "OpenClaw could not reach the gateway over the current network.",
actionLabel: "Check network",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cancelled") || lower.contains("canceled") {
return GatewayConnectionProblem(
kind: .websocketCancelled,
owner: .network,
title: "Connection interrupted",
message: "The connection to the gateway was interrupted before setup completed.",
actionLabel: "Retry",
actionCommand: nil,
docsURL: URL(string: "https://docs.openclaw.ai/gateway/troubleshooting"),
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
}
return nil
}
private static func pairingProblem(for authError: GatewayConnectAuthError) -> GatewayConnectionProblem {
let requestId = authError.requestId
let pairingCommand = self.approvalCommand(requestId: requestId)
switch authError.detailsReason {
case "role-upgrade":
return self.problem(
kind: .pairingRoleUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Additional approval required",
message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case "scope-upgrade":
return self.problem(
kind: .pairingScopeUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Additional permissions required",
message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting new permissions that require approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
case "metadata-upgrade":
return self.problem(
kind: .pairingMetadataUpgradeRequired,
owner: .gateway,
title: authError.titleOverride ?? "Device approval needs refresh",
message: authError.userMessageOverride
?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
default:
return self.problem(
kind: .pairingRequired,
owner: .gateway,
title: authError.titleOverride ?? "This iPhone is not approved yet",
message: authError.userMessageOverride
?? "The gateway received the connection request, but this device must be approved first.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
requestId: requestId,
retryable: false,
pauseReconnect: true,
authError: authError)
}
}
private static func problem(
kind: GatewayConnectionProblem.Kind,
owner: GatewayConnectionProblem.Owner,
title: String,
message: String,
actionLabel: String?,
actionCommand: String?,
docsURL: URL?,
requestId: String?,
retryable: Bool,
pauseReconnect: Bool,
authError: GatewayConnectAuthError)
-> GatewayConnectionProblem
{
GatewayConnectionProblem(
kind: kind,
owner: authError.ownerRaw.flatMap(self.owner(from:)) ?? owner,
title: title,
message: message,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURL: docsURL,
requestId: requestId,
retryable: authError.retryableOverride ?? retryable,
pauseReconnect: authError.pauseReconnectOverride ?? pauseReconnect,
technicalDetails: self.technicalDetails(for: authError))
}
private static func approvalCommand(requestId: String?) -> String {
if let requestId = self.nonEmpty(requestId) {
return "openclaw devices approve \(requestId)"
}
return "openclaw devices list"
}
private static func technicalDetails(for authError: GatewayConnectAuthError) -> String? {
var parts: [String] = []
if let detail = self.nonEmpty(authError.detailCodeRaw) {
parts.append(detail)
}
if let reason = self.nonEmpty(authError.detailsReason) {
parts.append("reason=\(reason)")
}
if let requestId = self.nonEmpty(authError.requestId) {
parts.append("requestId=\(requestId)")
}
if let nextStep = self.nonEmpty(authError.recommendedNextStepRaw) {
parts.append("next=\(nextStep)")
}
if authError.canRetryWithDeviceToken {
parts.append("deviceTokenRetry=true")
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
private static func docsURL(_ preferred: String?, fallback: String?) -> URL? {
if let preferred = self.nonEmpty(preferred), let url = URL(string: preferred) {
return url
}
if let fallback = self.nonEmpty(fallback), let url = URL(string: fallback) {
return url
}
return nil
}
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "gateway":
return .gateway
case "iphone", "ios", "device":
return .iphone
case "both":
return .both
case "network":
return .network
case "unknown", "":
return .unknown
default:
return nil
}
}
private static func stringValue(_ value: Any?) -> String? {
self.nonEmpty(value as? String)
}
private static func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -43,32 +43,12 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
public let detailCodeRaw: String?
public let recommendedNextStepRaw: String?
public let canRetryWithDeviceToken: Bool
public let requestId: String?
public let detailsReason: String?
public let ownerRaw: String?
public let titleOverride: String?
public let userMessageOverride: String?
public let actionLabel: String?
public let actionCommand: String?
public let docsURLString: String?
public let retryableOverride: Bool?
public let pauseReconnectOverride: Bool?
public init(
message: String,
detailCodeRaw: String?,
canRetryWithDeviceToken: Bool,
recommendedNextStepRaw: String? = nil,
requestId: String? = nil,
detailsReason: String? = nil,
ownerRaw: String? = nil,
titleOverride: String? = nil,
userMessageOverride: String? = nil,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURLString: String? = nil,
retryableOverride: Bool? = nil,
pauseReconnectOverride: Bool? = nil)
recommendedNextStepRaw: String? = nil)
{
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -79,54 +59,19 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
self.canRetryWithDeviceToken = canRetryWithDeviceToken
self.recommendedNextStepRaw =
trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
self.requestId = Self.trimmedOrNil(requestId)
self.detailsReason = Self.trimmedOrNil(detailsReason)
self.ownerRaw = Self.trimmedOrNil(ownerRaw)
self.titleOverride = Self.trimmedOrNil(titleOverride)
self.userMessageOverride = Self.trimmedOrNil(userMessageOverride)
self.actionLabel = Self.trimmedOrNil(actionLabel)
self.actionCommand = Self.trimmedOrNil(actionCommand)
self.docsURLString = Self.trimmedOrNil(docsURLString)
self.retryableOverride = retryableOverride
self.pauseReconnectOverride = pauseReconnectOverride
}
public init(
message: String,
detailCode: String?,
canRetryWithDeviceToken: Bool,
recommendedNextStep: String? = nil,
requestId: String? = nil,
detailsReason: String? = nil,
ownerRaw: String? = nil,
titleOverride: String? = nil,
userMessageOverride: String? = nil,
actionLabel: String? = nil,
actionCommand: String? = nil,
docsURLString: String? = nil,
retryableOverride: Bool? = nil,
pauseReconnectOverride: Bool? = nil)
recommendedNextStep: String? = nil)
{
self.init(
message: message,
detailCodeRaw: detailCode,
canRetryWithDeviceToken: canRetryWithDeviceToken,
recommendedNextStepRaw: recommendedNextStep,
requestId: requestId,
detailsReason: detailsReason,
ownerRaw: ownerRaw,
titleOverride: titleOverride,
userMessageOverride: userMessageOverride,
actionLabel: actionLabel,
actionCommand: actionCommand,
docsURLString: docsURLString,
retryableOverride: retryableOverride,
pauseReconnectOverride: pauseReconnectOverride)
}
private static func trimmedOrNil(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
recommendedNextStepRaw: recommendedNextStep)
}
public var detailCode: String? { self.detailCodeRaw }
@@ -182,12 +127,6 @@ public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
self.details = details ?? [:]
}
public var detailsReason: String? {
let raw = self.details["reason"]?.value as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
public var errorDescription: String? {
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
return "\(self.method): [\(self.code)] \(self.message)"

View File

@@ -130,7 +130,44 @@ public enum ToolDisplayRegistry {
"messageId",
],
actions: nil),
tools: nil)
tools: [
"bash": ToolDisplaySpec(
emoji: "🛠️",
title: "Bash",
label: nil,
detailKeys: ["command"],
actions: nil),
"read": ToolDisplaySpec(
emoji: "📖",
title: "Read",
label: nil,
detailKeys: ["path"],
actions: nil),
"write": ToolDisplaySpec(
emoji: "✍️",
title: "Write",
label: nil,
detailKeys: ["path"],
actions: nil),
"edit": ToolDisplaySpec(
emoji: "📝",
title: "Edit",
label: nil,
detailKeys: ["path"],
actions: nil),
"attach": ToolDisplaySpec(
emoji: "📎",
title: "Attach",
label: nil,
detailKeys: ["path", "url", "fileName"],
actions: nil),
"process": ToolDisplaySpec(
emoji: "🧰",
title: "Process",
label: nil,
detailKeys: ["sessionId"],
actions: nil),
])
}
private static func titleFromName(_ name: String) -> String {

View File

@@ -5,36 +5,12 @@ public enum OpenClawWatchCommand: String, Codable, Sendable {
case notify = "watch.notify"
}
public enum OpenClawWatchPayloadType: String, Codable, Sendable, Equatable {
case notify = "watch.notify"
case reply = "watch.reply"
case execApprovalPrompt = "watch.execApproval.prompt"
case execApprovalResolve = "watch.execApproval.resolve"
case execApprovalResolved = "watch.execApproval.resolved"
case execApprovalExpired = "watch.execApproval.expired"
case execApprovalSnapshot = "watch.execApproval.snapshot"
case execApprovalSnapshotRequest = "watch.execApproval.snapshotRequest"
}
public enum OpenClawWatchRisk: String, Codable, Sendable, Equatable {
case low
case medium
case high
}
public enum OpenClawWatchExecApprovalDecision: String, Codable, Sendable, Equatable {
case allowOnce = "allow-once"
case deny
}
public enum OpenClawWatchExecApprovalCloseReason: String, Codable, Sendable, Equatable {
case expired
case notFound = "not-found"
case unavailable
case replaced
case resolved
}
public struct OpenClawWatchAction: Codable, Sendable, Equatable {
public var id: String
public var label: String
@@ -47,151 +23,6 @@ public struct OpenClawWatchAction: Codable, Sendable, Equatable {
}
}
public struct OpenClawWatchExecApprovalItem: Codable, Sendable, Equatable, Identifiable {
public var id: String
public var commandText: String
public var commandPreview: String?
public var host: String?
public var nodeId: String?
public var agentId: String?
public var expiresAtMs: Int?
public var allowedDecisions: [OpenClawWatchExecApprovalDecision]
public var risk: OpenClawWatchRisk?
public init(
id: String,
commandText: String,
commandPreview: String? = nil,
host: String? = nil,
nodeId: String? = nil,
agentId: String? = nil,
expiresAtMs: Int? = nil,
allowedDecisions: [OpenClawWatchExecApprovalDecision] = [],
risk: OpenClawWatchRisk? = nil)
{
self.id = id
self.commandText = commandText
self.commandPreview = commandPreview
self.host = host
self.nodeId = nodeId
self.agentId = agentId
self.expiresAtMs = expiresAtMs
self.allowedDecisions = allowedDecisions
self.risk = risk
}
}
public struct OpenClawWatchExecApprovalPromptMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approval: OpenClawWatchExecApprovalItem
public var sentAtMs: Int?
public var deliveryId: String?
public var resetResolvingState: Bool?
public init(
approval: OpenClawWatchExecApprovalItem,
sentAtMs: Int? = nil,
deliveryId: String? = nil,
resetResolvingState: Bool? = nil)
{
self.type = .execApprovalPrompt
self.approval = approval
self.sentAtMs = sentAtMs
self.deliveryId = deliveryId
self.resetResolvingState = resetResolvingState
}
}
public struct OpenClawWatchExecApprovalResolveMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var decision: OpenClawWatchExecApprovalDecision
public var replyId: String
public var sentAtMs: Int?
public init(
approvalId: String,
decision: OpenClawWatchExecApprovalDecision,
replyId: String,
sentAtMs: Int? = nil)
{
self.type = .execApprovalResolve
self.approvalId = approvalId
self.decision = decision
self.replyId = replyId
self.sentAtMs = sentAtMs
}
}
public struct OpenClawWatchExecApprovalResolvedMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var decision: OpenClawWatchExecApprovalDecision?
public var resolvedAtMs: Int?
public var source: String?
public init(
approvalId: String,
decision: OpenClawWatchExecApprovalDecision? = nil,
resolvedAtMs: Int? = nil,
source: String? = nil)
{
self.type = .execApprovalResolved
self.approvalId = approvalId
self.decision = decision
self.resolvedAtMs = resolvedAtMs
self.source = source
}
}
public struct OpenClawWatchExecApprovalExpiredMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvalId: String
public var reason: OpenClawWatchExecApprovalCloseReason
public var expiredAtMs: Int?
public init(
approvalId: String,
reason: OpenClawWatchExecApprovalCloseReason,
expiredAtMs: Int? = nil)
{
self.type = .execApprovalExpired
self.approvalId = approvalId
self.reason = reason
self.expiredAtMs = expiredAtMs
}
}
public struct OpenClawWatchExecApprovalSnapshotMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var approvals: [OpenClawWatchExecApprovalItem]
public var sentAtMs: Int?
public var snapshotId: String?
public init(
approvals: [OpenClawWatchExecApprovalItem],
sentAtMs: Int? = nil,
snapshotId: String? = nil)
{
self.type = .execApprovalSnapshot
self.approvals = approvals
self.sentAtMs = sentAtMs
self.snapshotId = snapshotId
}
}
public struct OpenClawWatchExecApprovalSnapshotRequestMessage: Codable, Sendable, Equatable {
public var type: OpenClawWatchPayloadType
public var requestId: String
public var sentAtMs: Int?
public init(requestId: String, sentAtMs: Int? = nil) {
self.type = .execApprovalSnapshotRequest
self.requestId = requestId
self.sentAtMs = sentAtMs
}
}
public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable {
public var supported: Bool
public var paired: Bool

View File

@@ -537,8 +537,6 @@ public struct AgentParams: Codable, Sendable {
public let besteffortdeliver: Bool?
public let lane: String?
public let extrasystemprompt: String?
public let bootstrapcontextmode: AnyCodable?
public let bootstrapcontextrunkind: AnyCodable?
public let internalevents: [[String: AnyCodable]]?
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
@@ -568,8 +566,6 @@ public struct AgentParams: Codable, Sendable {
besteffortdeliver: Bool?,
lane: String?,
extrasystemprompt: String?,
bootstrapcontextmode: AnyCodable?,
bootstrapcontextrunkind: AnyCodable?,
internalevents: [[String: AnyCodable]]?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
@@ -598,8 +594,6 @@ public struct AgentParams: Codable, Sendable {
self.besteffortdeliver = besteffortdeliver
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.bootstrapcontextmode = bootstrapcontextmode
self.bootstrapcontextrunkind = bootstrapcontextrunkind
self.internalevents = internalevents
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
@@ -630,8 +624,6 @@ public struct AgentParams: Codable, Sendable {
case besteffortdeliver = "bestEffortDeliver"
case lane
case extrasystemprompt = "extraSystemPrompt"
case bootstrapcontextmode = "bootstrapContextMode"
case bootstrapcontextrunkind = "bootstrapContextRunKind"
case internalevents = "internalEvents"
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
@@ -1335,236 +1327,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
}
public struct SessionCompactionCheckpoint: Codable, Sendable {
public let checkpointid: String
public let sessionkey: String
public let sessionid: String
public let createdat: Int
public let reason: AnyCodable
public let tokensbefore: Int?
public let tokensafter: Int?
public let summary: String?
public let firstkeptentryid: String?
public let precompaction: [String: AnyCodable]
public let postcompaction: [String: AnyCodable]
public init(
checkpointid: String,
sessionkey: String,
sessionid: String,
createdat: Int,
reason: AnyCodable,
tokensbefore: Int?,
tokensafter: Int?,
summary: String?,
firstkeptentryid: String?,
precompaction: [String: AnyCodable],
postcompaction: [String: AnyCodable])
{
self.checkpointid = checkpointid
self.sessionkey = sessionkey
self.sessionid = sessionid
self.createdat = createdat
self.reason = reason
self.tokensbefore = tokensbefore
self.tokensafter = tokensafter
self.summary = summary
self.firstkeptentryid = firstkeptentryid
self.precompaction = precompaction
self.postcompaction = postcompaction
}
private enum CodingKeys: String, CodingKey {
case checkpointid = "checkpointId"
case sessionkey = "sessionKey"
case sessionid = "sessionId"
case createdat = "createdAt"
case reason
case tokensbefore = "tokensBefore"
case tokensafter = "tokensAfter"
case summary
case firstkeptentryid = "firstKeptEntryId"
case precompaction = "preCompaction"
case postcompaction = "postCompaction"
}
}
public struct SessionsCompactionListParams: Codable, Sendable {
public let key: String
public init(
key: String)
{
self.key = key
}
private enum CodingKeys: String, CodingKey {
case key
}
}
public struct SessionsCompactionGetParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionBranchParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionRestoreParams: Codable, Sendable {
public let key: String
public let checkpointid: String
public init(
key: String,
checkpointid: String)
{
self.key = key
self.checkpointid = checkpointid
}
private enum CodingKeys: String, CodingKey {
case key
case checkpointid = "checkpointId"
}
}
public struct SessionsCompactionListResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoints: [SessionCompactionCheckpoint]
public init(
ok: Bool,
key: String,
checkpoints: [SessionCompactionCheckpoint])
{
self.ok = ok
self.key = key
self.checkpoints = checkpoints
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoints
}
}
public struct SessionsCompactionGetResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let checkpoint: SessionCompactionCheckpoint
public init(
ok: Bool,
key: String,
checkpoint: SessionCompactionCheckpoint)
{
self.ok = ok
self.key = key
self.checkpoint = checkpoint
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case checkpoint
}
}
public struct SessionsCompactionBranchResult: Codable, Sendable {
public let ok: Bool
public let sourcekey: String
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
sourcekey: String,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.sourcekey = sourcekey
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case sourcekey = "sourceKey"
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCompactionRestoreResult: Codable, Sendable {
public let ok: Bool
public let key: String
public let sessionid: String
public let checkpoint: SessionCompactionCheckpoint
public let entry: [String: AnyCodable]
public init(
ok: Bool,
key: String,
sessionid: String,
checkpoint: SessionCompactionCheckpoint,
entry: [String: AnyCodable])
{
self.ok = ok
self.key = key
self.sessionid = sessionid
self.checkpoint = checkpoint
self.entry = entry
}
private enum CodingKeys: String, CodingKey {
case ok
case key
case sessionid = "sessionId"
case checkpoint
case entry
}
}
public struct SessionsCreateParams: Codable, Sendable {
public let key: String?
public let agentid: String?
@@ -3657,20 +3419,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
}
}
public struct ExecApprovalGetParams: Codable, Sendable {
public let id: String
public init(
id: String)
{
self.id = id
}
private enum CodingKeys: String, CodingKey {
case id
}
}
public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String?
public let command: String?

View File

@@ -1,4 +1,3 @@
import Foundation
import OpenClawKit
import Testing
@@ -12,81 +11,4 @@ import Testing
#expect(error.isNonRecoverable)
#expect(error.detail == .authBootstrapTokenInvalid)
}
@Test func connectAuthErrorPreservesStructuredMetadata() {
let error = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
recommendedNextStep: "review_auth_configuration",
requestId: "req-123",
detailsReason: "scope-upgrade",
ownerRaw: "gateway",
titleOverride: "Additional permissions required",
userMessageOverride: "Approve the requested permissions on the gateway, then reconnect.",
actionLabel: "Approve on gateway",
actionCommand: "openclaw devices approve req-123",
docsURLString: "https://docs.openclaw.ai/gateway/pairing",
retryableOverride: false,
pauseReconnectOverride: true)
#expect(error.requestId == "req-123")
#expect(error.detailsReason == "scope-upgrade")
#expect(error.ownerRaw == "gateway")
#expect(error.titleOverride == "Additional permissions required")
#expect(error.actionCommand == "openclaw devices approve req-123")
#expect(error.docsURLString == "https://docs.openclaw.ai/gateway/pairing")
#expect(error.pauseReconnectOverride == true)
}
@Test func pairingProblemUsesStructuredRequestMetadata() {
let error = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123",
detailsReason: "scope-upgrade")
let problem = GatewayConnectionProblemMapper.map(error: error)
#expect(problem?.kind == .pairingScopeUpgradeRequired)
#expect(problem?.requestId == "req-123")
#expect(problem?.pauseReconnect == true)
#expect(problem?.actionCommand == "openclaw devices approve req-123")
}
@Test func cancelledTransportDoesNotReplaceStructuredPairingProblem() {
let pairing = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123")
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
let cancelled = NSError(
domain: URLError.errorDomain,
code: URLError.cancelled.rawValue,
userInfo: [NSLocalizedDescriptionKey: "gateway receive: cancelled"])
let preserved = GatewayConnectionProblemMapper.map(error: cancelled, preserving: previousProblem)
#expect(preserved?.kind == .pairingRequired)
#expect(preserved?.requestId == "req-123")
}
@Test func unmappedTransportErrorClearsStaleStructuredProblem() {
let pairing = GatewayConnectAuthError(
message: "pairing required",
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
canRetryWithDeviceToken: false,
requestId: "req-123")
let previousProblem = GatewayConnectionProblemMapper.map(error: pairing)
let unknownTransport = NSError(
domain: NSURLErrorDomain,
code: -1202,
userInfo: [NSLocalizedDescriptionKey: "certificate chain validation failed"])
let mapped = GatewayConnectionProblemMapper.map(error: unknownTransport, preserving: previousProblem)
#expect(mapped == nil)
}
}

View File

@@ -9,8 +9,8 @@ import Testing
}
@Test func resolvesKnownToolFromConfig() {
let summary = ToolDisplayRegistry.resolve(name: "exec", args: nil)
let summary = ToolDisplayRegistry.resolve(name: "bash", args: nil)
#expect(summary.emoji == "🛠️")
#expect(summary.title == "Exec")
#expect(summary.title == "Bash")
}
}

View File

@@ -1,4 +1,4 @@
f7b342080a730da84d1ac84a888e9506d24ee7ce7ec6ec6c0cc4f1897fabcde3 config-baseline.json
c3dd9fb8a0059dba411c4d88a6b84ca28af1e0b1925c669058ef9f38c6d2718b config-baseline.core.json
d22f4414b79ee03d896e58d875c80523bcc12303cbacb1700261e6ec73945187 config-baseline.channel.json
d42cee3dea4668bdb7daf6ff5e6f87f326fdef56a8c3716d73079b92cab6e7b2 config-baseline.plugin.json
20a882f9991e17310013471756ac7ec62c272e29490daeede9c0901bd51c0e69 config-baseline.json
8ba6e5c959d5fc3eee9e6c5d1d8f764f164052f4207c0352bb39e2a7dbad64a8 config-baseline.core.json
ca6d1fa8a3507566979ea2da2b88a6a7ae49d650f3ebd3eee14a22ed18e5be89 config-baseline.channel.json
17fd37605bf6cb087932ec2ebcfa9dd22e669fa6b8b93081ab2deac9d24821c5 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
2efa99907731355b31a1b95a6baa9cf5bf8d25c67931837857c9bb9dd39fad95 plugin-sdk-api-baseline.json
6c99467113b5d6a015cbd424f2eb5c7e21a6c665b3e8d0372e0e09a2218ef13e plugin-sdk-api-baseline.jsonl
cbffdf76d6a7254d8b2d3a601e1206d7b6c835bc44f170d4038bc711a35ef756 plugin-sdk-api-baseline.json
fe026bf3ba1e3b55f6c0b560d76940f3c301d8f593d6f0f6dcc4625745c76d31 plugin-sdk-api-baseline.jsonl

View File

@@ -1,42 +1,11 @@
# OpenClaw docs i18n assets
This folder stores translation config for the source docs repo.
This folder stores **generated** and **config** files for documentation translations.
Generated locale trees and live translation memory now live in the publish repo:
## Files
- repo: `openclaw/docs`
- local checkout: `~/Projects/openclaw-docs`
## Source of truth
- English docs are authored in `openclaw/openclaw`.
- The source docs tree lives under `docs/`.
- The source repo no longer keeps committed generated locale trees such as `docs/zh-CN/**`, `docs/ja-JP/**`, `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/ar/**`, `docs/it/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, or `docs/pl/**`.
## End-to-end flow
1. Edit English docs in `openclaw/openclaw`.
2. Push to `main`.
3. `openclaw/openclaw/.github/workflows/docs-sync-publish.yml` mirrors the docs tree into `openclaw/docs`.
4. The sync script rewrites the publish `docs/docs.json` so the generated locale picker blocks exist there even though they are no longer committed in the source repo.
5. `openclaw/docs/.github/workflows/translate-zh-cn.yml` refreshes `docs/zh-CN/**` once a day, on demand, and after source-repo release dispatches.
6. `openclaw/docs/.github/workflows/translate-ja-jp.yml` does the same for `docs/ja-JP/**`.
7. `openclaw/docs/.github/workflows/translate-es.yml`, `translate-pt-br.yml`, `translate-ko.yml`, `translate-de.yml`, `translate-fr.yml`, `translate-ar.yml`, `translate-it.yml`, `translate-tr.yml`, `translate-uk.yml`, `translate-id.yml`, and `translate-pl.yml` do the same for `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/ar/**`, `docs/it/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, and `docs/pl/**`.
## Why the split exists
- Keep generated locale output out of the main product repo.
- Keep Mintlify on a single published docs tree.
- Preserve the built-in language switcher by letting the publish repo own generated locale trees.
## Files in this folder
- `glossary.<lang>.json` — preferred term mappings used as prompt guidance.
- `zh-Hans-navigation.json` — curated zh-Hans Mintlify locale navigation reinserted into the publish repo during sync.
- `ar-navigation.json`, `de-navigation.json`, `es-navigation.json`, `fr-navigation.json`, `id-navigation.json`, `it-navigation.json`, `ja-navigation.json`, `ko-navigation.json`, `pl-navigation.json`, `pt-BR-navigation.json`, `tr-navigation.json` — starter locale metadata kept alongside the source repo, but the publish sync now clones the full English nav tree for these locales so translated pages are visible in Mintlify without hand-maintaining per-locale nav JSON.
- `<lang>.tm.jsonl` — translation memory keyed by workflow + model + text hash.
In this repo, generated locale TM files such as `docs/.i18n/zh-CN.tm.jsonl`, `docs/.i18n/ja-JP.tm.jsonl`, `docs/.i18n/es.tm.jsonl`, `docs/.i18n/pt-BR.tm.jsonl`, `docs/.i18n/ko.tm.jsonl`, `docs/.i18n/de.tm.jsonl`, `docs/.i18n/fr.tm.jsonl`, `docs/.i18n/ar.tm.jsonl`, `docs/.i18n/it.tm.jsonl`, `docs/.i18n/tr.tm.jsonl`, `docs/.i18n/uk.tm.jsonl`, `docs/.i18n/id.tm.jsonl`, and `docs/.i18n/pl.tm.jsonl` are intentionally no longer committed.
- `glossary.<lang>.json` — preferred term mappings (used in prompt guidance).
- `<lang>.tm.jsonl` — translation memory (cache) keyed by workflow + model + text hash.
## Glossary format
@@ -45,7 +14,9 @@ In this repo, generated locale TM files such as `docs/.i18n/zh-CN.tm.jsonl`, `do
```json
{
"source": "troubleshooting",
"target": "故障排除"
"target": "故障排除",
"ignore_case": true,
"whole_word": false
}
```
@@ -54,19 +25,7 @@ Fields:
- `source`: English (or source) phrase to prefer.
- `target`: preferred translation output.
## Translation mechanics
## Notes
- `scripts/docs-i18n` still owns translation generation.
- Doc mode writes `x-i18n.source_hash` into each translated page.
- Each publish workflow precomputes a pending file list by comparing the current English source hash to the stored locale `x-i18n.source_hash`.
- If the pending count is `0`, the expensive translation step is skipped entirely.
- If there are pending files, the workflow translates only those files.
- The publish workflow retries transient model-format failures, but unchanged files stay skipped because the same hash check runs on each retry.
- The source repo also dispatches zh-CN, ja-JP, es, pt-BR, ko, de, fr, ar, it, tr, uk, id, and pl refreshes after published GitHub releases so release docs can catch up without waiting for the daily cron.
## Operational notes
- Sync metadata is written to `.openclaw-sync/source.json` in the publish repo.
- Source repo secret: `OPENCLAW_DOCS_SYNC_TOKEN`
- Publish repo secret: `OPENCLAW_DOCS_I18N_OPENAI_API_KEY`
- If locale output looks stale, check the matching `Translate <locale>` workflow in `openclaw/docs` first.
- Glossary entries are passed to the model as **prompt guidance** (no deterministic rewrites).
- The translation memory is updated by `scripts/docs-i18n`.

View File

@@ -1,18 +0,0 @@
{
"language": "ar",
"tabs": [
{
"tab": "ابدأ",
"groups": [
{
"group": "نظرة عامة",
"pages": ["ar/index"]
},
{
"group": "الخطوات الأولى",
"pages": ["ar/start/getting-started", "ar/start/wizard"]
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
{
"language": "de",
"tabs": [
{
"tab": "Loslegen",
"groups": [
{
"group": "Überblick",
"pages": ["de/index"]
},
{
"group": "Erste Schritte",
"pages": ["de/start/getting-started", "de/start/wizard"]
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
{
"language": "es",
"tabs": [
{
"tab": "Comenzar",
"groups": [
{
"group": "Resumen",
"pages": ["es/index"]
},
{
"group": "Primeros pasos",
"pages": ["es/start/getting-started", "es/start/wizard"]
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
{
"language": "fr",
"tabs": [
{
"tab": "Commencer",
"groups": [
{
"group": "Vue d'ensemble",
"pages": ["fr/index"]
},
{
"group": "Premiers pas",
"pages": ["fr/start/getting-started", "fr/start/wizard"]
}
]
}
]
}

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,5 +0,0 @@
[
{ "source": "CLI", "target": "CLI" },
{ "source": "Mintlify", "target": "Mintlify" },
{ "source": "OpenClaw", "target": "OpenClaw" }
]

View File

@@ -1,18 +0,0 @@
{
"language": "id",
"tabs": [
{
"tab": "Mulai",
"groups": [
{
"group": "Ringkasan",
"pages": ["id/index"]
},
{
"group": "Langkah pertama",
"pages": ["id/start/getting-started", "id/start/wizard"]
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
{
"language": "it",
"tabs": [
{
"tab": "Inizia",
"groups": [
{
"group": "Panoramica",
"pages": ["it/index"]
},
{
"group": "Primi passi",
"pages": ["it/start/getting-started", "it/start/wizard"]
}
]
}
]
}

View File

View File

@@ -1,18 +0,0 @@
{
"language": "ja",
"tabs": [
{
"tab": "はじめに",
"groups": [
{
"group": "概要",
"pages": ["ja-JP/index"]
},
{
"group": "初回セットアップ",
"pages": ["ja-JP/start/getting-started", "ja-JP/start/wizard"]
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
{
"language": "ko",
"tabs": [
{
"tab": "시작하기",
"groups": [
{
"group": "개요",
"pages": ["ko/index"]
},
{
"group": "첫 단계",
"pages": ["ko/start/getting-started", "ko/start/wizard"]
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
{
"language": "pl",
"tabs": [
{
"tab": "Start",
"groups": [
{
"group": "Przeglad",
"pages": ["pl/index"]
},
{
"group": "Pierwsze kroki",
"pages": ["pl/start/getting-started", "pl/start/wizard"]
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
{
"language": "pt-BR",
"tabs": [
{
"tab": "Começar",
"groups": [
{
"group": "Visão geral",
"pages": ["pt-BR/index"]
},
{
"group": "Primeiros passos",
"pages": ["pt-BR/start/getting-started", "pt-BR/start/wizard"]
}
]
}
]
}

View File

@@ -1,18 +0,0 @@
{
"language": "tr",
"tabs": [
{
"tab": "Baslangic",
"groups": [
{
"group": "Genel bakis",
"pages": ["tr/index"]
},
{
"group": "Ilk adimlar",
"pages": ["tr/start/getting-started", "tr/start/wizard"]
}
]
}
]
}

1329
docs/.i18n/zh-CN.tm.jsonl Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,544 +0,0 @@
{
"language": "zh-Hans",
"tabs": [
{
"tab": "快速开始",
"groups": [
{
"group": "首页",
"pages": ["zh-CN/index"]
},
{
"group": "概览",
"pages": ["zh-CN/start/showcase"]
},
{
"group": "核心概念",
"pages": ["zh-CN/concepts/features"]
},
{
"group": "第一步",
"pages": ["zh-CN/start/getting-started", "zh-CN/start/wizard", "zh-CN/start/onboarding"]
},
{
"group": "指南",
"pages": ["zh-CN/start/openclaw"]
}
]
},
{
"tab": "安装",
"groups": [
{
"group": "安装概览",
"pages": ["zh-CN/install/index", "zh-CN/install/installer"]
},
{
"group": "安装方式",
"pages": [
"zh-CN/install/docker",
"zh-CN/install/nix",
"zh-CN/install/ansible",
"zh-CN/install/bun"
]
},
{
"group": "维护",
"pages": ["zh-CN/install/updating", "zh-CN/install/migrating", "zh-CN/install/uninstall"]
},
{
"group": "托管与部署",
"pages": [
"zh-CN/vps",
"zh-CN/install/fly",
"zh-CN/install/hetzner",
"zh-CN/install/gcp",
"zh-CN/install/macos-vm",
"zh-CN/install/exe-dev",
"zh-CN/install/railway",
"zh-CN/install/render",
"zh-CN/install/northflank"
]
},
{
"group": "高级",
"pages": ["zh-CN/install/development-channels"]
}
]
},
{
"tab": "消息渠道",
"groups": [
{
"group": "概览",
"pages": ["zh-CN/channels/index"]
},
{
"group": "消息平台",
"pages": [
"zh-CN/channels/bluebubbles",
"zh-CN/channels/discord",
"zh-CN/channels/feishu",
"zh-CN/channels/grammy",
"zh-CN/channels/googlechat",
"zh-CN/channels/imessage",
"zh-CN/channels/line",
"zh-CN/channels/matrix",
"zh-CN/channels/mattermost",
"zh-CN/channels/msteams",
"zh-CN/channels/nextcloud-talk",
"zh-CN/channels/nostr",
"zh-CN/channels/signal",
"zh-CN/channels/slack",
"zh-CN/channels/telegram",
"zh-CN/channels/tlon",
"zh-CN/channels/twitch",
"zh-CN/channels/whatsapp",
"zh-CN/channels/zalo",
"zh-CN/channels/zalouser"
]
},
{
"group": "配置",
"pages": [
"zh-CN/channels/pairing",
"zh-CN/channels/group-messages",
"zh-CN/channels/groups",
"zh-CN/channels/broadcast-groups",
"zh-CN/channels/channel-routing",
"zh-CN/channels/location",
"zh-CN/channels/troubleshooting"
]
}
]
},
{
"tab": "代理",
"groups": [
{
"group": "基础",
"pages": [
"zh-CN/pi",
"zh-CN/concepts/architecture",
"zh-CN/concepts/agent",
"zh-CN/concepts/agent-loop",
"zh-CN/concepts/system-prompt",
"zh-CN/concepts/context",
"zh-CN/concepts/agent-workspace",
"zh-CN/concepts/oauth"
]
},
{
"group": "引导",
"pages": ["zh-CN/start/bootstrapping"]
},
{
"group": "会话与记忆",
"pages": [
"zh-CN/concepts/session",
"zh-CN/concepts/session-pruning",
"zh-CN/concepts/session-tool",
"zh-CN/concepts/memory",
"zh-CN/concepts/compaction"
]
},
{
"group": "多代理",
"pages": ["zh-CN/concepts/multi-agent", "zh-CN/concepts/presence"]
},
{
"group": "消息与投递",
"pages": [
"zh-CN/concepts/messages",
"zh-CN/concepts/streaming",
"zh-CN/concepts/retry",
"zh-CN/concepts/queue"
]
}
]
},
{
"tab": "工具",
"groups": [
{
"group": "概览",
"pages": ["zh-CN/tools/index"]
},
{
"group": "内置工具",
"pages": [
"zh-CN/tools/apply-patch",
"zh-CN/brave-search",
"zh-CN/tools/elevated",
"zh-CN/tools/exec",
"zh-CN/tools/exec-approvals",
"zh-CN/tools/firecrawl",
"zh-CN/tools/llm-task",
"zh-CN/tools/lobster",
"zh-CN/perplexity",
"zh-CN/tools/reactions",
"zh-CN/tools/thinking",
"zh-CN/tools/web"
]
},
{
"group": "浏览器",
"pages": [
"zh-CN/tools/browser",
"zh-CN/tools/browser-login",
"zh-CN/tools/browser-linux-troubleshooting"
]
},
{
"group": "代理协作",
"pages": [
"zh-CN/tools/agent-send",
"zh-CN/tools/subagents",
"zh-CN/tools/multi-agent-sandbox-tools"
]
},
{
"group": "技能",
"pages": [
"zh-CN/tools/creating-skills",
"zh-CN/tools/slash-commands",
"zh-CN/tools/skills",
"zh-CN/tools/skills-config",
"zh-CN/tools/clawhub",
"zh-CN/tools/plugin"
]
},
{
"group": "扩展",
"pages": [
"zh-CN/plugins/voice-call",
"zh-CN/plugins/zalouser",
"zh-CN/plugins/manifest",
"zh-CN/plugins/agent-tools",
"zh-CN/prose"
]
},
{
"group": "自动化与任务",
"pages": ["zh-CN/automation/hooks", "zh-CN/automation/cron-jobs"]
},
{
"group": "媒体与设备",
"pages": [
"zh-CN/nodes/index",
"zh-CN/nodes/troubleshooting",
"zh-CN/nodes/media-understanding",
"zh-CN/nodes/images",
"zh-CN/nodes/audio",
"zh-CN/nodes/camera",
"zh-CN/nodes/talk",
"zh-CN/nodes/voicewake",
"zh-CN/nodes/location-command",
"zh-CN/tts"
]
}
]
},
{
"tab": "模型",
"groups": [
{
"group": "概览",
"pages": ["zh-CN/providers/index", "zh-CN/providers/models"]
},
{
"group": "模型概念",
"pages": ["zh-CN/concepts/models"]
},
{
"group": "配置",
"pages": ["zh-CN/concepts/model-providers", "zh-CN/concepts/model-failover"]
},
{
"group": "提供商",
"pages": [
"zh-CN/providers/anthropic",
"zh-CN/providers/bedrock",
"zh-CN/providers/claude-max-api-proxy",
"zh-CN/providers/deepgram",
"zh-CN/providers/github-copilot",
"zh-CN/providers/glm",
"zh-CN/providers/moonshot",
"zh-CN/providers/minimax",
"zh-CN/providers/opencode",
"zh-CN/providers/ollama",
"zh-CN/providers/openai",
"zh-CN/providers/openrouter",
"zh-CN/providers/qianfan",
"zh-CN/providers/qwen",
"zh-CN/providers/synthetic",
"zh-CN/providers/venice",
"zh-CN/providers/vercel-ai-gateway",
"zh-CN/providers/xiaomi",
"zh-CN/providers/zai"
]
}
]
},
{
"tab": "平台",
"groups": [
{
"group": "平台概览",
"pages": [
"zh-CN/platforms/index",
"zh-CN/platforms/macos",
"zh-CN/platforms/linux",
"zh-CN/platforms/windows",
"zh-CN/platforms/android",
"zh-CN/platforms/ios",
"zh-CN/platforms/digitalocean",
"zh-CN/platforms/oracle",
"zh-CN/platforms/raspberry-pi"
]
},
{
"group": "macOS 配套应用",
"pages": [
"zh-CN/platforms/mac/dev-setup",
"zh-CN/platforms/mac/menu-bar",
"zh-CN/platforms/mac/voicewake",
"zh-CN/platforms/mac/voice-overlay",
"zh-CN/platforms/mac/webchat",
"zh-CN/platforms/mac/canvas",
"zh-CN/platforms/mac/child-process",
"zh-CN/platforms/mac/health",
"zh-CN/platforms/mac/icon",
"zh-CN/platforms/mac/logging",
"zh-CN/platforms/mac/permissions",
"zh-CN/platforms/mac/remote",
"zh-CN/platforms/mac/signing",
"zh-CN/platforms/mac/bundled-gateway",
"zh-CN/platforms/mac/xpc",
"zh-CN/platforms/mac/skills",
"zh-CN/platforms/mac/peekaboo"
]
}
]
},
{
"tab": "网关与运维",
"groups": [
{
"group": "网关",
"pages": [
"zh-CN/gateway/index",
{
"group": "配置与运维",
"pages": [
"zh-CN/gateway/configuration",
"zh-CN/gateway/configuration-examples",
"zh-CN/gateway/authentication",
"zh-CN/gateway/health",
"zh-CN/gateway/heartbeat",
"zh-CN/gateway/doctor",
"zh-CN/gateway/logging",
"zh-CN/gateway/gateway-lock",
"zh-CN/gateway/background-process",
"zh-CN/gateway/multiple-gateways",
"zh-CN/gateway/troubleshooting"
]
},
{
"group": "安全与沙箱",
"pages": [
"zh-CN/gateway/security/index",
"zh-CN/gateway/sandboxing",
"zh-CN/gateway/sandbox-vs-tool-policy-vs-elevated"
]
},
{
"group": "协议与 API",
"pages": [
"zh-CN/gateway/protocol",
"zh-CN/gateway/bridge-protocol",
"zh-CN/gateway/openai-http-api",
"zh-CN/gateway/openresponses-http-api",
"zh-CN/gateway/tools-invoke-http-api",
"zh-CN/gateway/cli-backends",
"zh-CN/gateway/local-models"
]
},
{
"group": "网络与发现",
"pages": [
"zh-CN/gateway/network-model",
"zh-CN/gateway/pairing",
"zh-CN/gateway/discovery",
"zh-CN/gateway/bonjour"
]
}
]
},
{
"group": "远程访问",
"pages": [
"zh-CN/gateway/remote",
"zh-CN/gateway/remote-gateway-readme",
"zh-CN/gateway/tailscale"
]
},
{
"group": "运维专题",
"pages": ["zh-CN/network", "zh-CN/logging"]
},
{
"group": "安全",
"pages": ["zh-CN/security/formal-verification"]
},
{
"group": "Web 界面",
"pages": [
"zh-CN/web/index",
"zh-CN/web/control-ui",
"zh-CN/web/dashboard",
"zh-CN/web/webchat",
"zh-CN/web/tui"
]
}
]
},
{
"tab": "参考",
"groups": [
{
"group": "CLI 命令",
"pages": [
"zh-CN/cli/index",
"zh-CN/cli/acp",
"zh-CN/cli/agent",
"zh-CN/cli/agents",
"zh-CN/cli/approvals",
"zh-CN/cli/browser",
"zh-CN/cli/channels",
"zh-CN/cli/config",
"zh-CN/cli/configure",
"zh-CN/cli/cron",
"zh-CN/cli/dashboard",
"zh-CN/cli/devices",
"zh-CN/cli/directory",
"zh-CN/cli/dns",
"zh-CN/cli/docs",
"zh-CN/cli/doctor",
"zh-CN/cli/gateway",
"zh-CN/cli/health",
"zh-CN/cli/hooks",
"zh-CN/cli/logs",
"zh-CN/cli/memory",
"zh-CN/cli/message",
"zh-CN/cli/models",
"zh-CN/cli/node",
"zh-CN/cli/nodes",
"zh-CN/cli/onboard",
"zh-CN/cli/pairing",
"zh-CN/cli/plugins",
"zh-CN/cli/reset",
"zh-CN/cli/sandbox",
"zh-CN/cli/security",
"zh-CN/cli/sessions",
"zh-CN/cli/setup",
"zh-CN/cli/skills",
"zh-CN/cli/status",
"zh-CN/cli/system",
"zh-CN/cli/tui",
"zh-CN/cli/uninstall",
"zh-CN/cli/update",
"zh-CN/cli/voicecall",
"zh-CN/cli/webhooks"
]
},
{
"group": "RPC 与 API",
"pages": ["zh-CN/reference/rpc", "zh-CN/reference/device-models"]
},
{
"group": "模板",
"pages": [
"zh-CN/reference/AGENTS.default",
"zh-CN/reference/templates/AGENTS",
"zh-CN/reference/templates/BOOT",
"zh-CN/reference/templates/BOOTSTRAP",
"zh-CN/reference/templates/HEARTBEAT",
"zh-CN/reference/templates/IDENTITY",
"zh-CN/reference/templates/SOUL",
"zh-CN/reference/templates/TOOLS",
"zh-CN/reference/templates/USER"
]
},
{
"group": "技术参考",
"pages": [
"zh-CN/reference/wizard",
"zh-CN/reference/token-use",
"zh-CN/reference/api-usage-costs",
"zh-CN/reference/transcript-hygiene",
"zh-CN/date-time"
]
},
{
"group": "概念内部机制",
"pages": [
"zh-CN/concepts/typebox",
"zh-CN/concepts/markdown-formatting",
"zh-CN/concepts/typing-indicators",
"zh-CN/concepts/usage-tracking",
"zh-CN/concepts/timezone"
]
},
{
"group": "项目",
"pages": ["zh-CN/reference/credits"]
},
{
"group": "发布策略",
"pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"]
}
]
},
{
"tab": "帮助",
"groups": [
{
"group": "帮助",
"pages": ["zh-CN/help/index", "zh-CN/help/troubleshooting", "zh-CN/help/faq"]
},
{
"group": "社区",
"pages": ["zh-CN/start/lore"]
},
{
"group": "环境与调试",
"pages": [
"zh-CN/help/environment",
"zh-CN/help/debugging",
"zh-CN/help/testing",
"zh-CN/help/scripts",
"zh-CN/debug/node-issue",
"zh-CN/diagnostics/flags"
]
},
{
"group": "Node 运行时",
"pages": ["zh-CN/install/node"]
},
{
"group": "压缩机制内部参考",
"pages": ["zh-CN/reference/session-management-compaction"]
},
{
"group": "开发者设置",
"pages": ["zh-CN/start/setup", "zh-CN/pi-dev"]
},
{
"group": "文档元信息",
"pages": ["zh-CN/start/hubs", "zh-CN/start/docs-directory", "zh-CN/AGENTS"]
}
]
}
]
}

View File

@@ -77,14 +77,9 @@ openclaw tasks flow cancel <lookup>
| Subagent orchestration | `subagent` | Spawning a subagent via `sessions_spawn` | `done_only` |
| Cron jobs (all types) | `cron` | Every cron execution (main-session and isolated) | `silent` |
| CLI operations | `cli` | `openclaw agent` commands that run through the gateway | `silent` |
| Agent media jobs | `cli` | Session-backed `video_generate` runs | `silent` |
Main-session cron tasks use `silent` notify policy by default — they create records for tracking but do not generate notifications. Isolated cron tasks also default to `silent` but are more visible because they run in their own session.
Session-backed `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished video itself. If you opt into `tools.media.asyncCompletion.directSend`, async `music_generate` and `video_generate` completions try direct channel delivery first before falling back to the requester-session wake path.
While a session-backed `video_generate` task is still active, the tool also acts as a guardrail: repeated `video_generate` calls in that same session return the active task status instead of starting a second concurrent generation. Use `action: "status"` when you want an explicit progress/status lookup from the agent side.
**What does not create tasks:**
- Heartbeat turns — main-session; see [Heartbeat](/gateway/heartbeat)

View File

@@ -571,14 +571,8 @@ Default slash command settings:
- `off` (default)
- `first`
- `all`
- `batched`
Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
`first` always attaches the implicit native reply reference to the first outbound Discord message for the turn.
`batched` only attaches Discord's implicit native reply reference when the
inbound turn was a debounced batch of multiple messages. This is useful
when you want native replies mainly for ambiguous bursty chats, not every
single-message turn.
Message IDs are surfaced in context/history so agents can target specific messages.
@@ -1237,7 +1231,7 @@ High-signal Discord fields:
- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage`
- streaming: `streaming` (legacy alias: `streamMode`), `draftChunk`, `blockStreaming`, `blockStreamingCoalesce`
- media/retry: `mediaMaxMb`, `retry`
- `mediaMaxMb` caps outbound Discord uploads (default: `100MB`)
- `mediaMaxMb` caps outbound Discord uploads (default: `8MB`)
- actions: `actions.*`
- presence: `activity`, `status`, `activityType`, `activityUrl`
- UI: `ui.components.accentColor`

View File

@@ -190,8 +190,8 @@ Control how group/room messages are handled per channel:
groupPolicy: "allowlist",
groupAllowFrom: ["@owner:example.org"],
groups: {
"!roomId:example.org": { enabled: true },
"#alias:example.org": { enabled: true },
"!roomId:example.org": { allow: true },
"#alias:example.org": { allow: true },
},
},
},
@@ -227,10 +227,7 @@ Quick mental model (evaluation order for group messages):
Group messages require a mention unless overridden per group. Defaults live per subsystem under `*.groups."*"`.
Replying to a bot message counts as an implicit mention when the channel
supports reply metadata. Quoting a bot message can also count as an implicit
mention on channels that expose quote metadata. Current built-in cases include
Telegram, WhatsApp, Slack, Discord, Microsoft Teams, and ZaloUser.
Replying to a bot message counts as an implicit mention (when the channel supports reply metadata). This applies to Telegram, WhatsApp, Slack, Discord, and Microsoft Teams.
```json5
{
@@ -400,7 +397,7 @@ Channel specific notes:
- BlueBubbles can optionally enrich unnamed macOS group participants from the local Contacts database before populating `GroupMembers`. This is off by default and only runs after normal group gating passes.
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, minimize empty lines and follow normal chat spacing, and avoid typing literal `\n` sequences.
The agent system prompt includes a group intro on the first turn of a new group session. It reminds the model to respond like a human, avoid Markdown tables, and avoid typing literal `\n` sequences.
## iMessage specifics

View File

@@ -21,7 +21,7 @@ IRC ships as an extension plugin, but it is configured in the main config under
channels: {
irc: {
enabled: true,
host: "irc.example.com",
host: "irc.libera.chat",
port: 6697,
tls: true,
nick: "openclaw-bot",
@@ -31,8 +31,6 @@ IRC ships as an extension plugin, but it is configured in the main config under
}
```
Prefer a private IRC server for bot coordination. If you intentionally use a public IRC network, common choices include Libera.Chat, OFTC, and Snoonet. Avoid predictable public channels for bot or swarm backchannel traffic.
3. Start/restart gateway:
```bash

Some files were not shown because too many files have changed in this diff Show More