mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 11:38:44 +08:00
Compare commits
273 Commits
pe/exec-ap
...
codex/tele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f9661960 | ||
|
|
7154ad89c5 | ||
|
|
a8d2b54fd3 | ||
|
|
b89c57a1ba | ||
|
|
131577a4dc | ||
|
|
e996159738 | ||
|
|
8bd24ad6d4 | ||
|
|
3ee0342061 | ||
|
|
10b313d628 | ||
|
|
e9989f3a92 | ||
|
|
ff4bf0c367 | ||
|
|
a559ccc084 | ||
|
|
f169e0aafd | ||
|
|
6f7d9736e2 | ||
|
|
8eb0a1777f | ||
|
|
f526d96c98 | ||
|
|
19065e4a2f | ||
|
|
88585da2e8 | ||
|
|
eb6dd2c65d | ||
|
|
0b4fc26d4a | ||
|
|
48d9966aa1 | ||
|
|
d160342b55 | ||
|
|
1a242cd4f5 | ||
|
|
2c8f78e723 | ||
|
|
a9eaf0c993 | ||
|
|
6f18decb7a | ||
|
|
f1a55cbd52 | ||
|
|
9b517b50cb | ||
|
|
8aff1807fa | ||
|
|
b4fdd1470b | ||
|
|
1c3ff34d75 | ||
|
|
59defa3e71 | ||
|
|
ab398ae86d | ||
|
|
87aa319568 | ||
|
|
567fe2957d | ||
|
|
df8505b09d | ||
|
|
0903fa61d0 | ||
|
|
bcdfbb8b84 | ||
|
|
26bcc95665 | ||
|
|
583eb711ec | ||
|
|
46d53d3b59 | ||
|
|
b77444ee48 | ||
|
|
cde6d60c18 | ||
|
|
17eab1ed4d | ||
|
|
02f8fb7147 | ||
|
|
8477a67faf | ||
|
|
85a3d5312f | ||
|
|
d761b98adc | ||
|
|
98cc6df7ff | ||
|
|
83c225b243 | ||
|
|
c1579b7727 | ||
|
|
9968db65db | ||
|
|
d124c5aa20 | ||
|
|
721ad1587a | ||
|
|
b2c5ba6d4c | ||
|
|
424c6d0a5f | ||
|
|
583a60f8b5 | ||
|
|
94abfa76e2 | ||
|
|
c92ebd6a41 | ||
|
|
1fb09069c3 | ||
|
|
70f580041f | ||
|
|
9995e1b4d5 | ||
|
|
bf9329486b | ||
|
|
44c6ad7dce | ||
|
|
3e6f7494af | ||
|
|
78f3985c60 | ||
|
|
06a39015f2 | ||
|
|
0901801238 | ||
|
|
cb408bb06b | ||
|
|
fa814eb9ed | ||
|
|
a4f80f905d | ||
|
|
5702858553 | ||
|
|
3631af8107 | ||
|
|
57854219d0 | ||
|
|
324a95db8b | ||
|
|
f4e17a4b54 | ||
|
|
9cbe28d75e | ||
|
|
1fbb4e4e6a | ||
|
|
46c622aa3b | ||
|
|
3fb5b4bec9 | ||
|
|
890139f998 | ||
|
|
0802a10273 | ||
|
|
f199cec885 | ||
|
|
a433cef05f | ||
|
|
7cc4258dd5 | ||
|
|
e4fba78d81 | ||
|
|
9dc7bd4d05 | ||
|
|
38f11a0844 | ||
|
|
cf194419c3 | ||
|
|
9657b8e8ce | ||
|
|
fffb8c9e2c | ||
|
|
023e33cb07 | ||
|
|
98256b192b | ||
|
|
8c2a390fbc | ||
|
|
4af590a5f8 | ||
|
|
03c303d953 | ||
|
|
27c7e1e07b | ||
|
|
516356835d | ||
|
|
cce00498cd | ||
|
|
ae29d14abf | ||
|
|
13deea2a9d | ||
|
|
1912be8619 | ||
|
|
c49d909b60 | ||
|
|
f0b43bfd34 | ||
|
|
1b82c0e3d9 | ||
|
|
4f4d108639 | ||
|
|
5613f5fd05 | ||
|
|
35cd2af159 | ||
|
|
6a5a1353c7 | ||
|
|
220d3ec26f | ||
|
|
bf95f762b5 | ||
|
|
40a5942091 | ||
|
|
b823a5a266 | ||
|
|
29f39db857 | ||
|
|
651ec2027d | ||
|
|
4f5e817782 | ||
|
|
d204ec0cc9 | ||
|
|
4712931e71 | ||
|
|
ce039eb103 | ||
|
|
d43f2f73f7 | ||
|
|
db2858cec7 | ||
|
|
ae25afdb62 | ||
|
|
022a422755 | ||
|
|
f1f92b8656 | ||
|
|
5fb9c0c937 | ||
|
|
880b39f061 | ||
|
|
d29f77bece | ||
|
|
c32878d1b7 | ||
|
|
4b35003051 | ||
|
|
0ed24da686 | ||
|
|
e973aa278f | ||
|
|
2bb448908d | ||
|
|
125ebd0987 | ||
|
|
a7ab09fa4e | ||
|
|
384ddae86f | ||
|
|
508945965a | ||
|
|
67f8683ca3 | ||
|
|
2fa86c6a42 | ||
|
|
86885f31c1 | ||
|
|
70c326f2be | ||
|
|
61d583d59d | ||
|
|
2a0350b5b4 | ||
|
|
3132969c68 | ||
|
|
d1fa0f9628 | ||
|
|
edd97365f2 | ||
|
|
6e80294079 | ||
|
|
1f01ab3a30 | ||
|
|
a2d67959d7 | ||
|
|
83b525bc1f | ||
|
|
25aa72edbd | ||
|
|
1ba9f5ded3 | ||
|
|
3bf518e518 | ||
|
|
e3d802a10b | ||
|
|
9fa8b86891 | ||
|
|
fd8877b5fd | ||
|
|
81f20d8464 | ||
|
|
57c952f679 | ||
|
|
53d14d0561 | ||
|
|
ff47c51608 | ||
|
|
6940a01e74 | ||
|
|
55f4b66a52 | ||
|
|
c86b89a5fe | ||
|
|
22d98b4d52 | ||
|
|
172eadbb6f | ||
|
|
ade3a6a3ad | ||
|
|
3a2502de92 | ||
|
|
5c01418442 | ||
|
|
ea3749872a | ||
|
|
0c125be717 | ||
|
|
419eea2462 | ||
|
|
9536d66a35 | ||
|
|
76ce72cbe5 | ||
|
|
3a5627d911 | ||
|
|
253b24445e | ||
|
|
5e33bb6458 | ||
|
|
d831b8e7bd | ||
|
|
8e9d5c43d2 | ||
|
|
957d50ad49 | ||
|
|
e6e1696c28 | ||
|
|
6c6bc7fff5 | ||
|
|
018a6db132 | ||
|
|
8bfdffad32 | ||
|
|
f14a0ed6cf | ||
|
|
b4cf06b0d7 | ||
|
|
3015eeca94 | ||
|
|
53773ccee4 | ||
|
|
f85534fc52 | ||
|
|
0f4eccefd4 | ||
|
|
9eda9d1114 | ||
|
|
25a4a620d1 | ||
|
|
4e3e659a20 | ||
|
|
826eb7c552 | ||
|
|
08ecc518ec | ||
|
|
54f87184f0 | ||
|
|
6062f90d8b | ||
|
|
a792068d9d | ||
|
|
a51ee5b02d | ||
|
|
8725364cf0 | ||
|
|
3553aa3763 | ||
|
|
4b4f71a2cc | ||
|
|
1e5450f23e | ||
|
|
5a7d31108e | ||
|
|
491ce8b753 | ||
|
|
3a58621e72 | ||
|
|
ac1b48efbc | ||
|
|
46bad8676c | ||
|
|
bd69510662 | ||
|
|
4a1745281e | ||
|
|
856a1692ff | ||
|
|
b7735f88fa | ||
|
|
adc37670e8 | ||
|
|
3c36ea0dd7 | ||
|
|
4c613fbfe0 | ||
|
|
81b9058cc3 | ||
|
|
a9407d2f65 | ||
|
|
9e00234d2d | ||
|
|
3b5d30b5fd | ||
|
|
27adbf9a1f | ||
|
|
e0bb46b93a | ||
|
|
1ed4f747e9 | ||
|
|
e7933c9137 | ||
|
|
69aec10852 | ||
|
|
322f0bb7bc | ||
|
|
56024b7828 | ||
|
|
94c012b2ec | ||
|
|
fb70de8046 | ||
|
|
2696f2576d | ||
|
|
ad4a74c884 | ||
|
|
b6fd843288 | ||
|
|
b3fc9fe079 | ||
|
|
394037c174 | ||
|
|
e20de0f603 | ||
|
|
2976517bc7 | ||
|
|
00205cab08 | ||
|
|
db8de0db7a | ||
|
|
aec0c56386 | ||
|
|
b278098a7c | ||
|
|
29664863a5 | ||
|
|
61d9a6d750 | ||
|
|
ce62516251 | ||
|
|
5a7b861ea2 | ||
|
|
e96428b008 | ||
|
|
102e4f2c9d | ||
|
|
476bd35431 | ||
|
|
be17d55a5e | ||
|
|
51d44ab1fc | ||
|
|
e292d3976a | ||
|
|
f79d842029 | ||
|
|
74949eda2f | ||
|
|
9d4500f3ac | ||
|
|
3782294e92 | ||
|
|
3809ff4f2a | ||
|
|
c946ced9d5 | ||
|
|
dd4790130e | ||
|
|
532a6a7a89 | ||
|
|
c9b9fffc40 | ||
|
|
2bf5e5f20d | ||
|
|
20ec5cdc42 | ||
|
|
fc16df30dd | ||
|
|
47b8e56e3f | ||
|
|
57bc26893e | ||
|
|
eca402da79 | ||
|
|
e453a39d6b | ||
|
|
f7196e3b53 | ||
|
|
9d5db92cda | ||
|
|
939712fbbf | ||
|
|
55ca2df62a | ||
|
|
198f20fd20 | ||
|
|
e3d5518838 | ||
|
|
46f27b6e07 | ||
|
|
8f27b3e21f | ||
|
|
cd15ce35a0 | ||
|
|
395bd578d2 |
@@ -28,6 +28,7 @@ Use when:
|
||||
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
|
||||
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
- For OpenClaw maintainers, keep autoreview validation Crabbox/Testbox-aware when maintainer validation mode is enabled (`OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`). A review pass may inspect files and run cheap non-Node probes, but it must not start local `pnpm`, Vitest, `tsgo`, `npm test`, or `node scripts/run-vitest.mjs` from a Codex/worktree review unless the operator explicitly requested local proof. For runtime proof, use existing evidence or route through Crabbox/Testbox and report the id. Do not apply this rule to ordinary contributors who do not have maintainer Testbox access.
|
||||
|
||||
## Pick Target
|
||||
|
||||
@@ -50,7 +51,11 @@ git fetch origin
|
||||
codex review --base origin/main
|
||||
```
|
||||
|
||||
Do not pass an inline prompt with `--base`; current CLI rejects `--base` + `[PROMPT]` even though help text is ambiguous. If custom instructions are needed, run the plain base review first, then do a local/manual follow-up pass.
|
||||
Do not pass any prompt with `--base`. Some Codex CLI versions reject both inline
|
||||
and stdin prompt forms, including the helper's `codex review --base <ref> -`,
|
||||
with `--base <BRANCH> cannot be used with [PROMPT]`. If the helper hits this
|
||||
error, run plain `codex review --base <ref>` and report that the helper prompt
|
||||
injection was skipped.
|
||||
|
||||
If an open PR exists, use its actual base:
|
||||
|
||||
@@ -107,6 +112,7 @@ The helper:
|
||||
- chooses dirty `--uncommitted` first
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- auto-runs `PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check` in parallel when a repo has `package.json`, `pnpm-lock.yaml`, `node_modules`, and a `check` script; disable with `AUTOREVIEW_AUTO_TESTS=0`
|
||||
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
|
||||
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
|
||||
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
|
||||
@@ -115,6 +121,8 @@ The helper:
|
||||
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
|
||||
- supports `--dry-run`, `--parallel-tests`, and commit refs
|
||||
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
|
||||
- injects maintainer-only OpenClaw validation policy into native Codex review when `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, so local memory-heavy Node/Vitest checks are avoided in favor of Crabbox/Testbox proof
|
||||
- branch mode may fail on Codex CLI versions that reject `--base` plus the helper's stdin prompt; on that exact parser error, rerun plain `codex review --base <ref>` instead of falling back to a non-Codex reviewer
|
||||
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
|
||||
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
|
||||
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
|
||||
@@ -128,3 +136,10 @@ Include:
|
||||
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
|
||||
|
||||
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
|
||||
|
||||
## PR / CI Closeout
|
||||
|
||||
- Prefer direct run/job APIs after CI starts: `gh run view <run-id> --json jobs`; use PR rollup only for final mergeability.
|
||||
- After rebase, compare `origin/main..HEAD`; drop CI-fix commits already upstream before pushing.
|
||||
- For prompt snapshot CI failures, prove/generate with Linux Node 24 before rerunning the failed job.
|
||||
- Update PR body once near the final head unless proof labels are missing or stale enough to block CI.
|
||||
|
||||
@@ -24,9 +24,14 @@ Options:
|
||||
--no-yolo Run nested Codex review with normal sandbox/approval prompts.
|
||||
--output FILE Also save output to file.
|
||||
--parallel-tests CMD Run review and test command concurrently.
|
||||
Default: PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check when available.
|
||||
--dry-run Print selected commands, do not run.
|
||||
-h, --help Show help.
|
||||
|
||||
Environment:
|
||||
OPENCLAW_TESTBOX=1 or AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1
|
||||
Enable maintainer-only OpenClaw Crabbox/Testbox validation policy.
|
||||
|
||||
Modes:
|
||||
local codex review --uncommitted
|
||||
branch codex review --base <base>
|
||||
@@ -50,7 +55,12 @@ codex_args=()
|
||||
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
|
||||
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
|
||||
parallel_tests=
|
||||
parallel_tests_auto=false
|
||||
dry_run=false
|
||||
codex_review_prompt=
|
||||
codex_review_stdin_prompt=false
|
||||
codex_review_prompt_file=false
|
||||
openclaw_maintainer_validation=${AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION:-${OPENCLAW_TESTBOX:-0}}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -159,6 +169,21 @@ case "$fallback_reviewer" in
|
||||
esac
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
printf -v quoted_repo_root '%q' "$repo_root"
|
||||
|
||||
has_package_check_script() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
node -e 'const { readFileSync } = require("node:fs"); const p = JSON.parse(readFileSync(process.argv[1], "utf8")); process.exit(p.scripts?.check ? 0 : 1)' \
|
||||
"$repo_root/package.json" \
|
||||
>/dev/null 2>&1
|
||||
}
|
||||
|
||||
auto_tests_disabled() {
|
||||
case "${AUTOREVIEW_AUTO_TESTS:-${CODEX_REVIEW_AUTO_TESTS:-1}}" in
|
||||
0|false|False|FALSE|no|No|NO|off|Off|OFF) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
current_branch=$(git branch --show-current 2>/dev/null || true)
|
||||
dirty=false
|
||||
@@ -201,6 +226,38 @@ else
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref")
|
||||
fi
|
||||
|
||||
repo_url=$(git -C "$repo_root" config --get remote.origin.url 2>/dev/null || true)
|
||||
case "$openclaw_maintainer_validation" in
|
||||
1|true|True|TRUE|yes|Yes|YES|on|On|ON) openclaw_maintainer_validation=1 ;;
|
||||
*) openclaw_maintainer_validation=0 ;;
|
||||
esac
|
||||
if [[ -z "$parallel_tests" && "$openclaw_maintainer_validation" != 1 ]] &&
|
||||
! auto_tests_disabled; then
|
||||
if [[ -f "$repo_root/package.json" && -f "$repo_root/pnpm-lock.yaml" && -d "$repo_root/node_modules" ]] &&
|
||||
command -v pnpm >/dev/null 2>&1 &&
|
||||
has_package_check_script; then
|
||||
parallel_tests="cd $quoted_repo_root && PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check"
|
||||
parallel_tests_auto=true
|
||||
fi
|
||||
fi
|
||||
if [[ "$repo_url" == *"openclaw/openclaw"* && "$openclaw_maintainer_validation" == 1 ]]; then
|
||||
codex_review_prompt=$(cat <<'EOF'
|
||||
OpenClaw maintainer autoreview validation policy:
|
||||
- Review the diff by reading code, tests, and dependency contracts.
|
||||
- Do not run local memory-heavy Node validation from review mode. This includes local pnpm checks/tests, Vitest, tsgo, npm test, and node scripts/run-vitest.mjs.
|
||||
- If runtime proof is needed, use existing proof or route validation through Crabbox / Blacksmith Testbox and report the exact provider and id.
|
||||
- If remote validation is not necessary for the finding, state the targeted proof that should be run instead of starting local tests.
|
||||
EOF
|
||||
)
|
||||
if [[ "$review_kind" == local ]]; then
|
||||
review_cmd+=(-)
|
||||
codex_review_stdin_prompt=true
|
||||
else
|
||||
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
|
||||
codex_review_prompt_file=true
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'autoreview target: %s\n' "$review_kind"
|
||||
printf 'branch: %s\n' "${current_branch:-detached}"
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
@@ -221,11 +278,18 @@ if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
|
||||
printf 'review:'
|
||||
printf ' %q' "${review_cmd[@]}"
|
||||
printf '\n'
|
||||
if [[ "$codex_review_stdin_prompt" == true || "$codex_review_prompt_file" == true ]]; then
|
||||
printf 'review policy: OpenClaw maintainer Crabbox/Testbox-aware validation prompt injected\n'
|
||||
fi
|
||||
else
|
||||
printf 'review: %s prompt review\n' "$reviewer"
|
||||
fi
|
||||
if [[ -n "$parallel_tests" ]]; then
|
||||
printf 'tests: %s\n' "$parallel_tests"
|
||||
printf 'tests: %s' "$parallel_tests"
|
||||
if [[ "$parallel_tests_auto" == true ]]; then
|
||||
printf ' (auto)'
|
||||
fi
|
||||
printf '\n'
|
||||
fi
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
printf 'fetch: git fetch origin --quiet\n'
|
||||
@@ -264,8 +328,20 @@ cleanup() {
|
||||
trap cleanup EXIT
|
||||
|
||||
run_review() {
|
||||
local status=0
|
||||
mkdir -p "$(dirname "$review_output")"
|
||||
"${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
if [[ "$codex_review_prompt_file" == true ]]; then
|
||||
build_prompt_file || return
|
||||
"${review_cmd[@]}" < "$prompt_file" 2>&1 | tee "$review_output"
|
||||
status=${PIPESTATUS[0]}
|
||||
rm -f "$prompt_file"
|
||||
prompt_file=
|
||||
return "$status"
|
||||
elif [[ "$codex_review_stdin_prompt" == true ]]; then
|
||||
printf '%s\n' "$codex_review_prompt" | "${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
else
|
||||
"${review_cmd[@]}" 2>&1 | tee "$review_output"
|
||||
fi
|
||||
}
|
||||
|
||||
diff_for_review() {
|
||||
@@ -306,6 +382,7 @@ Rules:
|
||||
- Review the proposed code change as a closeout reviewer.
|
||||
- Focus on the diff below. If your CLI exposes read-only repository tools, inspect surrounding code and tests to verify findings; never modify files.
|
||||
- Do not modify files.
|
||||
${codex_review_prompt}
|
||||
- Report only discrete, actionable issues introduced by this change.
|
||||
- Prioritize correctness, regressions, security, data loss, performance cliffs, and missing tests that would catch a real bug.
|
||||
- Do not report pre-existing issues, speculative risks, broad rewrites, style nits, changelog gaps, or findings that depend on unstated assumptions.
|
||||
|
||||
64
.agents/skills/openclaw-docker-e2e-authoring/SKILL.md
Normal file
64
.agents/skills/openclaw-docker-e2e-authoring/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: openclaw-docker-e2e-authoring
|
||||
description: "Author OpenClaw Docker E2E and live provider Docker lanes."
|
||||
---
|
||||
|
||||
# OpenClaw Docker E2E Authoring
|
||||
|
||||
Use this when adding or changing Docker E2E lanes, release-path Docker tests,
|
||||
or live-provider Docker proof.
|
||||
|
||||
## Lane Choice
|
||||
|
||||
- Deterministic Docker: fake the dependency/server and assert the exact runtime
|
||||
contract crossing the boundary.
|
||||
- Live Docker: use real provider credentials/model only when user-visible
|
||||
behavior needs the real service.
|
||||
- Prefer both when they prove different risks: deterministic for byte/payload
|
||||
routing, live for actual provider behavior.
|
||||
|
||||
## Authoring Rules
|
||||
|
||||
- Test-only helpers live in `test/helpers` or `scripts/e2e/lib/<lane>/`, not
|
||||
`src/**`, unless production imports them.
|
||||
- Package-installed app runs from `/app`; mount only explicit harness/helper
|
||||
paths read-only.
|
||||
- Fake servers should log boundary requests as JSONL and clients should assert
|
||||
the real dependency payload, not just process success.
|
||||
- Add the package script and `scripts/lib/docker-e2e-scenarios.mjs` lane in the
|
||||
same change.
|
||||
- If a lane installs a plugin from npm, default the spec via env so published
|
||||
and local override paths are both testable.
|
||||
|
||||
## Media And Vision
|
||||
|
||||
- Expected answer must exist only in pixels or provider output being tested.
|
||||
- Use neutral filenames, neutral prompts, and no metadata leaks.
|
||||
- Random bitmap/OCR tokens reuse the repo OCR-safe alphabet `24567ACEF` unless
|
||||
the test owns a stronger glyph set.
|
||||
- Make the expected answer unique per run when proving real image
|
||||
understanding.
|
||||
|
||||
## `chat.send` E2E
|
||||
|
||||
- Require `chat.send` to return `status: "started"` and a string `runId`.
|
||||
- Wait for completion with `agent.wait`.
|
||||
- Assert final user-visible text via `chat.history` when event ordering is not
|
||||
the behavior under test.
|
||||
- Keep originating channel/account metadata only when the bug path needs queued
|
||||
inbound/channel context.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the smallest proof that covers the touched lane:
|
||||
|
||||
```bash
|
||||
pnpm exec oxfmt --write <changed files>
|
||||
node --check <new .mjs files>
|
||||
bash -n <new .sh files>
|
||||
node scripts/run-vitest.mjs test/scripts/docker-e2e-plan.test.ts
|
||||
OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:<lane>
|
||||
```
|
||||
|
||||
For real-provider lanes, run the matching live Docker script after deterministic
|
||||
Docker is green. Finish with `$autoreview` before commit/PR.
|
||||
95
.agents/skills/openclaw-mac-release/SKILL.md
Normal file
95
.agents/skills/openclaw-mac-release/SKILL.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: openclaw-mac-release
|
||||
description: "Run or recover OpenClaw macOS release signing, notarization, appcast, and asset promotion."
|
||||
---
|
||||
|
||||
# OpenClaw Mac Release
|
||||
|
||||
Use with `$openclaw-release-maintainer`, `$openclaw-release-ci`, and `$one-password` when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
|
||||
|
||||
## Credentials
|
||||
|
||||
- Canonical ASC item: vault `Molty`, title `API Key - App Store Connect - Personal - Release`.
|
||||
- Fields: `private_key_p8`, `key_id`, `issuer_id`.
|
||||
- Current known good key id: `AKVLXW849T`.
|
||||
- Legacy mirror: vault `Private`, title `API Key - App Store Connect - Personal`; keep it synced for older refs.
|
||||
- Stale/revoked key symptom: `xcrun notarytool submit` fails with `HTTP status code: 401. Unauthenticated`.
|
||||
- Validate candidate ASC credentials with `xcrun notarytool history` before setting GitHub secrets.
|
||||
|
||||
## 1Password
|
||||
|
||||
- Use `$one-password`: all `op` work inside one persistent tmux session, no secret output.
|
||||
- Prefer `OP_SERVICE_ACCOUNT_TOKEN` from `~/.profile` for Molty reads.
|
||||
- Do not assume `MOLTY_OP_SERVICE_ACCOUNT_TOKEN` is alive; it has previously pointed at a deleted service account.
|
||||
- If a service token fails, run status-only checks: token present/length and `op whoami`; never print token values.
|
||||
- If desktop app auth is needed but Touch ID is unavailable, set `OP_BIOMETRIC_UNLOCK_ENABLED=false` for the manual `op account add --signin` path.
|
||||
|
||||
## GitHub Secrets
|
||||
|
||||
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
|
||||
|
||||
Set only after local notary auth validation:
|
||||
|
||||
- `APP_STORE_CONNECT_API_KEY_P8`
|
||||
- `APP_STORE_CONNECT_KEY_ID`
|
||||
- `APP_STORE_CONNECT_ISSUER_ID`
|
||||
|
||||
Do not update these from mixed sources. All three ASC fields must come from the same 1Password item.
|
||||
|
||||
## Workflow Shape
|
||||
|
||||
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
|
||||
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
|
||||
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
|
||||
- Real mac publish must reuse:
|
||||
- a successful private mac preflight run for the same tag/source SHA
|
||||
- a successful private mac validation run for the same tag/source SHA
|
||||
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
|
||||
|
||||
## Notarization
|
||||
|
||||
- OpenClaw uses `scripts/notarize-mac-artifact.sh`.
|
||||
- `xcrun notarytool submit` should use `--no-s3-acceleration`; accelerated upload can surface misleading 401s even when `notarytool history` succeeds.
|
||||
- If signing succeeds but notarization fails immediately with 401, check ASC key freshness first.
|
||||
- If notarization stays in progress for several minutes after key-file write, that is normal Apple wait time; do not edit blindly.
|
||||
|
||||
## Dispatch
|
||||
|
||||
Private preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D \
|
||||
-f preflight_only=true \
|
||||
-f smoke_test_only=false \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
```
|
||||
|
||||
Private validation for a branch-variation preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D
|
||||
```
|
||||
|
||||
Real publish:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f preflight_only=false \
|
||||
-f smoke_test_only=false \
|
||||
-f preflight_run_id=<successful-preflight-run> \
|
||||
-f validate_run_id=<successful-validation-run> \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
|
||||
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
|
||||
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.
|
||||
@@ -27,7 +27,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
use the Crabbox wrapper with the provider that matches the proof surface.
|
||||
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
|
||||
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
|
||||
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
|
||||
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
|
||||
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
|
||||
@@ -131,6 +131,8 @@ gh run view <run-id> --job <job-id> --log
|
||||
- Check exact SHA. Ignore newer unrelated `main` unless asked.
|
||||
- For cancelled same-branch runs, confirm whether a newer run superseded it.
|
||||
- Fetch full logs only for failed or relevant jobs.
|
||||
- Prefer `gh run view <run-id> --json jobs` over PR rollup while debugging; rollup can be stale/noisy.
|
||||
- For `prompt:snapshots:check` failures, treat Linux Node 24 as CI truth. If macOS passes but CI drifts, reproduce in a Linux Node 24 container or Testbox, commit that generated output, then rerun.
|
||||
|
||||
## GitHub Release Workflows
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ artifact bundle. The runner leases the shared burner account from Convex.
|
||||
Run from the OpenClaw repo and branch under test:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- start \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" start \
|
||||
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
|
||||
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
|
||||
```
|
||||
@@ -39,7 +40,8 @@ For deterministic visual repros, put the exact mock-model reply in a file and
|
||||
pass it to `start`:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- start \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" start \
|
||||
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
|
||||
--mock-response-file .artifacts/qa-e2e/telegram-user-crabbox/reply.txt \
|
||||
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
|
||||
@@ -55,15 +57,16 @@ For visual proof, first send or identify a bottom marker message, then open the
|
||||
group/topic directly by message id:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- view \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" view \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--message-id <message-id>
|
||||
```
|
||||
|
||||
This uses Telegram Desktop directly with `tg://privatepost`, not `xdg-open`.
|
||||
It also resizes Telegram to `650x1000` at the tested desktop position so
|
||||
Telegram switches to single-chat mode with no left chat list or right info
|
||||
pane. Do not press Escape after this; Escape can close the selected chat.
|
||||
the crop can isolate the chat pane even if Telegram keeps a split/sidebar
|
||||
layout. Do not press Escape after this; Escape can close the selected chat.
|
||||
|
||||
Bottom behavior matters:
|
||||
|
||||
@@ -71,13 +74,14 @@ Bottom behavior matters:
|
||||
later messages appear live in the recording
|
||||
- deep-linking to an older message does not auto-scroll to new arrivals; link
|
||||
again to the newest/final marker instead of clicking the down-arrow
|
||||
- `650px` is the largest tested clean width; `660px` switches Telegram back to
|
||||
split/sidebar layout
|
||||
- the cropped GIF intentionally uses the chat pane, not the whole desktop or
|
||||
whole Telegram window
|
||||
|
||||
Send as the real Telegram user:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- send \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" send \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--text /status
|
||||
```
|
||||
@@ -87,7 +91,8 @@ For slash commands, omit the bot username; the runner targets the SUT bot.
|
||||
Run arbitrary commands on the Crabbox:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- run \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" run \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
-- bash -lc 'source /tmp/openclaw-telegram-user-crabbox/env.sh && python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py transcript --limit 20 --json'
|
||||
```
|
||||
@@ -106,14 +111,16 @@ python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py probe --text '@{sut}
|
||||
Capture the current desktop without ending the session:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- screenshot \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" screenshot \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
|
||||
```
|
||||
|
||||
Check lease state and get the WebVNC command:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- status \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" status \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
|
||||
```
|
||||
|
||||
@@ -122,7 +129,8 @@ pnpm qa:telegram-user:crabbox -- status \
|
||||
Always finish or explicitly keep the box:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- finish \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" finish \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--preview-crop telegram-window
|
||||
```
|
||||
@@ -150,7 +158,8 @@ Attach only the useful visual artifact to the PR unless logs are needed. The
|
||||
runner is GIF-only by default:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- publish \
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" publish \
|
||||
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
|
||||
--pr <pr-number> \
|
||||
--summary 'Telegram real-user Crabbox session motion GIF'
|
||||
@@ -189,7 +198,8 @@ experiments unless those artifacts are explicitly needed.
|
||||
For a fast one-shot check, use:
|
||||
|
||||
```bash
|
||||
pnpm qa:telegram-user:crabbox -- --text /status
|
||||
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
|
||||
"$proof_cmd" --text /status
|
||||
```
|
||||
|
||||
This is a start/send/finish shortcut. Prefer the held session for PR review,
|
||||
|
||||
@@ -18,6 +18,10 @@ Hard limits:
|
||||
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
|
||||
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
|
||||
outcome when GIFs would be misleading, but it is not proof that the PR passed.
|
||||
- Do not skip Telegram-visible PRs just because the proof needs a specific
|
||||
message, mock response, media attachment, command, button, reaction, stop
|
||||
timing, approval prompt, or progress/final delivery sequence. First write a
|
||||
concrete proof plan and try the standard harness path.
|
||||
- Keep public-facing manifest summaries short and user-domain. Do not mention
|
||||
harness internals, mock-provider limits, secret/trust boundaries, local paths,
|
||||
transcript seeding, or workflow implementation details in the summary.
|
||||
@@ -42,7 +46,15 @@ Required workflow:
|
||||
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
|
||||
`gh pr diff "$MANTIS_PR_NUMBER"`.
|
||||
3. Decide whether the PR has a visibly reproducible Telegram Desktop
|
||||
before/after. If it does not, write
|
||||
before/after. Treat these as visible until proven otherwise: message text
|
||||
formatting/content, progress drafts, native drafts, final delivery, media or
|
||||
document delivery, inline buttons, approval prompts, stop/abort behavior,
|
||||
reactions/status indicators, guest/inline responses, TTS/voice/audio
|
||||
delivery, and routing changes whose result is visible in the chat. For those
|
||||
PRs, define the exact Telegram stimulus and expected main/PR visual delta
|
||||
before deciding to skip.
|
||||
|
||||
If the PR does not have a Telegram-visible before/after, write
|
||||
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
|
||||
artifacts, and a summary that starts with
|
||||
`Mantis did not generate before/after GIFs because`. Include a short
|
||||
@@ -78,8 +90,9 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
|
||||
```
|
||||
|
||||
If the PR appears visual but proof is blocked by Telegram Desktop session
|
||||
state, authorization, credentials, Crabbox, or another capture-infrastructure
|
||||
issue, do not describe it as a no-visual PR. Write a manifest with
|
||||
state, authorization, credentials, Crabbox, missing Telegram client support,
|
||||
unavailable media/provider setup, or another capture-infrastructure issue,
|
||||
do not describe it as a no-visual PR. Write a manifest with
|
||||
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
|
||||
starts with `Mantis could not capture Telegram Desktop proof because`. The
|
||||
publisher will keep that out of PR comments so the failure stays in the
|
||||
|
||||
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -5,7 +5,7 @@ Describe the problem and fix in 2–5 bullets:
|
||||
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
|
||||
|
||||
- Problem:
|
||||
- Why it matters:
|
||||
- Solution:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
@@ -35,6 +35,12 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
- Related #
|
||||
- [ ] This PR fixes a bug or regression
|
||||
|
||||
## Motivation
|
||||
|
||||
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
|
||||
|
||||
-
|
||||
|
||||
## Real behavior proof (required for external PRs)
|
||||
|
||||
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only. Screenshots are encouraged even for CLI, console, text, or log changes; terminal screenshots and copied live output count. Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.
|
||||
|
||||
181
.github/workflows/ci.yml
vendored
181
.github/workflows/ci.yml
vendored
@@ -60,14 +60,11 @@ jobs:
|
||||
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
|
||||
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
|
||||
run_checks: ${{ steps.manifest.outputs.run_checks }}
|
||||
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
|
||||
run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }}
|
||||
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
|
||||
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
|
||||
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
|
||||
run_check: ${{ steps.manifest.outputs.run_check }}
|
||||
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 }}
|
||||
@@ -134,6 +131,7 @@ jobs:
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
|
||||
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
|
||||
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
import { appendFileSync } from "node:fs";
|
||||
@@ -175,6 +173,7 @@ jobs:
|
||||
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
|
||||
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
|
||||
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
|
||||
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
|
||||
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
|
||||
const runNodeFastOnly =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
|
||||
@@ -199,7 +198,7 @@ jobs:
|
||||
const checksFastCoreTasks = [];
|
||||
if (runNodeFull) {
|
||||
checksFastCoreTasks.push(
|
||||
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
|
||||
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
|
||||
);
|
||||
} else {
|
||||
if (runNodeFastCiRouting) {
|
||||
@@ -248,21 +247,12 @@ jobs:
|
||||
runNodeFull ? createChannelContractTestShards() : [],
|
||||
),
|
||||
run_checks: runNodeFull,
|
||||
checks_matrix: createMatrix(
|
||||
runNodeFull
|
||||
? [
|
||||
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
|
||||
]
|
||||
: [],
|
||||
),
|
||||
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
|
||||
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
|
||||
run_checks_node_core_dist: nodeTestDistShards.length > 0,
|
||||
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
|
||||
run_check: runNodeFull,
|
||||
run_check_additional: runNodeFull,
|
||||
run_build_smoke: runNodeFull,
|
||||
run_check_docs: docsChanged,
|
||||
run_check_docs: docsChanged && eventName !== "push",
|
||||
run_control_ui_i18n: runControlUiI18n,
|
||||
run_skills_python_job: runSkillsPython,
|
||||
run_checks_windows: runWindows,
|
||||
@@ -297,9 +287,9 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
|
||||
# Run the fast security/SCM checks in parallel with scope detection so the
|
||||
# Run dependency-free security checks in parallel with scope detection so the
|
||||
# main Node jobs do not have to wait for Python/pre-commit setup.
|
||||
security-scm-fast:
|
||||
security-fast:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
@@ -392,22 +382,6 @@ jobs:
|
||||
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
|
||||
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
security-dependency-audit:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.target_ref || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -417,35 +391,6 @@ jobs:
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
|
||||
security-fast:
|
||||
permissions: {}
|
||||
needs: [security-scm-fast, security-dependency-audit]
|
||||
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Verify fast security jobs
|
||||
env:
|
||||
DEPENDENCY_AUDIT_RESULT: ${{ needs.security-dependency-audit.result }}
|
||||
SCM_RESULT: ${{ needs.security-scm-fast.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
|
||||
for result in \
|
||||
"security-scm-fast=${SCM_RESULT}" \
|
||||
"security-dependency-audit=${DEPENDENCY_AUDIT_RESULT}"
|
||||
do
|
||||
job="${result%%=*}"
|
||||
status="${result#*=}"
|
||||
if [ "$status" != "success" ]; then
|
||||
echo "::error::${job} ended with ${status}"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$failed"
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
|
||||
# test/build feedback sooner instead of waiting behind a full `check` pass.
|
||||
@@ -733,14 +678,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TASK" in
|
||||
bundled)
|
||||
bundled-protocol)
|
||||
pnpm test:bundled
|
||||
;;
|
||||
contracts-channels)
|
||||
pnpm test:contracts:channels
|
||||
;;
|
||||
contracts-plugins)
|
||||
pnpm test:contracts:plugins
|
||||
pnpm protocol:check
|
||||
;;
|
||||
contracts-plugins-ci-routing)
|
||||
pnpm test:contracts:plugins
|
||||
@@ -923,71 +863,6 @@ jobs:
|
||||
EOF
|
||||
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
|
||||
|
||||
checks-fast-protocol:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "checks-fast-protocol"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Run protocol check
|
||||
run: pnpm protocol:check
|
||||
|
||||
checks-node-compat:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1048,7 +923,7 @@ jobs:
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
node-version: "22.19.0"
|
||||
cache-key-suffix: "node22-pnpm11"
|
||||
install-bun: "false"
|
||||
|
||||
@@ -1188,8 +1063,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- check_name: check-preflight-guards
|
||||
task: preflight-guards
|
||||
- check_name: check-guards
|
||||
task: guards
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-prod-types
|
||||
task: prod-types
|
||||
@@ -1200,15 +1075,9 @@ jobs:
|
||||
- check_name: check-dependencies
|
||||
task: dependencies
|
||||
runner: blacksmith-8vcpu-ubuntu-2404
|
||||
- check_name: check-policy-guards
|
||||
task: policy-guards
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-test-types
|
||||
task: test-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-strict-smoke
|
||||
task: strict-smoke
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
@@ -1271,12 +1140,18 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TASK" in
|
||||
preflight-guards)
|
||||
guards)
|
||||
pnpm check:no-conflict-markers
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
@@ -1293,19 +1168,9 @@ jobs:
|
||||
pnpm deadcode:ci
|
||||
fi
|
||||
;;
|
||||
policy-guards)
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
;;
|
||||
test-types)
|
||||
pnpm check:test-types
|
||||
;;
|
||||
strict-smoke)
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported check task: $TASK" >&2
|
||||
exit 1
|
||||
@@ -1335,15 +1200,9 @@ jobs:
|
||||
- check_name: check-additional-boundaries-a
|
||||
group: boundaries
|
||||
boundary_shard: 1/4
|
||||
- check_name: check-additional-boundaries-b
|
||||
- check_name: check-additional-boundaries-bcd
|
||||
group: boundaries
|
||||
boundary_shard: 2/4
|
||||
- check_name: check-additional-boundaries-c
|
||||
group: boundaries
|
||||
boundary_shard: 3/4
|
||||
- check_name: check-additional-boundaries-d
|
||||
group: boundaries
|
||||
boundary_shard: 4/4
|
||||
boundary_shard: 2/4,3/4,4/4
|
||||
- check_name: check-additional-extension-channels
|
||||
group: extension-channels
|
||||
- check_name: check-additional-extension-bundled
|
||||
|
||||
2
.github/workflows/docs-sync-publish.yml
vendored
2
.github/workflows/docs-sync-publish.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Clone publish repo
|
||||
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
|
||||
|
||||
10
.github/workflows/docs.yml
vendored
10
.github/workflows/docs.yml
vendored
@@ -36,5 +36,15 @@ jobs:
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Checkout ClawHub docs source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: openclaw/clawhub
|
||||
path: clawhub-source
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source
|
||||
run: pnpm check:docs
|
||||
|
||||
2
.github/workflows/mantis-discord-smoke.yml
vendored
2
.github/workflows/mantis-discord-smoke.yml
vendored
@@ -168,7 +168,7 @@ jobs:
|
||||
|
||||
- name: Upload Mantis artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/mantis/
|
||||
|
||||
@@ -528,7 +528,7 @@ jobs:
|
||||
- name: Upload Mantis status reaction artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
@@ -536,7 +536,7 @@ jobs:
|
||||
- name: Upload Mantis thread attachment artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
- name: Upload Mantis Slack desktop artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
@@ -311,11 +311,13 @@ jobs:
|
||||
while true; do
|
||||
blockers="$(
|
||||
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done
|
||||
done | sort -u
|
||||
)"
|
||||
if [[ -z "$blockers" ]]; then
|
||||
@@ -479,7 +481,7 @@ jobs:
|
||||
- name: Upload Mantis Telegram desktop artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.inspect.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.inspect.outputs.output_dir }}
|
||||
|
||||
14
.github/workflows/mantis-telegram-live.yml
vendored
14
.github/workflows/mantis-telegram-live.yml
vendored
@@ -275,11 +275,13 @@ jobs:
|
||||
while true; do
|
||||
blockers="$(
|
||||
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
for status in queued in_progress waiting pending requested; do
|
||||
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
|
||||
| jq -r \
|
||||
--argjson current_id "$GITHUB_RUN_ID" \
|
||||
--arg current_created "$current_created" \
|
||||
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
|
||||
done
|
||||
done | sort -u
|
||||
)"
|
||||
if [[ -z "$blockers" ]]; then
|
||||
@@ -479,7 +481,7 @@ jobs:
|
||||
- name: Upload Mantis Telegram artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
|
||||
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -270,7 +270,7 @@ jobs:
|
||||
|
||||
- name: Upload npm Telegram E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/
|
||||
|
||||
@@ -321,9 +321,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
trusted_reason=""
|
||||
|
||||
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||
git fetch --tags origin '+refs/tags/*:refs/tags/*'
|
||||
|
||||
# Resolve here instead of in actions/checkout so short SHAs work too.
|
||||
if ! selected_sha="$(git rev-parse --verify "${INPUT_REF}^{commit}")"; then
|
||||
echo "Ref '${INPUT_REF}' could not be resolved to a commit." >&2
|
||||
|
||||
2
.github/workflows/openclaw-performance.yml
vendored
2
.github/workflows/openclaw-performance.yml
vendored
@@ -468,7 +468,7 @@ jobs:
|
||||
|
||||
- name: Upload Kova artifacts
|
||||
if: ${{ always() && steps.lane.outputs.run == 'true' }}
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-performance-${{ matrix.lane }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
|
||||
22
.github/workflows/openclaw-release-checks.yml
vendored
22
.github/workflows/openclaw-release-checks.yml
vendored
@@ -794,7 +794,7 @@ jobs:
|
||||
|
||||
- name: Upload parity lane artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -830,7 +830,7 @@ jobs:
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download parity lane artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -853,7 +853,7 @@ jobs:
|
||||
|
||||
- name: Upload parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -948,7 +948,7 @@ jobs:
|
||||
|
||||
- name: Upload runtime parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -983,7 +983,7 @@ jobs:
|
||||
install-bun: "true"
|
||||
|
||||
- name: Download runtime parity artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -999,7 +999,7 @@ jobs:
|
||||
|
||||
- name: Upload runtime tool coverage artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/runtime-parity-standard-report/
|
||||
@@ -1079,7 +1079,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1175,7 +1175,7 @@ jobs:
|
||||
|
||||
- name: Upload Telegram QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1271,7 +1271,7 @@ jobs:
|
||||
|
||||
- name: Upload Discord QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1370,7 +1370,7 @@ jobs:
|
||||
|
||||
- name: Upload WhatsApp QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -1466,7 +1466,7 @@ jobs:
|
||||
|
||||
- name: Upload Slack QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF as workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opengrep-full-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
- name: Upload SARIF as workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opengrep-pr-diff-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
|
||||
16
.github/workflows/qa-live-transports-convex.yml
vendored
16
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -222,7 +222,7 @@ jobs:
|
||||
|
||||
- name: Upload parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: .artifacts/qa-e2e/
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
|
||||
- name: Upload live runtime token-efficiency artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -389,7 +389,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -474,7 +474,7 @@ jobs:
|
||||
|
||||
- name: Upload Matrix QA shard artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -569,7 +569,7 @@ jobs:
|
||||
|
||||
- name: Upload Telegram QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -664,7 +664,7 @@ jobs:
|
||||
|
||||
- name: Upload Discord QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -762,7 +762,7 @@ jobs:
|
||||
|
||||
- name: Upload WhatsApp QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
@@ -857,7 +857,7 @@ jobs:
|
||||
|
||||
- name: Upload Slack QA artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
|
||||
21
.github/workflows/real-behavior-proof.yml
vendored
21
.github/workflows/real-behavior-proof.yml
vendored
@@ -18,6 +18,7 @@ jobs:
|
||||
name: Real behavior proof
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -25,5 +26,25 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
permission-issues: read
|
||||
permission-members: read
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
permission-issues: read
|
||||
permission-members: read
|
||||
- name: Check real behavior proof
|
||||
env:
|
||||
GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/github/real-behavior-proof-check.mjs
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"rules": {
|
||||
"curly": "error",
|
||||
"eslint/no-underscore-dangle": "error",
|
||||
"eslint-plugin-unicorn/prefer-array-find": "error",
|
||||
"eslint/no-array-constructor": "error",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -35,9 +35,11 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
|
||||
- Fix shape: prefer bounded owner-boundary refactors over local patches/shims when they remove stale abstractions, duplicate policy, or wrong ownership.
|
||||
- Compat default: no new internal shims, aliases, fallback APIs, or legacy names just to reduce diff. Migrate callers and delete old paths.
|
||||
- Public plugin API is the only compat exception: document/version breaks, aggressively deprecate unused SDK surface, and migrate ALL bundled/internal plugins to the modern API in the same change.
|
||||
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
|
||||
- Lean code is a goal. No internal shims, aliases, legacy names, broad fallbacks, or defensive branches just to reduce diff or handle unrealistic edge cases.
|
||||
- Handle real production states, shipped upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
|
||||
- Public plugin SDK/API is the compat exception. New API first, old path only via named compat/deprecation metadata, docs, warnings when useful, tests for old+new, planned removal.
|
||||
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
|
||||
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
|
||||
@@ -50,7 +52,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## Commands
|
||||
|
||||
- Runtime: Node 22+. Keep Node + Bun paths working.
|
||||
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
|
||||
- Package manager/runtime: repo defaults only. No swaps without approval.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- Sharp/Homebrew libvips source-build fail: `SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install`.
|
||||
@@ -77,6 +79,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- If proof is blocked, say exactly what is missing and why.
|
||||
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
|
||||
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
|
||||
- Prompt snapshots: CI truth is Linux Node 24. If macOS local passes but CI drifts, reproduce/generate in Linux before rerun.
|
||||
|
||||
## GitHub / PRs
|
||||
|
||||
@@ -108,6 +111,10 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
|
||||
- External boundaries: prefer `zod` or existing schema helpers.
|
||||
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
|
||||
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
|
||||
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
|
||||
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
|
||||
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
|
||||
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
|
||||
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
|
||||
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
|
||||
|
||||
133
CHANGELOG.md
133
CHANGELOG.md
@@ -4,12 +4,30 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga.
|
||||
|
||||
## 2026.5.19
|
||||
|
||||
### Changes
|
||||
|
||||
- Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.
|
||||
- Dependencies: update `@openclaw/proxyline` to 0.3.3.
|
||||
- Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.
|
||||
- Docker/Podman: add `OPENCLAW_IMAGE_APT_PACKAGES` as the runtime-neutral image build arg for extra apt packages while keeping `OPENCLAW_DOCKER_APT_PACKAGES` as a legacy fallback. (#62431) Thanks @urtabajev.
|
||||
- Gateway/ACPX: attribute startup probe, config, runtime, and resource-count costs in restart traces without changing readiness behavior. (#83300) Thanks @samzong.
|
||||
- Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving `/readyz` sidecar gating. (#83301) Thanks @samzong.
|
||||
- Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.
|
||||
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
|
||||
- Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.
|
||||
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
|
||||
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
|
||||
- Skills CLI: allow `openclaw skills install` and `openclaw skills update` to target shared managed skills with `--global`. (#74466) Thanks @Marvae.
|
||||
- Browser: surface pending and recently handled modal dialogs in snapshots, return `blockedByDialog` when an action opens a modal, and allow `browser dialog --dialog-id` to answer pending dialogs.
|
||||
- Browser CLI: add `openclaw browser evaluate --timeout-ms` so long-running page functions can extend both the evaluate action and request timeout budgets. (#83447) Thanks @eefreenyc.
|
||||
- Codex app-server: scope OpenClaw prompt guidance by runtime surface so native Codex keeps Codex-owned base/personality instructions while OpenClaw contributes only runtime context, delivery guidance, and explicitly scoped command hints. (#83454) Thanks @100yenadmin.
|
||||
- Docker/Podman: add `OPENCLAW_IMAGE_PIP_PACKAGES` for opt-in Python package installation in local image builds. (#83771) Thanks @stephenredmond-straiteis.
|
||||
- Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.
|
||||
- Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.
|
||||
- CLI/plugins: add `defineToolPlugin` plus `openclaw plugins build`, `validate`, and `init` for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.
|
||||
@@ -17,6 +35,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`.
|
||||
- Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.
|
||||
- Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated.
|
||||
- Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection.
|
||||
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
|
||||
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.
|
||||
- QA-Lab: add `openclaw qa suite --runtime-parity-tier` and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.
|
||||
@@ -27,29 +46,124 @@ Docs: https://docs.openclaw.ai
|
||||
- QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.
|
||||
- QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.
|
||||
- QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.
|
||||
- QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1.
|
||||
- QA-Lab: add a report-only dreaming shadow-trial scenario so candidate memory promotion can be evaluated without mutating `MEMORY.md`. Thanks @iFiras-Max1.
|
||||
- Gateway/performance: add `pnpm test:restart:gateway` benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong.
|
||||
- Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens.
|
||||
- Gateway/config: expose config lookup reload metadata so tools can distinguish restart-required, hot-reloadable, and no-op fields before applying config edits. Fixes #81409. (#81612) Thanks @LLagoon3.
|
||||
- Telegram: add allowlisted native DM draft previews for transient tool progress while keeping final answers on the normal persistent delivery path. (#83622) Thanks @akrimm702.
|
||||
- QA-Lab: add a personal-agent share-safe diagnostics artifact scenario so support handoffs keep useful status while omitting raw personal content. Thanks @iFiras-Max1.
|
||||
- QA-Lab: add a personal-agent no-fake-progress scenario so completion claims stay tied to local evidence instead of unsupported external progress. (#83824) Thanks @iFiras-Max1.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Codex app-server: preserve plugin tool auth profiles when Codex owns model transport so OpenClaw dynamic tools can resolve their provider credentials. (#83603) Thanks @rubencu.
|
||||
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
|
||||
- Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.
|
||||
- Telegram: keep forum topics from blocking sibling topic traffic by routing inbound serialization, media/text buffers, and account API queues on topic-aware lanes. (#83829)
|
||||
- Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc.
|
||||
- CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.
|
||||
- CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.
|
||||
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
|
||||
- Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.
|
||||
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
|
||||
- Providers/DeepSeek: normalize MCP tool schemas with `anyOf`/`oneOf` unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle.
|
||||
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
|
||||
- Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.
|
||||
- Control UI: keep the chat delete confirmation popover clamped inside the visible viewport on small screens. (#83804) Thanks @ThiagoCAltoe.
|
||||
- Browser: enforce current-tab URL allowlist checks for `/act` evaluate/batch actions and `/highlight` routes while leaving tab-management actions unblocked. (#78523)
|
||||
- CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)
|
||||
- Models: show the effective OpenAI/Codex auth profile in `/models` provider headers instead of falling back to the OpenAI env-key label. (#83697) Thanks @yu-xin-c.
|
||||
- CLI: include active bundled loopback MCP tools in CLI system prompts and reset provider-side CLI sessions when that prompt-visible tool surface changes. (#83785) Thanks @TurboTheTurtle.
|
||||
- Browser: keep a profile `cdpPort` when its `cdpUrl` omits a port, while still letting explicitly written URL ports win. (#82166) Thanks @Marvae.
|
||||
- Agents/image generation: allow distinct `image_generate` prompts to start separate session-backed background tasks while same-prompt retries still return the active task status. (#83614) Thanks @Elarwei001.
|
||||
- Gateway/WebChat: honor configured `channels.webchat.textChunkLimit` and `chunkMode` overrides when chunking WebChat replies. (#83713)
|
||||
- Control UI: stop the chat reading indicator from sticking after an assistant response finishes. (#83515) Thanks @njuboy11.
|
||||
- Skills: reject empty or whitespace-only skill names and descriptions during quick validation. (#27061)
|
||||
- Sessions: skip trailing custom transcript entries when checking tail assistant replies so embedded CLI gap-fill does not duplicate canonical assistant output. (#83635) Thanks @yaoyi1222.
|
||||
- Memory Wiki: keep `wiki_lint` tool output path-safe by reporting vault-internal lint reports as relative paths in tool text and details while preserving absolute report paths for CLI/file callers. (#83439) Thanks @LLagoon3.
|
||||
- Telegram: keep verbose tool progress visible without mirroring non-final progress into active session transcripts, preventing embedded provider replies from aborting mid-run. (#83631) Thanks @kurplunkin.
|
||||
- Telegram: log successful outbound text and media deliveries with account, chat, message, operation, thread, reply, silent, and chunk metadata while keeping message bodies out of logs. Fixes #83196. (#83247) Thanks @jrwrest.
|
||||
- Cron: link isolated scheduled task runs to their stable cron session so task status and cleanup can follow the backing agent run. (#83606) Thanks @jai.
|
||||
- CLI: enforce the documented Node.js 22.19 runtime floor in the source launcher.
|
||||
- Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage.
|
||||
- Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval.
|
||||
- Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.
|
||||
- Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.
|
||||
- Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.
|
||||
- Gateway/sessions: clear stored CLI provider resume bindings on non-subagent `/reset` so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.
|
||||
- Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.
|
||||
- Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.
|
||||
- LM Studio: resolve env-template API keys like `${LMSTUDIO_API_KEY}` through the standard SecretInput path instead of sending the raw template as the bearer token, and preserve header-auth and discovery-key precedence when the template is unset. Fixes #80495. (#80568) Thanks @MonkeyLeeT.
|
||||
- Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.
|
||||
- Gateway/sessions: rotate failed agent sessions when their transcript file is missing instead of wedging per-channel lanes. Fixes #83488. (#83553) Thanks @LLagoon3.
|
||||
- Agents: refresh final-delivery routing from fresh session state before declaring a no-send failure, keeping recovered runs on the normal durable delivery path. (#83835) Thanks @joshavant.
|
||||
- Agents: guard final-delivery fresh session routing against mismatched logical sessions before reusing recovered delivery context. (#83928) Thanks @joshavant.
|
||||
- Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.
|
||||
- Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.
|
||||
- Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.
|
||||
- Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.
|
||||
- Telegram: keep `/btw` and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.
|
||||
- Agents: log strict-agentic execution contract diagnostics only when the planning-only retry path actually triggers.
|
||||
- Agents: stop embedded session takeover and session write-lock errors from consuming model fallbacks while preserving provider fallback metadata. Fixes #83510. Thanks @luyao618.
|
||||
- Agents/video: hide `video_generate` reference-audio parameters unless a registered video provider supports audio inputs.
|
||||
- Plugins: fall back to npm for official ClawHub updates when artifact downloads are unavailable, including beta-to-default fallback and dry-run version reporting.
|
||||
- Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.
|
||||
- Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.
|
||||
- Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.
|
||||
- Codex app-server: expose OpenClaw's sandbox-routed shell as `sandbox_exec`/`sandbox_process` for non-Docker sandbox backends so SSH sandbox agents keep a correctly routed shell path without shadowing Codex native shell. Fixes #80322. Thanks @keramblock.
|
||||
- Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.
|
||||
- Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.
|
||||
- Messages/Codex: keep Codex direct/source chats on message-tool visible delivery by default while documenting and testing `messages.visibleReplies: "automatic"` as the old-mode opt-out; channel wildcard model overrides now apply to direct chats before harness delivery defaults.
|
||||
- Memory/QMD: keep archived session transcript hits visible after QMD export while preserving normal `.md` session ids that only resemble archive names. (#83518; fixes #83506) Thanks @tanshanshan.
|
||||
- Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.
|
||||
- Codex app-server: honor writable Docker bind mounts for sandboxed workspace-write turns while disabling native Code Mode when container-path aliases or read-only bind shadows cannot be represented safely host-side. Fixes #83737. (#83849) Thanks @joshavant.
|
||||
- QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.
|
||||
- Messages: default group/channel visible replies to automatic final delivery again, keeping `message_tool` opt-in for ambient/shared rooms and tool-reliable models.
|
||||
- CLI/TUI: force standalone `/exit` runs to terminate after `runTui` returns so onboarding-launched TUI children do not stay alive invisibly. (#83501) Thanks @fuller-stack-dev.
|
||||
- Agents/code mode: honor per-agent code-mode config in schema, runtime catalog activation, and model payload filtering. Fixes #83388. Thanks @Kaspre.
|
||||
- Agents/code mode: preserve agent, session, run, and channel context in `before_tool_call` hooks for top-level `exec`/`wait` dispatches. Fixes #83387.
|
||||
- QQBot: shorten C2C typing indicators to a 10-second window renewed every 5 seconds, capped to keep a final passive-reply slot available. (#83469)
|
||||
- Replies: keep final payload delivery after live preview updates so channels can finalize or send the completed answer instead of losing preview-only drafts. (#83468)
|
||||
- Discord: deliver final replies in progress-mode preview streams instead of deduplicating the final visible message. (#83443) Thanks @compoodment.
|
||||
- Providers/Xiaomi: replay MiMo Anthropic-compatible `reasoning_content` as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for `mimo-v2-flash`. Fixes #83407. Thanks @Xgenious7.
|
||||
- Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.
|
||||
- Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings.
|
||||
- CLI/config: keep broken discovered plugins that are not referenced by active config from failing `openclaw config validate`, while preserving fatal errors for explicitly configured plugin entries.
|
||||
- GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev.
|
||||
- Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.
|
||||
- QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.
|
||||
- Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.
|
||||
- UI: show reasoning choices as plain labels instead of leaking internal override wording in session and chat pickers.
|
||||
- Mac app: avoid repeating the Configuration heading inside channel quick settings.
|
||||
- Mac app: keep the Settings sidebar always visible and remove the redundant titlebar hide/show control.
|
||||
- Mac app: normalize Settings pane content margins so pages share the same left and right rail.
|
||||
- Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.
|
||||
- Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.
|
||||
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected, and keep the Settings sidebar toggle in the leading titlebar area.
|
||||
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected.
|
||||
- Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.
|
||||
- Mac app: tighten remote Gateway fields in Settings so the Connection pane keeps readable labels and full action button text.
|
||||
- Mac app: keep custom Settings card rows left-aligned and full-width so Discovery and status sections no longer appear centered or detached.
|
||||
- Mac app: align Location permission controls to the same trailing column as the rest of Settings.
|
||||
- Mac app: add Dashboard, Chat, Canvas, and Settings shortcuts to the Dock icon menu.
|
||||
- Mac app: replace the Settings window's native split-view sidebar with an explicit layout so page content keeps its leading gutter when the sidebar is shown or hidden.
|
||||
- Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane.
|
||||
- Gateway/webchat: hide internal runtime-context and other `display: false` transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.
|
||||
- CLI/help: keep `gateway`, `doctor`, `status`, and `health` help registration out of action/runtime imports so subcommand `--help` stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.
|
||||
- CLI/help: show plugin-owned command help based on the active memory slot so LanceDB memory users see `ltm` instead of unavailable `memory` commands. Fixes #83745. (#83841) Thanks @joshavant.
|
||||
- Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.
|
||||
- Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.
|
||||
- Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.
|
||||
- Telegram: fail topic sends closed when Telegram reports `message thread not found` instead of retrying without `message_thread_id` into the base chat. Refs #83302.
|
||||
- Config/subagents: remove ignored agent-model `timeoutMs` keys, keep subagent model config to primary/fallback selection, and clean shipped stale config through doctor. Fixes #83291. Thanks @giodl73-repo.
|
||||
- Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.
|
||||
- OpenAI/Codex: stop rejecting available `openai-codex` GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.
|
||||
- Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.
|
||||
- Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.
|
||||
- Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.
|
||||
- Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.
|
||||
- Codex app-server: keep OpenClaw session spawning searchable while steering Codex-native delegation through native subagents, avoiding duplicate direct subagent surfaces. (#83329) Thanks @fuller-stack-dev.
|
||||
- Codex app-server: recover stale childless Codex-native subagent task mirrors during maintenance and allow their registry rows to be cancelled without an OpenClaw child session. (#82836) Thanks @yshimadahrs-ship-it and @joshavant.
|
||||
- Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.
|
||||
- CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.
|
||||
- Plugins: apply a default 15-second timeout to legacy `before_agent_start` hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.
|
||||
@@ -66,10 +180,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.
|
||||
- Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.
|
||||
- Agents/Codex: keep legacy `oauthRef`-backed OAuth profiles usable while `openclaw doctor --fix` migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.
|
||||
- Agents/Codex: load the selected provider owner alongside the Codex harness runtime so `openai-codex` models resolve when plugin allowlists scope runtime loading. Fixes #83380. (#83519) Thanks @joshavant.
|
||||
- Telegram: fail stalled isolated-ingress handlers into tombstones and abort same-lane reply work before restarting, so later same-chat updates drain after a hung turn. Fixes #83272. (#83505) Thanks @joshavant.
|
||||
- CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.
|
||||
- CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like `0.0.0.0`. Fixes #83286. Thanks @giodl73-repo.
|
||||
- CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly.
|
||||
- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.
|
||||
- CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back only to official `@openclaw/*` npm plugin packages when ClawHub plugin artifacts are unavailable, and keep managed service package roots authoritative during updates.
|
||||
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
|
||||
- Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.
|
||||
- CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.
|
||||
@@ -80,6 +197,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded `memory index` warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.
|
||||
- Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.
|
||||
- Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.
|
||||
- Gateway/plugins: bind admin HTTP RPC dispatch to the accepting gateway instance so multi-gateway processes cannot execute plugin HTTP control-plane calls against another live gateway. Fixes #83486. (#83487) Thanks @coygeek.
|
||||
- Telegram: keep hot-reload restarts from marking polling accounts manually stopped and restart isolated ingress cleanly after worker shutdown, preserving Telegram replies across config reloads. Fixes #83008. (#83410) Thanks @joshavant.
|
||||
- Telegram/Ollama: pass current Telegram image attachments into native PI/Ollama vision turns so live photo prompts reach Ollama as native images. Fixes #83023. (#83516) Thanks @joshavant.
|
||||
- Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.
|
||||
- Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.
|
||||
- Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.
|
||||
@@ -93,6 +213,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.
|
||||
- Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.
|
||||
- Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.
|
||||
- Browser/CDP: keep loopback proxy bypass active across both `NO_PROXY` casings and redact home-relative Chrome MCP profile paths in attach-failure diagnostics.
|
||||
- Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward `music_generate` audio creation instead of lyric-only replies, and reserve `lyrics` for exact sung words.
|
||||
- Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.
|
||||
- Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)
|
||||
@@ -111,6 +232,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
|
||||
- Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.
|
||||
- Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving `default` instead of silently dropping it.
|
||||
- Agents/CLI: reject empty successful CLI subprocess replies as `empty_response` and keep them out of shared auth-profile health, so blank Claude CLI results no longer become green no-payload turns. Fixes #83231. (#83421) Thanks @joshavant.
|
||||
- Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram `/verbose` stays visible when command events arrive only at completion.
|
||||
- Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.
|
||||
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
|
||||
@@ -123,6 +245,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.
|
||||
- Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.
|
||||
- Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.
|
||||
- Telegram: redact nested raw-update identifiers and user metadata before verbose raw update logging, preserving useful update/message ids without exposing chat, user, command, or profile details. (#82945) Thanks @galiniliev and @joshavant.
|
||||
- Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)
|
||||
- Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style `reasoning.enabled`/`max_tokens` controls for reasoning-capable OpenAI-completions models.
|
||||
- Agents/diagnostics: split slow embedded-run `attempt-dispatch` startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.
|
||||
@@ -152,6 +275,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.
|
||||
- Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng.
|
||||
- Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.
|
||||
- Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman.
|
||||
- WhatsApp: treat `upload-file` as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman.
|
||||
- iOS: end Live Activities when OpenClaw is connected, idle, or disconnected, and show compact attention states for approval-required reconnects. (#83597) Thanks @ngutman.
|
||||
- Control UI: hide child nav items when collapsing the active sidebar group. Fixes #42167. (#42223) Thanks @Aroool.
|
||||
- CI/proof: skip the real-behavior-proof gate for private org maintainers by minting a least-privilege (`members: read`) GitHub App token and checking active membership in the `maintainer` team, instead of treating `author_association=CONTRIBUTOR` as definitively external. (#83418) Thanks @romneyda.
|
||||
|
||||
## 2026.5.17
|
||||
|
||||
@@ -1559,6 +1687,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Dependencies: bump transitive `basic-ftp` to 5.3.1 so the runtime lockfile no longer includes the vulnerable 5.3.0 build flagged by the production dependency audit. (#78637) Thanks @sallyom.
|
||||
- Hooks/cron: log returned `/hooks/agent` isolated-run errors and failed cron jobs with cron diagnostic summaries, so rejected `payload.model` values are visible instead of looking like accepted-but-missing runs. Fixes #78597. (#78655) Thanks @kevinslin.
|
||||
- Managed proxy/security: classify raw socket callsites and proxy runtime mutations in boundary checks so new direct egress or unmanaged proxy-state changes cannot land without explicit review. (#77126) Thanks @jesse-merhi.
|
||||
- Memory indexing: propagate memory directory creation failures immediately instead of reporting an unusable directory as ready. Thanks @he-yufeng.
|
||||
- Channels/iMessage: surface the silent group-allowlist drop at default log level by emitting a one-time `warn` per account at monitor startup when `channels.imessage.groupPolicy: "allowlist"` is set without a `channels.imessage.groups` block, plus a one-time `warn` per `chat_id` when the runtime gate drops a specific group, naming the exact `channels.imessage.groups[...]` key to add to allow it. Fixes #78749. (#79190) Thanks @omarshahine.
|
||||
- WhatsApp: stop Gateway-originated outbound echoes from advancing inbound activity in `openclaw channels status`, so outbound self-sends no longer look like handled inbound messages. Fixes #79056. (#79057) Thanks @ai-hpc and @bittoby.
|
||||
- Gateway/nodes: preserve the live node registry session and invoke ownership when an older same-node WebSocket closes after reconnecting. (#78351) Thanks @samzong.
|
||||
@@ -3457,6 +3586,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as `tsserver` do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.
|
||||
- Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.
|
||||
- Logging: write validated diagnostic trace context as top-level `traceId`, `spanId`, `parentSpanId`, and `traceFlags` fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.
|
||||
- Nextcloud-Talk: wire the existing reaction sender into the channel `actions` adapter so agents can react to messages via the shared `message` tool, instead of advertising the `reactions` capability without a dispatch path. Fixes #70110. Thanks @powerpaul17.
|
||||
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
|
||||
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
|
||||
- Control UI/Agents: remount the Overview model controls when switching agents so the primary-model picker cannot retain stale per-agent selection. Fixes #39392; carries forward #39401, notes the duplicate #39495 approach, and keeps #46275/#54724 broader stabilization out of scope. Thanks @daijunyi002, @SergioChan, @aworki, and @wsyjh8.
|
||||
@@ -5099,6 +5229,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/skills: require unique case-insensitive fallback matches in `openclaw skills info` so case-only collisions return not-found instead of showing guidance for the wrong skill. (#38713)
|
||||
- Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.
|
||||
- Models/Codex: include `apiKey` in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in `models.json`. (#66180) Thanks @hoyyeva.
|
||||
- Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -198,13 +198,29 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
|
||||
chmod -R a+rX "$COREPACK_HOME"
|
||||
|
||||
# Install additional system packages needed by your skills or extensions.
|
||||
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
|
||||
# Example: docker build --build-arg OPENCLAW_IMAGE_APT_PACKAGES="python3 wget" .
|
||||
# Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES is still accepted as a fallback.
|
||||
ARG OPENCLAW_IMAGE_APT_PACKAGES
|
||||
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
packages="${OPENCLAW_IMAGE_APT_PACKAGES-$OPENCLAW_DOCKER_APT_PACKAGES}"; \
|
||||
if [ -n "$packages" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $packages; \
|
||||
fi
|
||||
|
||||
# Install additional Python packages needed by your plugins or skills.
|
||||
# Example: docker build --build-arg OPENCLAW_IMAGE_PIP_PACKAGES="requests humanize" .
|
||||
ARG OPENCLAW_IMAGE_PIP_PACKAGES=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_IMAGE_PIP_PACKAGES" ]; then \
|
||||
if ! python3 -m pip --version >/dev/null 2>&1; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-pip; \
|
||||
fi && \
|
||||
python3 -m pip install --no-cache-dir --break-system-packages $OPENCLAW_IMAGE_PIP_PACKAGES; \
|
||||
fi
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
|
||||
@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.19+**.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.19+**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
|
||||
|
||||
### Node.js Version
|
||||
|
||||
OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes important security patches:
|
||||
OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches:
|
||||
|
||||
- CVE-2025-59466: async_hooks DoS vulnerability
|
||||
- CVE-2026-21636: Permission model bypass vulnerability
|
||||
@@ -320,7 +320,7 @@ OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes impo
|
||||
Verify your Node.js version:
|
||||
|
||||
```bash
|
||||
node --version # Should be v22.16.0 or later
|
||||
node --version # Should be v22.19.0 or later
|
||||
```
|
||||
|
||||
### Docker Security
|
||||
|
||||
616
appcast.xml
616
appcast.xml
@@ -2,6 +2,222 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.5.18</title>
|
||||
<pubDate>Mon, 18 May 2026 22:41:13 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026051890</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.18</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.18</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.</li>
|
||||
<li>Dependencies: update <code>@openclaw/proxyline</code> to 0.3.3.</li>
|
||||
<li>Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.</li>
|
||||
<li>Docker/Podman: add <code>OPENCLAW_IMAGE_APT_PACKAGES</code> as the runtime-neutral image build arg for extra apt packages while keeping <code>OPENCLAW_DOCKER_APT_PACKAGES</code> as a legacy fallback. (#62431) Thanks @urtabajev.</li>
|
||||
<li>Gateway/ACPX: attribute startup probe, config, runtime, and resource-count costs in restart traces without changing readiness behavior. (#83300) Thanks @samzong.</li>
|
||||
<li>Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving <code>/readyz</code> sidecar gating. (#83301) Thanks @samzong.</li>
|
||||
<li>Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.</li>
|
||||
<li>Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.</li>
|
||||
<li>Skills: rename the repo-local Codex closeout review skill and helper to <code>autoreview</code> while preserving the Codex-first fallback behavior.</li>
|
||||
<li>Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.</li>
|
||||
<li>Browser: surface pending and recently handled modal dialogs in snapshots, return <code>blockedByDialog</code> when an action opens a modal, and allow <code>browser dialog --dialog-id</code> to answer pending dialogs.</li>
|
||||
<li>Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.</li>
|
||||
<li>Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.</li>
|
||||
<li>CLI/plugins: add <code>defineToolPlugin</code> plus <code>openclaw plugins build</code>, <code>validate</code>, and <code>init</code> for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.</li>
|
||||
<li>Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.</li>
|
||||
<li>Skills: update the Obsidian skill to target the official <code>obsidian</code> CLI and require its registered binary instead of the third-party <code>obsidian-cli</code>.</li>
|
||||
<li>Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.</li>
|
||||
<li>Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy <code>interactive</code>/Slack directive producer APIs as deprecated.</li>
|
||||
<li>Proxy: support HTTPS managed forward-proxy endpoints and scoped <code>proxy.tls.caFile</code> CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.</li>
|
||||
<li>QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add <code>openclaw qa suite --runtime-parity-tier</code> and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add live-only harness self-health scenarios for plugin hook crashes, manifest contract errors, and WebChat direct-reply self-message routing. (#80323) Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add runtime tool fixture scenarios and coverage reporting for Codex-native workspace tools, OpenClaw dynamic tools, and optional plugin-backed tools. Fixes #80173. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: expose runtime tool fixture coverage through <code>openclaw qa coverage --tools</code>, with optional suite-summary evaluation for parity gate artifacts. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.</li>
|
||||
<li>QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1.</li>
|
||||
<li>Gateway/performance: add <code>pnpm test:restart:gateway</code> benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong.</li>
|
||||
<li>Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.</li>
|
||||
<li>Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.</li>
|
||||
<li>Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.</li>
|
||||
<li>Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.</li>
|
||||
<li>Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.</li>
|
||||
<li>Telegram: keep <code>/btw</code> and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.</li>
|
||||
<li>Agents/video: hide <code>video_generate</code> reference-audio parameters unless a registered video provider supports audio inputs.</li>
|
||||
<li>Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.</li>
|
||||
<li>Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.</li>
|
||||
<li>Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.</li>
|
||||
<li>Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.</li>
|
||||
<li>Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.</li>
|
||||
<li>Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.</li>
|
||||
<li>QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.</li>
|
||||
<li>Messages: default group/channel visible replies to automatic final delivery again, keeping <code>message_tool</code> opt-in for ambient/shared rooms and tool-reliable models.</li>
|
||||
<li>CLI/TUI: force standalone <code>/exit</code> runs to terminate after <code>runTui</code> returns so onboarding-launched TUI children do not stay alive invisibly. (#83501) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/code mode: honor per-agent code-mode config in schema, runtime catalog activation, and model payload filtering. Fixes #83388. Thanks @Kaspre.</li>
|
||||
<li>Agents/code mode: preserve agent, session, run, and channel context in <code>before_tool_call</code> hooks for top-level <code>exec</code>/<code>wait</code> dispatches. Fixes #83387.</li>
|
||||
<li>QQBot: shorten C2C typing indicators to a 10-second window renewed every 5 seconds, capped to keep a final passive-reply slot available. (#83469)</li>
|
||||
<li>Replies: keep final payload delivery after live preview updates so channels can finalize or send the completed answer instead of losing preview-only drafts. (#83468)</li>
|
||||
<li>Discord: deliver final replies in progress-mode preview streams instead of deduplicating the final visible message. (#83443) Thanks @compoodment.</li>
|
||||
<li>Providers/Xiaomi: replay MiMo Anthropic-compatible <code>reasoning_content</code> as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for <code>mimo-v2-flash</code>. Fixes #83407. Thanks @Xgenious7.</li>
|
||||
<li>Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.</li>
|
||||
<li>Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow <code>system.which</code> timeout warnings.</li>
|
||||
<li>CLI/config: keep broken discovered plugins that are not referenced by active config from failing <code>openclaw config validate</code>, while preserving fatal errors for explicitly configured plugin entries.</li>
|
||||
<li>GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with <code>invalid_request_body</code>. Fixes #83220. Thanks @galiniliev.</li>
|
||||
<li>Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.</li>
|
||||
<li>QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.</li>
|
||||
<li>Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.</li>
|
||||
<li>UI: show reasoning choices as plain labels instead of leaking internal override wording in session and chat pickers.</li>
|
||||
<li>Mac app: avoid repeating the Configuration heading inside channel quick settings.</li>
|
||||
<li>Mac app: keep the Settings sidebar always visible and remove the redundant titlebar hide/show control.</li>
|
||||
<li>Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.</li>
|
||||
<li>Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.</li>
|
||||
<li>Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected.</li>
|
||||
<li>Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.</li>
|
||||
<li>Mac app: tighten remote Gateway fields in Settings so the Connection pane keeps readable labels and full action button text.</li>
|
||||
<li>Mac app: keep custom Settings card rows left-aligned and full-width so Discovery and status sections no longer appear centered or detached.</li>
|
||||
<li>Mac app: align Location permission controls to the same trailing column as the rest of Settings.</li>
|
||||
<li>Mac app: add Dashboard, Chat, Canvas, and Settings shortcuts to the Dock icon menu.</li>
|
||||
<li>Mac app: replace the Settings window's native split-view sidebar with an explicit layout so page content keeps its leading gutter when the sidebar is shown or hidden.</li>
|
||||
<li>Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane.</li>
|
||||
<li>Gateway/webchat: hide internal runtime-context and other <code>display: false</code> transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.</li>
|
||||
<li>CLI/help: keep <code>gateway</code>, <code>doctor</code>, <code>status</code>, and <code>health</code> help registration out of action/runtime imports so subcommand <code>--help</code> stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.</li>
|
||||
<li>Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.</li>
|
||||
<li>Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.</li>
|
||||
<li>Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.</li>
|
||||
<li>Telegram: fail topic sends closed when Telegram reports <code>message thread not found</code> instead of retrying without <code>message_thread_id</code> into the base chat. Refs #83302.</li>
|
||||
<li>Config/subagents: remove ignored agent-model <code>timeoutMs</code> keys, keep subagent model config to primary/fallback selection, and clean shipped stale config through doctor. Fixes #83291. Thanks @giodl73-repo.</li>
|
||||
<li>Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.</li>
|
||||
<li>OpenAI/Codex: stop rejecting available <code>openai-codex</code> GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.</li>
|
||||
<li>Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.</li>
|
||||
<li>Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.</li>
|
||||
<li>Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.</li>
|
||||
<li>Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.</li>
|
||||
<li>Codex app-server: keep OpenClaw session spawning searchable while steering Codex-native delegation through native subagents, avoiding duplicate direct subagent surfaces. (#83329) Thanks @fuller-stack-dev.</li>
|
||||
<li>Codex app-server: recover stale childless Codex-native subagent task mirrors during maintenance and allow their registry rows to be cancelled without an OpenClaw child session. (#82836) Thanks @yshimadahrs-ship-it and @joshavant.</li>
|
||||
<li>Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.</li>
|
||||
<li>CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.</li>
|
||||
<li>Plugins: apply a default 15-second timeout to legacy <code>before_agent_start</code> hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.</li>
|
||||
<li>Feishu: refresh inbound session delivery context for DM, group, and broadcast turns so later replies do not inherit stale WebChat routing. Fixes #78274.</li>
|
||||
<li>Agents/subagents: require the initial subagent registry save before reporting spawn accepted, returning a spawn error instead of losing an untracked run when the registry write fails. (#83146) Thanks @yetval.</li>
|
||||
<li>QA-Lab/qa-channel: attach redacted agent tool-start traces to outbound <code>QaBusMessage</code> records so scenarios can assert actual tool use instead of relying only on reply text. Fixes #67637. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: fail live runtime parity reports when assistant-message usage is missing, preventing <code>0 vs 0</code> live token rows from being reported as passing proof. Fixes #80411. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: add a runtime token-efficiency sidecar report that classifies Codex savings separately from regressions and fails only positive Codex-over-Pi live token deltas above threshold. Fixes #81093. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: fail Codex-backed OpenAI live runtime-pair runs before launching isolated workers when no portable Codex auth is available, while staging API-key fallbacks and configured Codex keys for isolated QA agents. Fixes #80412. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: refresh parity gates, mock frontier fixtures, model scenarios, and workflow artifact lanes to compare GPT-5.5 against Claude Opus 4.7. Fixes #74262. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: make mock parity dispatch provider-aware for source discovery and subagent scenarios so OpenAI and Anthropic lanes no longer share identical canned plans. Fixes #64879. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: stop returning Control UI bearer tokens from unauthenticated bootstrap payloads and bind Docker harness ports to loopback-only host addresses. (#66355) Thanks @pgondhi987.</li>
|
||||
<li>Mac app: avoid a SwiftUI metadata crash when rendering the Cron Jobs settings pane.</li>
|
||||
<li>Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.</li>
|
||||
<li>Agents/OpenAI streams: yield via <code>setTimeout(0)</code> instead of <code>setImmediate</code> between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.</li>
|
||||
<li>Agents/Codex: keep legacy <code>oauthRef</code>-backed OAuth profiles usable while <code>openclaw doctor --fix</code> migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.</li>
|
||||
<li>Agents/Codex: load the selected provider owner alongside the Codex harness runtime so <code>openai-codex</code> models resolve when plugin allowlists scope runtime loading. Fixes #83380. (#83519) Thanks @joshavant.</li>
|
||||
<li>Telegram: fail stalled isolated-ingress handlers into tombstones and abort same-lane reply work before restarting, so later same-chat updates drain after a hung turn. Fixes #83272. (#83505) Thanks @joshavant.</li>
|
||||
<li>CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.</li>
|
||||
<li>CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like <code>0.0.0.0</code>. Fixes #83286. Thanks @giodl73-repo.</li>
|
||||
<li>CLI/plugins: ship the bundled memory CLI as a package entry so package-installed <code>openclaw memory</code> commands register correctly.</li>
|
||||
<li>CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.</li>
|
||||
<li>CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back only to official <code>@openclaw/*</code> npm plugin packages when ClawHub plugin artifacts are unavailable, and keep managed service package roots authoritative during updates.</li>
|
||||
<li>Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.</li>
|
||||
<li>Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.</li>
|
||||
<li>CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.</li>
|
||||
<li>Telegram: keep isolated long polling below the hard <code>getUpdates</code> request guard so idle bot accounts with high <code>timeoutSeconds</code> do not false-disconnect and restart-loop. Fixes #83264. Thanks @riccodecarvalho.</li>
|
||||
<li>Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing <code>thought_signature</code> 400s. Fixes #72879. (#80358) Thanks @abnershang.</li>
|
||||
<li>Telegram: skip transcript-only delivery mirrors and gateway-injected rows when resolving latest assistant text, preventing retained previews from replacing final replies with stale fragments. Fixes #83159. (#83362) Thanks @joshavant.</li>
|
||||
<li>Memory/QMD: keep lexical search on raw hyphenated queries while normalizing semantic QMD sub-searches, avoiding fallback to the builtin index for dashed identifiers and dates. Fixes #81328.</li>
|
||||
<li>Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded <code>memory index</code> warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.</li>
|
||||
<li>Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.</li>
|
||||
<li>Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.</li>
|
||||
<li>Telegram: keep hot-reload restarts from marking polling accounts manually stopped and restart isolated ingress cleanly after worker shutdown, preserving Telegram replies across config reloads. Fixes #83008. (#83410) Thanks @joshavant.</li>
|
||||
<li>Telegram/Ollama: pass current Telegram image attachments into native PI/Ollama vision turns so live photo prompts reach Ollama as native images. Fixes #83023. (#83516) Thanks @joshavant.</li>
|
||||
<li>Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.</li>
|
||||
<li>Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.</li>
|
||||
<li>Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.</li>
|
||||
<li>Agents/Codex: use the Codex runtime context window for OpenAI-model preflight compaction and memory flush checks, so GPT-5.5 Codex sessions compact before hitting the smaller native context limit. Fixes #82982. Thanks @vliuyt.</li>
|
||||
<li>QA-Lab: clean orphaned gateway temp roots when a suite parent exits and wait on gateway plus transport readiness after config restarts, reducing stale <code>qa-channel</code> noise from interrupted runs. Fixes #65506. Thanks @100yenadmin.</li>
|
||||
<li>QA-Lab: wake qa-bus long polls that arrive with stale future cursors after a bus restart, preserving reconnect readiness for harness clients. (#67142) Thanks @hxy91819.</li>
|
||||
<li>QA-Lab: stage Multipass transfer scripts under OpenClaw's preferred temp root instead of raw OS temp paths, keeping the VM runner inside temp-path guardrails. (#64098) Thanks @ImLukeF.</li>
|
||||
<li>Agents/replies: keep surviving reply media and append a warning when other media references fail, so partial media normalization no longer drops failures silently. Thanks @Jerry-Xin.</li>
|
||||
<li>Config/models: accept <code>thinkingFormat: "together"</code> in model compat config so Together routes can opt into the Together-specific thinking response shape.</li>
|
||||
<li>Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.7.1, bringing Codex hook approval compatibility, pre-tool command wrapping fixes, and Rolldown/Vitest output compaction improvements into the OpenClaw plugin.</li>
|
||||
<li>Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.</li>
|
||||
<li>Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.</li>
|
||||
<li>Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.</li>
|
||||
<li>Browser/CDP: keep loopback proxy bypass active across both <code>NO_PROXY</code> casings and redact home-relative Chrome MCP profile paths in attach-failure diagnostics.</li>
|
||||
<li>Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward <code>music_generate</code> audio creation instead of lyric-only replies, and reserve <code>lyrics</code> for exact sung words.</li>
|
||||
<li>Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.</li>
|
||||
<li>Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)</li>
|
||||
<li>CLI/infer: run gateway model probes in fresh explicit sessions so one-shot provider checks do not inherit default agent transcript state. (#82861) Thanks @Kaspre.</li>
|
||||
<li>Providers/Together: send video-generation requests to Together's v2 video API even when shared text-model config still points at the v1 base URL. (#82992)</li>
|
||||
<li>Browser CLI: preserve browser-level options on nested commands, skip option values during lazy command registration, and keep long-running wait/download/dialog hooks open for their advertised wait window.</li>
|
||||
<li>CLI/sessions: accept <code>openclaw sessions list</code> as an alias for <code>openclaw sessions</code>, matching other list-style commands. Fixes #81139. (#81163) Thanks @YB0y.</li>
|
||||
<li>Channels/stream previews: widen compact progress draft lines and cut prose at word boundaries while preserving command/path suffixes, with <code>streaming.progress.maxLineChars</code> for channel-specific tuning.</li>
|
||||
<li>CLI/plugins: have <code>openclaw plugins doctor</code> warn when a configured runtime needs a missing owner plugin, sharing the same install mapping as <code>openclaw doctor --fix</code>. Fixes #81326. (#81674) Thanks @Zavianx.</li>
|
||||
<li>Agents/Codex: route OpenAI runs that resolve to <code>openai-codex</code> through the Codex provider and bootstrap OpenClaw's stored OAuth profile into the Codex harness when the harness owns transport, so <code>openai/*</code> model refs no longer fail with <code>No API key found for openai-codex</code> despite an existing Codex OAuth profile. (#82864) Thanks @ragesaq.</li>
|
||||
<li>Agents/ACP: distinguish prompt-submitted and runtime-active child stalls from true interactive waits, including redacted proxy-env diagnostics for Codex ACP no-output runs. Fixes #44810.</li>
|
||||
<li>Agents/memory: explain that memory-triggered compaction exposes only <code>read</code> and append-only <code>write</code> when configured core tools are unavailable in <code>tools.allow</code> warnings. Fixes #82941. Thanks @galiniliev.</li>
|
||||
<li>Agents/OpenAI: preserve deterministic tool payload ordering for prompt-cache reuse across OpenAI Responses and chat completions calls. (#82940) Thanks @galiniliev.</li>
|
||||
<li>ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.</li>
|
||||
<li>Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.</li>
|
||||
<li>Agents/skills: apply the full effective tool policy pipeline to inline <code>command-dispatch: tool</code> skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)</li>
|
||||
<li>Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.</li>
|
||||
<li>Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving <code>default</code> instead of silently dropping it.</li>
|
||||
<li>Agents/CLI: reject empty successful CLI subprocess replies as <code>empty_response</code> and keep them out of shared auth-profile health, so blank Claude CLI results no longer become green no-payload turns. Fixes #83231. (#83421) Thanks @joshavant.</li>
|
||||
<li>Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram <code>/verbose</code> stays visible when command events arrive only at completion.</li>
|
||||
<li>Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.</li>
|
||||
<li>Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.</li>
|
||||
<li>Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.</li>
|
||||
<li>Gateway/protocol: restore Gateway WS protocol v4 and keep <code>message.action</code> room-event metadata on the existing <code>inboundTurnKind</code> wire field while preserving internal inbound-event classification.</li>
|
||||
<li>Agents/tools: prefer non-webchat session-key routes when the message tool has stale webchat context, so message-tool-only replies keep delivering to the originating channel. Fixes #82911. (#83004) Thanks @joshavant.</li>
|
||||
<li>Channels: keep direct-message last-route writes on isolated <code>per-channel-peer</code> sessions instead of contaminating the agent main session with channel delivery context. Fixes #36614. Thanks @aspenas.</li>
|
||||
<li>Mac app: move the Settings sidebar toggle into the native titlebar and tighten the General pane width.</li>
|
||||
<li>Mac app: keep visited Settings panes mounted so switching tabs no longer blanks and reloads their content.</li>
|
||||
<li>Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.</li>
|
||||
<li>Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.</li>
|
||||
<li>Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.</li>
|
||||
<li>Telegram: redact nested raw-update identifiers and user metadata before verbose raw update logging, preserving useful update/message ids without exposing chat, user, command, or profile details. (#82945) Thanks @galiniliev and @joshavant.</li>
|
||||
<li>Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)</li>
|
||||
<li>Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style <code>reasoning.enabled</code>/<code>max_tokens</code> controls for reasoning-capable OpenAI-completions models.</li>
|
||||
<li>Agents/diagnostics: split slow embedded-run <code>attempt-dispatch</code> startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.</li>
|
||||
<li>Agents/Codex: flatten nested tool-result middleware blocks into bounded text so successful message sends are no longer replaced with <code>Tool output unavailable due to post-processing error</code>. Fixes #82912. Thanks @joeykrug.</li>
|
||||
<li>CLI/media: accept HTTP(S) URLs in <code>openclaw infer image describe --file</code>, fetching remote images through the guarded media path instead of treating URLs as local files. Fixes #82837. (#82854) Thanks @neeravmakwana.</li>
|
||||
<li>Agents/subagents: keep session-backed parent runs active when the child wait call times out before the child session has actually settled, so late subagent completions are reconciled instead of being lost. Fixes #82787. Thanks @ramitrkar-hash.</li>
|
||||
<li>Control UI: advertise shared Gateway protocol constants in browser connect frames, fixing protocol mismatch handshakes after protocol constant drift. Fixes #82882. Thanks @galiniliev.</li>
|
||||
<li>Gateway: add rollback protocol-mismatch diagnostics, including client protocol ranges in Gateway logs and deep status/doctor hints for stale client processes. Fixes #82841. (#82908)</li>
|
||||
<li>Agents/subagents: keep successful keep-mode completion payloads pending after final-delivery retry exhaustion, so requester recovery no longer loses final subagent results. Fixes #82583. (#82999) Thanks @joshavant.</li>
|
||||
<li>Gateway/auth: allow same-host trusted-proxy callers to use the documented local direct <code>gateway.auth.password</code> fallback after revisiting the #78684 fail-closed policy, while keeping token fallback rejected and forwarded-header requests on the trusted-proxy path. Fixes #82607. (#82953) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: wait for queued completion handoffs to reach the parent transcript before marking them announced, preventing busy parent runs from cleaning up before observing child results. Fixes #82913. (#83039) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.</li>
|
||||
<li>Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo.</li>
|
||||
<li>Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo.</li>
|
||||
<li>CLI/models: reuse command-scoped plugin metadata across model listing, provider catalog, auth, and synthetic-auth checks, restoring fast <code>openclaw models</code> runs for plugin-heavy installs. Fixes #82881. (#83033) Thanks @joshavant.</li>
|
||||
<li>CLI/channels: show configured official external channels such as Discord in <code>openclaw channels list</code> when their plugin package is missing, including the install and doctor repair command instead of reporting no configured channels. Fixes #82813.</li>
|
||||
<li>Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.</li>
|
||||
<li>Agents/tools: keep the <code>message</code> tool available in embedded runs when it is explicitly allowed through <code>tools.alsoAllow</code> or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.</li>
|
||||
<li>WhatsApp: honor forced document delivery for outbound image, GIF, and video media so <code>forceDocument</code>/<code>asDocument</code> sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.</li>
|
||||
<li>WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as <code>file.pdf</code> and <code>file.csv</code> instead of an extensionless <code>file</code>. Thanks @mcaxtr.</li>
|
||||
<li>Process/diagnostics: report active lane blockers in lane wait warnings so <code>queueAhead=0</code> no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.</li>
|
||||
<li>Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.</li>
|
||||
<li>Agents/replies: classify provider conversation-state rejections and return a clear message-channel error instead of auto-resetting or falling back to a generic runner failure. (#82616) Thanks @dutifulbob.</li>
|
||||
<li>Browser plugin: trust managed Chrome CDP diagnostics when launch HTTP probes race cold-start readiness, avoiding false startup failures. Fixes #82904. (#82986) Thanks @kmanan and @hclsys.</li>
|
||||
<li>Android: prompt before replacing a changed Gateway TLS thumbprint, showing the old and new SHA-256 fingerprints so users can accept expected certificate rotations instead of hard failing on pin mismatch. (#83077) Thanks @sliekens.</li>
|
||||
<li>CLI/status: render extra gateway-like service diagnostics as warning/info output instead of error output. Fixes #46930. (#82922) thanks @giodl73-repo.</li>
|
||||
<li>Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.</li>
|
||||
<li>Plugin SDK: bundle <code>openclaw/plugin-sdk/zod</code> into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local <code>zod</code> symlink. Fixes #78398. (#78515) Thanks @ggzeng.</li>
|
||||
<li>Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.</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.5.18/OpenClaw-2026.5.18.zip" length="53924201" type="application/octet-stream" sparkle:edSignature="cU0TfUmBZbVOpgwou+GS7RQiDhEGVUxjK+bwsl1RXiqvJi9ErsYebZIxVayH8++v5PeycoK5+LQF5gLiXQa2AA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.12</title>
|
||||
<pubDate>Fri, 15 May 2026 13:25:16 +0000</pubDate>
|
||||
@@ -523,405 +739,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.7/OpenClaw-2026.5.7.zip" length="51130645" type="application/octet-stream" sparkle:edSignature="Zu+EzBGMRE1k7N4//L8HUxtUCPdO0ImrfDbgr2GrPMBrj7VGI1tOOl74gxNJoi/wfWvXz3fYVcBz2W/84ojuCw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.2</title>
|
||||
<pubDate>Sun, 03 May 2026 01:11:51 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026050290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.2</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>External plugin installation, update, doctor repair, dependency reporting, and artifact metadata now cover the npm-first cutover, stale configured installs, missing package payloads, and beta-channel plugin fallback. Thanks @vincentkoc.</li>
|
||||
<li>Gateway and agent hot paths are leaner across startup, session listing, task maintenance, prompt prep, plugin loading, tool descriptor planning, filesystem guards, and large runtime configs.</li>
|
||||
<li>Control UI and WebChat are more resilient across Sessions, Cron, long-running Gateway WebSockets, grouped-message width, slash-command feedback, iOS PWA bounds, selection contrast, and Talk diagnostics.</li>
|
||||
<li>Messaging fixes cover WhatsApp Channel/Newsletter targets, Telegram topic commands and networking, Discord delivery/startup edge cases, Slack threads, Signal groups/media, and visible reply routing.</li>
|
||||
<li>Provider and media fixes cover OpenAI-compatible TTS/Realtime, OpenRouter/DeepSeek replay, Anthropic-compatible streaming, LM Studio reasoning metadata, Brave/SearXNG/Firecrawl web search, media paths, music, and voice-call routing.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Gateway/startup and restart: skip plugin-backed auth-profile overlays during startup secrets preflight, reducing gateway readiness latency while keeping reload and OAuth recovery paths overlay-capable; add <code>openclaw gateway restart --force</code> and <code>--wait <duration></code>, log active task run IDs before restart deferral timers, and report timeout restarts as explicit forced restarts. (#68327) Thanks @JIRBOY.</li>
|
||||
<li>Plugins/ClawHub: make diagnostics, onboarding, doctor repair, and channel setup carry ClawPack metadata through install records while keeping explicit <code>clawhub:</code> installs on ClawHub and bare package installs on npm for the launch cutover. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/CLI: include package dependency install state in <code>openclaw plugins list --json</code> so scripts can spot missing plugin dependencies without runtime-loading plugins.</li>
|
||||
<li>Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try <code>@beta</code> first and fall back to default/latest when no plugin beta release exists.</li>
|
||||
<li>Plugins/runtime: scope broad runtime preloads to the effective plugin ids derived from config, startup planning, configured channels, slots, and auto-enable rules instead of importing every discoverable plugin.</li>
|
||||
<li>Agents/runtime: reuse the startup-loaded plugin registry for request-time providers, tools, channel actions, web/capability/memory/migration helpers, and memoized provider extra-params, and memoize transcript replay-policy resolution for stable config and process-env runs while preserving model-specific transport hook patches and custom-env provider behavior. Thanks @DmitryPogodaev.</li>
|
||||
<li>Infra/path-guards: add a fast path for canonical absolute POSIX containment checks, avoiding repeated <code>path.resolve</code> and <code>path.relative</code> work in hot filesystem walkers. Refs #75895, #75575, and #68782. Thanks @Enderfga.</li>
|
||||
<li>Tools/plugins: add a platform-level tool descriptor planner for descriptor-first visibility, generic availability checks, and executor references, and cache plugin tool descriptors captured from <code>api.registerTool(...)</code> so repeated prompt-time planning can skip plugin runtime loading while execution still loads the live plugin tool. (#76079) Thanks @shakkernerd.</li>
|
||||
<li>Docs/Codex: clarify that ChatGPT/Codex subscription setups should use <code>openai/gpt-*</code> with <code>agentRuntime.id: "codex"</code> for native Codex runtime, while <code>openai-codex/*</code> remains the PI OAuth route. Thanks @pashpashpash.</li>
|
||||
<li>Plugins/source checkout: load bundled plugins from the <code>extensions/*</code> pnpm workspace tree in source checkouts, so plugin-local dependencies and edits are used directly while packaged installs keep using the built runtime tree. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/beta: externalize ACPX behind <code>@openclaw/acpx</code> and diagnostics OpenTelemetry behind <code>@openclaw/diagnostics-otel</code>, keeping their heavier runtime stacks out of the core package until installed; prepare Google Chat, LINE, Matrix, Mattermost, BlueBubbles, diagnostics Prometheus, Google Meet, Nextcloud Talk, Nostr, Zalo, Zalo Personal, diagnostics OpenTelemetry, Discord, Diffs, Lobster, Memory LanceDB, Microsoft Teams, QQ Bot, Voice Call, WhatsApp, Brave, Codex, Feishu, Synology Chat, Tlon, and Twitch for <code>2026.5.1-beta.1</code>/<code>2026.5.1-beta.2</code> npm and ClawHub publishing, and keep publishable plugin dist trees out of the core npm package. Thanks @vincentkoc.</li>
|
||||
<li>Providers/xAI: add Grok 4.3 to the bundled catalog and make it the default xAI chat model.</li>
|
||||
<li>Google Meet: let API-created rooms set <code>accessType</code> and <code>entryPointAccess</code>, add <code>googlemeet end-active-conference</code> for closing managed spaces after a call, and add <code>googlemeet test-listen</code> plus the matching <code>google_meet</code> <code>test_listen</code> action so transcribe-mode joins wait for real caption or transcript movement before reporting listen-first health. (#74824; refs #72478) Thanks @BsnizND and @DougButdorf.</li>
|
||||
<li>Plugins/ClawHub/onboarding: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verify ClawPack response headers and downloaded bytes, persist ClawPack digest/artifact metadata on install/update records and install-on-demand provider setup entries, and allow official bundled-plugin cutovers to record ClawHub artifact metadata while preserving npm as the launch default for bare package specs and retaining npm/local fallback paths. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall.</li>
|
||||
<li>Channels/thread bindings: replace split subagent/ACP thread-spawn toggles with <code>threadBindings.spawnSessions</code>, default thread-bound spawns on, and let <code>openclaw doctor --fix</code> migrate the legacy keys. (#75943)</li>
|
||||
<li>Providers/OpenAI: add <code>extraBody</code>/<code>extra_body</code> passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as <code>lang</code> in <code>/audio/speech</code> requests. Fixes #39900. Thanks @R3NK0R.</li>
|
||||
<li>Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter <code>@newsletter</code> outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred.</li>
|
||||
<li>Dependencies: refresh workspace, bundled runtime, and plugin dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, Marked 18.0.3, Pi 0.71.1, OpenAI 6.35.0, Codex 0.128.0, Zod 4.4.1, and Matrix 41.4.0. Thanks @mariozechner, @aws, and @microsoft.</li>
|
||||
<li>Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference <code>accessGroup:<name></code> across channel auth paths. (#75813)</li>
|
||||
<li>Crabbox/scripts: print the selected Crabbox binary, version, and supported providers before <code>pnpm crabbox:*</code> commands, and reject stale binaries that lack <code>blacksmith-testbox</code> provider support.</li>
|
||||
<li>Agents/Codex: add committed happy-path prompt snapshots for Codex/message-tool Telegram direct, Discord group, and heartbeat turns so prompt drift can be reviewed. Thanks @pashpashpash.</li>
|
||||
<li>Agents/workspace: add <code>agents.defaults.skipOptionalBootstrapFiles</code> for skipping selected optional workspace files during bootstrap without disabling required workspace setup. (#62110) Thanks @mainstay22.</li>
|
||||
<li>Plugins/CLI: add first-class <code>git:</code> plugin installs with ref checkout, commit metadata, normal scanner/staging, and <code>plugins update</code> support for recorded git sources. Thanks @badlogic.</li>
|
||||
<li>Google Meet: add live caption health for Chrome transcribe mode, including caption observer state, transcript counters, last caption text, and recent transcript lines in status and doctor output. Refs #72478. Thanks @DougButdorf.</li>
|
||||
<li>Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP.</li>
|
||||
<li>macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti.</li>
|
||||
<li>Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc.</li>
|
||||
<li>Discord: keep active buttons, selects, and forms working across Gateway restarts until they expire, so multi-step Discord interactions are less likely to break during upgrades or restarts. Thanks @amknight.</li>
|
||||
<li>Messages/docs: clarify that <code>BodyForAgent</code> is the primary inbound model text while <code>Body</code> is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box.</li>
|
||||
<li>Slack: publish a safe default App Home tab view on <code>app_home_opened</code>, include the Home tab event in setup manifests, and keep track of bot-participated threads across restarts so ongoing threaded conversations can continue auto-replying after the Gateway restarts. Fixes #11655; refs #52020. Thanks @TinyTb and @amknight.</li>
|
||||
<li>Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok.</li>
|
||||
<li>BlueBubbles: add opt-in <code>channels.bluebubbles.replyContextApiFallback</code> that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through <code>mergeAccountConfig</code>; routed through the typed <code>BlueBubblesClient</code> so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (<code>p:0/<guid></code>) are stripped before the request; concurrent webhooks for the same <code>replyToId</code> coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends <code>sanitizeForLog</code> to redact <code>?password=…</code>/<code>?token=…</code> query params and <code>Authorization:</code> headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.</li>
|
||||
<li>CLI/proxy: add <code>openclaw proxy validate</code> so operators can verify effective proxy configuration, proxy reachability, and expected allow/deny destination behavior before deploying proxy-routed OpenClaw commands. (#73438) Thanks @jesse-merhi.</li>
|
||||
<li>Agents/Codex: default Codex app-server dynamic tools to native-first, keeping OpenClaw integration tools while leaving file, patch, exec, and process ownership to the Codex harness; default Codex-harness direct source replies to the OpenClaw <code>message</code> tool when visible reply delivery is not explicitly configured, keeping channel-visible output as a deliberate tool call. (#75308, #75765) Thanks @pashpashpash.</li>
|
||||
<li>Heartbeats/agents: add a structured <code>heartbeat_respond</code> tool for tool-capable heartbeat runs so agents can record quiet outcomes or explicit notification text without relying only on <code>HEARTBEAT_OK</code> parsing. (#75765) Thanks @pashpashpash.</li>
|
||||
<li>Gateway/config: allow <code>$include</code> directives to read files from operator-approved <code>OPENCLAW_INCLUDE_ROOTS</code> directories while preserving default config-directory confinement. Thanks @ificator.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Agents/OpenAI: default GPT-5 API-key sessions to the SSE Responses transport unless WebSocket is explicitly selected, restoring replies in fresh Control UI and WebChat beta installs where the auto WebSocket path connected but produced no model events.</li>
|
||||
<li>Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing sessions from staying stuck as running after completed or timed-out turns.</li>
|
||||
<li>Gateway/CLI/status: make <code>openclaw gateway start</code> repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting; add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway; avoid repeated plugin tool descriptor config hashing so large runtime configs do not block reply startup and trigger reconnect/timeouts. Refs #49012. (#75944) Thanks @vincentkoc and @joshavant.</li>
|
||||
<li>Plugins/update/config: stop treating the non-plugin <code>auth</code> command root as a bundled plugin id, keep packaged upgrades and beta external plugin installs on stable runtime aliases and matching prerelease npm specs, detect tracked plugin install records whose package directories disappeared during <code>openclaw update</code>, reinstall them before normal plugin updates, fail the update if install records still point at missing disk payloads, and validate configured web-search providers plus statically suppressed model/provider pairs against the active plugin set at config load. Thanks @vincentkoc.</li>
|
||||
<li>Codex/app-server: resolve managed binaries from bundled <code>dist</code> chunks and from the <code>@openai/codex</code> package bin when installs do not provide a nearby <code>.bin/codex</code> shim, avoiding false missing-binary startup failures.</li>
|
||||
<li>Status: show the <code>openai-codex</code> OAuth profile for <code>openai/gpt-*</code> sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky.</li>
|
||||
<li>Status/update: resolve beta update-channel checks from the installed version when config still says <code>stable</code>, show configured channels in <code>openclaw status</code> and config-only <code>openclaw channels status</code> output even when the Gateway is unreachable, and let <code>status --deep</code> reuse live gateway channel credential state instead of warning on command-path-only token misses. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/externalization: add official npm-first catalogs for externalized channel, provider, and generic plugins; install official external web-search plugins before saving provider config; repair missing configured, selected-search, and env-selected plugin installs from npm by default; keep official install docs, update examples, live Codex checks, diagnostics ClawHub packages, and persisted bundled-plugin relocation on default npm tags; keep Matrix and Mattermost bundled until their npm packages cut over; and keep ACPX, Google Chat, and LINE publishable plugin dist trees out of the core package while ClawHub pack files roll out. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/ClawHub/source/registry: use the ClawHub artifact resolver response as the install decision before downloading, keep bare plugin package specs on npm for the launch cutover and reserve ClawHub resolution for explicit <code>clawhub:</code> specs until ClawHub pack readiness is deployed, discover source-only plugins such as Codex from <code>extensions/*</code>, install ClawPack artifacts from the explicit npm-pack <code>.tgz</code> resolver path, persist artifact kind, npm integrity, shasum, and tarball metadata for update/diagnostics flows, fall back to version metadata when the artifact resolver route is missing, keep the Docker ClawHub fixture aligned with npm-pack artifact resolution, explain unavailable explicit ClawHub ClawPack artifact downloads with a temporary npm install hint, and hash manifest/package metadata when validating persisted plugin registries so fast same-size rewrites cannot leave stale plugin metadata trusted. Thanks @vincentkoc.</li>
|
||||
<li>Control UI: add validated <code>gateway.controlUi.chatMessageMaxWidth</code> instead of patched bundled CSS, ignore malformed persisted cron rows before they enter UI state, guard stale cron render paths, and bound the default Sessions tab query to recent activity and fewer rows while keeping filters editable. Fixes #67935, #55047, #54439, and #76050; supersedes #54550 and #54552. (#76051) Thanks @xiew4589-lang and @Neomail2.</li>
|
||||
<li>Gateway/channels: cap startup fanout at four channel/account handoffs and recover from Bonjour ciao self-probe races, reducing Windows startup stalls with many Telegram accounts. Fixes #75687.</li>
|
||||
<li>Gateway/sessions: keep <code>sessions.list</code> polling responsive on large session stores by reusing list-safe session cache/indexes and returning a lightweight compaction checkpoint preview instead of heavyweight summaries. Thanks @rolandrscheel.</li>
|
||||
<li>Control UI/Gateway: keep long-running dashboard WebSocket sessions alive with protocol pings, keep Stop available after reconnect or reload by recovering session-scoped active-run abort state, contain standalone iOS PWA viewports with safe-area-aware document locking, use high-contrast text selection colors, and show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly. Fixes #70991, #60850, and #52105; supersedes #60854. Thanks @alexandre-leng, @kvncrw, @Badschaff, @efe-arv, and @MooreQiao.</li>
|
||||
<li>CLI/update: treat inherited Gateway service markers as origin hints and only block package replacement when the managed Gateway is still live, so self-updates can stop the service and continue safely. (#75729) Thanks @hxy91819.</li>
|
||||
<li>Agents/failover: exempt run-level timeouts that fire during tool execution from model fallback, timeout-triggered compaction, and generic timeout payload synthesis, avoiding misleading "LLM request timed out" errors after the primary model has already responded. Fixes #52147. (#75873) Thanks @simonusa.</li>
|
||||
<li>Docker: copy Bun 1.3.13 from a digest-pinned image and keep CI on the same version. Fixes #74356. Thanks @fede-kamel and @sallyom.</li>
|
||||
<li>Agents/compaction: keep prior context on consecutive turns against z.ai-style providers (z.ai direct, openrouter z-ai/\*, in-house GLM gateways), avoiding accidental Pi state reset after successful turns. (#76056) Thanks @openperf.</li>
|
||||
<li>Doctor/plugins: run a one-time 2026.5.2 configured-plugin install repair based on <code>meta.lastTouchedVersion</code>, update stale configured plugin manifests that still declare channels without <code>channelConfigs</code>, install actively used downloadable OpenClaw plugins through the configured external source, preserve unmanaged third-party plugin <code>node_modules</code>, and then mark the config touched for the release.</li>
|
||||
<li>Sessions/transcripts: use one <code>session.writeLock.acquireTimeoutMs</code> policy for session transcript lock acquisitions and raise the default wait to 60 seconds, avoiding user-visible lock timeouts during legitimate slow prep, cleanup, compaction, and mirror work. Fixes #75894. Thanks @shandutta.</li>
|
||||
<li>Agents/restart recovery: match cleaned transcript locks by exact transcript lock paths plus the canonical session fallback, so interrupted main sessions using topic-suffixed transcripts resume after gateway restart. Refs #76052. Thanks @anyech.</li>
|
||||
<li>Agents/runtime: cache the stable system-prompt prefix and reuse prompt-report tool schema stats during dispatch prep, reducing repeated CPU work before streaming starts. Fixes #75999; supersedes #76061. Thanks @zackchiutw and @STLI69.</li>
|
||||
<li>Telegram/native commands: pass persisted session files into plugin commands for topic-bound sessions, so <code>/codex bind</code> works from Telegram forum topics. Refs #75845 and #76049. Thanks @MatthewSchleder.</li>
|
||||
<li>Security audit/plugins: ignore plugin install backup, disabled, and dependency debris directories when enumerating installed plugin roots, avoiding false-positive findings for <code>.openclaw-install-backups</code> after plugin updates. Fixes #75456.</li>
|
||||
<li>Telegram: honor runtime conversation bindings for native slash commands in bound top-level groups, so commands like <code>/status@bot</code> route to the active non-<code>main</code> session instead of falling back to the default route. Fixes #75405; supersedes #75558. Thanks @ziptbm and @yfge.</li>
|
||||
<li>Gateway/tasks: make task registry maintenance use pass-local backing-session lookups and fresh active child-session indexes, avoiding repeated full task snapshots and session-store clones on large stale registries. Fixes #73517 and #75708; supersedes #74406 and #75709. Thanks @Lightningxxl, @glfruit, and @jared-rebel.</li>
|
||||
<li>Auth/sessions: JSON-clone auth-profile cache/runtime snapshots and remaining session cleanup previews instead of using <code>structuredClone</code>, preserving mutation isolation while avoiding native-memory growth on large stores. Fixes #45438. Thanks @markus-lassfolk.</li>
|
||||
<li>Models CLI: restore <code>openclaw models list --provider <id></code> catalog and registry fallback rows for unconfigured providers, so provider-specific verification commands no longer report "No models found." Fixes #75517; supersedes #75615. Thanks @lotsoftick and @koshaji.</li>
|
||||
<li>Gateway/macOS: write LaunchAgent services with a canonical system PATH and stop preserving old plist PATH entries, so Volta, asdf, fnm, and pnpm shell paths no longer affect gateway child-process Node resolution. Fixes #75233; supersedes #75246. Thanks @nphyde2.</li>
|
||||
<li>Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn.</li>
|
||||
<li>Sessions/agents: route Gateway session-store writes, CLI cleanup maintenance, and agent-delete session purges through a dedicated in-process writer and borrow the validated mutable cache during the writer slot, avoiding runtime file locks plus repeated <code>sessions.json</code> rereads and JSON clones on hot metadata updates. Refs #68554. Thanks @henkterharmsel.</li>
|
||||
<li>Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot.</li>
|
||||
<li>Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser.</li>
|
||||
<li>Agents/sessions: preserve pre-existing runtime model and context window after heartbeat turns so a per-run heartbeat model override does not bleed into shared-session status. Fixes #75452. Thanks @zhangguiping-xydt.</li>
|
||||
<li>Model commands: clarify direct and inline <code>/model</code> acknowledgements for non-default selections as session-scoped. Thanks @addu2612.</li>
|
||||
<li>Doctor/gateway: stop warning that non-existent, unconfigured user-bin directories are required in the Gateway service PATH. Fixes #76017. Thanks @xiphis.</li>
|
||||
<li>TUI/setup: skip full provider model normalization during context-window warmup and bound Terminal hatch bootstrap provider requests, avoiding cold-start stalls with large model registries and first-run hatching stuck behind the watchdog. (#76241) Thanks @547895019 and @joshavant.</li>
|
||||
<li>Agents: enable malformed tool-call argument repair for Codex and Azure OpenAI Responses transports while keeping generic OpenAI Responses paths out of the repair gate. Fixes #75154. Thanks @Nimraakram22.</li>
|
||||
<li>Memory Wiki: accept relative Markdown links that include the <code>.md</code> suffix during broken-wikilink validation, avoiding false positives for native render-mode links. Thanks @Kenneth8128.</li>
|
||||
<li>OpenAI Codex: show the device-pairing code in the interactive SSH/headless prompt while keeping the short-lived code out of persistent runtime logs. Fixes #74212. Thanks @da22le123.</li>
|
||||
<li>QA Lab: stop gateway children when the suite parent disappears, so interrupted local QA runs cannot leave hot orphaned gateways behind.</li>
|
||||
<li>Codex/app-server/plugins: tolerate second connection closes during startup recovery, include retry counts plus stringified restart errors, and allow the official npm Codex plugin to install without the unsafe-install override while keeping <code>/codex</code> command ownership and covering the real npm Docker live path through managed <code>.openclaw/npm</code> dependencies plus uninstall failure proof.</li>
|
||||
<li>Plugins/CLI: cache plugin CLI registration entries per command program so completion state generation does not repeat the full plugin sweep in one invocation. Thanks @ScientificProgrammer.</li>
|
||||
<li>Plugins: reuse gateway-bindable plugin loader cache entries for later default-mode loads without serving default-built registries to gateway-bound requests, reducing repeated plugin registration during dispatch. Refs #61756. Thanks @DmitryPogodaev.</li>
|
||||
<li>Gateway/secrets: include the caught error message in <code>secrets.reload</code> and <code>secrets.resolve</code> warning logs while keeping RPC errors generic, so operators can diagnose reload and permission failures. Thanks @davidangularme.</li>
|
||||
<li>Providers/OpenRouter/LM Studio/Anthropic: fill DeepSeek V4 <code>reasoning_content</code> replay placeholders for <code>openrouter/deepseek/deepseek-v4-flash</code> and <code>openrouter/deepseek/deepseek-v4-pro</code>, normalize binary LM Studio reasoning metadata from Gemma 4 and other local models, and recover Anthropic-compatible stream text deltas that arrive before their matching content block. Fixes #76018 and #76007. Thanks @cloph-dsp and @vliuyt.</li>
|
||||
<li>fix(infra): block workspace state-directory env override [AI]. (#75940) Thanks @pgondhi987.</li>
|
||||
<li>MCP/OpenAI and media: normalize parameter-free MCP tool schemas before OpenAI tool submission, honor explicit short <code>[[tts:text]]...[[/tts:text]]</code> blocks while keeping untagged short auto-TTS suppressed, and accept home-relative <code>MEDIA:~/...</code> attachment paths under the existing file-read policy. Fixes #75362, #73758, and #73796. Thanks @tolkonepiu, @SymbolStar, @yfge, and @fabkury.</li>
|
||||
<li>Hooks/doctor: warn when <code>hooks.transformsDir</code> points outside the canonical hooks transform directory, so invalid workspace skill paths get a direct recovery hint before the Gateway crash-loops. Fixes #75853. Thanks @midobk.</li>
|
||||
<li>Proxy/audio: convert standard <code>FormData</code> bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send <code>[object FormData]</code> when <code>HTTP_PROXY</code> or <code>HTTPS_PROXY</code> is configured. Fixes #48554. Thanks @dco5.</li>
|
||||
<li>Discord/setup/startup/native commands: write resolved guild/channel allowlist selections to the selected guild and channel, persist slash-command deploy hashes across process restarts, treat abort-time Carbon reconnect-exhausted events as expected shutdown during stale-socket restarts, allow explicit ack reactions in tool-only guild channels, and warn when slash dispatch or direct plugin execution produces no visible reply. Fixes #74922 and #58986; carries forward #58216; supersedes #47788, #73949, and #62057. Thanks @samvilian, @BlueBirdBack, @Eldersonar, @Perttulands, and @jb510.</li>
|
||||
<li>Discord/delivery/media: use session-backed A2A announce target lookup for multi-account <code>sessions_send</code>, keep typing indicators alive during long tool runs and auto-compaction, preserve multipart Content-Type headers for uploads, preserve attachment and sticker filenames, and keep non-ASCII channel names in session labels while preserving ASCII-slug allowlists. Fixes #42652 and #59744; refs #51626 and #44773; supersedes #73975. Thanks @irchelper, @dpalfox, @Lanfei, @Squirbie, @FunJim, @xela92, @rockcent, and @swjeong9.</li>
|
||||
<li>Discord/threads/PluralKit: canonicalize proxied webhook turns to the original message id for dedupe, inject thread starter context only on the first effective thread turn, and resolve thread <code>ownerId</code>/<code>parentId</code> from Discord API-style snake_case payload fields so bot-owned autoThreads do not require unnecessary mentions. Fixes #41355; supersedes #44447 and #44449. Thanks @acgh213, @p3nchan, and @mgh3326.</li>
|
||||
<li>Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma.</li>
|
||||
<li>Gateway/pricing: defer optional model pricing catalog refresh until after sidecars and channels reach the ready path, so slow OpenRouter or LiteLLM pricing fetches cannot block Gateway readiness. Fixes #74128; supersedes #73486. Thanks @ctbritt and @alprclbi.</li>
|
||||
<li>Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq.</li>
|
||||
<li>Codex/app-server: make startup retry cleanup ownership-aware so concurrent Codex lanes cannot close another lane's freshly restarted shared app-server client. Thanks @vincentkoc.</li>
|
||||
<li>Google Meet/Twilio/Voice Call: report missing dial-in details during setup, explain that Twilio needs a phone dial plan for Meet URLs, start the phone leg before Meet PIN DTMF, delay intro speech until after post-connect dialing, log each stage, and accept provider call IDs for gateway speak/continue while reporting ended-call state from history.</li>
|
||||
<li>Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427.</li>
|
||||
<li>Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah.</li>
|
||||
<li>CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via <code>reliability.outputLimits</code>, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840.</li>
|
||||
<li>Telegram/DMs/network/commands: keep incidental <code>message_thread_id</code> reply-with-quote metadata on flat DM sessions unless topic isolation is configured, raise outbound text and typing Bot API guards to 60 seconds with safe timeout overrides and typing fallback retries, and register/clear command menus in default and group-chat scopes so <code>/status</code> and plugin commands stay available in forum topics. Fixes #75975, #76013, and #74032; updates #6457. Thanks @ProjectEvolutionEVE, @iaki1206, @dae-sun, and @WouldenShyp.</li>
|
||||
<li>Providers/OpenAI: resolve <code>keychain:<service>:<account></code> <code>OPENAI_API_KEY</code> refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt.</li>
|
||||
<li>Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai.</li>
|
||||
<li>Voice Call: add <code>sessionScope: "per-call"</code> for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry.</li>
|
||||
<li>Music generation: raise too-small tool timeouts to the provider-safe 10-second floor and collapse cascading abort fallback errors into a clearer root-cause summary. Thanks @shakkernerd.</li>
|
||||
<li>Memory-core/dreaming: include the primary runtime workspace in multi-agent dreaming sweeps without mixing main-agent session transcripts into configured subagent workspaces. Fixes #70014. Thanks @ttomiczek.</li>
|
||||
<li>Control UI: add tab/RPC timing attribution and decouple slow Overview/Cron secondary refreshes so Sessions navigation gets immediate visible feedback. Refs #64004. Thanks @WaMaSeDu.</li>
|
||||
<li>Memory: retry transient SQLite index file swaps during atomic reindex on Windows, so brief <code>EBUSY</code>, <code>EPERM</code>, or <code>EACCES</code> locks do not fail memory rebuilds. Fixes #64187. Thanks @kunpeng-ai-lab.</li>
|
||||
<li>Telegram/startup/models: use the existing <code>getMe</code> request guard and higher <code>timeoutSeconds</code> configs for slow Bot API paths, and make model picker confirmations say selections are session-scoped. Fixes #75783 and #75965. Thanks @tankotan and @sd1114820.</li>
|
||||
<li>Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with <code>process is not defined</code>. Fixes #75987. Thanks @novkien.</li>
|
||||
<li>Heartbeat/Discord: keep async exec completion events out of the generic <code>System (untrusted)</code> prompt block and let the dedicated exec heartbeat prompt handle them, so Discord no longer receives raw exec failure tails as separate system-style messages. Fixes #66366. Thanks @Promee-ThaBossHoss.</li>
|
||||
<li>Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC <code>activeHours.timezone</code> values (e.g. <code>Asia/Shanghai</code>) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight.</li>
|
||||
<li>Channels: strip plain-text MiniMax and XML tool-call scaffolding from shared user-facing reply sanitization, so messaging channels do not deliver raw model tool syntax when a provider emits it as text instead of structured tool calls. Fixes #62820. Thanks @canh0chua.</li>
|
||||
<li>Infer/media: report missing image-understanding and audio-transcription provider configuration for <code>image describe</code>, <code>image describe-many</code>, and <code>audio transcribe</code> instead of blaming the input path when no provider is available. Fixes #73569 and supersedes #73593, #74288, and #74495. Thanks @bittoby, @tmimmanuel, @Linux2010, and @vyctorbrzezowski.</li>
|
||||
<li>CLI/infer: reject local <code>codex/*</code> one-shot model probes before simple-completion dispatch and point operators at the Codex app-server runtime path instead of ending with an empty-output error.</li>
|
||||
<li>Docs/health: clarify that session listing surfaces stored conversation rows rather than Discord/channel socket liveness, and point connectivity checks at channel status and health probes. Fixes #70420. Thanks @ashersoutherncities-art and @martingarramon.</li>
|
||||
<li>WhatsApp/Cron: keep DM pairing-store approvals out of implicit cron and heartbeat recipient fallback, so scheduled automation only uses explicit targets, active configured recipients, or configured <code>allowFrom</code> entries. Fixes #62339. Thanks @kelvinisly-collab.</li>
|
||||
<li>Google Meet: keep the agent-facing <code>google_meet</code> tool visible on non-macOS hosts but block local Chrome realtime actions with guidance, so Linux agents can still use transcribe, Twilio, chrome-node, and artifact flows without choosing the macOS-only BlackHole path. Refs #75950. Thanks @actual-software-inc.</li>
|
||||
<li>macOS/settings: keep opening General from rewriting <code>openclaw.json</code> during Tailscale settings hydration, preserving <code>gateway</code>, <code>auth</code>, <code>meta</code>, and <code>wizard</code> until the user changes a setting. Fixes #59545. Thanks @Tengdw.</li>
|
||||
<li>Discord: prioritize interaction callbacks ahead of stale background REST work without polling active REST buckets, validate oversized gateway payloads and member-intent requests before send, and forward explicit component payloads from message actions. (#75363)</li>
|
||||
<li>Active Memory: use the configured recall timeout as the blocking prompt-build hook budget by default and move cold-start setup grace behind explicit <code>setupGraceTimeoutMs</code> config, so the plugin no longer silently extends 15000 ms configs to 45000 ms on the main lane. Fixes #75843. Thanks @vishutdhar.</li>
|
||||
<li>Plugins/web-provider: reuse the active gateway plugin registry for runtime web provider resolution after deriving the same candidate plugin ids as the loader path, avoiding a redundant <code>loadOpenClawPlugins</code> call on every request while preserving origin and scope filters. Fixes #75513. Thanks @jochen.</li>
|
||||
<li>Crestodian/CLI: exit non-zero when interactive Crestodian is invoked without a TTY, so scripts and CI no longer treat the setup error as success. Fixes #73646 and supersedes #73928 and #74059. Thanks @bittoby, @luyao618, and @Linux2010.</li>
|
||||
<li>Cron: keep implicit/default isolated cron announce deliveries out of the main session awareness queue, so isolated jobs do not accumulate in the main conversation. Fixes #61426. Thanks @Lihannon.</li>
|
||||
<li>Subagents: avoid duplicate parent-visible replies when a parent uses <code>sessions_send</code> on its own persistent native subagent session, while preserving announce delivery for async sends. Fixes #73550. Thanks @sylviazhang2006-design.</li>
|
||||
<li>Web search/Brave: add opt-in <code>brave.http</code> diagnostics for Brave request URLs/query params, response status/timing, and cache hit/miss/write events without logging API keys or response bodies. Fixes #55196. Thanks @mecampbellsoup.</li>
|
||||
<li>Web search/Brave: add <code>plugins.entries.brave.config.webSearch.baseUrl</code> for Brave-compatible proxies, including endpoint-aware cache keys for both web and LLM Context modes. Fixes #19075. Thanks @jkoprax and @vishnukool.</li>
|
||||
<li>Web search/config: validate explicit <code>tools.web.search.provider</code> values against bundled and installed plugin manifests, while warning for stale third-party plugin config. Fixes #53092. Thanks @TinyTb.</li>
|
||||
<li>Web search/SearXNG: retry empty non-general category searches once with the general category, so unsupported category engines do not return empty results when general search has matches. Fixes #73552. Thanks @Loukky.</li>
|
||||
<li>CLI/message: skip gateway-stop hooks for read-only <code>message read</code> and bound stop-hook shutdown for other message actions, so one-shot Discord reads cannot hang behind plugin lifecycle cleanup.</li>
|
||||
<li>Plugins/web-provider: cache repeated bundled web search and web fetch provider registry loads by default while preserving explicit cache opt-outs. Supersedes #75992. Thanks @DmitryPogodaev.</li>
|
||||
<li>Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan.</li>
|
||||
<li>Control UI/WebChat: route typed <code>/new</code> through the New Chat dashboard-session creation flow instead of <code>chat.send</code>, while keeping <code>/reset</code> as the explicit current-session reset. Fixes #69599. Thanks @WolvenRA.</li>
|
||||
<li>Agents/models: keep legacy CLI runtime model refs such as <code>claude-cli/*</code> in the configured allowlist after canonical runtime migration, so cron <code>payload.model</code> overrides keep working. Fixes #75753. Thanks @RyanSandoval.</li>
|
||||
<li>Codex/app-server: restart the shared Codex app-server client once when it closes during startup thread resume, preserving the existing thread binding instead of retrying <code>thread/start</code> on a closed client. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/watch: keep colored subsystem log prefixes in the managed tmux pane even when the parent shell exports <code>NO_COLOR</code>, while preserving explicit <code>FORCE_COLOR=0</code> opt-out. Thanks @vincentkoc.</li>
|
||||
<li>Agents/compaction: submit a non-empty runtime-event marker for pre-compaction memory flush turns, so strict Anthropic providers no longer reject the silent flush as an empty user message. Fixes #75305. Thanks @sableassistant3777-source.</li>
|
||||
<li>Plugin SDK: re-export <code>isPrivateIpAddress</code> from <code>plugin-sdk/ssrf-runtime</code>, restoring source-checkout builds for SearXNG and Firecrawl private-network guards. Thanks @vincentkoc.</li>
|
||||
<li>Discord/message actions: advertise <code>upload-file</code> and route it through Discord's send runtime with agent-scoped media reads, so agents can discover and send file attachments. Fixes #60652 and supersedes #60808, #61087, and #61100. Thanks @claw-io, @efe-arv, @joelnishanth, and @sjhddh.</li>
|
||||
<li>Sessions: suppress exact inter-session control replies such as <code>NO_REPLY</code> and keep agent-to-agent announce bookkeeping out of visible transcripts. Fixes #53145. Thanks @TarahAssistant.</li>
|
||||
<li>CLI/directory: report unsupported directory operations for installed channel plugins instead of prompting to reinstall the plugin when it lacks a directory adapter. Fixes #75770. Thanks @lawong888.</li>
|
||||
<li>Web search/SearXNG/Firecrawl/Kimi: show the SearXNG JSON API <code>search.formats</code> prerequisite, pass through <code>img_src</code> image URLs, fail explicitly when Kimi returns ungrounded answers, keep public provider requests on strict SSRF guards, reject private/loopback/metadata/non-HTTP(S) hosted Firecrawl scrape targets, and allow explicit self-hosted private Firecrawl endpoints. Fixes #52573, #74357, and #63877; supersedes #65592, #61416, #74360, #48133, #59666, #63941, and #74013. Thanks @evanpaul14, @sghael, @wangwllu, @fede-kamel, @kn1ghtc, @jhthompson12, @jzakirov, @Mlightsnow, and @shad0wca7.</li>
|
||||
<li>CLI/models: report gateway model fallback attempts in <code>infer model run --json</code> and avoid double-prefixing provider-qualified defaults such as <code>openrouter/auto</code> in <code>models status</code>. Partially fixes #69527. Thanks @alexifra.</li>
|
||||
<li>Providers/OpenRouter: strip trailing assistant prefill turns from verified OpenRouter Anthropic model requests when reasoning is enabled, so Claude 4.6 routes no longer fail with Anthropic's prefill rejection through the OpenAI-compatible adapter. Fixes #75395. Thanks @sbmilburn.</li>
|
||||
<li>Voice Call: add per-number inbound routing for dialed-number greetings, response agents/models/prompts, and TTS voice overrides. Fixes #56604. Thanks @healthstatus.</li>
|
||||
<li>Feishu: preserve Feishu/Lark HTTP error bodies for message sends, media sends, and chat member lookups, so HTTP 400 failures include vendor code, message, log id, and troubleshooter details. Fixes #73860. Thanks @desksk.</li>
|
||||
<li>Agents/transcripts: avoid reopening large Pi transcript files through the synchronous session manager for maintenance rewrites, persisted tool-result truncation, manual compaction boundary hardening, and queued compaction rotation. Thanks @mariozechner.</li>
|
||||
<li>Web search/Exa/MiniMax: accept Exa <code>webSearch.baseUrl</code> overrides with endpoint-partitioned caches, include MiniMax Search in setup, and let <code>MINIMAX_API_KEY</code> participate in MiniMax Search auto-detection. Fixes #54928; supersedes #54939 and #65828. Thanks @mrpl327, @lyfuci, and @Jah-yee.</li>
|
||||
<li>Plugins/ClawHub: preserve official source-linked trust through archive installs, so OpenClaw can install trusted ClawHub plugin packages that trigger the built-in dangerous-pattern scanner. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/ClawHub: install package runtime dependencies for archive-backed plugin installs, so ClawHub packages such as WhatsApp load declared dependencies after download. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/tools: cache repeated plugin tool factory results only for matching request context, reducing per-turn tool prep without leaking sandbox, session, browser, delivery, or runtime config state. Fixes #75956. Thanks @Linux2010.</li>
|
||||
<li>Providers/LM Studio: allow <code>models.providers.lmstudio.params.preload: false</code> to skip OpenClaw's native model-load call so LM Studio JIT loading, idle TTL, and auto-evict can own model lifecycle. Fixes #75921. Thanks @garyd9.</li>
|
||||
<li>Agents/transcripts: keep chat history, restart recovery, fork token checks, and stale-token compaction checks on bounded async transcript reads or cached async indexes instead of reparsing large session files. Thanks @mariozechner.</li>
|
||||
<li>Telegram: inherit the process DNS result order for Bot API transport and downgrade recovered sticky IPv4 fallback promotions to debug logs, while keeping pinned-IP escalation warnings visible. Fixes #75904. Thanks @highfly-hi and @neeravmakwana.</li>
|
||||
<li>Sessions: keep durable external conversation pointers, including group and thread-scoped chat sessions, out of age, count, and disk-budget maintenance eviction while still allowing synthetic runtime entries to age out. Fixes #58088. Thanks @drinkflav.</li>
|
||||
<li>Web search/Providers MiniMax: allow <code>MINIMAX_OAUTH_TOKEN</code> to satisfy MiniMax Search credentials and derive Coding Plan usage polling from the configured MiniMax base URL, so OAuth-authorized and global setups use the right endpoint. Fixes #65768 and #65054. Thanks @kikibrian, @zhouhe-xydt, @sixone74, and @Yanhu007.</li>
|
||||
<li>Control UI/WebChat: skip assistant-media transcript supplements when stale media refs resolve to no playable media, so text-only final replies are not stored a second time as gateway-injected assistant messages. Fixes #73956. Thanks @HemantSudarshan.</li>
|
||||
<li>Sessions: reject <code>sessions_send</code> targets that resolve to thread-scoped chat sessions, so inter-agent coordination cannot be injected into active human-facing Slack or Discord threads. Fixes #52496. Thanks @barry-p5cc.</li>
|
||||
<li>Subagents: honor <code>sessions_spawn</code> with <code>expectsCompletionMessage: false</code> by skipping parent completion handoff delivery while still running child cleanup. Fixes #75848. Thanks @alfredjbclaw.</li>
|
||||
<li>Media/completions: treat media-only message-tool sends as delivered async completion output, avoiding duplicate raw <code>MEDIA:</code> fallback posts after video or music generation finishes.</li>
|
||||
<li>Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc.</li>
|
||||
<li>Codex/app-server: recover JSON-RPC frames split by raw command-output newlines and include a redacted preview when malformed app-server messages still reach the console. Thanks @vincentkoc.</li>
|
||||
<li>Replies/typing: keep typing alive for queued follow-up messages that are genuinely waiting behind an active run, instead of making chat surfaces look idle while work is queued. Fixes #65685. Thanks @papag00se.</li>
|
||||
<li>ACP/Discord: suppress completion announce delivery for inline thread-bound ACP session runs, so Discord thread-bound ACP replies are not delivered twice. Fixes #60780. Thanks @solavrc.</li>
|
||||
<li>Discord/threads: ignore webhook-authored copies in already-bound Discord session threads even when the webhook id differs, preventing PluralKit proxy copies from creating duplicate turn pressure. Fixes #52005. Thanks @acgh213.</li>
|
||||
<li>Discord/threads: return the created thread as partial success when the follow-up initial message fails, so agents do not retry thread creation and create empty duplicate threads. Fixes #48450. Thanks @dahifi.</li>
|
||||
<li>Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei.</li>
|
||||
<li>macOS/config: preserve existing <code>gateway.auth</code> and unrelated config keys during app fallback writes, so dashboard or Talk settings changes cannot strand Control UI clients by dropping persisted auth. Fixes #75631. Thanks @Fuma2013.</li>
|
||||
<li>Control UI/TUI: keep reconnecting chat sends bound to the same backing session id and let TUI relaunches resume the last selected session, avoiding silent fresh sessions after refresh, reconnect, or terminal restart. Fixes #63195, #68162, and #73546. Thanks @bond260312-cmyk, @zhong18804784882, and @mtuwei.</li>
|
||||
<li>Plugins/tools: let plugin manifests declare static tool availability so reply startup skips unavailable plugin tool runtimes instead of importing factories that only return <code>null</code>. Thanks @shakkernerd.</li>
|
||||
<li>Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has <code>reactionNotifications: "off"</code>, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120.</li>
|
||||
<li>CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw.</li>
|
||||
<li>Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury.</li>
|
||||
<li>Config: log the "newer OpenClaw" version warning once per process instead of once per config snapshot read. (#75927) Thanks @romneyda.</li>
|
||||
<li>Telegram/message actions: treat benign delete-message 400s as no-op warnings instead of runtime errors, so stale or already-removed messages do not create noisy delete failures. Fixes #73726. Thanks @Avicennasis.</li>
|
||||
<li>Telegram: split long default markdown sends and media follow-up text into safe HTML chunks, so outbound messages over Telegram's limit no longer fail as one oversized Bot API request. Fixes #75868. Thanks @zhengsx.</li>
|
||||
<li>Gateway/chat history: merge Claude CLI transcript imports for Anthropic-routed sessions that still have a Claude CLI binding, so local chat history does not hide CLI JSONL turns. Fixes #75850. Thanks @alfredjbclaw.</li>
|
||||
<li>Media: trim serialized JSON suffixes after local <code>MEDIA:</code> directive file extensions, so generated-image metadata cannot pollute the parsed media path and cause false <code>ENOENT</code> delivery failures. Fixes #75182. Thanks @TnzGit and @hclsys.</li>
|
||||
<li>Plugins/runtime: hot-reload Gateway plugin runtime surfaces after plugin enable/disable changes while keeping source-changing plugin install, update, and uninstall operations restart-backed so loaded module code is not reused. Fixes #72097.</li>
|
||||
<li>Cron: make scheduler reload schedule comparison tolerate malformed persisted jobs, so one bad cron entry no longer aborts the whole tick. Fixes #75886. Thanks @samfox-ai.</li>
|
||||
<li>Doctor/channels: warn after migrations when default Telegram or Discord accounts have no configured token and their env fallback (<code>TELEGRAM_BOT_TOKEN</code> or <code>DISCORD_BOT_TOKEN</code>) is unavailable, with secret-safe migration docs for checking state-dir <code>.env</code>. Fixes #74298. Thanks @lolaopenclaw.</li>
|
||||
<li>Gateway/diagnostics: keep idle liveness samples in telemetry instead of visible warning logs unless diagnostic work is active, waiting, or queued. Thanks @vincentkoc.</li>
|
||||
<li>Channels/cron: reject provider-prefixed targets for the wrong channel and let prefixed announce targets such as <code>telegram:123</code> select their channel when delivery falls back to <code>last</code>, so Telegram IDs cannot be coerced into WhatsApp phone numbers. Fixes #56839. Thanks @bencoremans.</li>
|
||||
<li>Control UI/chat: keep live replies visible when a raw session alias such as <code>main</code> sends the chat turn but Gateway emits events under the canonical session key for the same run. Fixes #73716. Thanks @teebes.</li>
|
||||
<li>CLI/models: reject <code>--agent</code> on <code>openclaw models set</code> and <code>set-image</code> instead of silently writing agent-scoped requests to global model defaults. Fixes #68391. Thanks @derrickabellard.</li>
|
||||
<li>CLI: stop treating the legacy singular <code>openclaw tool ...</code> token as a plugin id under restrictive <code>plugins.allow</code>, so it falls through as a normal unknown/reserved command instead of suggesting a stale allowlist entry. Fixes #64732. Thanks @efe-arv, @SweetSophia, and @hashtag1974.</li>
|
||||
<li>Media: write inbound media buffers through same-directory temp files before rename, so failed disk writes do not leave zero-byte artifacts for later voice transcription. Fixes #55966. Thanks @OpenCodeEngineer.</li>
|
||||
<li>TTS/Telegram: keep trusted local audio generated by the TTS tool queued for voice-note delivery even when the run-level built-in tool list omits the raw <code>tts</code> name. Fixes #74752. Thanks @Loveworld3033 and @andyliu.</li>
|
||||
<li>TTS: require explicit user or config audio intent for the agent speech tool so dashboard chats stay text unless audio is requested. Fixes #69777. Thanks @alexandre-leng.</li>
|
||||
<li>Plugins/config: keep bundled source-checkout plugins from being runtime-gated by install-only <code>minHostVersion</code> metadata, accept prerelease host floors, trim plugin-service startup failures to one log line, and avoid broad channel-runtime loading during base config parsing. Thanks @vincentkoc.</li>
|
||||
<li>Heartbeat: strip legacy <code>[TOOL_CALL]...[/TOOL_CALL]</code> and <code>[TOOL_RESULT]...[/TOOL_RESULT]</code> pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.</li>
|
||||
<li>macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.</li>
|
||||
<li>Providers/xAI: give Grok <code>web_search</code> a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129.</li>
|
||||
<li>Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc.</li>
|
||||
<li>Slack/DMs/routing: honor <code>dmHistoryLimit</code> for fresh 1:1 DMs, keep top-level DMs on stable DM sessions even when <code>replyToMode</code> targets thread replies, send text/block-only proactive DMs directly with <code>chat.postMessage(channel=<user id>)</code>, match Slack target route syntax such as <code>channel:C...</code>, <code>user:U...</code>, or <code><@U...></code>, and match public-channel allowlists against bare runtime channel IDs. Fixes #64427, #58832, #62042, #41608, and #41264; supersedes #56530. Thanks @brantley-creator, @daye-jjeong, @MarkMolina, @Winnsolutionsadmin, @babutree, and @Realworld404.</li>
|
||||
<li>Slack/delivery/capabilities: preserve missing-scope details in outbound errors, read granted scopes from <code>auth.test</code> metadata before legacy APIs, retry Slack writes only for wrapped DNS request failures such as <code>EAI_AGAIN</code>, and prefer the account bound to the outbound target peer in multi-workspace sends. Fixes #62391, #44625, and #68789; supersedes #66807. Thanks @alexey-pelykh, @Qquanwei, @martingarramon, @sonnyb9, and @rijhsinghani.</li>
|
||||
<li>Slack/message actions/tools: send media before follow-up Block Kit messages for file sends, forward agent-scoped media roots through the bundled upload-file path, resolve <code><!subteam^...></code> user-group mentions before waking mention-gated channels, and let <code>read</code> fetch an exact Slack message timestamp or thread reply. Fixes #51458, #64625, #73827, and #53943. Thanks @HirokiKobayashi-R, @benpchandler, @CG-Intelligence-Agent-Jack, and @zomars.</li>
|
||||
<li>PDF/Gemini: send native PDF analysis API keys in the <code>x-goog-api-key</code> header instead of the request URL, keeping secrets out of proxy and access logs. Supersedes #60600. Thanks @garagon.</li>
|
||||
<li>Web search/Gemini/DuckDuckGo/Brave/fetch: route abort signals into Gemini provider fetches, late-bind managed agent <code>web_search</code> calls to the current runtime config snapshot, reuse Google provider API key/base URL as lower-priority Gemini search fallbacks, pass Gemini freshness/date filters through grounding, include DuckDuckGo in setup, honor Gemini/Grok/x_search <code>baseUrl</code> overrides, point Brave metadata at canonical docs, support Brave LLM Context freshness/date ranges, resolve external <code>webFetchProviders</code> for non-sandboxed fetches, and point missing-key errors to <code>web_fetch</code> or browser where appropriate. Fixes #72995, #75420, #66498, #65862, #65870, and #74915; supersedes #57496, #65940, #61972, #65892, and #51005. Thanks @RoseKongPS, @richardmqq, @Aoiujz, @ismael-81, @Jah-yee, @Lanfei, @Magicray1217, @remusao, @ultrahighsuper, @mingmingtsao, and @zhaoyang97.</li>
|
||||
<li>Slack/directory: make <code>openclaw directory peers/groups list --channel slack</code> prefer token-backed live readers and return the connected Slack account from <code>directory self</code>, so valid Slack tokens no longer produce empty directory CLI results. Fixes #50776. Thanks @pjaillon.</li>
|
||||
<li>Slack: keep assistant typing status, temporary typing reactions, and status reactions active for group/channel turns that use message-tool-only visible replies, while still suppressing automatic source replies. Fixes #75877. Thanks @teosborne.</li>
|
||||
<li>Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter.</li>
|
||||
<li>Replies: strip legacy <code>[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]</code> pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua.</li>
|
||||
<li>WhatsApp: close long-lived web sockets through Baileys <code>end(error)</code> before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber.</li>
|
||||
<li>Twitch/plugins: emit a flat JSON Schema for Twitch channel config so single-account and multi-account configs validate before runtime load, and add source-checkout diagnostics for missing pnpm workspace dependencies. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/sessions: move hot transcript reads and mirror appends onto async bounded IO with serialized parent-linked writes, keeping large session histories from stalling Gateway requests and channel replies. Fixes #75656. Thanks @DerFlash.</li>
|
||||
<li>macOS/Talk Mode: downmix multi-channel microphone buffers before handing them to Apple Speech across Push-to-Talk, Talk Mode, Voice Wake, and the wake-word tester, so pro audio interfaces no longer produce empty transcripts. Fixes #42533. Thanks @jbuecker.</li>
|
||||
<li>macOS/Talk Mode: subscribe native WebChat to active-session transcript updates and render external spoken user turns in the chat thread instead of only showing assistant replies. Fixes #75155. Thanks @SledderBling.</li>
|
||||
<li>macOS/Voice Wake: accept trigger-only phrases in the built-in Voice Wake test, matching the settings UI and runtime trigger-only path instead of requiring extra command text after the wake word. Fixes #64986. Thanks @zoiks65.</li>
|
||||
<li>Cron/TTS: run cron announce payloads through the normal TTS directive transform before outbound delivery, so scheduled <code>[[tts]]</code> replies generate voice payloads instead of leaking raw tags. Fixes #52125. Thanks @kenchen3000.</li>
|
||||
<li>WhatsApp: save downloadable quoted image media from reply context as inbound media, so agents can inspect an image that a user replied to instead of only seeing <code><media:image></code>. Fixes #59174. Thanks @gaffner.</li>
|
||||
<li>Sessions/store: stop persisting the runtime-only <code>skillsSnapshot.resolvedSkills</code> array inside each session entry, so <code>sessions.json</code> no longer carries a copy of every parsed <code>SKILL.md</code> body for every active session; <code>ensureSkillSnapshot</code> rehydrates the array from disk on cold resume so the embedded runner, the Claude CLI skills plugin, and the Claude live-session fingerprint all see populated skills, and legacy stores self-heal on the next save. Refs #11950, #6650, #15000. Thanks @amoghasgekar.</li>
|
||||
<li>Doctor/WhatsApp: warn when Linux crontabs still run the legacy <code>ensure-whatsapp.sh</code> health check, which can misreport <code>Gateway inactive</code> when cron lacks the systemd user-bus environment. Fixes #60204. Thanks @mySebbe.</li>
|
||||
<li>Slack/setup: print the generated app manifest as plain JSON instead of embedding it inside the framed setup note, so it can be copied into Slack without deleting border characters. Fixes #65751. Thanks @theDanielJLewis.</li>
|
||||
<li>Channels/WhatsApp: route CLI logout through the live Gateway and stop runtime-backed listeners before channel removal, so removing a WhatsApp account does not leave the old socket replying until restart. Fixes #67746. Thanks @123Mismail.</li>
|
||||
<li>Voice Call/Twilio: honor TTS directive text and provider voice/model overrides during telephony synthesis, so <code>[[tts:...]]</code> tags are not spoken literally and voiceId overrides reach OpenAI/ElevenLabs calls. Fixes #58114. Thanks @legonhilltech-jpg.</li>
|
||||
<li>Agents/session-locks: reclaim untracked current-process session locks with matching starttime during acquisition and startup cleanup, so Gateway restarts recover from self-owned orphan <code>.jsonl.lock</code> files. Fixes #75805; refs #49603. Thanks @cdznho.</li>
|
||||
<li>Agents/subagents: initialize built-in context engines before native <code>sessions_spawn</code> resolves spawn preparation, so cliBackend-only cold starts no longer fail with an unregistered <code>legacy</code> context engine. Fixes #73095. (#73904) Thanks @brokemac79.</li>
|
||||
<li>Plugins/Bonjour: ship the ciao runtime dependency with packaged OpenClaw so fresh OCM envs can start default mDNS discovery without a missing-module failure. Thanks @shakkernerd.</li>
|
||||
<li>Agents/tools: scope reply plugin-tool discovery to manifest-declared tool owners and already-active matching tool entries, avoiding broad plugin runtime loading for narrow or core-only tool allowlists. Thanks @shakkernerd.</li>
|
||||
<li>Agents/replies: defer implicit image model discovery and keep OAuth auth-store adoption on persisted profiles during reply startup, cutting OCM MarCodex warm prep to sub-second in live checks. Thanks @shakkernerd.</li>
|
||||
<li>Plugins/tools: enforce <code>contracts.tools</code> as the manifest ownership contract for plugin tool registration, rejecting undeclared runtime tool names and adding bundled plugin drift coverage. Thanks @shakkernerd.</li>
|
||||
<li>Agents/Codex: stop prompting message-tool-only source turns to finish with <code>NO_REPLY</code>, so quiet turns are represented by not calling the visible message tool instead of conflicting final-text instructions. Thanks @pashpashpash.</li>
|
||||
<li>Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme.</li>
|
||||
<li>Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer.</li>
|
||||
<li>Gateway/config: allow <code>gateway config.patch</code> to update documented subagent thinking defaults. Fixes #75764. (#75802) Thanks @kAIborg24.</li>
|
||||
<li>Plugins/CLI: keep git plugin install paths credential-free, preserve existing git checkouts until replacement succeeds, honor duplicate npm install mode, and remove managed git repos on uninstall. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/CLI: redact authenticated git URLs from git install command failure details, so failed clone or checkout output cannot leak credentials during plugin installs. Thanks @vincentkoc.</li>
|
||||
<li>Channels/status reactions: remove stale non-terminal lifecycle reactions when a run reaches done or error, so Discord does not leave a permanent thinking emoji after completion. Fixes #75458. Thanks @davelutztx.</li>
|
||||
<li>Discord/doctor: migrate unsupported per-channel <code>agentId</code> entries under guild channel config into top-level <code>bindings[]</code> routes, so <code>openclaw doctor --fix</code> preserves the intended agent route instead of stripping it as an unknown key. Fixes #62455. Thanks @lobster-biscuit.</li>
|
||||
<li>Discord/DMs: set inbound direct-message <code>ctx.To</code> to the semantic <code>user:<id></code> target while keeping delivery routed through the DM channel, so mirror and recovery paths do not treat DMs as channel conversations. Fixes #68126. Thanks @illuminate0623.</li>
|
||||
<li>Discord/DMs: keep no-guild inbound messages on direct-message routing when Discord channel lookup is temporarily unavailable, preventing degraded DMs from forking into channel sessions. Fixes #59817. Thanks @DooPeePey.</li>
|
||||
<li>Discord: retry outbound API calls on HTTP 5xx, request-timeout, and transient transport failures instead of only Discord rate limits, reducing dropped cron and agent replies during short Discord or network outages. Fixes #52396. Thanks @sunshineo.</li>
|
||||
<li>Discord: include Components v2 Text Display content from referenced replies and forwarded snapshots, so component-only messages still appear in reply context. Fixes #56228. Thanks @HollandDrive.</li>
|
||||
<li>Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos.</li>
|
||||
<li>Discord: preserve native slash-command description localizations through command reconcile, so localized Discord descriptions no longer get overwritten by English defaults. Fixes #56580. Thanks @mhseo93.</li>
|
||||
<li>Discord: add configured outbound mention aliases so known <code>@Name</code> references can be rewritten to real Discord user mentions instead of relying only on the transient directory cache. Fixes #67587. Thanks @McoreD.</li>
|
||||
<li>Discord: avoid startup REST amplification by skipping native command deploy retries after Discord rate limits and deriving the bot id from parseable bot tokens instead of requiring a <code>/users/@me</code> lookup. Fixes #75341. Thanks @PrinceOfEgypt.</li>
|
||||
<li>Plugins/hooks: derive hook <code>ctx.channelId</code> from the conversation target instead of the provider name, so Discord and other channel plugins can keep per-channel state isolated. Fixes #59881. Thanks @bradfreels.</li>
|
||||
<li>Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.</li>
|
||||
<li>Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated <code>session.stuck</code> diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu.</li>
|
||||
<li>Gateway/agents: avoid rebuilding core tools for plugin-only allowlists and keep the full plugin registry cache warm across scoped plugin loads, reducing per-turn latency spikes. Fixes #75882, #75907, #75906, #75887, and #75851. (#75922) Thanks @obviyus.</li>
|
||||
<li>Agents/failover: classify bare <code>status: internal server error</code> provider messages as retryable server errors so model fallback can rotate instead of stopping. (#73844) Thanks @thesomewhatyou.</li>
|
||||
<li>Gateway/startup: return the shared retryable startup-sidecars error for startup-gated control-plane RPCs such as sessions.create, sessions.send, sessions.abort, agent.wait, and tools.effective, so clients can retry early sidecar races. (#76012) Thanks @scoootscooob.</li>
|
||||
<li>Providers/Google: fix Gemini 2.5 Flash-Lite <code>reasoning: "minimal"</code> rejections by raising its thinking-budget floor to 512 while preserving the existing Gemini 2.5 Pro and Flash minimal presets. (#70629) Thanks @ericberic.</li>
|
||||
<li>Agents/status: resolve <code>session_status(sessionKey="current")</code> for sparse channel-plugin sessions after literal current lookups miss, so Scope, Slack, Discord, and other plugin-driven agents avoid retrying through <code>Unknown sessionKey: current</code>. Fixes #74141. (#72306) Thanks @bittoby.</li>
|
||||
<li>Cron: retry recurring wake-now main-session jobs through temporary heartbeat busy skips before recording success, so queued cron events no longer appear as ok ghost runs while the main lane is still busy. Fixes #75964. (#76083) Thanks @kshetrajna12 and @xuruiray.</li>
|
||||
<li>Providers/Google: keep Gemini thinking-signature-only stream chunks active during reasoning, so Gemini 3.1 Pro Preview replies no longer hit idle timeouts before visible text. Fixes #76071. (#76080) Thanks @marcoschierhorn and @zhangguiping-xydt.</li>
|
||||
<li>CLI/skills: show per-agent model and command visibility in <code>openclaw skills check --agent</code>, and let doctor report or disable unavailable skills allowed for the default agent. (#75983) Thanks @mbelinky.</li>
|
||||
<li>Agents/runtime/tools: keep reply startup on Gateway metadata, manifest catalog rows, auth-store state, and plugin loader cache-key compatibility checks so scoped runtime registries, model allowlists, thinking metadata, media/PDF/generation tools, Comfy workflows, OpenAI Codex OAuth image generation, and image/video/music tool registration avoid broad provider/runtime loads while preserving explicit config and auth-backed providers. Thanks @shakkernerd.</li>
|
||||
<li>Discord: document canonical mention formatting in agent prompt hints and channel docs so outbound replies use <code><@USER_ID></code>, <code><#CHANNEL_ID></code>, and <code><@&ROLE_ID></code> instead of legacy nickname mentions. (#75173)</li>
|
||||
<li>Heartbeat scheduler: gate exec-event/notification/spawn/retry wakes through a centralized cooldown so backgrounded <code>process.start</code> exit notifications can no longer self-feed runaway heartbeat runs (configured <code>every: "30m"</code> was firing every ~10s in production, pegging the gateway event loop with <code>eventLoopDelayMaxMs >6s</code> spikes that stalled control-UI asset serving and TUI handshakes). Documented wake-now paths (<code>manual</code>, <code>wake</code>, task completion, blocked-task follow-up, <code>/hooks/wake mode=now</code>, and cron <code>--wake now</code>) remain immediate; retryable busy skips no longer poison the cooldown for the next retry; per-agent flood guard caps any unexpected feedback loop at 5 runs/60s. (#64016, refs #17797 and #75436) Thanks @hexsprite.</li>
|
||||
<li>fix: block workspace CLOUDSDK_PYTHON override and always set trusted interpreter for gcloud. (#74492) Thanks @pgondhi987.</li>
|
||||
<li>Providers/Z.AI: move the bundled GLM catalog and auth env metadata into the plugin manifest, so <code>models list --all --provider zai</code> shows the full known catalog without duplicated runtime seed data. Thanks @shakkernerd.</li>
|
||||
<li>Providers/Qianfan and Providers/Stepfun: declare setup auth metadata (<code>api-key</code> method, <code>QIANFAN_API_KEY</code>, <code>STEPFUN_API_KEY</code>) in the plugin manifest so onboarding and <code>models setup</code> surface the expected env var without falling back to legacy <code>providerAuthEnvVars</code> runtime seed data. Thanks @shakkernerd.</li>
|
||||
<li>fix(infra): block ambient Homebrew env vars from brew resolution. (#74463) Thanks @pgondhi987.</li>
|
||||
<li>Onboarding/configure: avoid staging every default plugin runtime dependency after config writes, so skipped setup flows only prepare config-selected plugin deps instead of pulling broad feature-plugin packages. Thanks @vincentkoc.</li>
|
||||
<li>Thinking/providers: resolve bundled provider thinking profiles through lightweight provider policy artifacts when startup-lazy providers are not active, so OpenAI Codex GPT-5.x keeps xhigh available in Gateway session validation. Fixes #74796. Thanks @maxschachere.</li>
|
||||
<li>Security/Windows: ignore workspace <code>.env</code> system-path variables and resolve stale-process <code>taskkill.exe</code> from the validated Windows install root, preventing repository-local env files from redirecting cleanup helpers. Thanks @pgondhi987.</li>
|
||||
<li>CLI/plugins: refresh persisted plugin registry policy in place for <code>plugins enable</code> and <code>plugins disable</code>, so routine toggles no longer rebuild and hash every plugin source when the target is already indexed. Thanks @vincentkoc.</li>
|
||||
<li>Windows/install: run npm from a writable installer temp directory and pin the Bedrock runtime dependency below a Windows ARM Node 24 npm resolver failure, so global OpenClaw installs no longer fail before onboarding. Thanks @mariozechner.</li>
|
||||
<li>CLI/plugins: scope install and enable slot selection to the selected plugin manifest/runtime fallback, so plugin installs no longer load every plugin runtime or broad status snapshot just to update memory/context slots. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc.</li>
|
||||
<li>Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in <code>googlemeet doctor</code>, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf.</li>
|
||||
<li>Plugins/tools: prefer built bundled plugin code during tool discovery and skip channel runtime hydration while preserving companion provider registrations, reducing per-run plugin-tool prep cost without dropping executable plugin tools. Fixes #75290. Thanks @thanos-openclaw.</li>
|
||||
<li>Plugins/loader: scope plugin-tool registry reuse to the enabled plugin plan and stored Gateway method keys, so embedded runner tool lookup can reuse compatible startup registries without hiding enabled non-startup plugin tools. Fixes #75520. Thanks @whtoo.</li>
|
||||
<li>Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps.</li>
|
||||
<li>Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582)</li>
|
||||
<li>Discord/voice: leave voice off for text-only configs unless explicitly configured, rerun configured voice auto-join after gateway RESUMED events, ignore already-destroyed stale voice connections during reconnect cleanup, lengthen the default voice join Ready wait with configurable timeouts, merge configured media-understanding providers such as Deepgram into partial active registries, apply per-channel <code>systemPrompt</code> overrides to voice transcript turns, and run voice-channel turns under a voice-output policy that hides the agent <code>tts</code> tool. Fixes #73753, #40665, #63098, #65687, #47095, and #61536; refs #74044, #39825, and #65039. Thanks @sanchezm86, @SecureCloudProjO, @liz709, @darealgege, @kzicherman, @ayochim, @OneMintJulep, @qearlyao, and @aounakram.</li>
|
||||
<li>Plugins/CLI: reuse the cold manifest registry while building plugin status and inspect reports, so large configured plugin sets no longer rediscover the bundled/plugin registry once per inspect row. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/health: refresh cached health RPC snapshots when channel runtime state diverges, so Discord and other channel status reads no longer report stale running or connected values until the cache TTL expires. (#75423)</li>
|
||||
<li>Gateway/sessions: keep session-store reads from running stale prune and entry-count cap maintenance during startup, so oversized stores no longer block chat history readiness after updates while writes and <code>sessions cleanup --enforce</code> still preserve the cleanup safeguards. Fixes #70050. Thanks @tangda18.</li>
|
||||
<li>Security/audit: keep plain <code>security audit</code> on the cold config/filesystem path and reserve plugin runtime security collectors for <code>--deep</code>, so large plugin installs cannot execute every plugin runtime during routine audits. Thanks @vincentkoc.</li>
|
||||
<li>WhatsApp: stage <code>qrcode</code> through root mirrored runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001.</li>
|
||||
<li>Interactive channel payloads: send Discord component-only interaction replies, Slack block-only slash replies, Telegram button/select fallback labels, and LINE quick-reply fallback option text instead of accepting empty renderable payloads. Thanks @vincentkoc.</li>
|
||||
<li>Auto-reply/docking: require <code>/dock-*</code> route switches to start from direct chats, so group or channel participants cannot reroute a shared session's future replies into a linked DM. Thanks @vincentkoc.</li>
|
||||
<li>Discord: keep text-DM main-session route updates pinned to the configured DM owner, matching component interactions so another direct-message sender cannot redirect future main-session replies. Thanks @vincentkoc.</li>
|
||||
<li>Mattermost/Matrix: keep direct-message main-session route updates pinned to the configured DM owner so paired or temporarily allowed senders cannot redirect future shared-session replies. Thanks @vincentkoc.</li>
|
||||
<li>Discord: keep SecretRef-backed bot tokens discoverable for message actions without resolving the token during schema generation, and resolve scoped channel SecretRefs before outbound agent message sends even when the tool is built from a config snapshot. Fixes #75324. Thanks @slideshow-dingo and @Conan-Scott.</li>
|
||||
<li>Updates: run package post-install doctor repair with the managed Gateway service profile and state paths when a daemon is installed, so shell/profile mismatches no longer repair the caller state while the restarted Gateway keeps stale config. Thanks @vincentkoc.</li>
|
||||
<li>Models/DeepInfra: declare DeepInfra manifest catalog discovery and derive its runtime fallback catalog from the manifest, restoring provider-filtered <code>models list --all --provider deepinfra</code> rows without duplicated static model data. Thanks @shakkernerd.</li>
|
||||
<li>CLI/update: verify managed gateway restarts against the installed service port instead of the caller shell port, so package updates do not report a healthy daemon as failed when profiles use different gateway ports. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/agent: reject strict <code>openclaw agent --deliver</code> requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc.</li>
|
||||
<li>Setup/import: honor non-interactive <code>--import-from</code> onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc.</li>
|
||||
<li>Doctor/plugins: keep plain <code>doctor --non-interactive</code> from installing bundled plugin runtime dependencies, so headless health checks report missing deps while <code>doctor --fix</code> remains the explicit repair path. Thanks @vincentkoc.</li>
|
||||
<li>Doctor/gateway: require an interactive confirmation before installing or rewriting the Gateway service, so <code>doctor --fix --non-interactive</code> can repair plugin/config drift without replacing the operator's launchd/systemd service from a temporary environment. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/runtime-deps: include packaged OpenClaw identity in bundled plugin loader cache keys, so same-path package upgrades stop reusing stale versioned runtime-deps mirrors. Fixes #75045. Thanks @sahilsatralkar.</li>
|
||||
<li>Plugin SDK: restore reply-prefix and reply-pipeline helpers on the deprecated root/compat SDK surface so external plugins still using <code>openclaw/plugin-sdk</code> do not fail message dispatch after update. Fixes #75171. Thanks @zhangxiliang.</li>
|
||||
<li>Plugins/runtime-deps: prune inactive same-package versioned runtime-deps roots after bundled dependency repair, so upgrades do not leave old <code>openclaw-<version>-<hash></code> package caches behind after doctor runs. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/runtime-deps: prune legacy version-scoped plugin runtime-deps roots during bundled dependency repair and cover the path in Package Acceptance's upgrade-survivor matrix, so upgrades from 2026.4.x no longer leave stale per-plugin runtime trees after doctor runs. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/runtime-deps: keep Gateway startup plugin imports and runtime plugin fallback loads verify-only after startup/config repair planning, so packaged installs no longer spawn package-manager repair from hot paths after readiness. Refs #75283 and #75069. Thanks @brokemac79 and @xiaohuaxi.</li>
|
||||
<li>Plugins/runtime-deps: treat package.json runtime-deps manifests as supersets when generated materialization metadata is absent, so bundled plugin activation stops restaging already-installed dependency subsets on every activation. Fixes #75429. (#75431) Thanks @loyur.</li>
|
||||
<li>iMessage: add stdin write callback and error listener to IMessageRpcClient so async EPIPE from a closed child process rejects the pending request instead of crashing the gateway with uncaughtException. Fixes #75438.</li>
|
||||
<li>MCP/stdio: settle MCP stdio transport send() from the write callback instead of resolving immediately on buffer acceptance, so async write errors reject the promise instead of being lost. Refs #75438.</li>
|
||||
<li>Process/exec: add stdin error listener in runCommandWithTimeout so EPIPE from a prematurely-exited child is swallowed instead of escaping to uncaughtException. Refs #75438.</li>
|
||||
<li>Voice Call/realtime: add default-off fast memory/session context for <code>openclaw_agent_consult</code>, giving live calls a bounded answer-or-miss path before the full agent consult. Fixes #71849. Thanks @amzzzzzzz.</li>
|
||||
<li>Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson.</li>
|
||||
<li>Gateway/config: cap oversized plugin-owned schemas in the full <code>config.schema</code> response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc.</li>
|
||||
<li>Plugins/update: skip ClawHub and marketplace plugin updates when the bundled version is newer than the recorded installed version, so <code>openclaw update</code> no longer overwrites working bundled plugins with older external packages. Fixes #75447. Thanks @amknight.</li>
|
||||
<li>Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks and cap bulk title/last-message hydration, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/sessions: yield during bulk transcript title/preview hydration and copy compaction checkpoints asynchronously, keeping the Gateway event loop responsive for large session stores and large transcripts. Refs #75330 and #75414. Thanks @amknight.</li>
|
||||
<li>Gateway/sessions: stream bounded transcript reads for session detail, history, artifacts, compaction, and send/subscribe sequence paths so small Gateway requests no longer materialize large transcripts or OOM on oversized session logs. Thanks @vincentkoc.</li>
|
||||
<li>Gateway/chat: bound chat-history transcript reads to the requested display window so large session logs no longer OOM the Gateway when clients ask for a small history page. Thanks @vincentkoc.</li>
|
||||
<li>BlueBubbles: detect audio attachments by Apple UTIs (<code>public.audio</code>, <code>public.mpeg-4-audio</code>, <code>com.apple.m4a-audio</code>, <code>com.apple.coreaudio-format</code>) in addition to <code>audio/*</code> MIME, so iMessage voice notes whose webhook payload only carries the UTI are now classified as audio in the inbound <code><media:audio></code> placeholder instead of falling through to the generic <code><media:attachment></code> tag. Thanks @omarshahine.</li>
|
||||
<li>Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP.</li>
|
||||
<li>Docs/sandboxing: clarify that sandbox setup scripts (<code>sandbox-setup.sh</code>, <code>sandbox-common-setup.sh</code>, <code>sandbox-browser-setup.sh</code>) are only available from a source checkout, and add inline <code>docker build</code> commands for npm-installed users so sandbox image setup works without cloning the repo. Fixes #75485. Thanks @amknight.</li>
|
||||
<li>Google Meet/Voice Call: play Twilio Meet DTMF before opening the realtime media stream and carry the intro as the initial Voice Call message, so the greeting is generated after Meet admits the phone participant instead of racing a live-call TwiML update. Thanks @donkeykong91 and @PfanP.</li>
|
||||
<li>Google Meet/Voice Call: make Twilio setup preflight honor explicit <code>--transport twilio</code> and fail local/private Voice Call webhook URLs, including IPv6 loopback and unique-local forms, before joins. Thanks @donkeykong91 and @PfanP.</li>
|
||||
<li>Voice Call/Twilio: retry transient 21220 live-call TwiML updates and catch answered-path initial-greeting failures, so a fast answered callback no longer crashes the Gateway or drops the Twilio greeting/listen transition. (#74606) Thanks @Sivan22.</li>
|
||||
<li>CLI/startup: preserve <code>OPENCLAW_HIDE_BANNER</code> banner suppression for route-first startup callers that rely on the default process environment while keeping read-only status/channel paths from repairing bundled plugin runtime dependencies. Refs #75183.</li>
|
||||
<li>Voice Call/Twilio: register accepted media streams immediately but wait for realtime transcription readiness before speaking the initial greeting, so reconnect grace handling stays live while OpenAI STT startup is no longer starved by TTS. Fixes #75197. (#75257) Thanks @donkeykong91 and @PfanP.</li>
|
||||
<li>Voice Call CLI: run gateway-delegated <code>voicecall continue</code> through operation-id polling and protocol-shaped errors, so long conversational turns keep their transcript result without blocking a single Gateway RPC. (#75459) Thanks @serrurco and @DougButdorf.</li>
|
||||
<li>Voice Call CLI: delegate operational <code>voicecall</code> commands to the running Gateway runtime and skip webhook startup during CLI-only plugin loading, preventing webhook port conflicts and <code>setup --json</code> hangs. Fixes #72345. Thanks @serrurco and @DougButdorf.</li>
|
||||
<li>Agents/pi-embedded-runner: extract the <code>abortable</code> provider-call wrapper from <code>runEmbeddedAttempt</code> to module scope so its promise handlers no longer close over the run lexical context, releasing transcripts, tool buffers, and subscription callbacks when a provider call hangs past abort. (#74182) Thanks @cjboy007.</li>
|
||||
<li>Docker: restore <code>python3</code> in the gateway runtime image after the slim-runtime switch. Fixes #75041.</li>
|
||||
<li>Agents/session-repair: fix resumed sessions failing with repeated 400 errors on Anthropic and strict OpenAI-compatible providers (Qwen, mlx-vlm) after an interrupted conversation or blank user input. Fixes #75271 and #75313. Thanks @amknight.</li>
|
||||
<li>CLI/Voice Call: scope <code>voicecall</code> command activation to the Voice Call plugin so setup and smoke checks no longer broad-load unrelated plugin runtimes or hang after printing JSON. Thanks @vincentkoc.</li>
|
||||
<li>Doctor/plugins: warn when restrictive <code>plugins.allow</code> is paired with wildcard or plugin-owned tool allowlists, making the exclusive plugin allowlist behavior visible before users hit empty callable-tool runs. Refs #58009 and #64982. Thanks @KR-Python and @BKF-Gitty.</li>
|
||||
<li>Google Meet/Voice Call: keep Twilio Meet joins in conversation mode and reuse the realtime intro prompt when no voice-call-specific intro is configured, so answered phone bridge calls speak instead of joining silently. Refs #72478. Thanks @DougButdorf.</li>
|
||||
<li>Auto-reply/group chats: keep the <code>message</code> tool available for message-tool-only visible replies and apply group-scoped tool policy before deciding fallback delivery, so Discord/Slack-style rooms reply visibly in the correct channel after upgrades. Fixes #74842; refs #75207. Thanks @davelutztx and @aa-on-ai.</li>
|
||||
<li>Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07.</li>
|
||||
<li>Telegram/agents: keep typing indicators and optional generation tools off the reply critical path, so fresh Telegram replies no longer stall while provider catalogs and media models load. (#75360) Thanks @obviyus.</li>
|
||||
<li>Agents/commitments: run hidden follow-up extraction on the configured agent/default model instead of falling back to direct OpenAI, so OpenAI Codex OAuth-only gateways no longer spam background API-key failures. Fixes #75334. Thanks @sene1337.</li>
|
||||
<li>Agents/media: keep async music generation completions on the requester-session wake path even when direct-send completion is enabled, so finished audio stays agent-mediated while video can still opt into direct channel delivery. (#75335) Thanks @vincentkoc.</li>
|
||||
<li>Security/config-audit: redact CLI argv and execArgv secrets before persisting config audit records, covering write, observe, and recovery paths. Fixes #60826. Thanks @koshaji.</li>
|
||||
<li>Gateway/models: keep default and configured model-list views responsive when provider catalog discovery stalls, without hiding real catalog load failures, while <code>--all</code> still waits for the exact full catalog. Fixes #75297; refs #74404. Thanks @lisandromachado and @najef1979-code.</li>
|
||||
<li>Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches <code>pnpm install</code> after gateway startup pre-staging, reducing event-loop pressure from repeated runtime-deps repair on packaged installs. Fixes #75283; refs #75297 and #72338. Thanks @brokemac79, @lisandromachado, and @midhunmonachan.</li>
|
||||
<li>Plugins/runtime-deps: remove OpenClaw-owned legacy runtime-deps symlinks before replacing staged bundled plugin dependencies, so updates can recover from older symlinked installs instead of failing the symlink safety guard. Thanks @goldmar.</li>
|
||||
<li>Discord: retry queued REST 429s against learned bucket/global cooldowns and reacquire fresh voice upload URLs after CDN upload rate limits, so outbound sends recover without reusing stale single-use upload URLs. Thanks @discord.</li>
|
||||
<li>TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens.</li>
|
||||
<li>Discord: collapse repeated native slash-command deploy rate-limit startup logs into one non-fatal warning while keeping per-request REST timing in verbose output. Thanks @discord.</li>
|
||||
<li>Discord: report native slash-command deploy aborts as REST timeouts with method, path, timeout budget, and observed duration, so startup logs explain slow Discord API calls instead of showing a generic aborted operation. Thanks @discord.</li>
|
||||
<li>Security/logging: redact payment credential field names such as card number, CVC/CVV, shared payment token, and payment credential across default log and tool-payload redaction patterns so wallet-style MCP tools do not expose raw payment credentials in UI events or transcripts. Thanks @stainlu.</li>
|
||||
<li>Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent.</li>
|
||||
<li>Plugins/runtime-deps: materialize newly required bundled plugin packages after local <code>openclaw onboard</code> and <code>openclaw configure</code> config writes, while keeping remote setup read-only, so first Gateway startup no longer discovers missing channel/provider deps after setup claimed success. Fixes #75309; refs #75069. Thanks @scottgl9 and @xiaohuaxi.</li>
|
||||
<li>Plugins/runtime-deps: expire stale legacy install locks whose live PID cannot be tied to the current process incarnation, so Docker PID reuse no longer leaves bundled dependency repair stuck behind old <code>.openclaw-runtime-deps.lock</code> directories. Fixes #74948; refs #74950 and #74346. Thanks @dchekmarev.</li>
|
||||
<li>Plugins/runtime-deps: recover interrupted bundled runtime-dependency installs whose package sentinels exist but generated materialization is incomplete, forcing npm/pnpm repair in Gateway startup, doctor, and lazy plugin loads instead of leaving channels crash-looping on missing packages. Fixes #75309; refs #75310, #75296, and #75304. Thanks @scottgl9.</li>
|
||||
<li>Plugins/runtime-deps: treat no-main and export-map package sentinels without reachable entry files as incomplete, so Gateway startup, doctor, and lazy plugin loads repair interrupted bundled dependency installs instead of accepting package.json-only partial installs. Fixes #75309; refs #75183. Thanks @shakkernerd.</li>
|
||||
<li>Plugins/runtime-deps: keep runtime inspection and channel maintenance commands from downloading bundled plugin dependencies, route explicit repairs through <code>openclaw plugins deps --repair</code>, and still allow Gateway/DO paths to repair missing deps before import. Refs #75069. Thanks @xiaohuaxi.</li>
|
||||
<li>Updates: force non-deferred, no-cooldown update restarts after package-manager updates requested through the live Gateway control plane and fail release validation on post-swap stale chunk import crashes, so Telegram/Discord imports do not stay pointed at removed dist files. Fixes #75206. Thanks @xonaman and @faux123.</li>
|
||||
<li>Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when <code>contextTokens</code> is larger than native <code>contextWindow</code>. Fixes #74917. Thanks @kAIborg24.</li>
|
||||
<li>Gateway/systemd: exit with sysexits 78 for supervised lock and <code>EADDRINUSE</code> conflicts so <code>RestartPreventExitStatus=78</code> stops <code>Restart=always</code> restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt.</li>
|
||||
<li>Agents/runtime: skip blank visible user prompts at the embedded-runner boundary before provider submission while still allowing internal runtime-only turns and media-only prompts, so Telegram/group sessions no longer leak raw empty-input provider errors when replay history exists. Fixes #74137. Thanks @yelog, @Gracker, and @nhaener.</li>
|
||||
<li>Agents/Codex: isolate local Codex app-server <code>CODEX_HOME</code> and <code>HOME</code> per agent and add a deliberate Codex migration path with selectable skill copies, so personal Codex CLI skills, plugins, config, and hooks no longer leak into OpenClaw agents unless the operator migrates them into the workspace. Thanks @pashpashpash.</li>
|
||||
<li>Security/Nextcloud Talk: make webhook signature validation use the padded timing-safe compare path even when the supplied signature length is wrong, keep normalized header lookup behavior, and extend regression coverage for tampered bodies, wrong secrets, array-backed headers, and truncated signatures. Carries forward earlier contributor work from #50516 by teddytennant. (#58097) Thanks @gavyngong.</li>
|
||||
<li>Plugins/runtime-deps: replace stale symlinked mirror target roots before writing runtime-mirror temp files and skip rewriting already materialized hardlinks, so cross-version container upgrades no longer crash-loop on read-only image-layer paths while warm mirrors do less churn. Fixes #75108; refs #75069. Thanks @coletebou and @xiaohuaxi.</li>
|
||||
<li>Auto-reply/group chats: fall back to automatic source delivery when a channel precomputes message-tool-only replies but the <code>message</code> tool is unavailable, so Discord/Slack-style group turns do not silently complete without a visible reply. Fixes #74868. Thanks @kagura-agent.</li>
|
||||
<li>Browser/gateway: share one browser control runtime across the HTTP control server and <code>browser.request</code>, and refresh browser profile config from the source snapshot, so CLI status/start honors configured <code>browser.executablePath</code>, <code>headless</code>, and <code>noSandbox</code> instead of falling back to stale auto-detection. Fixes #75087; repairs #73617. Thanks @civiltox and @martingarramon.</li>
|
||||
<li>Agents/subagents: bound automatic orphan recovery with persisted recovery attempts and a wedged-session tombstone, and teach task maintenance/doctor to reconcile those sessions so restart loops no longer require manual <code>sessions.json</code> surgery. Fixes #74864. Thanks @solosage1.</li>
|
||||
<li>Plugins/runtime-deps: keep bundled provider policy config loading from staging plugin runtime dependencies, so config reads no longer fail on locked-down <code>/var/lib/openclaw/plugin-runtime-deps</code> directories. Fixes #74971. Thanks @eurojojo.</li>
|
||||
<li>Memory/runtime-deps: retain the native <code>node-llama-cpp</code> runtime only when local memory search is configured, so packaged installs can repair local embeddings without relying on unreachable global npm installs. Fixes #74777. Thanks @LLagoon3.</li>
|
||||
<li>Gateway/startup: skip pre-bind web-fetch provider discovery for credential-free <code>tools.web.fetch</code> config, so Docker/Kubernetes gateways bind even when optional fetch limits are present. Fixes #74896. Thanks @KoykL.</li>
|
||||
<li>Signal: match group allowlists against inbound Signal group ids as well as sender ids, and process explicitly configured Signal groups without requiring mentions unless <code>requireMention</code> is set. Fixes #53308. Thanks @minupla and @juan-flores077.</li>
|
||||
<li>Signal: bound <code>signal-cli</code> installer release and archive downloads with explicit timeouts, declared and streamed size checks, and partial-file cleanup. Fixes #54153. Thanks @jinduwang1001-max and @juan-flores077.</li>
|
||||
<li>Slack: require bot-authored room messages with <code>allowBots=true</code> to come from an explicitly channel-allowlisted bot or from a room where an explicit Slack owner is present, so broad bot relays cannot run unattended. Fixes #59284. Thanks @andrewhong-translucent.</li>
|
||||
<li>Signal: derive <code>getAttachment</code> HTTP response caps from <code>channels.signal.mediaMaxMb</code> with base64 headroom, so inbound photos and videos no longer drop behind the 1 MiB RPC default. Fixes #73564. Thanks @heyhudson.</li>
|
||||
<li>Signal: keep the long-lived receive SSE monitor open while idle instead of applying the 10s RPC/check deadline, so <code>signal-cli</code> 0.14.3 event streams no longer reconnect before inbound messages arrive. Fixes #74741. Thanks @fgabelmannjr and @k7n4n5t3w4rt.</li>
|
||||
<li>CLI/progress: suppress nested progress spinners and line clears while TUI input owns raw stdin, so Crestodian <code>/status</code> no longer disturbs the active input row. (#75003) Thanks @velvet-shark.</li>
|
||||
<li>Models/OpenAI Codex: restore <code>openai-codex/gpt-5.4-mini</code> for ChatGPT/Codex OAuth PI runs after live OAuth proof, and align the manifest, forward-compat metadata, docs, and regression tests so stale cron and heartbeat configs resolve again. Fixes #74451. Thanks @0xCyda, @hclsys, and @Marvae.</li>
|
||||
<li>Plugins/runtime-deps: always write a dependency map in generated runtime-deps install manifests, so npm does not crash or prune staged bundled-plugin packages when the plan is empty. Fixes #74949. Thanks @hclsys.</li>
|
||||
<li>Telegram: use durable message edits for streaming previews instead of native draft state, so generated replies no longer flicker through draft-to-message transitions that look like duplicates. (#75073) Thanks @obviyus.</li>
|
||||
<li>Telegram: echo preflighted DM voice-note transcripts back to the originating chat, including Telegram DM topic thread metadata, instead of only echoing later media-understanding transcripts. Fixes #75084. Thanks @M-Lietz.</li>
|
||||
<li>Telegram: clamp low long-polling client timeouts so configured <code>timeoutSeconds</code> values below the <code>getUpdates</code> poll window no longer force a fresh HTTPS connection every few seconds. Fixes #75114. Thanks @hpinho77.</li>
|
||||
<li>Web search: describe <code>web_search</code> as using the configured provider instead of hard-coding Brave when DuckDuckGo or another provider is active. Fixes #75088. Thanks @sun-rongyang.</li>
|
||||
<li>Infra/tmp: tolerate concurrent temp-dir permission repairs by rechecking directories that another process already tightened, so parallel ACP subprocess startup no longer throws <code>Unsafe fallback OpenClaw temp dir</code>. Fixes #66867. Thanks @Kane808-AI and @jarvisz8.</li>
|
||||
<li>Agents/compaction: add an opt-in <code>agents.defaults.compaction.midTurnPrecheck</code> mid-turn precheck that detects tool-loop context pressure and triggers compaction before the next tool call instead of waiting for end-of-turn. (#73499) Thanks @marchpure and @haoxingjun.</li>
|
||||
<li>Gateway/approvals: let loopback token/password-backed native approval clients resolve exec approvals without attaching stale paired Gateway identities, while remote and unauthenticated approval clients keep normal device identity behavior. (#74472)</li>
|
||||
<li>Gateway/config: include rejected validation paths in foreground and service last-known-good recovery logs plus main-agent notices, so unsupported direct edits explain which key caused restore instead of looking like silent reversion. Fixes #75060. Thanks @amknight.</li>
|
||||
<li>Plugins/runtime-deps: hash the OS-canonical <code>packageRoot</code> via <code>fs.realpathSync.native</code> (with <code>path.resolve</code> fallback) when computing the bundled runtime-deps stage key, so loader and channel <code>bundled-root</code> callers no longer derive divergent stage directories under <code>~/.openclaw/plugin-runtime-deps/openclaw-<version>-<hash>/</code> and bundled channels stop failing with <code>ENOENT</code> on shared dist chunks under Windows npm symlinks, junctions, or PM2 multi-instance worker layouts. Fixes #74963. (#75048) Thanks @openperf and @vincentkoc.</li>
|
||||
<li>fix(logging): add redaction patterns for Tencent Cloud, Alibaba Cloud, HuggingFace and Replicate API keys (#58162). Thanks @gavyngong</li>
|
||||
<li>Pairing: surface unexpected allowlist filesystem stat errors instead of treating the allowlist as missing, so permission and I/O failures are visible during pairing authorization checks. (#63324) Thanks @franciscomaestre.</li>
|
||||
<li>macOS app: reserve layout space for exec approval command details so the allow dialog no longer overlaps the command, context, and action buttons. (#75470) Thanks @ngutman.</li>
|
||||
<li>Agents/failover: carry <code>sessionId</code>, <code>lane</code>, <code>provider</code>, <code>model</code>, and <code>profileId</code> attribution through <code>FailoverError</code> and <code>describeFailoverError</code>/<code>coerceToFailoverError</code> so structured error logs (e.g. <code>gateway.err.log</code> ingestion) can attribute exhausted-fallback wrapper errors to the originating session and last-attempted provider instead of dropping the metadata after the per-profile errors. Fixes #42713. (#73506) Thanks @wenxu007.</li>
|
||||
<li>Context Engine: treat assembled prompt as the default authority for preemptive overflow prechecks so engines that return a windowed, self-contained context no longer trigger false hard-fail compactions on huge raw history. Engines whose assembled view can hide overflow risk can opt back into the legacy behavior with <code>AssembleResult.promptAuthority: "preassembly_may_overflow"</code>. (#74255) Thanks @100yenadmin.</li>
|
||||
<li>Mattermost: refresh current native slash command registrations before accepting callbacks so stale tokens from deleted or regenerated commands stop being accepted without a gateway restart while failed validations stay briefly cached and lookup starts are rate-limited per command, gate each callback against the resolved command's own startup token so a token leaked for one slash command cannot poison another command's failure cache, redact slash validation lookup errors, and add a body read timeout to the multi-account routing path so slow callback senders cannot tie up the dispatcher. Thanks @feynman-hou and @eleqtrizit.</li>
|
||||
<li>Security/dotenv: block <code>COMSPEC</code> in workspace <code>.env</code> so a malicious repo cannot redirect Windows <code>cmd.exe</code> resolution, and lock in case-insensitive workspace-<code>.env</code> regression coverage for the full Windows shell trust-root family (<code>COMSPEC</code>, <code>PROGRAMFILES</code>, <code>PROGRAMW6432</code>, <code>SYSTEMROOT</code>, <code>WINDIR</code>). (#74460) Thanks @mmaps.</li>
|
||||
<li>Gateway/install: drop stale version-manager and package-manager PATH entries preserved from old service files during <code>gateway install --force</code> and doctor repair, so the repair path no longer recreates <code>gateway-path-nonminimal</code> warnings. Fixes #75220. (#75440) Thanks @leonaIee, @renaudcerrato, and @aaajiao.</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.5.2/OpenClaw-2026.5.2.zip" length="51078259" type="application/octet-stream" sparkle:edSignature="NwoecacHxJOYpltNmB/y7LV5I8ZIh5pENWSydbOM1vsfgSrcb7pRP+Zm2nih1IAq7hh1tOmQ0XWnsohic7U4DA=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026051700
|
||||
versionName = "2026.5.17"
|
||||
versionCode = 2026051900
|
||||
versionName = "2026.5.19"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -118,6 +118,8 @@ class MainViewModel(
|
||||
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
|
||||
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
|
||||
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
|
||||
val talkModeConversation: StateFlow<List<VoiceConversationEntry>> =
|
||||
runtimeState(initial = emptyList()) { it.talkModeConversation }
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
|
||||
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
|
||||
|
||||
@@ -50,6 +50,7 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -425,6 +426,42 @@ class NodeRuntime(
|
||||
MicCaptureManager(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
createTranscriptionSession = {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("mode", JsonPrimitive("transcription"))
|
||||
put("transport", JsonPrimitive("gateway-relay"))
|
||||
put("brain", JsonPrimitive("none"))
|
||||
}
|
||||
val response =
|
||||
operatorSession.request(
|
||||
"talk.session.create",
|
||||
params.toString(),
|
||||
timeoutMs = 15_000,
|
||||
)
|
||||
parseTalkSessionId(response)
|
||||
},
|
||||
appendTranscriptionAudio = { sessionId, audio, onError ->
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionId", JsonPrimitive(sessionId))
|
||||
put("audioBase64", JsonPrimitive(Base64.encodeToString(audio, Base64.NO_WRAP)))
|
||||
put("timestamp", JsonPrimitive(SystemClock.elapsedRealtime()))
|
||||
}
|
||||
operatorSession.sendRequestFrame(
|
||||
"talk.session.appendAudio",
|
||||
params.toString(),
|
||||
timeoutMs = 8_000,
|
||||
) { error -> onError(error.message) }
|
||||
},
|
||||
closeTranscriptionSession = { sessionId ->
|
||||
val params = buildJsonObject { put("sessionId", JsonPrimitive(sessionId)) }
|
||||
operatorSession.request(
|
||||
"talk.session.close",
|
||||
params.toString(),
|
||||
timeoutMs = 5_000,
|
||||
)
|
||||
},
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
val idempotencyKey = UUID.randomUUID().toString()
|
||||
// Notify MicCaptureManager of the idempotency key *before* the network
|
||||
@@ -486,6 +523,7 @@ class NodeRuntime(
|
||||
isConnected = { operatorConnected },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
onStoppedByRelay = { finishTalkModeAfterRelayClose() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -501,6 +539,9 @@ class NodeRuntime(
|
||||
val talkModeStatusText: StateFlow<String>
|
||||
get() = talkMode.statusText
|
||||
|
||||
val talkModeConversation: StateFlow<List<VoiceConversationEntry>>
|
||||
get() = talkMode.conversation
|
||||
|
||||
private fun syncMainSessionKey(agentId: String?) {
|
||||
val resolvedKey = resolveNodeMainSessionKey(agentId)
|
||||
// Always push the resolved session key into TalkMode, even when the
|
||||
@@ -969,6 +1010,14 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishTalkModeAfterRelayClose() {
|
||||
if (_voiceCaptureMode.value != VoiceCaptureMode.TalkMode) return
|
||||
_voiceCaptureMode.value = VoiceCaptureMode.Off
|
||||
talkMode.ttsOnAllResponses = false
|
||||
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off)
|
||||
externalAudioCaptureActive.value = false
|
||||
}
|
||||
|
||||
val speakerEnabled: StateFlow<Boolean>
|
||||
get() = prefs.speakerEnabled
|
||||
|
||||
@@ -1410,6 +1459,17 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTalkSessionId(response: String): String {
|
||||
val root = json.parseToJsonElement(response).asObjectOrNull()
|
||||
val sessionId =
|
||||
root?.get("transcriptionSessionId").asStringOrNull()
|
||||
?: root?.get("sessionId").asStringOrNull()
|
||||
if (sessionId.isNullOrBlank()) {
|
||||
throw IllegalStateException("talk.session.create returned no session id")
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
private suspend fun refreshBrandingFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
@@ -1651,7 +1711,16 @@ internal fun resolveOperatorSessionConnectAuth(
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (explicitBootstrapToken != null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
token = null,
|
||||
bootstrapToken = null,
|
||||
password = null,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldConnectOperatorSession(
|
||||
|
||||
@@ -17,80 +17,148 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PermissionRequester(
|
||||
class PermissionRequester internal constructor(
|
||||
private val activity: ComponentActivity,
|
||||
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private data class PendingPermissionRequest(
|
||||
val deferred: CompletableDeferred<Map<String, Boolean>>,
|
||||
var timedOut: Boolean = false,
|
||||
)
|
||||
|
||||
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
val p = pending
|
||||
pending = null
|
||||
p?.complete(result)
|
||||
}
|
||||
private class PermissionRequestSlot(
|
||||
val launcher: ActivityResultLauncher<Array<String>>,
|
||||
var request: PendingPermissionRequest? = null,
|
||||
)
|
||||
|
||||
constructor(activity: ComponentActivity) : this(
|
||||
activity = activity,
|
||||
launcherFactory = { callback ->
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), callback)
|
||||
},
|
||||
)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val requestSlotsLock = Any()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
|
||||
|
||||
suspend fun requestIfMissing(
|
||||
permissions: List<String>,
|
||||
timeoutMs: Long = 20_000,
|
||||
): Map<String, Boolean> =
|
||||
mutex.withLock {
|
||||
val missing =
|
||||
permissions.filter { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||
): Map<String, Boolean> {
|
||||
return mutex.withLock {
|
||||
while (true) {
|
||||
val missing =
|
||||
permissions.filter { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return permissions.associateWith { true }
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return permissions.associateWith { true }
|
||||
}
|
||||
|
||||
val needsRationale =
|
||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||
if (needsRationale) {
|
||||
val proceed = showRationaleDialog(missing)
|
||||
if (!proceed) {
|
||||
return permissions.associateWith { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
val needsRationale =
|
||||
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
|
||||
if (needsRationale) {
|
||||
val proceed = showRationaleDialog(missing)
|
||||
if (!proceed) {
|
||||
return permissions.associateWith { perm ->
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||
pending = deferred
|
||||
withContext(Dispatchers.Main) {
|
||||
launcher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.Default) {
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||
val deferred = CompletableDeferred<Map<String, Boolean>>()
|
||||
val request = PendingPermissionRequest(deferred)
|
||||
val slot = reservePermissionRequestSlot(request)
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
slot.launcher.launch(missing.toTypedArray())
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clearPermissionRequestSlot(slot, request)
|
||||
throw err
|
||||
}
|
||||
|
||||
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
result[perm] == true || nowGranted
|
||||
val result =
|
||||
try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
request.timedOut = true
|
||||
throw err
|
||||
}
|
||||
|
||||
val merged =
|
||||
permissions.associateWith { perm ->
|
||||
val nowGranted =
|
||||
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
|
||||
result[perm] == true || nowGranted
|
||||
}
|
||||
|
||||
val denied =
|
||||
merged.filterValues { !it }.keys.filter {
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||
}
|
||||
if (denied.isNotEmpty()) {
|
||||
showSettingsDialog(denied)
|
||||
}
|
||||
|
||||
val denied =
|
||||
merged.filterValues { !it }.keys.filter {
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
|
||||
}
|
||||
if (denied.isNotEmpty()) {
|
||||
showSettingsDialog(denied)
|
||||
return merged
|
||||
}
|
||||
|
||||
return merged
|
||||
error("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPermissionRequestSlot(
|
||||
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
|
||||
): PermissionRequestSlot {
|
||||
var slot: PermissionRequestSlot? = null
|
||||
val launcher = launcherFactory { result -> completePermissionRequest(checkNotNull(slot), result) }
|
||||
val created = PermissionRequestSlot(launcher)
|
||||
slot = created
|
||||
return created
|
||||
}
|
||||
|
||||
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
|
||||
synchronized(requestSlotsLock) {
|
||||
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
|
||||
slot.request = request
|
||||
slot
|
||||
}
|
||||
|
||||
private fun completePermissionRequest(
|
||||
slot: PermissionRequestSlot,
|
||||
result: Map<String, Boolean>,
|
||||
) {
|
||||
val request =
|
||||
synchronized(requestSlotsLock) {
|
||||
slot.request.also {
|
||||
slot.request = null
|
||||
}
|
||||
} ?: return
|
||||
if (request.timedOut) return
|
||||
request.deferred.complete(result)
|
||||
}
|
||||
|
||||
private fun clearPermissionRequestSlot(
|
||||
slot: PermissionRequestSlot,
|
||||
request: PendingPermissionRequest,
|
||||
) {
|
||||
synchronized(requestSlotsLock) {
|
||||
if (slot.request === request) {
|
||||
slot.request = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -149,7 +149,10 @@ class GatewaySession(
|
||||
val tls: GatewayTlsParams?,
|
||||
)
|
||||
|
||||
private var desired: DesiredConnection? = null
|
||||
private val lifecycleLock = Any()
|
||||
|
||||
@Volatile private var desired: DesiredConnection? = null
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
@Volatile private var currentConnection: Connection? = null
|
||||
@@ -168,26 +171,39 @@ class GatewaySession(
|
||||
options: GatewayConnectOptions,
|
||||
tls: GatewayTlsParams? = null,
|
||||
) {
|
||||
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
if (job == null) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
val connectionToClose: Connection?
|
||||
synchronized(lifecycleLock) {
|
||||
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
connectionToClose = currentConnection
|
||||
if (job?.isActive != true) {
|
||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||
}
|
||||
}
|
||||
connectionToClose?.closeQuietly()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
desired = null
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
currentConnection?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
job?.cancelAndJoin()
|
||||
val jobToCancel: Job?
|
||||
val connectionToClose: Connection?
|
||||
synchronized(lifecycleLock) {
|
||||
desired = null
|
||||
pendingDeviceTokenRetry = false
|
||||
deviceTokenRetryBudgetUsed = false
|
||||
reconnectPausedForAuthFailure = false
|
||||
connectionToClose = currentConnection
|
||||
jobToCancel = job
|
||||
job = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
connectionToClose?.closeQuietly()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
jobToCancel?.cancelAndJoin()
|
||||
if (desired == null) {
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
onDisconnected("Offline")
|
||||
}
|
||||
}
|
||||
@@ -316,6 +332,22 @@ class GatewaySession(
|
||||
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
|
||||
}
|
||||
|
||||
suspend fun sendRequestFrame(
|
||||
method: String,
|
||||
paramsJson: String?,
|
||||
timeoutMs: Long = 15_000,
|
||||
onError: (ErrorShape) -> Unit = {},
|
||||
) {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val params =
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
json.parseToJsonElement(paramsJson)
|
||||
}
|
||||
conn.sendRequestFrame(method = method, params = params, timeoutMs = timeoutMs, onError = onError)
|
||||
}
|
||||
|
||||
private data class RpcResponse(
|
||||
val id: String,
|
||||
val ok: Boolean,
|
||||
@@ -360,14 +392,12 @@ class GatewaySession(
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
val frame =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
sendJson(frame)
|
||||
try {
|
||||
sendJson(buildRequestFrame(id = id, method = method, params = params))
|
||||
} catch (err: Throwable) {
|
||||
pending.remove(id)
|
||||
throw err
|
||||
}
|
||||
return try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
@@ -376,13 +406,57 @@ class GatewaySession(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendRequestFrame(
|
||||
method: String,
|
||||
params: JsonElement?,
|
||||
timeoutMs: Long,
|
||||
onError: (ErrorShape) -> Unit,
|
||||
) {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val deferred = CompletableDeferred<RpcResponse>()
|
||||
pending[id] = deferred
|
||||
try {
|
||||
sendJson(buildRequestFrame(id = id, method = method, params = params))
|
||||
} catch (err: Throwable) {
|
||||
pending.remove(id)
|
||||
throw err
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val response =
|
||||
try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pending.remove(id)
|
||||
onError(ErrorShape("UNAVAILABLE", "request timeout"))
|
||||
return@launch
|
||||
}
|
||||
if (!response.ok) {
|
||||
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendJson(obj: JsonObject) {
|
||||
val jsonString = obj.toString()
|
||||
writeLock.withLock {
|
||||
socket?.send(jsonString)
|
||||
if (socket?.send(jsonString) != true) {
|
||||
throw IllegalStateException("gateway send failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRequestFrame(
|
||||
id: String,
|
||||
method: String,
|
||||
params: JsonElement?,
|
||||
): JsonObject =
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("req"))
|
||||
put("id", JsonPrimitive(id))
|
||||
put("method", JsonPrimitive(method))
|
||||
if (params != null) put("params", params)
|
||||
}
|
||||
|
||||
suspend fun awaitClose() = closedDeferred.await()
|
||||
|
||||
fun closeQuietly() {
|
||||
@@ -905,9 +979,11 @@ class GatewaySession(
|
||||
conn.connect()
|
||||
conn.awaitClose()
|
||||
} finally {
|
||||
currentConnection = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
if (currentConnection === conn) {
|
||||
currentConnection = null
|
||||
pluginSurfaceUrls = emptyMap()
|
||||
mainSessionKey = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,8 +1166,10 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
|
||||
role?.trim() == "node" &&
|
||||
scopes.isEmpty() &&
|
||||
error.details.reason == "not-paired" &&
|
||||
(error.details.pauseReconnect == false ||
|
||||
error.details.recommendedNextStep == "wait_then_retry")
|
||||
(
|
||||
error.details.pauseReconnect == false ||
|
||||
error.details.recommendedNextStep == "wait_then_retry"
|
||||
)
|
||||
)
|
||||
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
|
||||
else -> false
|
||||
|
||||
@@ -27,28 +27,23 @@ internal object JpegSizeLimiter {
|
||||
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
|
||||
require(maxBytes > 0) { "Invalid maxBytes" }
|
||||
|
||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
||||
var width = initialWidth
|
||||
var height = initialHeight
|
||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
||||
var best =
|
||||
JpegSizeLimiterResult(
|
||||
bytes = encode(width, height, clampedStartQuality),
|
||||
width = width,
|
||||
height = height,
|
||||
quality = clampedStartQuality,
|
||||
)
|
||||
if (best.bytes.size <= maxBytes) return best
|
||||
var best: JpegSizeLimiterResult? = null
|
||||
|
||||
repeat(maxScaleAttempts) {
|
||||
repeat(maxScaleAttempts + 1) { scaleAttempt ->
|
||||
var quality = clampedStartQuality
|
||||
repeat(maxQualityAttempts) {
|
||||
val bytes = encode(width, height, quality)
|
||||
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
||||
val attempt = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
||||
best = attempt
|
||||
if (bytes.size <= maxBytes) return best
|
||||
if (quality <= minQuality) return@repeat
|
||||
quality = max(minQuality, (quality * 0.75).roundToInt())
|
||||
}
|
||||
|
||||
if (scaleAttempt == maxScaleAttempts) return@repeat
|
||||
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
|
||||
val nextScale = max(scaleStep, minScale)
|
||||
val nextWidth = max(minSize, (width * nextScale).roundToInt())
|
||||
@@ -58,10 +53,11 @@ internal object JpegSizeLimiter {
|
||||
height = min(nextHeight, height)
|
||||
}
|
||||
|
||||
if (best.bytes.size > maxBytes) {
|
||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
|
||||
val failed = checkNotNull(best)
|
||||
if (failed.bytes.size > maxBytes) {
|
||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${failed.bytes.size} bytes > $maxBytes bytes")
|
||||
}
|
||||
|
||||
return best
|
||||
return failed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,27 +143,15 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
|
||||
?.trim()
|
||||
?.lowercase(Locale.US)
|
||||
.orEmpty()
|
||||
val tls =
|
||||
when (scheme) {
|
||||
"ws", "http" -> false
|
||||
"wss", "https" -> true
|
||||
else -> true
|
||||
}
|
||||
if (scheme !in setOf("ws", "wss", "http", "https")) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
}
|
||||
val tls = scheme == "wss" || scheme == "https"
|
||||
if (!tls && !isLoopbackGatewayHost(host)) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
|
||||
}
|
||||
val defaultPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 18789
|
||||
else -> 443
|
||||
}
|
||||
val displayPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 80
|
||||
else -> 443
|
||||
}
|
||||
val defaultPort = if (tls) 443 else 18789
|
||||
val displayPort = if (tls) 443 else 80
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
|
||||
val displayHost = if (host.contains(":")) "[$host]" else host
|
||||
val displayUrl =
|
||||
|
||||
@@ -96,8 +96,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
|
||||
|
||||
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
|
||||
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
|
||||
val hasStreamingAssistant = activeConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
|
||||
val showThinkingBubble = micIsSending && !hasStreamingAssistant
|
||||
|
||||
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
|
||||
@@ -131,8 +133,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
pendingVoicePermissionAction = null
|
||||
}
|
||||
|
||||
LaunchedEffect(micConversation.size, showThinkingBubble) {
|
||||
val total = micConversation.size + if (showThinkingBubble) 1 else 0
|
||||
LaunchedEffect(voiceCaptureMode, activeConversation.size, showThinkingBubble) {
|
||||
val total = activeConversation.size + if (showThinkingBubble) 1 else 0
|
||||
if (total > 0) {
|
||||
listState.animateScrollToItem(total - 1)
|
||||
}
|
||||
@@ -154,7 +156,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
contentPadding = PaddingValues(vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (micConversation.isEmpty() && !showThinkingBubble) {
|
||||
if (activeConversation.isEmpty() && !showThinkingBubble) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
|
||||
@@ -185,7 +187,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
items(items = micConversation, key = { it.id }) { entry ->
|
||||
items(items = activeConversation, key = { it.id }) { entry ->
|
||||
VoiceTurnBubble(entry = entry)
|
||||
}
|
||||
|
||||
@@ -347,10 +349,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "Talk speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Talk listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk on"
|
||||
micEnabled || micIsSending || micCooldown -> micStatusText
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micCooldown -> "Cooldown"
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
val stateColor =
|
||||
|
||||
@@ -13,7 +13,7 @@ import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
|
||||
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
|
||||
internal const val CHAT_IMAGE_MAX_BASE64_CHARS = 300 * 1024
|
||||
private const val CHAT_ATTACHMENT_START_QUALITY = 85
|
||||
private const val CHAT_DECODE_MAX_DIMENSION = 1600
|
||||
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
|
||||
@@ -35,7 +35,7 @@ internal fun loadSizedImageAttachment(
|
||||
if (bitmap == null) {
|
||||
throw IllegalStateException("unsupported attachment")
|
||||
}
|
||||
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
|
||||
val maxBytes = (CHAT_IMAGE_MAX_BASE64_CHARS / 4) * 3
|
||||
val encoded =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = bitmap.width,
|
||||
|
||||
@@ -75,10 +75,13 @@ import org.commonmark.node.SoftLineBreak
|
||||
import org.commonmark.node.StrongEmphasis
|
||||
import org.commonmark.node.ThematicBreak
|
||||
import org.commonmark.parser.Parser
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
import org.commonmark.node.Image as MarkdownImage
|
||||
import org.commonmark.node.Text as MarkdownTextNode
|
||||
|
||||
private const val LIST_INDENT_DP = 14
|
||||
private const val DATA_IMAGE_HEADER_MAX_CHARS = 64
|
||||
private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$")
|
||||
|
||||
private val markdownParser: Parser by lazy {
|
||||
@@ -547,15 +550,13 @@ private fun AnnotatedString.Builder.appendLinkNode(
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
)
|
||||
if (destination.isEmpty()) {
|
||||
withStyle(linkStyle) {
|
||||
appendInlineNode(
|
||||
link.firstChild,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
}
|
||||
if (destination.isEmpty() || !isSafeMarkdownLinkDestination(destination)) {
|
||||
appendInlineNode(
|
||||
link.firstChild,
|
||||
inlineCodeBg = inlineCodeBg,
|
||||
inlineCodeColor = inlineCodeColor,
|
||||
linkColor = linkColor,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -569,6 +570,14 @@ private fun AnnotatedString.Builder.appendLinkNode(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSafeMarkdownLinkDestination(destination: String): Boolean {
|
||||
val scheme =
|
||||
runCatching { URI(destination).scheme?.lowercase(Locale.US) }
|
||||
.getOrNull()
|
||||
?: return false
|
||||
return scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
internal fun buildChatInlineMarkdown(
|
||||
text: String,
|
||||
linkColor: Color = Color.Blue,
|
||||
@@ -606,9 +615,10 @@ private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
|
||||
return parseDataImageDestination(only.destination)
|
||||
}
|
||||
|
||||
private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
internal fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
val raw = destination?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return null
|
||||
if (raw.length > CHAT_IMAGE_MAX_BASE64_CHARS + DATA_IMAGE_HEADER_MAX_CHARS) return null
|
||||
val match = dataImageRegex.matchEntire(raw) ?: return null
|
||||
val subtype =
|
||||
match.groupValues
|
||||
@@ -623,6 +633,7 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (base64.isEmpty()) return null
|
||||
if (base64.length > CHAT_IMAGE_MAX_BASE64_CHARS) return null
|
||||
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
|
||||
}
|
||||
|
||||
@@ -650,7 +661,7 @@ private data class TableRenderRow(
|
||||
val cells: List<AnnotatedString>,
|
||||
)
|
||||
|
||||
private data class ParsedDataImage(
|
||||
internal data class ParsedDataImage(
|
||||
val mimeType: String,
|
||||
val base64: String,
|
||||
)
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
enum class VoiceConversationRole {
|
||||
User,
|
||||
@@ -40,6 +41,13 @@ data class VoiceConversationEntry(
|
||||
class MicCaptureManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val createTranscriptionSession: suspend () -> String,
|
||||
private val appendTranscriptionAudio: suspend (
|
||||
sessionId: String,
|
||||
audio: ByteArray,
|
||||
onError: (String) -> Unit,
|
||||
) -> Unit,
|
||||
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
|
||||
/**
|
||||
* Send [message] to the gateway and return the run ID.
|
||||
* [onRunIdKnown] is called with the idempotency key *before* the network
|
||||
@@ -50,15 +58,15 @@ class MicCaptureManager(
|
||||
) {
|
||||
companion object {
|
||||
private const val tag = "MicCapture"
|
||||
private const val speechMinSessionMs = 30_000L
|
||||
private const val speechCompleteSilenceMs = 1_500L
|
||||
private const val speechPossibleSilenceMs = 900L
|
||||
private const val transcriptionSampleRateHz = 8_000
|
||||
private const val transcriptionAudioFrameMs = 100
|
||||
private const val pcmuBias = 0x84
|
||||
private const val pcmuClip = 32635
|
||||
private const val transcriptIdleFlushMs = 1_600L
|
||||
private const val maxConversationEntries = 40
|
||||
private const val pendingRunTimeoutMs = 45_000L
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val _micEnabled = MutableStateFlow(false)
|
||||
@@ -95,9 +103,11 @@ class MicCaptureManager(
|
||||
private var pendingAssistantEntryId: String? = null
|
||||
private var gatewayConnected = false
|
||||
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var restartJob: Job? = null
|
||||
private var drainJob: Job? = null
|
||||
@Volatile private var transcriptionSessionId: String? = null
|
||||
private var transcriptionStartJob: Job? = null
|
||||
private var transcriptionCaptureJob: Job? = null
|
||||
private var transcriptionAppendJob: Job? = null
|
||||
private var transcriptionDrainJob: Job? = null
|
||||
private var transcriptFlushJob: Job? = null
|
||||
private var pendingRunTimeoutJob: Job? = null
|
||||
private var stopRequested = false
|
||||
@@ -153,23 +163,23 @@ class MicCaptureManager(
|
||||
_statusText.value = if (_isSending.value) "Speaking · waiting for reply" else "Speaking…"
|
||||
return
|
||||
}
|
||||
transcriptionDrainJob?.cancel()
|
||||
transcriptionDrainJob = null
|
||||
_micCooldown.value = false
|
||||
start()
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
// Give the recognizer time to finish processing buffered audio.
|
||||
// Cancel any prior drain to prevent duplicate sends on rapid toggle.
|
||||
drainJob?.cancel()
|
||||
transcriptionDrainJob?.cancel()
|
||||
_micCooldown.value = true
|
||||
drainJob =
|
||||
transcriptionDrainJob =
|
||||
scope.launch {
|
||||
delay(2000L)
|
||||
stop()
|
||||
// Capture any partial transcript that didn't get a final result from the recognizer
|
||||
val partial = _liveTranscript.value?.trim().orEmpty()
|
||||
if (partial.isNotEmpty()) {
|
||||
queueRecognizedMessage(partial)
|
||||
}
|
||||
drainJob = null
|
||||
transcriptionDrainJob = null
|
||||
_micCooldown.value = false
|
||||
sendQueuedIfIdle()
|
||||
}
|
||||
@@ -182,11 +192,9 @@ class MicCaptureManager(
|
||||
ttsPauseDepth += 1
|
||||
if (ttsPauseDepth > 1) return@synchronized false
|
||||
resumeMicAfterTts = _micEnabled.value
|
||||
val active = resumeMicAfterTts || recognizer != null || _isListening.value
|
||||
val active = resumeMicAfterTts || transcriptionSessionId != null || _isListening.value
|
||||
if (!active) return@synchronized false
|
||||
stopRequested = true
|
||||
restartJob?.cancel()
|
||||
restartJob = null
|
||||
transcriptFlushJob?.cancel()
|
||||
transcriptFlushJob = null
|
||||
_isListening.value = false
|
||||
@@ -196,11 +204,7 @@ class MicCaptureManager(
|
||||
true
|
||||
}
|
||||
if (!shouldPause) return
|
||||
withContext(Dispatchers.Main) {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
}
|
||||
stopTranscription(preserveStatus = true)
|
||||
}
|
||||
|
||||
suspend fun resumeAfterTts() {
|
||||
@@ -231,9 +235,14 @@ class MicCaptureManager(
|
||||
fun onGatewayConnectionChanged(connected: Boolean) {
|
||||
gatewayConnected = connected
|
||||
if (connected) {
|
||||
if (_micEnabled.value && transcriptionSessionId == null) {
|
||||
start()
|
||||
}
|
||||
sendQueuedIfIdle()
|
||||
return
|
||||
}
|
||||
stopRequested = true
|
||||
stopTranscription(preserveStatus = true)
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
pendingRunId = null
|
||||
@@ -248,6 +257,10 @@ class MicCaptureManager(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
) {
|
||||
if (event == "talk.event") {
|
||||
handleTranscriptionEvent(payloadJson)
|
||||
return
|
||||
}
|
||||
if (event != "chat") return
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
val payload =
|
||||
@@ -304,93 +317,96 @@ class MicCaptureManager(
|
||||
|
||||
private fun start() {
|
||||
stopRequested = false
|
||||
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
_statusText.value = "Speech recognizer unavailable"
|
||||
_micEnabled.value = false
|
||||
return
|
||||
}
|
||||
if (!hasMicPermission()) {
|
||||
_statusText.value = "Microphone permission required"
|
||||
_micEnabled.value = false
|
||||
return
|
||||
}
|
||||
|
||||
mainHandler.post {
|
||||
try {
|
||||
if (recognizer == null) {
|
||||
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
|
||||
}
|
||||
startListeningSession()
|
||||
} catch (err: Throwable) {
|
||||
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
|
||||
_micEnabled.value = false
|
||||
}
|
||||
if (!gatewayConnected) {
|
||||
_statusText.value = "Mic on · waiting for gateway"
|
||||
return
|
||||
}
|
||||
if (transcriptionSessionId != null || transcriptionStartJob?.isActive == true) return
|
||||
|
||||
val startJob =
|
||||
scope.launch {
|
||||
var restartAfterCancellation = false
|
||||
try {
|
||||
val sessionId = createTranscriptionSession()
|
||||
if (stopRequested || !_micEnabled.value) {
|
||||
closeTranscriptionSession(sessionId)
|
||||
return@launch
|
||||
}
|
||||
transcriptionSessionId = sessionId
|
||||
_isListening.value = true
|
||||
_statusText.value = listeningStatus()
|
||||
startTranscriptionCapture(sessionId)
|
||||
Log.d(tag, "transcription session started sessionId=$sessionId")
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) {
|
||||
restartAfterCancellation = _micEnabled.value && gatewayConnected && !stopRequested
|
||||
return@launch
|
||||
}
|
||||
_statusText.value = "Transcription unavailable: ${err.message ?: err::class.simpleName}"
|
||||
_micEnabled.value = false
|
||||
stopTranscription(preserveStatus = true)
|
||||
} finally {
|
||||
if (transcriptionStartJob === coroutineContext[Job]) {
|
||||
transcriptionStartJob = null
|
||||
}
|
||||
if (restartAfterCancellation) {
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
transcriptionStartJob = startJob
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
stopRequested = true
|
||||
restartJob?.cancel()
|
||||
restartJob = null
|
||||
stopTranscription()
|
||||
}
|
||||
|
||||
private fun stopTranscription(preserveStatus: Boolean = false) {
|
||||
val status = _statusText.value
|
||||
val sessionId = transcriptionSessionId
|
||||
transcriptionSessionId = null
|
||||
if (sessionId != null) {
|
||||
transcriptionStartJob?.cancel()
|
||||
transcriptionStartJob = null
|
||||
} else if (transcriptionStartJob?.isActive != true) {
|
||||
transcriptionStartJob = null
|
||||
}
|
||||
transcriptionCaptureJob?.cancel()
|
||||
transcriptionAppendJob?.cancel()
|
||||
transcriptionCaptureJob = null
|
||||
transcriptionAppendJob = null
|
||||
transcriptFlushJob?.cancel()
|
||||
transcriptFlushJob = null
|
||||
_isListening.value = false
|
||||
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
|
||||
_inputLevel.value = 0f
|
||||
mainHandler.post {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
if (!preserveStatus) {
|
||||
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
|
||||
} else {
|
||||
_statusText.value = status
|
||||
}
|
||||
}
|
||||
|
||||
private fun startListeningSession() {
|
||||
val recognizerInstance = recognizer ?: return
|
||||
val intent =
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
|
||||
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs)
|
||||
putExtra(
|
||||
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
|
||||
speechPossibleSilenceMs,
|
||||
)
|
||||
}
|
||||
_statusText.value =
|
||||
when {
|
||||
_isSending.value -> "Listening · sending queued voice"
|
||||
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
|
||||
else -> "Listening"
|
||||
}
|
||||
_isListening.value = true
|
||||
recognizerInstance.startListening(intent)
|
||||
}
|
||||
|
||||
private fun scheduleRestart(delayMs: Long = 300L) {
|
||||
if (stopRequested) return
|
||||
if (!_micEnabled.value) return
|
||||
restartJob?.cancel()
|
||||
restartJob =
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
scope.launch {
|
||||
delay(delayMs)
|
||||
mainHandler.post {
|
||||
if (stopRequested || !_micEnabled.value) return@post
|
||||
try {
|
||||
startListeningSession()
|
||||
} catch (_: Throwable) {
|
||||
// retry through onError
|
||||
try {
|
||||
closeTranscriptionSession(sessionId)
|
||||
} catch (err: Throwable) {
|
||||
if (err !is CancellationException) {
|
||||
Log.d(tag, "transcription close ignored: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueRecognizedMessage(text: String) {
|
||||
val message = text.trim()
|
||||
_liveTranscript.value = null
|
||||
if (message.isEmpty()) return
|
||||
if (!message.hasTranscriptContent()) return
|
||||
appendConversation(
|
||||
role = VoiceConversationRole.User,
|
||||
text = message,
|
||||
@@ -572,23 +588,210 @@ class MicCaptureManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun disableMic(status: String) {
|
||||
stopRequested = true
|
||||
restartJob?.cancel()
|
||||
restartJob = null
|
||||
transcriptFlushJob?.cancel()
|
||||
transcriptFlushJob = null
|
||||
_micEnabled.value = false
|
||||
_isListening.value = false
|
||||
_inputLevel.value = 0f
|
||||
_statusText.value = status
|
||||
mainHandler.post {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startTranscriptionCapture(sessionId: String) {
|
||||
transcriptionCaptureJob?.cancel()
|
||||
transcriptionAppendJob?.cancel()
|
||||
val audioFrames =
|
||||
Channel<ByteArray>(
|
||||
capacity = 4,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
transcriptionAppendJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
for (frame in audioFrames) {
|
||||
if (transcriptionSessionId != sessionId) continue
|
||||
try {
|
||||
appendTranscriptionAudio(sessionId, pcm16ToPcmu(frame)) { message ->
|
||||
failTranscription(sessionId, message)
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) throw err
|
||||
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "request failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
transcriptionCaptureJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
var audioRecord: AudioRecord? = null
|
||||
try {
|
||||
val frameBytes = transcriptionSampleRateHz * 2 * transcriptionAudioFrameMs / 1000
|
||||
val minBuffer =
|
||||
AudioRecord.getMinBufferSize(
|
||||
transcriptionSampleRateHz,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
if (minBuffer <= 0) {
|
||||
throw IllegalStateException("AudioRecord buffer unavailable")
|
||||
}
|
||||
audioRecord =
|
||||
AudioRecord
|
||||
.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
|
||||
.setAudioFormat(
|
||||
AudioFormat
|
||||
.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(transcriptionSampleRateHz)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build(),
|
||||
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
|
||||
.build()
|
||||
val buffer = ByteArray(frameBytes)
|
||||
audioRecord.startRecording()
|
||||
while (coroutineContext.isActive && _micEnabled.value && transcriptionSessionId == sessionId) {
|
||||
val read = audioRecord.read(buffer, 0, buffer.size)
|
||||
if (read <= 0) continue
|
||||
_inputLevel.value = pcm16Level(buffer, read)
|
||||
audioFrames.trySend(buffer.copyOf(read))
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) throw err
|
||||
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
|
||||
} finally {
|
||||
audioFrames.close()
|
||||
audioRecord?.let { record ->
|
||||
try {
|
||||
record.stop()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
record.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTranscriptionEvent(payloadJson: String?) {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
val obj =
|
||||
try {
|
||||
json.parseToJsonElement(payloadJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return
|
||||
val sessionId = obj["transcriptionSessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull()
|
||||
val currentSessionId = transcriptionSessionId
|
||||
if (currentSessionId == null || sessionId != currentSessionId) return
|
||||
|
||||
when (obj["type"].asStringOrNull()) {
|
||||
"ready", "inputAudio", "speechStart" -> {
|
||||
_isListening.value = true
|
||||
_statusText.value = listeningStatus()
|
||||
}
|
||||
"partial" -> {
|
||||
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
_liveTranscript.value = text
|
||||
scheduleTranscriptFlush(text)
|
||||
}
|
||||
}
|
||||
"transcript" -> {
|
||||
transcriptFlushJob?.cancel()
|
||||
transcriptFlushJob = null
|
||||
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
if (text != flushedPartialTranscript) {
|
||||
queueRecognizedMessage(text)
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
flushedPartialTranscript = null
|
||||
_liveTranscript.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
val message =
|
||||
obj["message"]
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { "transcription failed" }
|
||||
failTranscription(currentSessionId, message)
|
||||
}
|
||||
"close" -> {
|
||||
_micEnabled.value = false
|
||||
stopTranscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun failTranscription(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
) {
|
||||
if (transcriptionSessionId != sessionId) return
|
||||
_statusText.value = "Transcription failed: $message"
|
||||
_micEnabled.value = false
|
||||
stopTranscription(preserveStatus = true)
|
||||
}
|
||||
|
||||
private fun listeningStatus(): String =
|
||||
when {
|
||||
_isSending.value -> "Listening · sending queued voice"
|
||||
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
|
||||
else -> "Listening"
|
||||
}
|
||||
|
||||
private fun pcm16Level(
|
||||
frame: ByteArray,
|
||||
length: Int,
|
||||
): Float {
|
||||
var total = 0L
|
||||
var count = 0
|
||||
var index = 0
|
||||
val limit = length - (length % 2)
|
||||
while (index < limit) {
|
||||
val sample =
|
||||
(frame[index].toInt() and 0xff) or
|
||||
(frame[index + 1].toInt() shl 8)
|
||||
total += kotlin.math.abs(sample.toShort().toInt())
|
||||
count += 1
|
||||
index += 2
|
||||
}
|
||||
if (count == 0) return 0f
|
||||
return ((total / count).toFloat() / Short.MAX_VALUE).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun pcm16ToPcmu(pcm16: ByteArray): ByteArray {
|
||||
val output = ByteArray(pcm16.size / 2)
|
||||
var inputIndex = 0
|
||||
var outputIndex = 0
|
||||
while (inputIndex + 1 < pcm16.size) {
|
||||
val sample =
|
||||
(
|
||||
(pcm16[inputIndex].toInt() and 0xff) or
|
||||
(pcm16[inputIndex + 1].toInt() shl 8)
|
||||
).toShort().toInt()
|
||||
output[outputIndex] = linear16ToPcmu(sample)
|
||||
inputIndex += 2
|
||||
outputIndex += 1
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private fun linear16ToPcmu(sample: Int): Byte {
|
||||
var sign = 0
|
||||
var magnitude = sample
|
||||
if (magnitude < 0) {
|
||||
sign = 0x80
|
||||
magnitude = -magnitude
|
||||
}
|
||||
if (magnitude > pcmuClip) {
|
||||
magnitude = pcmuClip
|
||||
}
|
||||
magnitude += pcmuBias
|
||||
|
||||
var exponent = 7
|
||||
var mask = 0x4000
|
||||
while ((magnitude and mask) == 0 && exponent > 0) {
|
||||
exponent -= 1
|
||||
mask = mask shr 1
|
||||
}
|
||||
val mantissa = (magnitude shr (exponent + 3)) and 0x0f
|
||||
return (sign or (exponent shl 4) or mantissa).inv().toByte()
|
||||
}
|
||||
|
||||
private fun hasMicPermission(): Boolean =
|
||||
(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
@@ -596,103 +799,10 @@ class MicCaptureManager(
|
||||
)
|
||||
|
||||
private fun parseAssistantText(payload: JsonObject): String? = ChatEventText.assistantTextFromPayload(payload)
|
||||
|
||||
private val listener =
|
||||
object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
_isListening.value = true
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {
|
||||
val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
|
||||
_inputLevel.value = level
|
||||
}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
|
||||
override fun onEndOfSpeech() {
|
||||
_inputLevel.value = 0f
|
||||
scheduleRestart()
|
||||
}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
if (stopRequested) return
|
||||
_isListening.value = false
|
||||
_inputLevel.value = 0f
|
||||
val status =
|
||||
when (error) {
|
||||
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
|
||||
SpeechRecognizer.ERROR_CLIENT -> "Client error"
|
||||
SpeechRecognizer.ERROR_NETWORK -> "Network error"
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
|
||||
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
|
||||
SpeechRecognizer.ERROR_SERVER -> "Server error"
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
|
||||
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
|
||||
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device"
|
||||
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device"
|
||||
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected"
|
||||
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying"
|
||||
else -> "Speech error ($error)"
|
||||
}
|
||||
_statusText.value = status
|
||||
|
||||
if (
|
||||
error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ||
|
||||
error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED ||
|
||||
error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE
|
||||
) {
|
||||
disableMic(status)
|
||||
return
|
||||
}
|
||||
|
||||
val restartDelayMs =
|
||||
when (error) {
|
||||
SpeechRecognizer.ERROR_NO_MATCH,
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
|
||||
-> 1_200L
|
||||
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L
|
||||
else -> 600L
|
||||
}
|
||||
scheduleRestart(delayMs = restartDelayMs)
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
transcriptFlushJob?.cancel()
|
||||
transcriptFlushJob = null
|
||||
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
|
||||
if (!text.isNullOrBlank()) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed != flushedPartialTranscript) {
|
||||
queueRecognizedMessage(trimmed)
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
flushedPartialTranscript = null
|
||||
_liveTranscript.value = null
|
||||
}
|
||||
}
|
||||
scheduleRestart()
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
|
||||
if (!text.isNullOrBlank()) {
|
||||
val trimmed = text.trim()
|
||||
_liveTranscript.value = trimmed
|
||||
scheduleTranscriptFlush(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
eventType: Int,
|
||||
params: Bundle?,
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
private fun String.hasTranscriptContent(): Boolean = any { it.isLetterOrDigit() }
|
||||
|
||||
@@ -11,8 +11,14 @@ internal data class TalkModeGatewayConfigState(
|
||||
val mainSessionKey: String,
|
||||
val interruptOnSpeech: Boolean?,
|
||||
val silenceTimeoutMs: Long,
|
||||
val executionMode: TalkModeExecutionMode,
|
||||
)
|
||||
|
||||
internal enum class TalkModeExecutionMode {
|
||||
Native,
|
||||
RealtimeRelay,
|
||||
}
|
||||
|
||||
internal object TalkModeGatewayConfigParser {
|
||||
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
@@ -21,9 +27,22 @@ internal object TalkModeGatewayConfigParser {
|
||||
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
|
||||
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
|
||||
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
|
||||
executionMode = resolvedExecutionMode(talk),
|
||||
)
|
||||
}
|
||||
|
||||
fun resolvedExecutionMode(talk: JsonObject?): TalkModeExecutionMode {
|
||||
val realtime = talk?.get("realtime").asObjectOrNull() ?: return TalkModeExecutionMode.Native
|
||||
val mode = realtime["mode"].asStringOrNull()
|
||||
val transport = realtime["transport"].asStringOrNull()
|
||||
val brain = realtime["brain"].asStringOrNull()
|
||||
return if (mode == "realtime" && transport == "gateway-relay" && (brain == null || brain == "agent-consult")) {
|
||||
TalkModeExecutionMode.RealtimeRelay
|
||||
} else {
|
||||
TalkModeExecutionMode.Native
|
||||
}
|
||||
}
|
||||
|
||||
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
|
||||
val fallback = TalkDefaults.defaultSilenceTimeoutMs
|
||||
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback
|
||||
|
||||
@@ -2,12 +2,17 @@ package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -17,6 +22,7 @@ import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -25,17 +31,23 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.util.LinkedHashMap
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
@@ -62,6 +74,16 @@ data class TalkPttStopPayload(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
internal data class RealtimeToolRun(
|
||||
val callId: String,
|
||||
val relaySessionId: String,
|
||||
)
|
||||
|
||||
private data class RealtimeToolCompletion(
|
||||
val state: String,
|
||||
val messageEl: JsonElement?,
|
||||
)
|
||||
|
||||
class TalkModeManager internal constructor(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
@@ -70,15 +92,19 @@ class TalkModeManager internal constructor(
|
||||
private val isConnected: () -> Boolean,
|
||||
private val onBeforeSpeak: suspend () -> Unit = {},
|
||||
private val onAfterSpeak: suspend () -> Unit = {},
|
||||
private val onStoppedByRelay: () -> Unit = {},
|
||||
private val talkSpeakClient: TalkSpeechSynthesizing = TalkSpeakClient(session = session),
|
||||
private val talkAudioPlayer: TalkAudioPlaying = TalkAudioPlayer(context),
|
||||
) {
|
||||
companion object {
|
||||
private const val tag = "TalkMode"
|
||||
private const val realtimeSampleRateHz = 24_000
|
||||
private const val realtimeAudioFrameMs = 100
|
||||
private const val listenWatchdogMs = 12_000L
|
||||
private const val chatFinalWaitWithSubscribeMs = 45_000L
|
||||
private const val chatFinalWaitWithoutSubscribeMs = 6_000L
|
||||
private const val maxCachedRunCompletions = 128
|
||||
private const val maxConversationEntries = 40
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@@ -98,6 +124,9 @@ class TalkModeManager internal constructor(
|
||||
private val _lastAssistantText = MutableStateFlow<String?>(null)
|
||||
val lastAssistantText: StateFlow<String?> = _lastAssistantText
|
||||
|
||||
private val _conversation = MutableStateFlow<List<VoiceConversationEntry>>(emptyList())
|
||||
val conversation: StateFlow<List<VoiceConversationEntry>> = _conversation
|
||||
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var restartJob: Job? = null
|
||||
private var stopRequested = false
|
||||
@@ -126,8 +155,29 @@ class TalkModeManager internal constructor(
|
||||
private val completedRunTexts = LinkedHashMap<String, String>()
|
||||
private var chatSubscribedSessionKey: String? = null
|
||||
private var configLoaded = false
|
||||
private var executionMode = TalkModeExecutionMode.Native
|
||||
private val startGeneration = AtomicLong(0L)
|
||||
|
||||
@Volatile private var playbackEnabled = true
|
||||
@Volatile private var realtimeSessionId: String? = null
|
||||
private var realtimeCaptureJob: Job? = null
|
||||
private var realtimeAppendJob: Job? = null
|
||||
private val realtimeToolRuns = LinkedHashMap<String, RealtimeToolRun>()
|
||||
private val pendingRealtimeToolCalls = LinkedHashSet<String>()
|
||||
private val pendingRealtimeToolCompletions = LinkedHashMap<String, RealtimeToolCompletion>()
|
||||
private var realtimeUserEntryId: String? = null
|
||||
private var realtimeAssistantEntryId: String? = null
|
||||
private val realtimePlaybackLock = Any()
|
||||
private var realtimeAudioTrack: AudioTrack? = null
|
||||
private var realtimePlaybackIdleJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var realtimePlaybackEndsAtMs = 0L
|
||||
|
||||
@Volatile
|
||||
private var realtimeOutputSuppressed = false
|
||||
|
||||
@Volatile
|
||||
private var playbackEnabled = true
|
||||
private val playbackGeneration = AtomicLong(0L)
|
||||
|
||||
private var ttsJob: Job? = null
|
||||
@@ -356,6 +406,10 @@ class TalkModeManager internal constructor(
|
||||
event: String,
|
||||
payloadJson: String?,
|
||||
) {
|
||||
if (event == "talk.event") {
|
||||
handleRealtimeTalkEvent(payloadJson)
|
||||
return
|
||||
}
|
||||
if (ttsOnAllResponses) {
|
||||
Log.d(tag, "gateway event: $event")
|
||||
}
|
||||
@@ -379,6 +433,13 @@ class TalkModeManager internal constructor(
|
||||
val activeSession = mainSessionKey.ifBlank { "main" }
|
||||
if (eventSession != null && eventSession != activeSession) return
|
||||
|
||||
if (maybeCompleteRealtimeToolCall(runId = runId, state = state, messageEl = obj["message"])) {
|
||||
return
|
||||
}
|
||||
if (holdPendingRealtimeToolCompletion(runId = runId, state = state, messageEl = obj["message"])) {
|
||||
return
|
||||
}
|
||||
|
||||
// If this is a response we initiated, handle normally below.
|
||||
// Otherwise, if ttsOnAllResponses, finish streaming TTS on terminal events.
|
||||
val pending = pendingRunId
|
||||
@@ -423,6 +484,7 @@ class TalkModeManager internal constructor(
|
||||
if (playbackEnabled == enabled) return
|
||||
playbackEnabled = enabled
|
||||
if (!enabled) {
|
||||
stopRealtimePlayback()
|
||||
stopSpeaking()
|
||||
}
|
||||
}
|
||||
@@ -442,16 +504,43 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
mainHandler.post {
|
||||
if (_isListening.value) return@post
|
||||
stopRequested = false
|
||||
listeningMode = true
|
||||
Log.d(tag, "start")
|
||||
if (realtimeSessionId != null || realtimeCaptureJob?.isActive == true) return
|
||||
val generation = startGeneration.incrementAndGet()
|
||||
stopRequested = false
|
||||
listeningMode = true
|
||||
Log.d(tag, "start")
|
||||
scope.launch {
|
||||
try {
|
||||
ensureConfigLoaded()
|
||||
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) return@launch
|
||||
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
|
||||
startRealtimeRelay(generation)
|
||||
} else {
|
||||
startNativeRecognition(generation)
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) return@launch
|
||||
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
|
||||
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
|
||||
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
|
||||
stopRealtimeRelay(closeSession = false, preserveStatus = true)
|
||||
disableRealtimeModeAndNotifyOwner()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startNativeRecognition(generation: Long) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (generation != startGeneration.get()) return@withContext
|
||||
if (!_isEnabled.value || stopRequested) return@withContext
|
||||
if (_isListening.value) return@withContext
|
||||
Log.d(tag, "start native")
|
||||
|
||||
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
_statusText.value = "Speech recognizer unavailable"
|
||||
Log.w(tag, "speech recognizer unavailable")
|
||||
return@post
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val micOk =
|
||||
@@ -460,19 +549,14 @@ class TalkModeManager internal constructor(
|
||||
if (!micOk) {
|
||||
_statusText.value = "Microphone permission required"
|
||||
Log.w(tag, "microphone permission required")
|
||||
return@post
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
recognizer?.destroy()
|
||||
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
|
||||
startListeningInternal(markListening = true)
|
||||
startSilenceMonitor()
|
||||
Log.d(tag, "listening")
|
||||
} catch (err: Throwable) {
|
||||
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
|
||||
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
recognizer?.destroy()
|
||||
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
|
||||
startListeningInternal(markListening = true)
|
||||
startSilenceMonitor()
|
||||
Log.d(tag, "listening")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +568,7 @@ class TalkModeManager internal constructor(
|
||||
pttAutoStopEnabled = false
|
||||
pttCompletion?.cancel()
|
||||
pttCompletion = null
|
||||
startGeneration.incrementAndGet()
|
||||
pttTimeoutJob?.cancel()
|
||||
pttTimeoutJob = null
|
||||
restartJob?.cancel()
|
||||
@@ -494,6 +579,7 @@ class TalkModeManager internal constructor(
|
||||
lastHeardAtMs = null
|
||||
_isListening.value = false
|
||||
_statusText.value = "Off"
|
||||
stopRealtimeRelay()
|
||||
stopSpeaking()
|
||||
chatSubscribedSessionKey = null
|
||||
pendingRunId = null
|
||||
@@ -512,6 +598,565 @@ class TalkModeManager internal constructor(
|
||||
shutdownTextToSpeech()
|
||||
}
|
||||
|
||||
private suspend fun startRealtimeRelay(generation: Long) {
|
||||
if (!isConnected()) {
|
||||
_statusText.value = "Gateway not connected"
|
||||
Log.w(tag, "realtime start: gateway not connected")
|
||||
disableRealtimeModeAndNotifyOwner()
|
||||
return
|
||||
}
|
||||
|
||||
val micOk =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!micOk) {
|
||||
_statusText.value = "Microphone permission required"
|
||||
Log.w(tag, "realtime start: microphone permission required")
|
||||
disableRealtimeModeAndNotifyOwner()
|
||||
return
|
||||
}
|
||||
|
||||
ensureConfigLoaded()
|
||||
cancelActivePlayback()
|
||||
stopTextToSpeechPlayback()
|
||||
withContext(Dispatchers.Main) {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
}
|
||||
|
||||
_statusText.value = "Connecting…"
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
|
||||
put("mode", JsonPrimitive("realtime"))
|
||||
put("transport", JsonPrimitive("gateway-relay"))
|
||||
put("brain", JsonPrimitive("agent-consult"))
|
||||
}
|
||||
val payload = session.request("talk.session.create", params.toString(), timeoutMs = 15_000)
|
||||
val root = json.parseToJsonElement(payload).asObjectOrNull()
|
||||
val relaySession = root?.get("relaySessionId").asStringOrNull()
|
||||
val sessionId = relaySession ?: root?.get("sessionId").asStringOrNull()
|
||||
if (sessionId.isNullOrBlank()) {
|
||||
throw IllegalStateException("talk.session.create returned no session id")
|
||||
}
|
||||
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) {
|
||||
closeRealtimeSession(sessionId)
|
||||
throw CancellationException("realtime talk stopped while connecting")
|
||||
}
|
||||
|
||||
realtimeSessionId = sessionId
|
||||
realtimeOutputSuppressed = false
|
||||
_isListening.value = true
|
||||
_statusText.value = "Listening"
|
||||
startRealtimeCapture(sessionId)
|
||||
Log.d(tag, "realtime session started relaySessionId=$sessionId")
|
||||
}
|
||||
|
||||
private fun disableRealtimeModeAndNotifyOwner() {
|
||||
if (!_isEnabled.value) return
|
||||
_isEnabled.value = false
|
||||
_isListening.value = false
|
||||
onStoppedByRelay()
|
||||
}
|
||||
|
||||
private fun failRealtimeRelay(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
) {
|
||||
if (realtimeSessionId != sessionId) return
|
||||
_statusText.value = "Talk failed: $message"
|
||||
stopRealtimeRelay(cancelCapture = false, cancelAppend = false, preserveStatus = true)
|
||||
disableRealtimeModeAndNotifyOwner()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startRealtimeCapture(sessionId: String) {
|
||||
realtimeCaptureJob?.cancel()
|
||||
realtimeAppendJob?.cancel()
|
||||
val audioFrames =
|
||||
Channel<ByteArray>(
|
||||
capacity = 4,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
realtimeAppendJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
for (frame in audioFrames) {
|
||||
if (realtimeSessionId != sessionId) continue
|
||||
if (isRealtimePlaybackActive()) continue
|
||||
val audioBase64 = Base64.encodeToString(frame, Base64.NO_WRAP)
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionId", JsonPrimitive(sessionId))
|
||||
put("audioBase64", JsonPrimitive(audioBase64))
|
||||
put("timestamp", JsonPrimitive(SystemClock.elapsedRealtime()))
|
||||
}
|
||||
try {
|
||||
session.sendRequestFrame(
|
||||
"talk.session.appendAudio",
|
||||
params.toString(),
|
||||
timeoutMs = 8_000,
|
||||
) { error ->
|
||||
Log.w(tag, "realtime appendAudio failed: ${error.message}")
|
||||
failRealtimeRelay(sessionId, error.message)
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) throw err
|
||||
Log.w(tag, "realtime appendAudio failed: ${err.message ?: err::class.simpleName}")
|
||||
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "request failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
realtimeCaptureJob =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
var audioRecord: AudioRecord? = null
|
||||
try {
|
||||
val frameBytes = realtimeSampleRateHz * 2 * realtimeAudioFrameMs / 1000
|
||||
val minBuffer =
|
||||
AudioRecord.getMinBufferSize(
|
||||
realtimeSampleRateHz,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
if (minBuffer <= 0) {
|
||||
throw IllegalStateException("AudioRecord buffer unavailable")
|
||||
}
|
||||
audioRecord =
|
||||
AudioRecord
|
||||
.Builder()
|
||||
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
|
||||
.setAudioFormat(
|
||||
AudioFormat
|
||||
.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(realtimeSampleRateHz)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build(),
|
||||
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
|
||||
.build()
|
||||
val buffer = ByteArray(frameBytes)
|
||||
audioRecord.startRecording()
|
||||
while (coroutineContext.isActive && _isEnabled.value && realtimeSessionId == sessionId) {
|
||||
val read = audioRecord.read(buffer, 0, buffer.size)
|
||||
if (read <= 0) continue
|
||||
if (!shouldAppendRealtimeCapturedFrame(read)) continue
|
||||
audioFrames.trySend(buffer.copyOf(read))
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) throw err
|
||||
Log.w(tag, "realtime capture failed: ${err.message ?: err::class.simpleName}")
|
||||
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
|
||||
} finally {
|
||||
audioFrames.close()
|
||||
audioRecord?.let { record ->
|
||||
try {
|
||||
record.stop()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
record.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldAppendRealtimeCapturedFrame(length: Int): Boolean = !isRealtimePlaybackActive() && length > 0
|
||||
|
||||
private fun isRealtimePlaybackActive(): Boolean = _isSpeaking.value || SystemClock.elapsedRealtime() < realtimePlaybackEndsAtMs
|
||||
|
||||
private fun handleRealtimeTalkEvent(payloadJson: String?) {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
val obj =
|
||||
try {
|
||||
json.parseToJsonElement(payloadJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} ?: return
|
||||
val sessionId = obj["relaySessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull()
|
||||
val currentSessionId = realtimeSessionId
|
||||
if (currentSessionId == null || sessionId != currentSessionId) return
|
||||
|
||||
when (val type = obj["type"].asStringOrNull()) {
|
||||
"ready" -> {
|
||||
_isListening.value = true
|
||||
_statusText.value = "Listening"
|
||||
}
|
||||
"inputAudio" -> {
|
||||
_isListening.value = true
|
||||
}
|
||||
"audio" -> {
|
||||
if (realtimeOutputSuppressed) return
|
||||
val audioBase64 = obj["audioBase64"].asStringOrNull() ?: return
|
||||
val bytes =
|
||||
try {
|
||||
Base64.decode(audioBase64, Base64.DEFAULT)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "realtime audio decode failed: ${err.message ?: err::class.simpleName}")
|
||||
return
|
||||
}
|
||||
playRealtimeAudio(bytes)
|
||||
}
|
||||
"clear" -> stopRealtimePlayback()
|
||||
"mark" -> Unit
|
||||
"transcript" -> {
|
||||
val role = obj["role"].asStringOrNull()
|
||||
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
|
||||
val isFinal = obj["final"].asBooleanOrNull() == true
|
||||
if (text.isNotEmpty()) {
|
||||
when (role) {
|
||||
"user" -> upsertRealtimeConversation(VoiceConversationRole.User, text, isFinal)
|
||||
"assistant" -> upsertRealtimeConversation(VoiceConversationRole.Assistant, text, isFinal)
|
||||
}
|
||||
}
|
||||
if (role == "assistant" && text.isNotEmpty()) {
|
||||
_lastAssistantText.value = text
|
||||
}
|
||||
if (isFinal && role == "user") {
|
||||
realtimeOutputSuppressed = false
|
||||
_statusText.value = "Thinking…"
|
||||
} else if (isFinal && role == "assistant") {
|
||||
scheduleRealtimePlaybackIdle()
|
||||
}
|
||||
}
|
||||
"toolCall" -> {
|
||||
val callId = obj["callId"].asStringOrNull() ?: return
|
||||
val name = obj["name"].asStringOrNull() ?: return
|
||||
handleRealtimeToolCall(
|
||||
callId = callId,
|
||||
name = name,
|
||||
args = obj["args"],
|
||||
)
|
||||
}
|
||||
"toolResult" -> Unit
|
||||
"error" -> {
|
||||
val message = obj["message"].asStringOrNull() ?: "realtime talk error"
|
||||
_statusText.value = "Talk failed: $message"
|
||||
Log.w(tag, "realtime error: $message")
|
||||
}
|
||||
"close" -> {
|
||||
Log.d(tag, "realtime close reason=${obj["reason"].asStringOrNull()}")
|
||||
stopRealtimeRelay(closeSession = false)
|
||||
if (_isEnabled.value) {
|
||||
_isEnabled.value = false
|
||||
_statusText.value = "Off"
|
||||
onStoppedByRelay()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (type != null) Log.d(tag, "ignored realtime event type=$type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playRealtimeAudio(bytes: ByteArray) {
|
||||
if (!playbackEnabled || realtimeOutputSuppressed || bytes.isEmpty()) return
|
||||
synchronized(realtimePlaybackLock) {
|
||||
val track =
|
||||
realtimeAudioTrack ?: run {
|
||||
val minBuffer =
|
||||
AudioTrack.getMinBufferSize(
|
||||
realtimeSampleRateHz,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
val created =
|
||||
AudioTrack
|
||||
.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes
|
||||
.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
).setAudioFormat(
|
||||
AudioFormat
|
||||
.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(realtimeSampleRateHz)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
).setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.setBufferSizeInBytes(maxOf(minBuffer, bytes.size * 4))
|
||||
.build()
|
||||
created.play()
|
||||
realtimeAudioTrack = created
|
||||
created
|
||||
}
|
||||
_isSpeaking.value = true
|
||||
_statusText.value = "Speaking…"
|
||||
track.write(bytes, 0, bytes.size)
|
||||
val durationMs = ((bytes.size / 2.0) / realtimeSampleRateHz * 1000.0).toLong()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
realtimePlaybackEndsAtMs = maxOf(now, realtimePlaybackEndsAtMs) + durationMs
|
||||
scheduleRealtimePlaybackIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleRealtimePlaybackIdle() {
|
||||
realtimePlaybackIdleJob?.cancel()
|
||||
val delayMs = maxOf(0L, realtimePlaybackEndsAtMs - SystemClock.elapsedRealtime())
|
||||
realtimePlaybackIdleJob =
|
||||
scope.launch {
|
||||
delay(delayMs)
|
||||
val idle =
|
||||
synchronized(realtimePlaybackLock) {
|
||||
val playbackIdle = SystemClock.elapsedRealtime() >= realtimePlaybackEndsAtMs
|
||||
if (playbackIdle) _isSpeaking.value = false
|
||||
playbackIdle
|
||||
}
|
||||
if (idle && _isEnabled.value && realtimeSessionId != null) {
|
||||
_statusText.value = "Listening"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRealtimePlayback() {
|
||||
realtimePlaybackIdleJob?.cancel()
|
||||
realtimePlaybackIdleJob = null
|
||||
realtimePlaybackEndsAtMs = 0L
|
||||
synchronized(realtimePlaybackLock) {
|
||||
realtimeAudioTrack?.let { track ->
|
||||
try {
|
||||
track.pause()
|
||||
track.flush()
|
||||
track.stop()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
track.release()
|
||||
}
|
||||
realtimeAudioTrack = null
|
||||
}
|
||||
_isSpeaking.value = false
|
||||
if (_isEnabled.value) {
|
||||
_statusText.value = "Listening"
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRealtimeRelay(
|
||||
closeSession: Boolean = true,
|
||||
cancelCapture: Boolean = true,
|
||||
cancelAppend: Boolean = true,
|
||||
preserveStatus: Boolean = false,
|
||||
) {
|
||||
val status = _statusText.value
|
||||
val sessionId = realtimeSessionId
|
||||
realtimeSessionId = null
|
||||
realtimeOutputSuppressed = false
|
||||
if (cancelCapture) {
|
||||
realtimeCaptureJob?.cancel()
|
||||
}
|
||||
if (cancelAppend) {
|
||||
realtimeAppendJob?.cancel()
|
||||
}
|
||||
realtimeCaptureJob = null
|
||||
realtimeAppendJob = null
|
||||
realtimeToolRuns.clear()
|
||||
pendingRealtimeToolCalls.clear()
|
||||
pendingRealtimeToolCompletions.clear()
|
||||
realtimeUserEntryId = null
|
||||
realtimeAssistantEntryId = null
|
||||
stopRealtimePlayback()
|
||||
if (preserveStatus) {
|
||||
_statusText.value = status
|
||||
}
|
||||
_isListening.value = false
|
||||
if (closeSession && !sessionId.isNullOrBlank()) {
|
||||
scope.launch {
|
||||
closeRealtimeSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun closeRealtimeSession(sessionId: String) {
|
||||
try {
|
||||
val params = buildJsonObject { put("sessionId", JsonPrimitive(sessionId)) }
|
||||
session.request("talk.session.close", params.toString(), timeoutMs = 5_000)
|
||||
} catch (err: Throwable) {
|
||||
if (err !is CancellationException) {
|
||||
Log.d(tag, "realtime close ignored: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRealtimeToolCall(
|
||||
callId: String,
|
||||
name: String,
|
||||
args: JsonElement?,
|
||||
) {
|
||||
val relaySessionId = realtimeSessionId ?: return
|
||||
pendingRealtimeToolCalls.add(callId)
|
||||
scope.launch {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
|
||||
put("callId", JsonPrimitive(callId))
|
||||
put("name", JsonPrimitive(name))
|
||||
put("relaySessionId", JsonPrimitive(relaySessionId))
|
||||
if (args != null) put("args", args)
|
||||
}
|
||||
val response =
|
||||
session.request("talk.client.toolCall", params.toString(), timeoutMs = 15_000)
|
||||
val runId = parseRunId(response)
|
||||
if (!runId.isNullOrBlank()) {
|
||||
if (realtimeSessionId != relaySessionId) return@launch
|
||||
realtimeToolRuns[runId] =
|
||||
RealtimeToolRun(callId = callId, relaySessionId = relaySessionId)
|
||||
val completion = pendingRealtimeToolCompletions.remove(runId)
|
||||
if (completion != null) {
|
||||
maybeCompleteRealtimeToolCall(
|
||||
runId = runId,
|
||||
state = completion.state,
|
||||
messageEl = completion.messageEl,
|
||||
)
|
||||
} else {
|
||||
_statusText.value = "Thinking…"
|
||||
}
|
||||
} else {
|
||||
submitRealtimeToolError(callId, "tool call returned no run id", relaySessionId)
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) throw err
|
||||
Log.w(tag, "realtime toolCall failed: ${err.message ?: err::class.simpleName}")
|
||||
submitRealtimeToolError(callId, err.message ?: "tool call failed", relaySessionId)
|
||||
} finally {
|
||||
pendingRealtimeToolCalls.remove(callId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun holdPendingRealtimeToolCompletion(
|
||||
runId: String,
|
||||
state: String,
|
||||
messageEl: JsonElement?,
|
||||
): Boolean {
|
||||
if (realtimeSessionId == null || pendingRealtimeToolCalls.isEmpty()) return false
|
||||
if (state != "final" && state != "aborted" && state != "error") return false
|
||||
pendingRealtimeToolCompletions[runId] =
|
||||
RealtimeToolCompletion(state = state, messageEl = messageEl)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun maybeCompleteRealtimeToolCall(
|
||||
runId: String,
|
||||
state: String,
|
||||
messageEl: JsonElement?,
|
||||
): Boolean {
|
||||
val toolRun = realtimeToolRuns[runId] ?: return false
|
||||
if (toolRun.relaySessionId != realtimeSessionId) {
|
||||
realtimeToolRuns.remove(runId)
|
||||
return true
|
||||
}
|
||||
when (state) {
|
||||
"final" -> {
|
||||
realtimeToolRuns.remove(runId)
|
||||
val text = extractTextFromChatEventMessage(messageEl).orEmpty()
|
||||
scope.launch {
|
||||
submitRealtimeToolResult(
|
||||
callId = toolRun.callId,
|
||||
result = buildJsonObject { put("text", JsonPrimitive(text)) },
|
||||
sessionId = toolRun.relaySessionId,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
"aborted", "error" -> {
|
||||
realtimeToolRuns.remove(runId)
|
||||
scope.launch {
|
||||
submitRealtimeToolError(toolRun.callId, state, toolRun.relaySessionId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun submitRealtimeToolError(
|
||||
callId: String,
|
||||
message: String,
|
||||
sessionId: String? = realtimeSessionId,
|
||||
) {
|
||||
submitRealtimeToolResult(
|
||||
callId = callId,
|
||||
result = buildJsonObject { put("error", JsonPrimitive(message)) },
|
||||
sessionId = sessionId,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun submitRealtimeToolResult(
|
||||
callId: String,
|
||||
result: JsonObject,
|
||||
sessionId: String? = realtimeSessionId,
|
||||
) {
|
||||
val activeSessionId = sessionId ?: return
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionId", JsonPrimitive(activeSessionId))
|
||||
put("callId", JsonPrimitive(callId))
|
||||
put("result", result)
|
||||
}
|
||||
try {
|
||||
session.request("talk.session.submitToolResult", params.toString(), timeoutMs = 15_000)
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) throw err
|
||||
Log.w(tag, "realtime submitToolResult failed: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertRealtimeConversation(
|
||||
role: VoiceConversationRole,
|
||||
text: String,
|
||||
isFinal: Boolean,
|
||||
) {
|
||||
val entryId =
|
||||
when (role) {
|
||||
VoiceConversationRole.User -> realtimeUserEntryId
|
||||
VoiceConversationRole.Assistant -> realtimeAssistantEntryId
|
||||
}
|
||||
val resolvedEntryId =
|
||||
if (entryId == null) {
|
||||
appendConversation(role = role, text = text, isStreaming = !isFinal)
|
||||
} else {
|
||||
updateConversationEntry(id = entryId, text = text, isStreaming = !isFinal)
|
||||
entryId
|
||||
}
|
||||
when (role) {
|
||||
VoiceConversationRole.User -> realtimeUserEntryId = if (isFinal) null else resolvedEntryId
|
||||
VoiceConversationRole.Assistant -> realtimeAssistantEntryId = if (isFinal) null else resolvedEntryId
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendConversation(
|
||||
role: VoiceConversationRole,
|
||||
text: String,
|
||||
isStreaming: Boolean,
|
||||
): String {
|
||||
val id = UUID.randomUUID().toString()
|
||||
_conversation.value =
|
||||
(_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
|
||||
.takeLast(maxConversationEntries)
|
||||
return id
|
||||
}
|
||||
|
||||
private fun updateConversationEntry(
|
||||
id: String,
|
||||
text: String,
|
||||
isStreaming: Boolean,
|
||||
) {
|
||||
val current = _conversation.value
|
||||
val targetIndex =
|
||||
when {
|
||||
current.isEmpty() -> -1
|
||||
current[current.lastIndex].id == id -> current.lastIndex
|
||||
else -> current.indexOfFirst { it.id == id }
|
||||
}
|
||||
if (targetIndex < 0) return
|
||||
val entry = current[targetIndex]
|
||||
if (entry.text == text && entry.isStreaming == isStreaming) return
|
||||
val updated = current.toMutableList()
|
||||
updated[targetIndex] = entry.copy(text = text, isStreaming = isStreaming)
|
||||
_conversation.value = updated
|
||||
}
|
||||
|
||||
private fun startListeningInternal(markListening: Boolean) {
|
||||
val r = recognizer ?: return
|
||||
val intent =
|
||||
@@ -522,8 +1167,8 @@ class TalkModeManager internal constructor(
|
||||
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
|
||||
// Use cloud recognition — it handles natural speech and pauses better
|
||||
// than on-device which cuts off aggressively after short silences.
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2500L)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1800L)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2500)
|
||||
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1800)
|
||||
}
|
||||
|
||||
if (markListening) {
|
||||
@@ -762,7 +1407,7 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForChatFinal(runId: String): Boolean {
|
||||
internal suspend fun waitForChatFinal(runId: String): Boolean {
|
||||
consumeRunCompletion(runId)?.let { return it }
|
||||
val deferred =
|
||||
if (pendingRunId == runId) {
|
||||
@@ -773,13 +1418,12 @@ class TalkModeManager internal constructor(
|
||||
|
||||
consumeRunCompletion(runId)?.let { return it }
|
||||
|
||||
val timeoutMs = if (supportsChatSubscribe) chatFinalWaitWithSubscribeMs else chatFinalWaitWithoutSubscribeMs
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
kotlinx.coroutines.withTimeout(120_000) { deferred.await() }
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
try {
|
||||
withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
false
|
||||
}
|
||||
|
||||
if (!result && pendingRunId == runId) {
|
||||
@@ -1077,11 +1721,32 @@ class TalkModeManager internal constructor(
|
||||
}
|
||||
|
||||
fun stopTts() {
|
||||
realtimeOutputSuppressed = true
|
||||
stopRealtimePlayback()
|
||||
cancelRealtimeOutput(reason = "android-stop-tts")
|
||||
stopSpeaking(resetInterrupt = true)
|
||||
_isSpeaking.value = false
|
||||
_statusText.value = "Listening"
|
||||
}
|
||||
|
||||
private fun cancelRealtimeOutput(reason: String) {
|
||||
val sessionId = realtimeSessionId ?: return
|
||||
scope.launch {
|
||||
try {
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionId", JsonPrimitive(sessionId))
|
||||
put("reason", JsonPrimitive(reason))
|
||||
}
|
||||
session.request("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000)
|
||||
} catch (err: Throwable) {
|
||||
if (err !is CancellationException) {
|
||||
Log.d(tag, "realtime cancelOutput ignored: ${err.message ?: err::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSpeaking(resetInterrupt: Boolean = true) {
|
||||
playbackGeneration.incrementAndGet()
|
||||
if (!_isSpeaking.value) {
|
||||
@@ -1245,9 +1910,11 @@ class TalkModeManager internal constructor(
|
||||
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
|
||||
silenceWindowMs = parsed.silenceTimeoutMs
|
||||
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
|
||||
executionMode = parsed.executionMode
|
||||
configLoaded = true
|
||||
} catch (_: Throwable) {
|
||||
silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
|
||||
executionMode = TalkModeExecutionMode.Native
|
||||
configLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class VoiceWakeManager(
|
||||
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var restartJob: Job? = null
|
||||
private var lastDispatched: String? = null
|
||||
private var lastCycleDispatched: String? = null
|
||||
private var stopRequested = false
|
||||
|
||||
fun setTriggerWords(words: List<String>) {
|
||||
@@ -110,8 +110,8 @@ class VoiceWakeManager(
|
||||
|
||||
private fun handleTranscription(text: String) {
|
||||
val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return
|
||||
if (command == lastDispatched) return
|
||||
lastDispatched = command
|
||||
if (command == lastCycleDispatched) return
|
||||
lastCycleDispatched = command
|
||||
|
||||
scope.launch { onCommand(command) }
|
||||
_statusText.value = "Triggered"
|
||||
@@ -121,6 +121,7 @@ class VoiceWakeManager(
|
||||
private val listener =
|
||||
object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
lastCycleDispatched = null
|
||||
_statusText.value = "Listening"
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class GatewayBootstrapAuthTest {
|
||||
storedOperatorToken = "stored-token",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
|
||||
storedOperatorToken = null,
|
||||
@@ -95,6 +95,17 @@ class GatewayBootstrapAuthTest {
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveOperatorSessionConnectAuthUsesNoAuthWhenGatewayHasNoAuth() {
|
||||
val resolved =
|
||||
resolveOperatorSessionConnectAuth(
|
||||
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null),
|
||||
storedOperatorToken = null,
|
||||
)
|
||||
|
||||
assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null), resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveOperatorSessionConnectAuthPrefersExplicitSharedAuth() {
|
||||
val resolved =
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class PermissionRequesterTest {
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun timedOutRequestCallbackDoesNotCompleteNextRequest() =
|
||||
runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val launchers = mutableListOf<FakePermissionLauncher>()
|
||||
val requester =
|
||||
PermissionRequester(activity()) { callback ->
|
||||
FakePermissionLauncher(callback).also { launchers += it }
|
||||
}
|
||||
|
||||
try {
|
||||
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
|
||||
runCurrent()
|
||||
advanceTimeBy(11)
|
||||
runCurrent()
|
||||
|
||||
assertTrue(first.isCompleted)
|
||||
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
|
||||
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[0].launches)
|
||||
|
||||
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
|
||||
runCurrent()
|
||||
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
|
||||
|
||||
launchers[0].deliver(mapOf(Manifest.permission.CAMERA to false))
|
||||
runCurrent()
|
||||
|
||||
assertFalse(second.isCompleted)
|
||||
|
||||
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
|
||||
runCurrent()
|
||||
|
||||
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun timedOutRequestWithoutCallbackDoesNotBlockNextRequest() =
|
||||
runTest {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val launchers = mutableListOf<FakePermissionLauncher>()
|
||||
val requester =
|
||||
PermissionRequester(activity()) { callback ->
|
||||
FakePermissionLauncher(callback).also { launchers += it }
|
||||
}
|
||||
|
||||
try {
|
||||
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
|
||||
runCurrent()
|
||||
advanceTimeBy(11)
|
||||
runCurrent()
|
||||
|
||||
assertTrue(first.isCompleted)
|
||||
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
|
||||
|
||||
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
|
||||
|
||||
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
|
||||
runCurrent()
|
||||
|
||||
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
|
||||
private fun activity(): ComponentActivity =
|
||||
Robolectric
|
||||
.buildActivity(ComponentActivity::class.java)
|
||||
.setup()
|
||||
.get()
|
||||
}
|
||||
|
||||
private class FakePermissionLauncher(
|
||||
private val callback: (Map<String, Boolean>) -> Unit,
|
||||
) : ActivityResultLauncher<Array<String>>() {
|
||||
val launches = mutableListOf<List<String>>()
|
||||
override val contract: ActivityResultContract<Array<String>, *> = ActivityResultContracts.RequestMultiplePermissions()
|
||||
|
||||
override fun launch(
|
||||
input: Array<String>,
|
||||
options: ActivityOptionsCompat?,
|
||||
) {
|
||||
launches += input.toList()
|
||||
}
|
||||
|
||||
override fun unregister() {}
|
||||
|
||||
fun deliver(result: Map<String, Boolean>) {
|
||||
callback(result)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,129 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
private const val LIFECYCLE_TEST_TIMEOUT_MS = 8_000L
|
||||
private const val LIFECYCLE_CONNECT_CHALLENGE_FRAME =
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
|
||||
|
||||
private class ReconnectDeviceAuthStore : DeviceAuthTokenStore {
|
||||
override fun loadEntry(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
): DeviceAuthEntry? = null
|
||||
|
||||
override fun saveToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: List<String>,
|
||||
) = Unit
|
||||
|
||||
override fun clearToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private data class ReconnectHarness(
|
||||
val session: GatewaySession,
|
||||
val sessionJob: Job,
|
||||
)
|
||||
|
||||
private data class ReconnectServer(
|
||||
val server: MockWebServer,
|
||||
val sockets: ConcurrentLinkedQueue<WebSocket>,
|
||||
) {
|
||||
val port: Int
|
||||
get() = server.port
|
||||
|
||||
val requestCount: Int
|
||||
get() = server.requestCount
|
||||
|
||||
fun shutdown() {
|
||||
sockets.forEach { runCatching { it.cancel() } }
|
||||
runCatching { server.shutdown() }
|
||||
.onFailure { err ->
|
||||
if (err.message != "Gave up waiting for queue to shut down") throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewaySessionReconnectTest {
|
||||
@Test
|
||||
fun connectToNewGatewayClosesActiveConnectionAndStartsReplacement() =
|
||||
runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val firstConnect = CompletableDeferred<Unit>()
|
||||
val firstClosed = CompletableDeferred<Unit>()
|
||||
val secondConnect = CompletableDeferred<Unit>()
|
||||
val secondClosed = CompletableDeferred<Unit>()
|
||||
val firstServer =
|
||||
startGatewayServer(
|
||||
json = json,
|
||||
onClosed = { firstClosed.complete(Unit) },
|
||||
) { webSocket, id, method ->
|
||||
if (method == "connect") {
|
||||
firstConnect.complete(Unit)
|
||||
webSocket.send(connectResponseFrame(id))
|
||||
}
|
||||
}
|
||||
val secondServer =
|
||||
startGatewayServer(
|
||||
json = json,
|
||||
onClosed = { secondClosed.complete(Unit) },
|
||||
) { webSocket, id, method ->
|
||||
if (method == "connect") {
|
||||
secondConnect.complete(Unit)
|
||||
webSocket.send(connectResponseFrame(id))
|
||||
}
|
||||
}
|
||||
val harness = createReconnectHarness()
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, firstServer.port)
|
||||
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstConnect.await() }
|
||||
|
||||
connectNodeSession(harness.session, secondServer.port)
|
||||
|
||||
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstClosed.await() }
|
||||
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondConnect.await() }
|
||||
assertEquals(1, secondServer.requestCount)
|
||||
harness.session.disconnect()
|
||||
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondClosed.await() }
|
||||
} finally {
|
||||
shutdownReconnectHarness(harness, firstServer, secondServer)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bootstrapNodePairingRequiredKeepsReconnectActive() {
|
||||
val error =
|
||||
@@ -113,4 +232,125 @@ class GatewaySessionReconnectTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createReconnectHarness(): ReconnectHarness {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = ReconnectDeviceAuthStore(),
|
||||
onConnected = { _, _, _ -> },
|
||||
onDisconnected = { _ -> },
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
)
|
||||
return ReconnectHarness(session = session, sessionJob = sessionJob)
|
||||
}
|
||||
|
||||
private suspend fun connectNodeSession(
|
||||
session: GatewaySession,
|
||||
port: Int,
|
||||
) {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|$port",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
bootstrapToken = null,
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun shutdownReconnectHarness(
|
||||
harness: ReconnectHarness,
|
||||
vararg servers: ReconnectServer,
|
||||
) {
|
||||
harness.session.disconnect()
|
||||
harness.sessionJob.cancelAndJoin()
|
||||
servers.forEach { it.shutdown() }
|
||||
}
|
||||
|
||||
private fun connectResponseFrame(id: String): String = """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
|
||||
private fun startGatewayServer(
|
||||
json: Json,
|
||||
onClosed: () -> Unit = {},
|
||||
onRequestFrame: (webSocket: WebSocket, id: String, method: String) -> Unit,
|
||||
): ReconnectServer {
|
||||
val sockets = ConcurrentLinkedQueue<WebSocket>()
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
sockets += webSocket
|
||||
webSocket.send(LIFECYCLE_CONNECT_CHALLENGE_FRAME)
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
onRequestFrame(webSocket, id, method)
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
onClosed()
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
onClosed()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
start()
|
||||
}
|
||||
return ReconnectServer(server = server, sockets = sockets)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,4 +44,27 @@ class JpegSizeLimiterTest {
|
||||
assertEquals(600, result.height)
|
||||
assertEquals(90, result.quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun triesFinalScaledImageBeforeFailing() {
|
||||
val result =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = 1000,
|
||||
initialHeight = 800,
|
||||
startQuality = 90,
|
||||
maxBytes = 100,
|
||||
minSize = 1,
|
||||
scaleStep = 0.5,
|
||||
maxScaleAttempts = 1,
|
||||
maxQualityAttempts = 1,
|
||||
encode = { width, _, _ ->
|
||||
if (width == 500) ByteArray(80) else ByteArray(120)
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(500, result.width)
|
||||
assertEquals(400, result.height)
|
||||
assertEquals(90, result.quality)
|
||||
assertEquals(80, result.bytes.size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,14 @@ class GatewayConfigResolverTest {
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointResultRejectsUnsupportedSchemes() {
|
||||
val parsed = parseGatewayEndpointResult("ftp://gateway.example:21")
|
||||
|
||||
assertNull(parsed.config)
|
||||
assertEquals(GatewayEndpointValidationError.INVALID_URL, parsed.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
|
||||
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
|
||||
|
||||
@@ -2,6 +2,7 @@ package ai.openclaw.app.ui.chat
|
||||
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -32,6 +33,22 @@ class ChatMarkdownTest {
|
||||
assertEquals("https://docs.openclaw.ai/help/testing", (links.single().item as LinkAnnotation.Url).url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markdownLinksDropUnsafeDestinations() {
|
||||
listOf(
|
||||
"intent://example/#Intent;scheme=openclaw;end",
|
||||
"file:///sdcard/Download/x",
|
||||
"content://downloads/public_downloads/1",
|
||||
"tel:+15551234567",
|
||||
"javascript:alert(1)",
|
||||
).forEach { destination ->
|
||||
val annotated = buildChatInlineMarkdown("Open [settings]($destination)")
|
||||
|
||||
assertEquals("Open settings", annotated.text)
|
||||
assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainTextDoesNotAddLinkAnnotations() {
|
||||
val annotated = buildChatInlineMarkdown("No link here")
|
||||
@@ -39,4 +56,20 @@ class ChatMarkdownTest {
|
||||
assertEquals("No link here", annotated.text)
|
||||
assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDataImageDestinationAcceptsBoundedPayloads() {
|
||||
val parsed = parseDataImageDestination("data:image/png;base64,QUJD")
|
||||
|
||||
assertEquals(ParsedDataImage(mimeType = "image/png", base64 = "QUJD"), parsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseDataImageDestinationRejectsOversizedPayloads() {
|
||||
val oversized = "A".repeat(CHAT_IMAGE_MAX_BASE64_CHARS + 1)
|
||||
|
||||
val parsed = parseDataImageDestination("data:image/png;base64,$oversized")
|
||||
|
||||
assertNull(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import android.Manifest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class MicCaptureManagerTest {
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun transcriptionFinalQueuesGatewayMessage() =
|
||||
runTest {
|
||||
val sentMessages = mutableListOf<String>()
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-1")
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.handleGatewayEvent(
|
||||
"talk.event",
|
||||
"""{"transcriptionSessionId":"transcription-1","type":"partial","text":"hello"}""",
|
||||
)
|
||||
manager.handleGatewayEvent(
|
||||
"talk.event",
|
||||
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":"hello world","final":true}""",
|
||||
)
|
||||
runCurrent()
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-1", text = "reply"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(manager.liveTranscript.value)
|
||||
assertEquals(listOf("hello world"), sentMessages)
|
||||
val conversation = manager.conversation.value.first()
|
||||
assertEquals(VoiceConversationRole.User, conversation.role)
|
||||
assertEquals("hello world", conversation.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcriptionErrorDisablesMic() {
|
||||
val manager = createManager()
|
||||
|
||||
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
|
||||
manager.handleGatewayEvent(
|
||||
"talk.event",
|
||||
"""{"transcriptionSessionId":"transcription-1","type":"error","message":"provider unavailable"}""",
|
||||
)
|
||||
|
||||
assertEquals(false, manager.micEnabled.value)
|
||||
assertEquals("Transcription failed: provider unavailable", manager.statusText.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun punctuationOnlyTranscriptDoesNotSendTurn() =
|
||||
runTest {
|
||||
val sentMessages = mutableListOf<String>()
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
sendToGateway = { message, onRunIdKnown ->
|
||||
sentMessages += message
|
||||
onRunIdKnown("run-1")
|
||||
"run-1"
|
||||
},
|
||||
)
|
||||
|
||||
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.handleGatewayEvent(
|
||||
"talk.event",
|
||||
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":".","final":true}""",
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(emptyList<String>(), sentMessages)
|
||||
assertEquals(emptyList<VoiceConversationEntry>(), manager.conversation.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pcm16FramesAreEncodedAsPcmuFrames() {
|
||||
val manager = createManager()
|
||||
val method = manager.javaClass.getDeclaredMethod("pcm16ToPcmu", ByteArray::class.java)
|
||||
method.isAccessible = true
|
||||
|
||||
val encoded = method.invoke(manager, byteArrayOf(0, 0, 0, 0)) as ByteArray
|
||||
|
||||
assertEquals(2, encoded.size)
|
||||
assertEquals(0xff.toByte(), encoded[0])
|
||||
assertEquals(0xff.toByte(), encoded[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun disablingMicDuringSessionCreateClosesReturnedSession() =
|
||||
runTest {
|
||||
val createdSession = CompletableDeferred<String>()
|
||||
val closedSessions = mutableListOf<String>()
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
createTranscriptionSession = { createdSession.await() },
|
||||
closeTranscriptionSession = { sessionId -> closedSessions += sessionId },
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.setMicEnabled(true)
|
||||
manager.setMicEnabled(false)
|
||||
createdSession.complete("transcription-1")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("transcription-1"), closedSessions)
|
||||
assertEquals(false, manager.isListening.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun disablingMicKeepsSessionOpenForFinalTranscript() =
|
||||
runTest {
|
||||
val manager = createManager(scope = this)
|
||||
|
||||
setPrivateMutableStateFlowValue(manager, "_micEnabled", true)
|
||||
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
|
||||
manager.setMicEnabled(false)
|
||||
manager.handleGatewayEvent(
|
||||
"talk.event",
|
||||
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":"testing testing 1 2 3","final":true}""",
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(
|
||||
"testing testing 1 2 3",
|
||||
manager.conversation.value
|
||||
.single()
|
||||
.text,
|
||||
)
|
||||
assertEquals("transcription-1", privateField<String?>(manager, "transcriptionSessionId"))
|
||||
privateField<Job?>(manager, "transcriptionDrainJob")?.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun reconnectRestartsAfterPendingCreateCancellation() =
|
||||
runTest {
|
||||
val firstCreate = CompletableDeferred<String>()
|
||||
val secondCreate = CompletableDeferred<String>()
|
||||
var createCalls = 0
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
createTranscriptionSession = {
|
||||
createCalls += 1
|
||||
if (createCalls == 1) firstCreate.await() else secondCreate.await()
|
||||
},
|
||||
)
|
||||
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
manager.setMicEnabled(true)
|
||||
runCurrent()
|
||||
manager.onGatewayConnectionChanged(false)
|
||||
manager.onGatewayConnectionChanged(true)
|
||||
firstCreate.completeExceptionally(CancellationException("connection closed"))
|
||||
runCurrent()
|
||||
|
||||
assertEquals(2, createCalls)
|
||||
assertEquals(true, manager.micEnabled.value)
|
||||
manager.setMicEnabled(false)
|
||||
secondCreate.completeExceptionally(CancellationException("test complete"))
|
||||
runCurrent()
|
||||
}
|
||||
|
||||
private fun createManager(
|
||||
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
|
||||
createTranscriptionSession: suspend () -> String = { "transcription-1" },
|
||||
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
|
||||
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
|
||||
onRunIdKnown("run-1")
|
||||
"run-1"
|
||||
},
|
||||
): MicCaptureManager =
|
||||
MicCaptureManager(
|
||||
context =
|
||||
RuntimeEnvironment.getApplication().also { app ->
|
||||
shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
scope = scope,
|
||||
createTranscriptionSession = createTranscriptionSession,
|
||||
appendTranscriptionAudio = { _, _, _ -> },
|
||||
closeTranscriptionSession = closeTranscriptionSession,
|
||||
sendToGateway = sendToGateway,
|
||||
)
|
||||
|
||||
private fun setPrivateField(
|
||||
target: Any,
|
||||
name: String,
|
||||
value: Any?,
|
||||
) {
|
||||
val field = target.javaClass.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
field.set(target, value)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun setPrivateMutableStateFlowValue(
|
||||
target: Any,
|
||||
name: String,
|
||||
value: Boolean,
|
||||
) {
|
||||
val field = target.javaClass.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
(field.get(target) as MutableStateFlow<Boolean>).value = value
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> privateField(
|
||||
target: Any,
|
||||
name: String,
|
||||
): T {
|
||||
val field = target.javaClass.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
return field.get(target) as T
|
||||
}
|
||||
|
||||
private fun chatFinalPayload(
|
||||
runId: String,
|
||||
text: String,
|
||||
): String =
|
||||
"""
|
||||
{
|
||||
"runId": "$runId",
|
||||
"state": "final",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "text", "text": "$text" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -62,4 +62,37 @@ class TalkModeConfigParsingTest {
|
||||
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsToNativeTalkMode() {
|
||||
val talk =
|
||||
buildJsonObject {
|
||||
put("realtime", buildJsonObject { put("transport", "webrtc") })
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
TalkModeExecutionMode.Native,
|
||||
TalkModeGatewayConfigParser.resolvedExecutionMode(talk),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun usesRealtimeRelayWhenGatewayRelayIsConfigured() {
|
||||
val talk =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"realtime",
|
||||
buildJsonObject {
|
||||
put("mode", "realtime")
|
||||
put("transport", "gateway-relay")
|
||||
put("brain", "agent-consult")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
TalkModeExecutionMode.RealtimeRelay,
|
||||
TalkModeGatewayConfigParser.resolvedExecutionMode(talk),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import ai.openclaw.app.gateway.DeviceAuthEntry
|
||||
import ai.openclaw.app.gateway.DeviceAuthTokenStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.currentTime
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -19,6 +23,7 @@ import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@@ -91,6 +96,87 @@ class TalkModeManagerTest {
|
||||
assertEquals(0L, playbackGeneration(manager).get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realtimeToolFinalDoesNotUseAllResponseTts() {
|
||||
val manager = createManager()
|
||||
|
||||
manager.ttsOnAllResponses = true
|
||||
setPrivateField(manager, "realtimeSessionId", "relay-1")
|
||||
realtimeToolRuns(manager)["run-tool"] =
|
||||
RealtimeToolRun(callId = "call-1", relaySessionId = "relay-1")
|
||||
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-tool", text = "tool result"))
|
||||
|
||||
assertEquals(0L, playbackGeneration(manager).get())
|
||||
assertTrue(realtimeToolRuns(manager).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realtimeTranscriptsPopulateVoiceConversation() {
|
||||
val manager = createManager()
|
||||
|
||||
setPrivateField(manager, "realtimeSessionId", "relay-1")
|
||||
|
||||
manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello"))
|
||||
manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello world", final = true))
|
||||
manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "hi"))
|
||||
manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "hi there", final = true))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
VoiceConversationEntry(
|
||||
id = manager.conversation.value[0].id,
|
||||
role = VoiceConversationRole.User,
|
||||
text = "hello world",
|
||||
),
|
||||
VoiceConversationEntry(
|
||||
id = manager.conversation.value[1].id,
|
||||
role = VoiceConversationRole.Assistant,
|
||||
text = "hi there",
|
||||
),
|
||||
),
|
||||
manager.conversation.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun realtimeStartWithoutGatewayTurnsTalkOff() =
|
||||
runTest {
|
||||
val stoppedByRelay = AtomicBoolean(false)
|
||||
val manager =
|
||||
createManager(
|
||||
scope = this,
|
||||
isConnected = { false },
|
||||
onStoppedByRelay = { stoppedByRelay.set(true) },
|
||||
)
|
||||
|
||||
setPrivateField(manager, "executionMode", TalkModeExecutionMode.RealtimeRelay)
|
||||
setPrivateField(manager, "configLoaded", true)
|
||||
manager.setEnabled(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(manager.isEnabled.value)
|
||||
assertFalse(manager.isListening.value)
|
||||
assertEquals("Gateway not connected", manager.statusText.value)
|
||||
assertTrue(stoppedByRelay.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleRealtimeToolFinalDoesNotUseAllResponseTts() {
|
||||
val manager = createManager()
|
||||
|
||||
manager.ttsOnAllResponses = true
|
||||
setPrivateField(manager, "realtimeSessionId", "relay-2")
|
||||
realtimeToolRuns(manager)["run-tool"] =
|
||||
RealtimeToolRun(callId = "call-1", relaySessionId = "relay-1")
|
||||
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-tool", text = "stale result"))
|
||||
|
||||
assertEquals(0L, playbackGeneration(manager).get())
|
||||
assertTrue(realtimeToolRuns(manager).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textReadyDoesNotEnterSpeakingUntilAudioPlaybackStarts() =
|
||||
runTest {
|
||||
@@ -125,9 +211,43 @@ class TalkModeManagerTest {
|
||||
job.join()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realtimeAudioFramesStreamUntilPlaybackStarts() {
|
||||
val manager = createManager()
|
||||
|
||||
assertFalse(shouldAppendRealtimeCapturedFrame(manager, 0))
|
||||
assertTrue(shouldAppendRealtimeCapturedFrame(manager, 16))
|
||||
assertTrue(shouldAppendRealtimeCapturedFrame(manager, 4_800))
|
||||
|
||||
setPrivateField(manager, "realtimePlaybackEndsAtMs", SystemClock.elapsedRealtime() + 1_000)
|
||||
|
||||
assertFalse(shouldAppendRealtimeCapturedFrame(manager, 4_800))
|
||||
|
||||
setPrivateField(manager, "realtimePlaybackEndsAtMs", SystemClock.elapsedRealtime() - 1)
|
||||
|
||||
assertTrue(shouldAppendRealtimeCapturedFrame(manager, 4_800))
|
||||
}
|
||||
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun chatFinalWaitWithoutSubscribeUsesShortTimeout() =
|
||||
runTest {
|
||||
val manager = createManager(scope = this, supportsChatSubscribe = false)
|
||||
|
||||
setPrivateField(manager, "pendingRunId", "run-missing-final")
|
||||
setPrivateField(manager, "pendingFinal", CompletableDeferred<Boolean>())
|
||||
|
||||
assertFalse(manager.waitForChatFinal("run-missing-final"))
|
||||
assertEquals(6_000, currentTime)
|
||||
}
|
||||
|
||||
private fun createManager(
|
||||
talkSpeakClient: TalkSpeechSynthesizing = TalkSpeakClient(),
|
||||
talkAudioPlayer: TalkAudioPlaying? = null,
|
||||
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
supportsChatSubscribe: Boolean = false,
|
||||
isConnected: () -> Boolean = { true },
|
||||
onStoppedByRelay: () -> Unit = {},
|
||||
): TalkModeManager {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
@@ -142,17 +262,21 @@ class TalkModeManagerTest {
|
||||
)
|
||||
return TalkModeManager(
|
||||
context = app,
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
scope = scope,
|
||||
session = session,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { true },
|
||||
supportsChatSubscribe = supportsChatSubscribe,
|
||||
isConnected = isConnected,
|
||||
onStoppedByRelay = onStoppedByRelay,
|
||||
talkSpeakClient = talkSpeakClient,
|
||||
talkAudioPlayer = talkAudioPlayer ?: TalkAudioPlayer(app),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun playbackGeneration(manager: TalkModeManager): AtomicLong = readPrivateField(manager, "playbackGeneration") as AtomicLong
|
||||
private fun playbackGeneration(manager: TalkModeManager) = readPrivateField(manager, "playbackGeneration") as AtomicLong
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun realtimeToolRuns(manager: TalkModeManager) = readPrivateField(manager, "realtimeToolRuns") as MutableMap<String, RealtimeToolRun>
|
||||
|
||||
private fun setPrivateField(
|
||||
target: Any,
|
||||
@@ -173,6 +297,19 @@ class TalkModeManagerTest {
|
||||
return field.get(target)
|
||||
}
|
||||
|
||||
private fun shouldAppendRealtimeCapturedFrame(
|
||||
manager: TalkModeManager,
|
||||
length: Int,
|
||||
): Boolean {
|
||||
val method =
|
||||
manager.javaClass.getDeclaredMethod(
|
||||
"shouldAppendRealtimeCapturedFrame",
|
||||
Int::class.javaPrimitiveType,
|
||||
)
|
||||
method.isAccessible = true
|
||||
return method.invoke(manager, length) as Boolean
|
||||
}
|
||||
|
||||
private fun chatFinalPayload(
|
||||
runId: String,
|
||||
text: String,
|
||||
@@ -191,6 +328,21 @@ class TalkModeManagerTest {
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun realtimeTranscriptPayload(
|
||||
role: String,
|
||||
text: String,
|
||||
final: Boolean = false,
|
||||
): String =
|
||||
"""
|
||||
{
|
||||
"relaySessionId": "relay-1",
|
||||
"type": "transcript",
|
||||
"role": "$role",
|
||||
"text": "$text",
|
||||
"final": $final
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private class FakeTalkSpeechSynthesizer : TalkSpeechSynthesizing {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.SpeechRecognizer
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class VoiceWakeManagerTest {
|
||||
@Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun repeatedCommandDispatchesInNewRecognitionCycle() =
|
||||
runTest {
|
||||
val commands = mutableListOf<String>()
|
||||
val manager =
|
||||
VoiceWakeManager(
|
||||
context = RuntimeEnvironment.getApplication(),
|
||||
scope = this,
|
||||
onCommand = { command -> commands += command },
|
||||
)
|
||||
manager.setTriggerWords(listOf("claude"))
|
||||
val listener = recognitionListener(manager)
|
||||
|
||||
listener.onReadyForSpeech(null)
|
||||
listener.onPartialResults(recognitionResults("claude take a photo"))
|
||||
listener.onResults(recognitionResults("claude take a photo"))
|
||||
advanceUntilIdle()
|
||||
|
||||
listener.onReadyForSpeech(null)
|
||||
listener.onResults(recognitionResults("claude take a photo"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("take a photo", "take a photo"), commands)
|
||||
}
|
||||
|
||||
private fun recognitionResults(text: String): Bundle =
|
||||
Bundle().apply {
|
||||
putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, arrayListOf(text))
|
||||
}
|
||||
|
||||
private fun recognitionListener(manager: VoiceWakeManager): RecognitionListener {
|
||||
val field = VoiceWakeManager::class.java.getDeclaredField("listener")
|
||||
field.isAccessible = true
|
||||
return field.get(manager) as RecognitionListener
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.CallLog
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -246,6 +247,13 @@ class CallLogHandlerTest : NodeHandlerRobolectricTest() {
|
||||
assertEquals(0, source.lastRequest?.offset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callLogLikeFiltersEscapeWildcards() {
|
||||
assertEquals("${CallLog.Calls.CACHED_NAME} LIKE ? ESCAPE '\\'", buildCallLogCachedNameLikeSelection())
|
||||
assertEquals("${CallLog.Calls.NUMBER} LIKE ? ESCAPE '\\'", buildCallLogNumberLikeSelection())
|
||||
assertEquals("%a\\%b\\_c\\\\d%", buildCallLogLikeArg("a%b_c\\d"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleCallLogSearch_mapsSearchFailuresToUnavailable() {
|
||||
val handler =
|
||||
|
||||
@@ -69,13 +69,13 @@ private object SystemCallLogDataSource : CallLogDataSource {
|
||||
val selectionArgs = mutableListOf<String>()
|
||||
|
||||
request.cachedName?.let {
|
||||
selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
|
||||
selectionArgs.add("%$it%")
|
||||
selections.add(buildCallLogCachedNameLikeSelection())
|
||||
selectionArgs.add(buildCallLogLikeArg(it))
|
||||
}
|
||||
|
||||
request.number?.let {
|
||||
selections.add("${CallLog.Calls.NUMBER} LIKE ?")
|
||||
selectionArgs.add("%$it%")
|
||||
selections.add(buildCallLogNumberLikeSelection())
|
||||
selectionArgs.add(buildCallLogLikeArg(it))
|
||||
}
|
||||
|
||||
// Support time range query
|
||||
@@ -149,6 +149,25 @@ private object SystemCallLogDataSource : CallLogDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun escapeCallLogSqlLikeLiteral(value: String): String =
|
||||
buildString(value.length) {
|
||||
for (ch in value) {
|
||||
when (ch) {
|
||||
'\\', '%', '_' -> {
|
||||
append('\\')
|
||||
append(ch)
|
||||
}
|
||||
else -> append(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildCallLogCachedNameLikeSelection(): String = "${CallLog.Calls.CACHED_NAME} LIKE ? ESCAPE '\\'"
|
||||
|
||||
internal fun buildCallLogNumberLikeSelection(): String = "${CallLog.Calls.NUMBER} LIKE ? ESCAPE '\\'"
|
||||
|
||||
internal fun buildCallLogLikeArg(value: String): String = "%${escapeCallLogSqlLikeLiteral(value)}%"
|
||||
|
||||
class CallLogHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val dataSource: CallLogDataSource,
|
||||
|
||||
@@ -13,8 +13,9 @@ struct OpenClawLiveActivity: Widget {
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.statusText)
|
||||
.font(.subheadline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
self.trailingView(state: context.state)
|
||||
@@ -22,10 +23,7 @@ struct OpenClawLiveActivity: Widget {
|
||||
} compactLeading: {
|
||||
self.statusDot(state: context.state)
|
||||
} compactTrailing: {
|
||||
Text(context.state.statusText)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 64)
|
||||
self.compactStatusIcon(state: context.state)
|
||||
} minimal: {
|
||||
self.statusDot(state: context.state)
|
||||
}
|
||||
@@ -33,39 +31,32 @@ struct OpenClawLiveActivity: Widget {
|
||||
}
|
||||
|
||||
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
self.statusDot(state: context.state)
|
||||
.frame(width: 10, height: 10)
|
||||
HStack(spacing: 10) {
|
||||
self.statusIcon(state: context.state)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(.thinMaterial, in: Circle())
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("OpenClaw")
|
||||
.font(.subheadline.bold())
|
||||
.lineLimit(1)
|
||||
Text(context.state.statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
Spacer()
|
||||
self.trailingView(state: context.state)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func trailingView(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
if state.isConnecting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if state.isDisconnected {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.red)
|
||||
} else if state.isIdle {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Text(state.startedAt, style: .timer)
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
self.statusIcon(state: state)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
|
||||
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
@@ -74,10 +65,34 @@ struct OpenClawLiveActivity: Widget {
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func compactStatusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
self.statusIcon(state: state)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
if state.isConnecting {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.cyan)
|
||||
} else if state.isDisconnected {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.red)
|
||||
} else if state.isIdle {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
|
||||
if state.isDisconnected { return .red }
|
||||
if state.isConnecting { return .gray }
|
||||
if state.isConnecting { return .cyan }
|
||||
if state.isIdle { return .green }
|
||||
return .blue
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.5.19 - 2026-05-19
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.17 - 2026-05-17
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.5.17
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.17
|
||||
OPENCLAW_IOS_VERSION = 2026.5.19
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.19
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -8,6 +8,8 @@ final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
|
||||
private let connectingStaleSeconds: TimeInterval = 120
|
||||
private let hydrationStaleSeconds: TimeInterval = 300
|
||||
private var currentActivity: Activity<OpenClawActivityAttributes>?
|
||||
private var activityStartDate: Date = .now
|
||||
|
||||
@@ -24,11 +26,11 @@ final class LiveActivityManager {
|
||||
return true
|
||||
}
|
||||
|
||||
func startActivity(agentName: String, sessionKey: String) {
|
||||
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
|
||||
if self.currentActivity != nil {
|
||||
self.handleConnecting()
|
||||
self.handleConnecting(statusText: statusText)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,11 +42,14 @@ final class LiveActivityManager {
|
||||
|
||||
self.activityStartDate = .now
|
||||
let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey)
|
||||
let state = self.connectingState(statusText: statusText)
|
||||
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: ActivityContent(state: self.connectingState(), staleDate: nil),
|
||||
content: ActivityContent(
|
||||
state: state,
|
||||
staleDate: Date().addingTimeInterval(self.connectingStaleSeconds)),
|
||||
pushType: nil)
|
||||
self.currentActivity = activity
|
||||
self.logger.info("started live activity id=\(activity.id, privacy: .public)")
|
||||
@@ -53,16 +58,57 @@ final class LiveActivityManager {
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnecting() {
|
||||
self.updateCurrent(state: self.connectingState())
|
||||
func showAttention(statusText: String, agentName: String, sessionKey: String) {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
|
||||
if self.currentActivity == nil {
|
||||
let authInfo = ActivityAuthorizationInfo()
|
||||
guard authInfo.areActivitiesEnabled else {
|
||||
self.logger.info("Live Activities disabled; skipping attention state")
|
||||
return
|
||||
}
|
||||
self.activityStartDate = .now
|
||||
let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey)
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: ActivityContent(state: self.attentionState(statusText: statusText), staleDate: nil),
|
||||
pushType: nil)
|
||||
self.currentActivity = activity
|
||||
self.logger.info("started attention live activity id=\(activity.id, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"failed to start attention live activity: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.updateCurrent(state: self.attentionState(statusText: statusText), staleDate: nil)
|
||||
}
|
||||
|
||||
func handleConnecting(statusText: String = "Connecting...") {
|
||||
self.updateCurrent(
|
||||
state: self.connectingState(statusText: statusText),
|
||||
staleDate: Date().addingTimeInterval(self.connectingStaleSeconds))
|
||||
}
|
||||
|
||||
func handleReconnect() {
|
||||
self.updateCurrent(state: self.idleState())
|
||||
self.endActivity(reason: "connected")
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
self.updateCurrent(state: self.disconnectedState())
|
||||
self.endActivity(reason: "disconnected")
|
||||
}
|
||||
|
||||
func endActivity(reason: String) {
|
||||
guard let activity = self.currentActivity else { return }
|
||||
self.currentActivity = nil
|
||||
self.logger.info("ending live activity reason=\(reason, privacy: .public)")
|
||||
Task {
|
||||
await activity.end(
|
||||
ActivityContent(state: self.disconnectedState(), staleDate: nil),
|
||||
dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
private func hydrateCurrentAndPruneDuplicates() {
|
||||
@@ -72,39 +118,71 @@ final class LiveActivityManager {
|
||||
return
|
||||
}
|
||||
|
||||
let keeper = active.max { lhs, rhs in
|
||||
let now = Date()
|
||||
let candidates = active.filter { activity in
|
||||
let state = activity.content.state
|
||||
guard activity.activityState == .active else { return false }
|
||||
guard !state.isIdle, !state.isDisconnected else { return false }
|
||||
return now.timeIntervalSince(state.startedAt) < self.hydrationStaleSeconds
|
||||
}
|
||||
|
||||
guard !candidates.isEmpty else {
|
||||
self.currentActivity = nil
|
||||
for activity in active {
|
||||
self.end(activity: activity)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let keeper = candidates.max { lhs, rhs in
|
||||
lhs.content.state.startedAt < rhs.content.state.startedAt
|
||||
} ?? active[0]
|
||||
} ?? candidates[0]
|
||||
|
||||
self.currentActivity = keeper
|
||||
self.activityStartDate = keeper.content.state.startedAt
|
||||
|
||||
let stale = active.filter { $0.id != keeper.id }
|
||||
for activity in stale {
|
||||
Task {
|
||||
await activity.end(
|
||||
ActivityContent(state: self.disconnectedState(), staleDate: nil),
|
||||
dismissalPolicy: .immediate)
|
||||
}
|
||||
self.end(activity: activity)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrent(state: OpenClawActivityAttributes.ContentState) {
|
||||
guard let activity = self.currentActivity else { return }
|
||||
private func updateCurrent(state: OpenClawActivityAttributes.ContentState, staleDate: Date? = nil) {
|
||||
guard let activity = self.currentActivity, activity.activityState == .active else {
|
||||
self.currentActivity = nil
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await activity.update(ActivityContent(state: state, staleDate: nil))
|
||||
await activity.update(ActivityContent(state: state, staleDate: staleDate))
|
||||
}
|
||||
}
|
||||
|
||||
private func connectingState() -> OpenClawActivityAttributes.ContentState {
|
||||
private func end(activity: Activity<OpenClawActivityAttributes>) {
|
||||
Task {
|
||||
await activity.end(
|
||||
ActivityContent(state: self.disconnectedState(), staleDate: nil),
|
||||
dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
|
||||
private func connectingState(statusText: String = "Connecting...") -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Connecting...",
|
||||
statusText: statusText,
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: true,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func attentionState(statusText: String) -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: statusText,
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func idleState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
|
||||
@@ -41,5 +41,12 @@ extension OpenClawActivityAttributes.ContentState {
|
||||
isDisconnected: true,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
|
||||
static let attention = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Approval needed",
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
startedAt: .now)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -546,6 +546,7 @@ final class NodeAppModel {
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
if self.isBackgrounded {
|
||||
self.gatewayStatusText = "Background idle"
|
||||
LiveActivityManager.shared.endActivity(reason: "background_idle")
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
@@ -1839,7 +1840,7 @@ extension NodeAppModel {
|
||||
self.operatorGatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
LiveActivityManager.shared.handleDisconnect()
|
||||
LiveActivityManager.shared.endActivity(reason: "manual_disconnect")
|
||||
self.gatewayHealthMonitor.stop()
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
@@ -1877,7 +1878,7 @@ extension NodeAppModel {
|
||||
self.operatorConnected = false
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
LiveActivityManager.shared.handleDisconnect()
|
||||
LiveActivityManager.shared.endActivity(reason: "new_gateway_connect")
|
||||
self.gatewayDefaultAgentId = nil
|
||||
self.gatewayAgents = []
|
||||
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
|
||||
@@ -1908,6 +1909,12 @@ extension NodeAppModel {
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
}
|
||||
if problem.needsPairingApproval || problem.pauseReconnect {
|
||||
LiveActivityManager.shared.showAttention(
|
||||
statusText: problem.needsPairingApproval ? "Approval needed" : "Action required",
|
||||
agentName: self.activeAgentName,
|
||||
sessionKey: self.mainSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
|
||||
@@ -2112,7 +2119,6 @@ extension NodeAppModel {
|
||||
await self.refreshShareRouteFromGateway()
|
||||
await self.registerAPNsTokenIfNeeded()
|
||||
await self.startVoiceWakeSync()
|
||||
await MainActor.run { LiveActivityManager.shared.handleReconnect() }
|
||||
await MainActor.run { self.startGatewayHealthMonitor() }
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
@@ -2120,7 +2126,7 @@ extension NodeAppModel {
|
||||
await MainActor.run {
|
||||
self.operatorConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
LiveActivityManager.shared.handleDisconnect()
|
||||
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
|
||||
}
|
||||
GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)")
|
||||
await MainActor.run { self.stopGatewayHealthMonitor() }
|
||||
@@ -2186,14 +2192,10 @@ extension NodeAppModel {
|
||||
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
let liveActivity = LiveActivityManager.shared
|
||||
if liveActivity.isActive {
|
||||
liveActivity.handleConnecting()
|
||||
} else {
|
||||
liveActivity.startActivity(
|
||||
agentName: self.selectedAgentId ?? "main",
|
||||
sessionKey: self.mainSessionKey)
|
||||
}
|
||||
LiveActivityManager.shared.showConnecting(
|
||||
statusText: (attempt == 0) ? "Connecting..." : "Reconnecting...",
|
||||
agentName: self.activeAgentName,
|
||||
sessionKey: self.mainSessionKey)
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -2220,6 +2222,7 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = true
|
||||
self.screen.errorText = nil
|
||||
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
|
||||
LiveActivityManager.shared.handleReconnect()
|
||||
}
|
||||
let usedBootstrapToken =
|
||||
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
|
||||
@@ -2360,6 +2363,7 @@ extension NodeAppModel {
|
||||
await MainActor.run {
|
||||
self.lastGatewayProblem = nil
|
||||
self.gatewayStatusText = "Offline"
|
||||
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
|
||||
self.gatewayServerName = nil
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = nil
|
||||
@@ -3962,7 +3966,7 @@ extension NodeAppModel {
|
||||
switch route {
|
||||
case let .agent(link):
|
||||
await self.handleAgentDeepLink(link, originalURL: url)
|
||||
case .gateway:
|
||||
case .gateway, .dashboard:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.5.17"
|
||||
"version": "2026.5.19"
|
||||
}
|
||||
|
||||
@@ -85,9 +85,7 @@ struct AboutSettings: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
.settingsDetailContent()
|
||||
.onAppear {
|
||||
guard let updater, !self.didLoadUpdaterState else { return }
|
||||
// Keep Sparkle’s auto-check setting in sync with the persisted toggle.
|
||||
|
||||
49
apps/macos/Sources/OpenClaw/AppNavigationActions.swift
Normal file
49
apps/macos/Sources/OpenClaw/AppNavigationActions.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
enum AppNavigationActions {
|
||||
static func openDashboard() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if DashboardManager.shared.showConfiguredWindowIfPossible() {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
if DashboardManager.shared.showConfiguredWindowIfPossible() {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await DashboardManager.shared.show()
|
||||
} catch {
|
||||
DashboardManager.shared.showFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func openChat() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
static func toggleCanvas() {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
Task { @MainActor in
|
||||
if AppStateStore.shared.canvasPanelVisible {
|
||||
CanvasManager.shared.hideAll()
|
||||
} else {
|
||||
let sessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func openSettings(tab: SettingsTab = .general) {
|
||||
SettingsTabRouter.request(tab)
|
||||
SettingsWindowOpener.shared.open()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,35 +42,34 @@ struct ConfigSchemaForm: View {
|
||||
let literals = nonNull.compactMap(\.literalValue)
|
||||
if !literals.isEmpty, literals.count == nonNull.count {
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Picker(
|
||||
"",
|
||||
selection: self.enumBinding(
|
||||
path,
|
||||
options: literals,
|
||||
defaultValue: schema.explicitDefault))
|
||||
{
|
||||
Text("Select…").tag(-1)
|
||||
ForEach(literals.indices, id: \ .self) { index in
|
||||
Text(String(describing: literals[index])).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
})
|
||||
self.renderEnumField(
|
||||
path: path,
|
||||
options: literals,
|
||||
defaultValue: schema.explicitDefault,
|
||||
label: label,
|
||||
help: help))
|
||||
}
|
||||
}
|
||||
|
||||
if let options = schema.enumValues, !options.isEmpty {
|
||||
return AnyView(
|
||||
self.renderEnumField(
|
||||
path: path,
|
||||
options: options,
|
||||
defaultValue: schema.explicitDefault,
|
||||
label: label,
|
||||
help: help))
|
||||
}
|
||||
|
||||
switch schema.schemaType {
|
||||
case "object":
|
||||
if self.mode == .channelQuick, self.isChannelRoot(path) {
|
||||
return AnyView(self.renderChannelQuickObject(schema, path: path, value: value))
|
||||
}
|
||||
let showHeader = !self.isNestedChannelQuickConfigurationObject(path: path, label: label)
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let label {
|
||||
if showHeader, let label {
|
||||
Text(label)
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
@@ -81,7 +80,7 @@ struct ConfigSchemaForm: View {
|
||||
}
|
||||
let properties = schema.properties
|
||||
let sortedKeys = self.visibleObjectKeys(properties: properties, path: path)
|
||||
ForEach(sortedKeys, id: \ .self) { key in
|
||||
ForEach(sortedKeys, id: \.self) { key in
|
||||
if let child = properties[key] {
|
||||
self.renderNode(child, path: path + [.key(key)])
|
||||
}
|
||||
@@ -96,6 +95,13 @@ struct ConfigSchemaForm: View {
|
||||
case "array":
|
||||
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
||||
case "boolean":
|
||||
if self.isChannelQuickLeaf(path) {
|
||||
return AnyView(
|
||||
SettingsCardToggleRow(
|
||||
title: label ?? "Enabled",
|
||||
subtitle: help,
|
||||
binding: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)))
|
||||
}
|
||||
return AnyView(
|
||||
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
||||
if let label { Text(label) } else { Text("Enabled") }
|
||||
@@ -106,6 +112,9 @@ struct ConfigSchemaForm: View {
|
||||
case "string":
|
||||
return AnyView(self.renderStringField(schema, path: path, label: label, help: help))
|
||||
default:
|
||||
if schema.literalValue != nil {
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
@@ -117,9 +126,19 @@ struct ConfigSchemaForm: View {
|
||||
}
|
||||
|
||||
private func fieldLabel(for schema: ConfigSchemaNode, path: ConfigPath) -> String? {
|
||||
hintForPath(path, hints: self.store.configUiHints)?.label
|
||||
let label = hintForPath(path, hints: self.store.configUiHints)?.label
|
||||
?? schema.title
|
||||
?? labelForConfigPath(path)
|
||||
return self.cleanedChannelQuickLabel(label, path: path)
|
||||
}
|
||||
|
||||
private func cleanedChannelQuickLabel(_ label: String?, path: ConfigPath) -> String? {
|
||||
guard self.mode == .channelQuick, path.count >= 3 else { return label }
|
||||
guard case let .key(channelId) = path[1] else { return label }
|
||||
guard let label else { return nil }
|
||||
let prefix = humanizeConfigKey(channelId) + " "
|
||||
guard label.hasPrefix(prefix) else { return label }
|
||||
return String(label.dropFirst(prefix.count))
|
||||
}
|
||||
|
||||
private func visibleObjectKeys(
|
||||
@@ -161,7 +180,10 @@ struct ConfigSchemaForm: View {
|
||||
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
|
||||
let nonNullVariants = variants.filter { !$0.isNullSchema }
|
||||
if !nonNullVariants.isEmpty {
|
||||
return nonNullVariants.allSatisfy(self.isSimpleField)
|
||||
if nonNullVariants.count == 1, let only = nonNullVariants.first {
|
||||
return self.isSimpleField(only)
|
||||
}
|
||||
return nonNullVariants.allSatisfy { $0.literalValue != nil }
|
||||
}
|
||||
if let enumValues = schema.enumValues {
|
||||
return !enumValues.isEmpty
|
||||
@@ -186,6 +208,13 @@ struct ConfigSchemaForm: View {
|
||||
return dict.keys.contains { !reserved.contains($0) }
|
||||
}
|
||||
|
||||
private func isChannelQuickLeaf(_ path: ConfigPath) -> Bool {
|
||||
guard self.mode == .channelQuick, path.count == 3 else { return false }
|
||||
guard case .key("channels") = path[0] else { return false }
|
||||
guard case .key = path[1], case .key = path[2] else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func isChannelRoot(_ path: ConfigPath) -> Bool {
|
||||
guard path.count == 2 else { return false }
|
||||
guard case .key("channels") = path[0] else { return false }
|
||||
@@ -193,6 +222,42 @@ struct ConfigSchemaForm: View {
|
||||
return true
|
||||
}
|
||||
|
||||
private func isNestedChannelQuickConfigurationObject(path: ConfigPath, label: String?) -> Bool {
|
||||
guard self.mode == .channelQuick, path.count == 3 else { return false }
|
||||
guard case .key("channels") = path[0] else { return false }
|
||||
guard case .key = path[1] else { return false }
|
||||
guard label == "Configuration" else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderChannelQuickObject(
|
||||
_ schema: ConfigSchemaNode,
|
||||
path: ConfigPath,
|
||||
value: Any?) -> some View
|
||||
{
|
||||
let properties = schema.properties
|
||||
let sortedKeys = self.visibleObjectKeys(properties: properties, path: path)
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if sortedKeys.isEmpty {
|
||||
self.renderChannelQuickEmptyState()
|
||||
} else {
|
||||
SettingsCardGroup("Configuration") {
|
||||
ForEach(sortedKeys, id: \.self) { key in
|
||||
if let child = properties[key] {
|
||||
self.renderNode(child, path: path + [.key(key)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.shouldRenderAdditionalProperties(schema, path: path, value: value) {
|
||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func renderChannelQuickEmptyState() -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("No quick settings for this channel.")
|
||||
@@ -226,6 +291,57 @@ struct ConfigSchemaForm: View {
|
||||
"webhookUrl",
|
||||
]
|
||||
|
||||
private func renderChannelQuickField(
|
||||
title: String?,
|
||||
subtitle: String?,
|
||||
@ViewBuilder control: () -> some View) -> some View
|
||||
{
|
||||
SettingsCardRow(title: title ?? "Value", subtitle: subtitle) {
|
||||
control()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderEnumField(
|
||||
path: ConfigPath,
|
||||
options: [Any],
|
||||
defaultValue: Any?,
|
||||
label: String?,
|
||||
help: String?) -> some View
|
||||
{
|
||||
let picker = Picker(
|
||||
"",
|
||||
selection: self.enumBinding(
|
||||
path,
|
||||
options: options,
|
||||
defaultValue: defaultValue))
|
||||
{
|
||||
Text("Select…").tag(-1)
|
||||
ForEach(options.indices, id: \.self) { index in
|
||||
Text(String(describing: options[index])).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
if self.isChannelQuickLeaf(path) {
|
||||
self.renderChannelQuickField(title: label, subtitle: help) {
|
||||
picker
|
||||
.labelsHidden()
|
||||
.frame(width: 180)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
picker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func renderStringField(
|
||||
_ schema: ConfigSchemaNode,
|
||||
@@ -237,27 +353,33 @@ struct ConfigSchemaForm: View {
|
||||
let placeholder = hint?.placeholder ?? ""
|
||||
let sensitive = hint?.sensitive ?? isSensitivePath(path)
|
||||
let defaultValue = schema.explicitDefault as? String
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let options = schema.enumValues {
|
||||
Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) {
|
||||
Text("Select…").tag(-1)
|
||||
ForEach(options.indices, id: \ .self) { index in
|
||||
Text(String(describing: options[index])).tag(index)
|
||||
}
|
||||
if self.isChannelQuickLeaf(path) {
|
||||
self.renderChannelQuickField(title: label, subtitle: help) {
|
||||
if sensitive {
|
||||
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
} else {
|
||||
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if sensitive {
|
||||
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
} else if sensitive {
|
||||
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
} else {
|
||||
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,20 +393,33 @@ struct ConfigSchemaForm: View {
|
||||
{
|
||||
let defaultValue = (schema.explicitDefault as? Double)
|
||||
?? (schema.explicitDefault as? Int).map(Double.init)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if self.isChannelQuickLeaf(path) {
|
||||
self.renderChannelQuickField(title: label, subtitle: help) {
|
||||
TextField(
|
||||
"",
|
||||
text: self.numberBinding(
|
||||
path,
|
||||
isInteger: schema.schemaType == "integer",
|
||||
defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if let label { Text(label).font(.callout.weight(.semibold)) }
|
||||
if let help {
|
||||
Text(help)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
TextField(
|
||||
"",
|
||||
text: self.numberBinding(
|
||||
path,
|
||||
isInteger: schema.schemaType == "integer",
|
||||
defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
TextField(
|
||||
"",
|
||||
text: self.numberBinding(
|
||||
path,
|
||||
isInteger: schema.schemaType == "integer",
|
||||
defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +440,7 @@ struct ConfigSchemaForm: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(items.indices, id: \ .self) { index in
|
||||
ForEach(items.indices, id: \.self) { index in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if let itemSchema {
|
||||
self.renderNode(itemSchema, path: path + [.index(index)])
|
||||
@@ -354,7 +489,7 @@ struct ConfigSchemaForm: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(extras, id: \ .self) { key in
|
||||
ForEach(extras, id: \.self) { key in
|
||||
let itemPath: ConfigPath = path + [.key(key)]
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
|
||||
@@ -477,7 +612,12 @@ struct ChannelConfigForm: View {
|
||||
|
||||
var body: some View {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
SettingsCardGroup("Configuration") {
|
||||
SettingsCardRow(title: "Loading channel settings", subtitle: nil, showsDivider: false) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||
ConfigSchemaForm(
|
||||
store: self.store,
|
||||
@@ -485,9 +625,15 @@ struct ChannelConfigForm: View {
|
||||
path: [.key("channels"), .key(self.channelId)],
|
||||
mode: .channelQuick)
|
||||
} else {
|
||||
Text("Schema unavailable for this channel.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
SettingsCardGroup("Configuration") {
|
||||
SettingsCardRow(
|
||||
title: "Schema unavailable",
|
||||
subtitle: "OpenClaw could not load editable settings for this channel.",
|
||||
showsDivider: false)
|
||||
{
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +95,7 @@ extension ChannelsSettings {
|
||||
|
||||
@ViewBuilder
|
||||
private func configEditorSection(channelId: String) -> some View {
|
||||
self.formSection("Configuration") {
|
||||
ChannelConfigForm(store: self.store, channelId: channelId)
|
||||
}
|
||||
ChannelConfigForm(store: self.store, channelId: channelId)
|
||||
|
||||
self.configStatusMessage
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ extension ChannelsSettings {
|
||||
self.detail
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.settingsDetailContent()
|
||||
.onAppear {
|
||||
self.updateActiveWork(active: self.isActive)
|
||||
self.ensureSelection(in: channels)
|
||||
@@ -60,7 +61,8 @@ extension ChannelsSettings {
|
||||
self.emptyDetail
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
private var emptyDetail: some View {
|
||||
@@ -71,8 +73,8 @@ extension ChannelsSettings {
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
|
||||
.padding(.vertical, SettingsLayout.detailVerticalPadding)
|
||||
}
|
||||
|
||||
private func channelDetail(_ channel: ChannelItem) -> some View {
|
||||
@@ -84,8 +86,8 @@ extension ChannelsSettings {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
|
||||
.padding(.vertical, SettingsLayout.detailVerticalPadding)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +137,7 @@ extension ChannelsSettings {
|
||||
self.statusBadge(
|
||||
self.channelSummary(channel),
|
||||
color: self.channelTint(channel))
|
||||
Spacer()
|
||||
Spacer(minLength: 12)
|
||||
self.channelHeaderActions(channel)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ struct ConfigSettings: View {
|
||||
self.detail
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.settingsDetailContent()
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
@@ -106,7 +107,8 @@ extension ConfigSettings {
|
||||
self.schemaUnavailableDetail
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
private var emptyDetail: some View {
|
||||
@@ -116,8 +118,8 @@ extension ConfigSettings {
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
|
||||
.padding(.vertical, SettingsLayout.detailVerticalPadding)
|
||||
}
|
||||
|
||||
private var schemaUnavailableDetail: some View {
|
||||
@@ -128,8 +130,8 @@ extension ConfigSettings {
|
||||
.foregroundStyle(.secondary)
|
||||
self.actionRow
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
|
||||
.padding(.vertical, SettingsLayout.detailVerticalPadding)
|
||||
}
|
||||
|
||||
private func sectionDetail(_ section: ConfigSection) -> some View {
|
||||
@@ -152,8 +154,8 @@ extension ConfigSettings {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
|
||||
.padding(.vertical, SettingsLayout.detailVerticalPadding)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ extension CronSettings {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.settingsDetailContent()
|
||||
.onAppear {
|
||||
self.updateActiveWork(active: self.isActive)
|
||||
}
|
||||
|
||||
@@ -62,9 +62,7 @@ struct DebugSettings: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.settingsDetailContent()
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
.task {
|
||||
|
||||
@@ -11,6 +11,9 @@ struct GeneralSettings: View {
|
||||
case connection
|
||||
}
|
||||
|
||||
private static let remoteFieldWidth: CGFloat = 320
|
||||
private static let remoteSecretFieldWidth: CGFloat = 300
|
||||
|
||||
@Bindable var state: AppState
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
let page: Page
|
||||
@@ -43,10 +46,7 @@ struct GeneralSettings: View {
|
||||
self.connectionPage
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 760, alignment: .leading)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.settingsDetailContent()
|
||||
}
|
||||
.onAppear {
|
||||
self.updateActiveWork(active: self.isActive)
|
||||
@@ -384,6 +384,7 @@ struct GeneralSettings: View {
|
||||
self.applyDiscoveredGateway(gateway)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.overlay(alignment: .bottom) {
|
||||
@@ -500,7 +501,7 @@ struct GeneralSettings: View {
|
||||
SettingsCardRow(title: "SSH target", subtitle: "User and host for the remote Gateway machine.") {
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 420)
|
||||
.frame(width: Self.remoteFieldWidth)
|
||||
self.remoteTestButton(disabled: !canTest)
|
||||
}
|
||||
if let validationMessage {
|
||||
@@ -514,20 +515,20 @@ struct GeneralSettings: View {
|
||||
}
|
||||
|
||||
private var remoteDirectRow: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SettingsCardRow(title: "Gateway URL", subtitle: "The WebSocket URL exposed by the remote Gateway.") {
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 420)
|
||||
self.remoteTestButton(
|
||||
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
SettingsCardRow(title: "Gateway URL", subtitle: "The WebSocket URL exposed by the remote Gateway.") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: Self.remoteFieldWidth)
|
||||
self.remoteTestButton(
|
||||
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text("Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Text(
|
||||
"Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +541,7 @@ struct GeneralSettings: View {
|
||||
{
|
||||
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 360)
|
||||
.frame(width: Self.remoteSecretFieldWidth)
|
||||
}
|
||||
if self.state.remoteTokenUnsupported {
|
||||
Text(
|
||||
@@ -566,6 +567,7 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(minWidth: 116)
|
||||
.disabled(self.remoteStatus == .checking || disabled)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,7 @@ struct InstancesSettings: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.settingsDetailContent()
|
||||
.onAppear { self.updateActiveWork(active: self.isActive) }
|
||||
.onChange(of: self.isActive) { _, active in
|
||||
self.updateActiveWork(active: active)
|
||||
|
||||
@@ -173,19 +173,7 @@ struct OpenClawApp: App {
|
||||
private func openDashboardWindow() {
|
||||
HoverHUDController.shared.setSuppressed(true)
|
||||
self.isMenuPresented = false
|
||||
if DashboardManager.shared.showConfiguredWindowIfPossible() {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
if DashboardManager.shared.showConfiguredWindowIfPossible() {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await DashboardManager.shared.show()
|
||||
} catch {
|
||||
DashboardManager.shared.showFailure(error)
|
||||
}
|
||||
}
|
||||
AppNavigationActions.openDashboard()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -250,6 +238,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat")
|
||||
let updaterController: UpdaterProviding = makeUpdaterController()
|
||||
|
||||
func applicationDockMenu(_: NSApplication) -> NSMenu? {
|
||||
let menu = NSMenu()
|
||||
menu.autoenablesItems = false
|
||||
menu.addItem(self.dockMenuItem(
|
||||
title: "Open Dashboard",
|
||||
systemImage: "gauge",
|
||||
action: #selector(self.openDashboardFromDockMenu(_:))))
|
||||
menu.addItem(self.dockMenuItem(
|
||||
title: "Open Chat",
|
||||
systemImage: "bubble.left.and.bubble.right",
|
||||
action: #selector(self.openChatFromDockMenu(_:))))
|
||||
let canvasTitle = AppStateStore.shared.canvasPanelVisible ? "Close Canvas" : "Open Canvas"
|
||||
let canvasItem = self.dockMenuItem(
|
||||
title: canvasTitle,
|
||||
systemImage: "rectangle.inset.filled.on.rectangle",
|
||||
action: #selector(self.toggleCanvasFromDockMenu(_:)))
|
||||
canvasItem.isEnabled = AppStateStore.shared.canvasEnabled
|
||||
menu.addItem(canvasItem)
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(self.dockMenuItem(
|
||||
title: "Settings…",
|
||||
systemImage: "gearshape",
|
||||
action: #selector(self.openSettingsFromDockMenu(_:))))
|
||||
return menu
|
||||
}
|
||||
|
||||
private func dockMenuItem(title: String, systemImage: String, action: Selector) -> NSMenuItem {
|
||||
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
|
||||
item.target = self
|
||||
item.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: title)
|
||||
return item
|
||||
}
|
||||
|
||||
@objc
|
||||
private func openDashboardFromDockMenu(_: Any?) {
|
||||
AppNavigationActions.openDashboard()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func openChatFromDockMenu(_: Any?) {
|
||||
AppNavigationActions.openChat()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func toggleCanvasFromDockMenu(_: Any?) {
|
||||
AppNavigationActions.toggleCanvas()
|
||||
}
|
||||
|
||||
@objc
|
||||
private func openSettingsFromDockMenu(_: Any?) {
|
||||
AppNavigationActions.openSettings()
|
||||
}
|
||||
|
||||
func application(_: NSApplication, open urls: [URL]) {
|
||||
Task { @MainActor in
|
||||
for url in urls {
|
||||
|
||||
@@ -111,28 +111,19 @@ struct MenuContent: View {
|
||||
self.voiceWakeMicMenu
|
||||
}
|
||||
Divider()
|
||||
Link(destination: URL(string: "openclaw://dashboard")!) {
|
||||
Button {
|
||||
AppNavigationActions.openDashboard()
|
||||
} label: {
|
||||
Label("Open Dashboard", systemImage: "gauge")
|
||||
}
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
let sessionKey = await WebChatManager.shared.preferredSessionKey()
|
||||
WebChatManager.shared.show(sessionKey: sessionKey)
|
||||
}
|
||||
AppNavigationActions.openChat()
|
||||
} label: {
|
||||
Label("Open Chat", systemImage: "bubble.left.and.bubble.right")
|
||||
}
|
||||
if self.state.canvasEnabled {
|
||||
Button {
|
||||
Task { @MainActor in
|
||||
if self.state.canvasPanelVisible {
|
||||
CanvasManager.shared.hideAll()
|
||||
} else {
|
||||
let sessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
// Don't force a navigation on re-open: preserve the current web view state.
|
||||
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
|
||||
}
|
||||
}
|
||||
AppNavigationActions.toggleCanvas()
|
||||
} label: {
|
||||
Label(
|
||||
self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas",
|
||||
@@ -330,12 +321,7 @@ struct MenuContent: View {
|
||||
}
|
||||
|
||||
private func open(tab: SettingsTab) {
|
||||
SettingsTabRouter.request(tab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
self.openSettings()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab)
|
||||
}
|
||||
AppNavigationActions.openSettings(tab: tab)
|
||||
}
|
||||
|
||||
private var macNodeStatus: (label: String, color: Color)? {
|
||||
|
||||
@@ -679,7 +679,7 @@ extension OnboardingView {
|
||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||
Text(
|
||||
"""
|
||||
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
|
||||
Installs a user-space Node 22.19+ runtime and the CLI (no Homebrew).
|
||||
Rerun anytime to reinstall or update.
|
||||
""")
|
||||
.font(.footnote)
|
||||
|
||||
@@ -36,9 +36,7 @@ struct PermissionsSettings: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 760, alignment: .leading)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.padding(.vertical, 4)
|
||||
.settingsDetailContent()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
@@ -80,6 +78,8 @@ struct PermissionsSettings: View {
|
||||
}
|
||||
|
||||
private struct LocationAccessSettings: View {
|
||||
private static let controlWidth: CGFloat = 180
|
||||
|
||||
@AppStorage(locationModeKey) private var locationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@@ -97,7 +97,7 @@ private struct LocationAccessSettings: View {
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 190)
|
||||
.frame(width: Self.controlWidth, alignment: .trailing)
|
||||
}
|
||||
|
||||
SettingsCardRow(
|
||||
@@ -108,6 +108,7 @@ private struct LocationAccessSettings: View {
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
.frame(width: Self.controlWidth, alignment: .trailing)
|
||||
.disabled(self.locationMode == .off)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.5.17</string>
|
||||
<string>2026.5.19</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026051700</string>
|
||||
<string>2026051900</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -54,7 +54,7 @@ enum RuntimeResolutionError: Error {
|
||||
|
||||
enum RuntimeLocator {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime")
|
||||
private static let minNode = RuntimeVersion(major: 22, minor: 16, patch: 0)
|
||||
private static let minNode = RuntimeVersion(major: 22, minor: 19, patch: 0)
|
||||
|
||||
static func resolve(
|
||||
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
|
||||
@@ -91,7 +91,7 @@ enum RuntimeLocator {
|
||||
switch error {
|
||||
case let .notFound(searchPaths):
|
||||
[
|
||||
"openclaw needs Node >=22.16.0 but found no runtime.",
|
||||
"openclaw needs Node >=22.19.0 but found no runtime.",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Install Node: https://nodejs.org/en/download",
|
||||
].joined(separator: "\n")
|
||||
@@ -105,7 +105,7 @@ enum RuntimeLocator {
|
||||
[
|
||||
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
|
||||
"PATH searched: \(searchPaths.joined(separator: ":"))",
|
||||
"Try reinstalling or pinning a supported version (Node >=22.16.0).",
|
||||
"Try reinstalling or pinning a supported version (Node >=22.19.0).",
|
||||
].joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ extension View {
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.openClawRowBackground(selected: selected, hovered: hovered)))
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
|
||||
@@ -24,8 +24,7 @@ struct SessionsSettings: View {
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.leading, 18)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.settingsDetailContent()
|
||||
.task {
|
||||
guard !self.hasLoaded else { return }
|
||||
guard !self.isPreview else { return }
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsLayout {
|
||||
static let scrollbarGutter: CGFloat = 36
|
||||
static let sidebarWidth: CGFloat = 250
|
||||
static let detailHorizontalPadding: CGFloat = 22
|
||||
static let detailVerticalPadding: CGFloat = 18
|
||||
static let nestedSidebarWidth: CGFloat = 260
|
||||
static let detailBottomPadding: CGFloat = 16
|
||||
}
|
||||
|
||||
extension View {
|
||||
func settingsDetailContent() -> some View {
|
||||
self
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.bottom, SettingsLayout.detailBottomPadding)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsPageHeader: View {
|
||||
@@ -63,9 +76,10 @@ struct SettingsCardGroup<Content: View>: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
self.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.38), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
@@ -110,6 +124,7 @@ struct SettingsCardRow<Content: View>: View {
|
||||
|
||||
self.content
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.overlay(alignment: .bottom) {
|
||||
|
||||
@@ -8,7 +8,6 @@ struct SettingsRootView: View {
|
||||
@State private var monitoringPermissions = false
|
||||
@State private var selectedTab: SettingsTab = .general
|
||||
@State private var cachedTabs: Set<SettingsTab>
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil)
|
||||
let updater: UpdaterProviding?
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
@@ -23,31 +22,14 @@ struct SettingsRootView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: self.$columnVisibility) {
|
||||
List(selection: self.$selectedTab) {
|
||||
ForEach(self.visibleGroups) { group in
|
||||
Section(group.title) {
|
||||
ForEach(group.tabs) { tab in
|
||||
NavigationLink(value: tab) {
|
||||
Label(tab.title, systemImage: tab.systemImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 240)
|
||||
} detail: {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.isNixMode {
|
||||
self.nixManagedBanner
|
||||
}
|
||||
self.cachedDetailViews
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 18)
|
||||
HStack(spacing: 0) {
|
||||
SettingsSidebar(
|
||||
groups: self.visibleGroups,
|
||||
selectedTab: self.$selectedTab)
|
||||
.frame(width: SettingsLayout.sidebarWidth)
|
||||
|
||||
self.detailContainer
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(SettingsWindowChromeConfigurator())
|
||||
@@ -93,6 +75,18 @@ struct SettingsRootView: View {
|
||||
SettingsTabGroup.defaultGroups(showDebug: self.state.debugPaneEnabled)
|
||||
}
|
||||
|
||||
private var detailContainer: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.isNixMode {
|
||||
self.nixManagedBanner
|
||||
}
|
||||
self.cachedDetailViews
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
|
||||
.padding(.vertical, SettingsLayout.detailVerticalPadding)
|
||||
}
|
||||
|
||||
private var cachedDetailTabs: [SettingsTab] {
|
||||
let cached = self.cachedTabs.union([self.selectedTab])
|
||||
return self.visibleGroups.flatMap(\.tabs).filter { cached.contains($0) }
|
||||
@@ -207,6 +201,77 @@ struct SettingsRootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsSidebar: View {
|
||||
let groups: [SettingsTabGroup]
|
||||
@Binding var selectedTab: SettingsTab
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
VisualEffectView(material: .sidebar)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.09), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.16), radius: 18, x: 0, y: 12)
|
||||
|
||||
ScrollView(.vertical) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ForEach(self.groups) { group in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(group.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
ForEach(group.tabs) { tab in
|
||||
SettingsSidebarRow(
|
||||
tab: tab,
|
||||
selected: self.selectedTab == tab)
|
||||
{
|
||||
self.selectedTab = tab
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsSidebarRow: View {
|
||||
let tab: SettingsTab
|
||||
let selected: Bool
|
||||
let select: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Label(self.tab.title, systemImage: self.tab.systemImage)
|
||||
.font(.body.weight(.medium))
|
||||
.labelStyle(.titleAndIcon)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.selected ? Color.white.opacity(0.13) : Color.clear)
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.onTapGesture(perform: self.select)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(self.tab.title)
|
||||
.accessibilityAddTraits(self.selected ? [.isButton, .isSelected] : .isButton)
|
||||
.accessibilityAction { self.select() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsTabGroup: Identifiable {
|
||||
let title: String
|
||||
let tabs: [SettingsTab]
|
||||
@@ -235,7 +300,7 @@ private struct SettingsTabGroup: Identifiable {
|
||||
enum SettingsTab: CaseIterable, Identifiable, Hashable {
|
||||
case general, connection, permissions, voiceWake, channels, skills, cron
|
||||
case execApprovals, sessions, instances, config, debug, about
|
||||
static let windowWidth: CGFloat = 960
|
||||
static let windowWidth: CGFloat = 1120
|
||||
static let windowHeight: CGFloat = 790
|
||||
|
||||
var id: Self {
|
||||
|
||||
@@ -3,7 +3,8 @@ import SwiftUI
|
||||
extension View {
|
||||
func settingsSidebarCardLayout() -> some View {
|
||||
self
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||
.frame(width: SettingsLayout.nestedSidebarWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||
|
||||
@@ -11,5 +11,6 @@ struct SettingsSidebarScroll<Content: View>: View {
|
||||
}
|
||||
.settingsSidebarCardLayout()
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ struct SkillsSettings: View {
|
||||
self.skillsList
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.frame(maxWidth: 860, alignment: .leading)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.padding(.vertical, 4)
|
||||
.settingsDetailContent()
|
||||
}
|
||||
.task {
|
||||
guard !self.didScheduleInitialRefresh else { return }
|
||||
|
||||
@@ -12,9 +12,7 @@ struct ExecApprovalsSettings: View {
|
||||
|
||||
SystemRunSettingsView()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.settingsDetailContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,9 +186,7 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.frame(maxWidth: 760, alignment: .leading)
|
||||
.padding(.trailing, SettingsLayout.scrollbarGutter)
|
||||
.padding(.vertical, 4)
|
||||
.settingsDetailContent()
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
@@ -253,67 +251,67 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
private var triggerTable: some View {
|
||||
SettingsCardGroup("Trigger Words") {
|
||||
HStack {
|
||||
Text("Wake phrases")
|
||||
.font(.callout.weight(.semibold))
|
||||
Spacer()
|
||||
Button {
|
||||
self.addWord()
|
||||
} label: {
|
||||
Label("Add word", systemImage: "plus")
|
||||
}
|
||||
.disabled(self.triggerEntries
|
||||
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
|
||||
Button("Reset defaults") {
|
||||
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(self.$triggerEntries) { $entry in
|
||||
HStack(spacing: 8) {
|
||||
TextField("Wake word", text: $entry.value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
|
||||
Button {
|
||||
self.removeWord(id: entry.id)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove trigger word")
|
||||
.frame(width: 24)
|
||||
SettingsCardRow(
|
||||
title: "Wake phrases",
|
||||
subtitle: "Short phrases that start voice wake detection.")
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
self.addWord()
|
||||
} label: {
|
||||
Label("Add word", systemImage: "plus")
|
||||
}
|
||||
.padding(8)
|
||||
.disabled(self.triggerEntries
|
||||
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||
|
||||
if entry.id != self.triggerEntries.last?.id {
|
||||
Divider()
|
||||
Button("Reset") {
|
||||
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
.padding(.horizontal, 14)
|
||||
|
||||
Text(
|
||||
"OpenClaw reacts when any trigger appears in a transcription. "
|
||||
+ "Keep them short to avoid false positives.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
self.triggerPhraseRows
|
||||
|
||||
TriggerPhraseHelpRow()
|
||||
}
|
||||
}
|
||||
|
||||
private var triggerPhraseRows: some View {
|
||||
Group {
|
||||
if self.triggerEntries.isEmpty {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "text.badge.plus")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 22)
|
||||
Text("No wake phrases configured")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.vertical, 14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(self.$triggerEntries) { $entry in
|
||||
TriggerPhraseRow(
|
||||
value: $entry.value,
|
||||
showsDivider: entry.id != self.triggerEntries.last?.id,
|
||||
onSubmit: {
|
||||
self.syncTriggerEntriesToState()
|
||||
},
|
||||
onRemove: {
|
||||
self.removeWord(id: entry.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
Divider()
|
||||
.padding(.leading, 14)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,67 +535,79 @@ struct VoiceWakeSettings: View {
|
||||
.frame(width: self.controlWidth)
|
||||
}
|
||||
|
||||
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Additional languages")
|
||||
.font(.footnote.weight(.semibold))
|
||||
ForEach(
|
||||
Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()),
|
||||
id: \.offset)
|
||||
{ idx, localeID in
|
||||
HStack(spacing: 8) {
|
||||
Picker("Extra \(idx + 1)", selection: Binding(
|
||||
get: { localeID },
|
||||
set: { newValue in
|
||||
guard self.state
|
||||
.voiceWakeAdditionalLocaleIDs.indices
|
||||
.contains(idx) else { return }
|
||||
self.state
|
||||
.voiceWakeAdditionalLocaleIDs[idx] =
|
||||
newValue
|
||||
})) {
|
||||
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
|
||||
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 220)
|
||||
|
||||
Button {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove language")
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardRow(
|
||||
title: "Additional languages",
|
||||
subtitle: self.additionalLanguagesSubtitle,
|
||||
showsDivider: !self.state.voiceWakeAdditionalLocaleIDs.isEmpty)
|
||||
{
|
||||
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
Button {
|
||||
if let first = availableLocales.first {
|
||||
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
|
||||
}
|
||||
self.addAdditionalLocale()
|
||||
} label: {
|
||||
Label("Add language", systemImage: "plus")
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.availableLocales.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
} else {
|
||||
Button {
|
||||
if let first = availableLocales.first {
|
||||
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
|
||||
}
|
||||
} label: {
|
||||
Label("Add additional language", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.disabled(self.availableLocales.isEmpty)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
self.additionalLanguageRows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var additionalLanguagesSubtitle: String {
|
||||
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||
return "None configured."
|
||||
}
|
||||
return "Tried after the primary language."
|
||||
}
|
||||
|
||||
private var additionalLanguageRows: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
|
||||
AdditionalLanguageRow(
|
||||
index: idx,
|
||||
selection: self.additionalLocaleBinding(index: idx, fallback: localeID),
|
||||
localeIDs: self.availableLocales.map(\.identifier),
|
||||
localeName: { id in self.friendlyName(for: Locale(identifier: id)) },
|
||||
showsDivider: true,
|
||||
onRemove: {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
|
||||
})
|
||||
}
|
||||
|
||||
SettingsCardRow(title: "Add another language", showsDivider: false) {
|
||||
Button {
|
||||
self.addAdditionalLocale()
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.availableLocales.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func additionalLocaleBinding(index: Int, fallback: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return fallback }
|
||||
return self.state.voiceWakeAdditionalLocaleIDs[index]
|
||||
},
|
||||
set: { newValue in
|
||||
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return }
|
||||
self.state.voiceWakeAdditionalLocaleIDs[index] = newValue
|
||||
})
|
||||
}
|
||||
|
||||
private func addAdditionalLocale() {
|
||||
let selected = Set([self.state.voiceWakeLocaleID] + self.state.voiceWakeAdditionalLocaleIDs)
|
||||
let next = self.availableLocales.first { !selected.contains($0.identifier) } ?? self.availableLocales.first
|
||||
if let next {
|
||||
self.state.voiceWakeAdditionalLocaleIDs.append(next.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +728,110 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct TriggerPhraseRow: View {
|
||||
@Binding var value: String
|
||||
let showsDivider: Bool
|
||||
let onSubmit: () -> Void
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "quote.opening")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24)
|
||||
|
||||
TextField("Wake phrase", text: self.$value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.callout.weight(.medium))
|
||||
.frame(maxWidth: 420)
|
||||
.onSubmit(self.onSubmit)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button(action: self.onRemove) {
|
||||
Image(systemName: "trash")
|
||||
.font(.callout)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 26, height: 26)
|
||||
.contentShape(Rectangle())
|
||||
.help("Remove trigger word")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.overlay(alignment: .bottom) {
|
||||
if self.showsDivider {
|
||||
Divider()
|
||||
.padding(.leading, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AdditionalLanguageRow: View {
|
||||
let index: Int
|
||||
@Binding var selection: String
|
||||
let localeIDs: [String]
|
||||
let localeName: (String) -> String
|
||||
let showsDivider: Bool
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
SettingsCardRow(
|
||||
title: "Language \(self.index + 2)",
|
||||
subtitle: "Fallback recognition language.",
|
||||
showsDivider: self.showsDivider)
|
||||
{
|
||||
HStack(spacing: 10) {
|
||||
Picker("Language \(self.index + 2)", selection: self.$selection) {
|
||||
ForEach(self.localeIDs, id: \.self) { id in
|
||||
Text(self.localeName(id)).tag(id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 220)
|
||||
|
||||
Button(action: self.onRemove) {
|
||||
Image(systemName: "trash")
|
||||
.font(.callout)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 26, height: 26)
|
||||
.contentShape(Rectangle())
|
||||
.help("Remove language")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TriggerPhraseHelpRow: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 18)
|
||||
.padding(.top, 1)
|
||||
|
||||
Text(
|
||||
"OpenClaw reacts when any trigger appears in a transcription. " +
|
||||
"Keep phrases short to avoid false positives.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct VoiceWakeSettings_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
@@ -48,7 +48,7 @@ import Testing
|
||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
||||
try makeExecutableForTests(at: nodePath)
|
||||
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try "#!/bin/sh\necho v22.19.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||
try makeExecutableForTests(at: scriptPath)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
@@ -38,4 +39,15 @@ struct MenuContentSmokeTests {
|
||||
let view = MenuContent(state: state, updater: nil)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `dock menu exposes primary shortcuts`() throws {
|
||||
let delegate = AppDelegate()
|
||||
let menu = try #require(delegate.applicationDockMenu(NSApplication.shared))
|
||||
let titles = menu.items.map(\.title)
|
||||
|
||||
#expect(titles.contains("Open Dashboard"))
|
||||
#expect(titles.contains("Open Chat"))
|
||||
#expect(titles.contains("Open Canvas") || titles.contains("Close Canvas"))
|
||||
#expect(titles.contains("Settings…"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ struct RuntimeLocatorTests {
|
||||
@Test func `resolve succeeds with valid node`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v22.16.0
|
||||
echo v22.19.0
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
@@ -25,13 +25,13 @@ struct RuntimeLocatorTests {
|
||||
return
|
||||
}
|
||||
#expect(res.path == node.path)
|
||||
#expect(res.version == RuntimeVersion(major: 22, minor: 16, patch: 0))
|
||||
#expect(res.version == RuntimeVersion(major: 22, minor: 19, patch: 0))
|
||||
}
|
||||
|
||||
@Test func `resolve fails on boundary below minimum`() throws {
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
echo v22.15.9
|
||||
echo v22.18.9
|
||||
"""
|
||||
let node = try self.makeTempExecutable(contents: script)
|
||||
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
|
||||
@@ -39,8 +39,8 @@ struct RuntimeLocatorTests {
|
||||
Issue.record("Expected unsupported error, got \(result)")
|
||||
return
|
||||
}
|
||||
#expect(found == RuntimeVersion(major: 22, minor: 15, patch: 9))
|
||||
#expect(required == RuntimeVersion(major: 22, minor: 16, patch: 0))
|
||||
#expect(found == RuntimeVersion(major: 22, minor: 18, patch: 9))
|
||||
#expect(required == RuntimeVersion(major: 22, minor: 19, patch: 0))
|
||||
#expect(path == node.path)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ struct RuntimeLocatorTests {
|
||||
|
||||
@Test func `describe failure includes paths`() {
|
||||
let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"]))
|
||||
#expect(msg.contains("Node >=22.16.0"))
|
||||
#expect(msg.contains("Node >=22.19.0"))
|
||||
#expect(msg.contains("PATH searched: /tmp/a:/tmp/b"))
|
||||
|
||||
let parseMsg = RuntimeLocator.describeFailure(
|
||||
@@ -85,7 +85,7 @@ struct RuntimeLocatorTests {
|
||||
raw: "garbage",
|
||||
path: "/usr/local/bin/node",
|
||||
searchPaths: ["/usr/local/bin"]))
|
||||
#expect(parseMsg.contains("Node >=22.16.0"))
|
||||
#expect(parseMsg.contains("Node >=22.19.0"))
|
||||
}
|
||||
|
||||
@Test func `runtime version parses with leading V and metadata`() {
|
||||
|
||||
@@ -2757,6 +2757,7 @@ public struct ConfigSchemaResponse: Codable, Sendable {
|
||||
public struct ConfigSchemaLookupResult: Codable, Sendable {
|
||||
public let path: String
|
||||
public let schema: AnyCodable
|
||||
public let reloadkind: AnyCodable?
|
||||
public let hint: [String: AnyCodable]?
|
||||
public let hintpath: String?
|
||||
public let children: [[String: AnyCodable]]
|
||||
@@ -2764,12 +2765,14 @@ public struct ConfigSchemaLookupResult: Codable, Sendable {
|
||||
public init(
|
||||
path: String,
|
||||
schema: AnyCodable,
|
||||
reloadkind: AnyCodable?,
|
||||
hint: [String: AnyCodable]?,
|
||||
hintpath: String?,
|
||||
children: [[String: AnyCodable]])
|
||||
{
|
||||
self.path = path
|
||||
self.schema = schema
|
||||
self.reloadkind = reloadkind
|
||||
self.hint = hint
|
||||
self.hintpath = hintpath
|
||||
self.children = children
|
||||
@@ -2778,6 +2781,7 @@ public struct ConfigSchemaLookupResult: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case schema
|
||||
case reloadkind = "reloadKind"
|
||||
case hint
|
||||
case hintpath = "hintPath"
|
||||
case children
|
||||
|
||||
@@ -125,7 +125,7 @@ const config = {
|
||||
"**/*.test-helpers.ts",
|
||||
"**/*.test-mocks.ts",
|
||||
"**/*.test-utils.ts",
|
||||
"src/gateway/live-image-probe.ts",
|
||||
"test/helpers/live-image-probe.ts",
|
||||
"src/secrets/credential-matrix.ts",
|
||||
"src/agents/claude-cli-runner.ts",
|
||||
"src/agents/pi-auth-json.ts",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
4b52f0bff12148f4695150a45c91d4b9bda2d1bfbc1162a79a2bb2cf62c3c1eb config-baseline.json
|
||||
73e11d9d5c5b27d8d075202f59b9f19537ded361ea761ed0aef78dc9446bc82f config-baseline.core.json
|
||||
fe4f1cb00d7d1dee9746779ec3cf14236e5f672c91502268a12ad6e467a2c4ad config-baseline.channel.json
|
||||
e9049ce0154f484f44bb0ac174a44198269256044da5ba62a6e107e78bfd7a70 config-baseline.plugin.json
|
||||
82d56352536e75291ec81540bd4d93e22aeae282e2ef864aa0f231b6deb11bba config-baseline.json
|
||||
5d6aa4d0789482b1bdb6681d19fe193a8696ca25c20cbb9e07edb6d1b23ad8f2 config-baseline.core.json
|
||||
e068db276fdff1727939d4f3a8001376e550c444bdff3e3443ab26812e2f8c5d config-baseline.channel.json
|
||||
a87fc4c9bc6499c5fb9d9343b8c1c4f0c3381a6afbdb0a676dc8ba9e03ff5755 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
048d8ff5e4455d16f75f6762a916f67c982e1211fb7085456647234255567466 plugin-sdk-api-baseline.json
|
||||
2d46a9660c9143f823a47df3c7ecfd315a4999e96af5eddb4ba4e71d9bb377a6 plugin-sdk-api-baseline.jsonl
|
||||
86a7102dd571ca4c14f45c890392a95a51244e9bfba108986d87ca37bfb0b3c4 plugin-sdk-api-baseline.json
|
||||
810d6807ff58ae79178dd0fadc84c10ce04813bbff738cc2fdaf12d1c2acef16 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -106,7 +106,7 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Concurrent media-generation guardrail">
|
||||
While a session-backed media-generation task is still active, the tool also acts as a guardrail: repeated `image_generate`, `music_generate`, or `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.
|
||||
While a session-backed media-generation task is still active, media tools also act as guardrails for accidental retries. Repeated `image_generate` calls for the same prompt return the matching active task status, while a distinct image prompt can start its own task. `music_generate` and `video_generate` calls still return the active task status for that session instead of starting a second concurrent generation. Use `action: "status"` when you want an explicit progress/status lookup from the agent side.
|
||||
</Accordion>
|
||||
<Accordion title="What does not create tasks">
|
||||
- Heartbeat turns - main-session; see [Heartbeat](/gateway/heartbeat)
|
||||
|
||||
@@ -176,9 +176,9 @@ The agent-specific `agents.list[].groupChat.unmentionedInbound` value overrides
|
||||
|
||||
## Visible reply modes
|
||||
|
||||
`messages.groupChat.visibleReplies: "message_tool"` is the recommended group and channel default. It lets the agent decide when to speak by calling the message tool. If the model returns final text without calling the tool, OpenClaw keeps that final text private and logs suppressed delivery metadata.
|
||||
`messages.groupChat.visibleReplies` defaults to `"automatic"` for normal group/channel user requests. Keep that default when you want final assistant text to post visibly without requiring an explicit message-tool call.
|
||||
|
||||
Use `messages.groupChat.visibleReplies: "automatic"` only when you want legacy behavior where normal group requests post final assistant text automatically.
|
||||
For ambient always-on rooms, `messages.groupChat.visibleReplies: "message_tool"` is still recommended, especially with latest-generation, tool-reliable models such as GPT 5.5. It lets the agent decide when to speak by calling the message tool. If the model returns final text without calling the tool, OpenClaw keeps that final text private and logs suppressed delivery metadata.
|
||||
|
||||
Room events stay strict even when other group requests use automatic replies. Unmentioned ambient room events still require `message(action=send)` for visible output.
|
||||
|
||||
@@ -198,7 +198,7 @@ If the room shows typing or token usage but no visible message:
|
||||
2. Confirm `requireMention: false` is set at the room level you expect.
|
||||
3. Check whether `messages.groupChat.unmentionedInbound` or the agent override is `"room_event"`.
|
||||
4. Inspect logs for suppressed final payload metadata or `didSendViaMessagingTool: false`.
|
||||
5. Use a model/runtime that reliably calls tools, or set `messages.groupChat.visibleReplies: "automatic"` for legacy final replies on normal group requests.
|
||||
5. For normal group requests, keep or restore `messages.groupChat.visibleReplies: "automatic"` if you want final replies posted automatically. For ambient rooms using `message_tool`, use a model/runtime that reliably calls tools.
|
||||
|
||||
If Telegram ambient rooms do not trigger at all, check BotFather privacy mode and verify the Gateway is receiving normal group messages.
|
||||
|
||||
|
||||
@@ -250,9 +250,9 @@ Once DMs are working, you can set up your Discord server as a full workspace whe
|
||||
<Step title="Allow responses without @mention">
|
||||
By default, your agent only responds in guild channels when @mentioned. For a private server, you probably want it to respond to every message.
|
||||
|
||||
In guild channels, visible Discord output should use the `message` tool by default, so the agent can lurk and only post when it decides a channel reply is useful. Ambient room events stay quiet unless the tool sends. See [Ambient room events](/channels/ambient-room-events) for the full lurk-mode config.
|
||||
In guild channels, normal replies post automatically by default. For shared always-on rooms, opt into `messages.groupChat.visibleReplies: "message_tool"` so the agent can lurk and only post when it decides a channel reply is useful. This works best with latest-generation, tool-reliable models such as GPT 5.5. Ambient room events stay quiet unless the tool sends. See [Ambient room events](/channels/ambient-room-events) for the full lurk-mode config.
|
||||
|
||||
This means the selected model should reliably call tools. If Discord shows typing and the logs show token usage but no posted message, check whether the turn was configured as an ambient room event or use the config below to restore legacy automatic final replies for normal group requests.
|
||||
If Discord shows typing and the logs show token usage but no posted message, check whether the turn was configured as an ambient room event or opted into message-tool visible replies.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Ask your agent">
|
||||
@@ -275,7 +275,7 @@ Once DMs are working, you can set up your Discord server as a full workspace whe
|
||||
}
|
||||
```
|
||||
|
||||
To restore legacy automatic final replies for group/channel rooms, set `messages.groupChat.visibleReplies: "automatic"`.
|
||||
To require message-tool sends for visible group/channel replies, set `messages.groupChat.visibleReplies: "message_tool"`.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user