mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
168 Commits
sqlite-str
...
v2026.5.6-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e9c8beaca | ||
|
|
2411dbcde2 | ||
|
|
4784361ee4 | ||
|
|
cf483c1d09 | ||
|
|
660958b022 | ||
|
|
8a904d2a1f | ||
|
|
c4fae41d4f | ||
|
|
803ba3ded3 | ||
|
|
06809c45fc | ||
|
|
7de45e0dfd | ||
|
|
7b53e58670 | ||
|
|
5507a1db76 | ||
|
|
f6415d51c3 | ||
|
|
cd070c2a49 | ||
|
|
543dbd179b | ||
|
|
84a0015973 | ||
|
|
a17876c9ba | ||
|
|
344f56a23f | ||
|
|
88752d9359 | ||
|
|
2db4d779db | ||
|
|
8737b1860e | ||
|
|
edbc6f4d49 | ||
|
|
105242f06d | ||
|
|
18ebbe3f36 | ||
|
|
6c1f92ed88 | ||
|
|
5e9e645458 | ||
|
|
f0bc87def4 | ||
|
|
4b8f9433b8 | ||
|
|
1ca61d1b8c | ||
|
|
04ccb48164 | ||
|
|
1141b4588f | ||
|
|
f204c1c390 | ||
|
|
e7e5de1c01 | ||
|
|
d74cf76be9 | ||
|
|
bfc12cb0da | ||
|
|
8e26a76bf0 | ||
|
|
6b57ec27f0 | ||
|
|
56e6a0e735 | ||
|
|
8da8a0b24b | ||
|
|
bb9e318b3a | ||
|
|
59d6d47f93 | ||
|
|
95577906da | ||
|
|
bbb60f6f2c | ||
|
|
44be32703e | ||
|
|
c768975ef5 | ||
|
|
1181698892 | ||
|
|
e0f840a869 | ||
|
|
3b05c2e897 | ||
|
|
d1f71fc117 | ||
|
|
3f08aad2b6 | ||
|
|
466ec818a7 | ||
|
|
20251902e0 | ||
|
|
437ce8d5a1 | ||
|
|
9d68ac9f50 | ||
|
|
76b0be61c9 | ||
|
|
533826780a | ||
|
|
67dcc9e8bd | ||
|
|
b412d7056e | ||
|
|
2c8e68cf1f | ||
|
|
3e34133137 | ||
|
|
983c546eac | ||
|
|
dbf185575a | ||
|
|
38989bdceb | ||
|
|
36bdb4b15a | ||
|
|
2c8a1c3cdb | ||
|
|
8a84958320 | ||
|
|
e4ee1e5b57 | ||
|
|
97305d2518 | ||
|
|
e30ffd0853 | ||
|
|
58a5fc3895 | ||
|
|
88503674af | ||
|
|
cbf08f16d5 | ||
|
|
acab03a594 | ||
|
|
e75480dbb8 | ||
|
|
c97b9f79ec | ||
|
|
1ec03a9538 | ||
|
|
8738f9e772 | ||
|
|
c4d3026a1e | ||
|
|
92339752ea | ||
|
|
623757011e | ||
|
|
24e8152a0c | ||
|
|
9d0dbe0754 | ||
|
|
ee1d5cc566 | ||
|
|
61c22a6eab | ||
|
|
1c724f4861 | ||
|
|
680064bf98 | ||
|
|
65ce07547a | ||
|
|
982c8d8654 | ||
|
|
b25dc1704f | ||
|
|
7615b425c5 | ||
|
|
a162aafe02 | ||
|
|
b1abf9d8ae | ||
|
|
665b164d4f | ||
|
|
4364d77442 | ||
|
|
50488f9f5f | ||
|
|
430c0bdaba | ||
|
|
9d3dcfdd51 | ||
|
|
5932467c0c | ||
|
|
62840687ed | ||
|
|
74ab84454d | ||
|
|
d9dcade46c | ||
|
|
6968e18998 | ||
|
|
39b6c580cf | ||
|
|
dcac9998da | ||
|
|
de6e2616a1 | ||
|
|
013a2c50f6 | ||
|
|
6896bd3ddf | ||
|
|
9cb0b97f74 | ||
|
|
e820821950 | ||
|
|
ecf2dc58e1 | ||
|
|
817b56f22d | ||
|
|
258e153705 | ||
|
|
acdf0e432a | ||
|
|
3c04d7e710 | ||
|
|
1205c9ef1f | ||
|
|
ba5bc48f70 | ||
|
|
cbbdaf92a4 | ||
|
|
1692c84b8c | ||
|
|
0097427d08 | ||
|
|
2d2fc19e36 | ||
|
|
daff8916de | ||
|
|
3569edb38e | ||
|
|
9cc6cca75d | ||
|
|
e8ad813282 | ||
|
|
e8f0608b09 | ||
|
|
7470ea9073 | ||
|
|
ed4ed5aead | ||
|
|
3544ef0afa | ||
|
|
7da737c67d | ||
|
|
95e9e29219 | ||
|
|
31633bc0ec | ||
|
|
30927c8491 | ||
|
|
9a61edb419 | ||
|
|
e12dbf527f | ||
|
|
23319a3cc2 | ||
|
|
82e914e506 | ||
|
|
c82f66e3c2 | ||
|
|
191e821b71 | ||
|
|
7a2e7dba73 | ||
|
|
eda33431de | ||
|
|
4aa91b0b97 | ||
|
|
8a601b0607 | ||
|
|
6f9f36a38f | ||
|
|
c03449678e | ||
|
|
edbd3355be | ||
|
|
325df3efef | ||
|
|
2fc80754cf | ||
|
|
41f028e2ea | ||
|
|
303ff716d4 | ||
|
|
5fcdeae80c | ||
|
|
b73317c217 | ||
|
|
8f6bf65162 | ||
|
|
8017dc4c3b | ||
|
|
578d9072cf | ||
|
|
30b73bbf41 | ||
|
|
ade922ba98 | ||
|
|
997f8af734 | ||
|
|
6204a6fecc | ||
|
|
9f15c29397 | ||
|
|
cac973972c | ||
|
|
f8f18d53fc | ||
|
|
696f639cf6 | ||
|
|
079b937b46 | ||
|
|
32e36d355d | ||
|
|
12e1c67f22 | ||
|
|
766d02ff3b | ||
|
|
e9ebb6ce6c | ||
|
|
e0002c4b5b |
@@ -42,10 +42,12 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
config footprint move, so do not blindly copy stale replacement annotations
|
||||
into release notes.
|
||||
- Do not delete or rewrite beta tags after their matching npm package has been
|
||||
published. If a pushed beta tag fails preflight before npm publish, delete and
|
||||
recreate the tag and prerelease at the fixed commit so npm prerelease versions
|
||||
stay contiguous. If a published beta needs a fix, commit the fix on the
|
||||
release branch and increment to the next `-beta.N`.
|
||||
published. If a pushed beta tag fails before npm publish, the version is not
|
||||
consumed: keep the same `-beta.N`, delete/recreate or force-move the git tag
|
||||
and prerelease to the fixed commit, and rerun preflight. Do not increment to
|
||||
the next beta number until the matching npm package has actually published.
|
||||
If a published beta needs a fix, commit the fix on the release branch and
|
||||
increment to the next `-beta.N`.
|
||||
- For a beta release train, run the fast local preflight first, publish the
|
||||
beta to npm `beta`, then run the expensive published-package roster focused
|
||||
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
|
||||
|
||||
@@ -1921,7 +1921,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-minimax
|
||||
label: Native live gateway profiles MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 90
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
@@ -2223,7 +2223,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-minimax-docker
|
||||
label: Docker live gateway MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=30000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=60000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 25m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=30000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=60000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 25m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 30
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
|
||||
@@ -558,7 +558,7 @@ jobs:
|
||||
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
|
||||
suite_profile: custom
|
||||
docker_lanes: doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update
|
||||
docker_lanes: doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor plugins-offline plugin-update
|
||||
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'all-since-2026.4.23' || '' }}
|
||||
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
|
||||
telegram_mode: mock-openai
|
||||
|
||||
92
.github/workflows/openclaw-release-publish.yml
vendored
92
.github/workflows/openclaw-release-publish.yml
vendored
@@ -33,7 +33,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
publish_openclaw_npm:
|
||||
description: Publish the OpenClaw npm package after plugin npm and ClawHub publish complete
|
||||
description: Publish the OpenClaw npm package after plugin npm succeeds; ClawHub may still run
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
@@ -169,15 +169,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dispatch_and_wait() {
|
||||
dispatch_workflow() {
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id status conclusion url
|
||||
local before_json dispatch_output run_id
|
||||
before_json="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
|
||||
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
printf '%s\n' "$dispatch_output"
|
||||
printf '%s\n' "$dispatch_output" >&2
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
sed -nE 's#.*actions/runs/([0-9]+).*#\1#p' |
|
||||
@@ -202,15 +202,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2
|
||||
printf '%s\n' "${run_id}"
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "$run_id" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cancel_child EXIT INT TERM
|
||||
wait_for_run() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local status conclusion url
|
||||
|
||||
while true; do
|
||||
status="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status --jq '.status')"
|
||||
@@ -219,7 +218,6 @@ jobs:
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
trap - EXIT INT TERM
|
||||
|
||||
conclusion="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json conclusion --jq '.conclusion')"
|
||||
url="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json url --jq '.url')"
|
||||
@@ -229,16 +227,36 @@ jobs:
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
|
||||
exit 1
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_run_background() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local result_file="$3"
|
||||
(
|
||||
if wait_for_run "${workflow}" "${run_id}"; then
|
||||
printf 'success\n' > "${result_file}"
|
||||
else
|
||||
printf 'failure\n' > "${result_file}"
|
||||
fi
|
||||
) &
|
||||
wait_run_pid="$!"
|
||||
}
|
||||
|
||||
{
|
||||
echo "### Publish sequence"
|
||||
echo
|
||||
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
|
||||
echo "- Release tag: \`${RELEASE_TAG}\`"
|
||||
echo "- Release SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
echo "- OpenClaw npm publish: starts after plugin npm succeeds; ClawHub may still be running"
|
||||
else
|
||||
echo "- OpenClaw npm publish: skipped by input"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
|
||||
@@ -248,15 +266,53 @@ jobs:
|
||||
clawhub_args+=(-f plugins="${PLUGINS}")
|
||||
fi
|
||||
|
||||
dispatch_and_wait plugin-npm-release.yml "${npm_args[@]}"
|
||||
dispatch_and_wait plugin-clawhub-release.yml "${clawhub_args[@]}"
|
||||
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
|
||||
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
|
||||
|
||||
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
|
||||
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
openclaw_npm_run_id=""
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
dispatch_and_wait openclaw-npm-release.yml \
|
||||
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
|
||||
-f tag="${RELEASE_TAG}" \
|
||||
-f preflight_only=false \
|
||||
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
|
||||
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}"
|
||||
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
|
||||
else
|
||||
echo "- OpenClaw npm publish: skipped by input" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
|
||||
openclaw_result=""
|
||||
openclaw_pid=""
|
||||
if [[ -n "${openclaw_npm_run_id}" ]]; then
|
||||
openclaw_result="$RUNNER_TEMP/openclaw-npm-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background openclaw-npm-release.yml "${openclaw_npm_run_id}" "${openclaw_result}"
|
||||
openclaw_pid="${wait_run_pid}"
|
||||
fi
|
||||
|
||||
failed=0
|
||||
if ! wait "${clawhub_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ "${failed}" != "0" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
4
.github/workflows/package-acceptance.yml
vendored
4
.github/workflows/package-acceptance.yml
vendored
@@ -386,10 +386,10 @@ jobs:
|
||||
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
|
||||
;;
|
||||
package)
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update"
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor plugins-offline plugin-update"
|
||||
;;
|
||||
product)
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
|
||||
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
|
||||
include_openwebui=true
|
||||
;;
|
||||
full)
|
||||
|
||||
206
.github/workflows/plugin-clawhub-release.yml
vendored
206
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -45,6 +45,7 @@ jobs:
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
plan_json: ${{ steps.plan.outputs.plan_json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -148,12 +149,14 @@ jobs:
|
||||
has_candidates="true"
|
||||
fi
|
||||
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
|
||||
plan_json="$(jq -c . .local/plugin-clawhub-release-plan.json)"
|
||||
|
||||
{
|
||||
echo "candidate_count=${candidate_count}"
|
||||
echo "skipped_published_count=${skipped_published_count}"
|
||||
echo "has_candidates=${has_candidates}"
|
||||
echo "matrix=${matrix_json}"
|
||||
echo "plan_json=${plan_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Plugin release candidates:"
|
||||
@@ -182,7 +185,7 @@ jobs:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 6
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
@@ -216,21 +219,26 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: main
|
||||
ref: ${{ env.CLAWHUB_REF }}
|
||||
path: clawhub-source
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Checkout pinned ClawHub CLI revision
|
||||
working-directory: clawhub-source
|
||||
env:
|
||||
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
|
||||
run: git checkout --detach "${CLAWHUB_REF}"
|
||||
- name: Cache ClawHub CLI Bun artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: clawhub-cli-bun-${{ runner.os }}-${{ env.CLAWHUB_REF }}-${{ hashFiles('clawhub-source/bun.lock', 'clawhub-source/bun.lockb') }}
|
||||
restore-keys: |
|
||||
clawhub-cli-bun-${{ runner.os }}-${{ env.CLAWHUB_REF }}-
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
id: clawhub_install
|
||||
continue-on-error: true
|
||||
working-directory: clawhub-source
|
||||
run: bun install --frozen-lockfile
|
||||
run: bash "$GITHUB_WORKSPACE/scripts/install-clawhub-cli-deps.sh"
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
if: steps.clawhub_install.outcome == 'success'
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
@@ -241,9 +249,15 @@ jobs:
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify package-local runtime build
|
||||
id: runtime_build
|
||||
if: steps.clawhub_install.outcome == 'success'
|
||||
continue-on-error: true
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Preview publish command
|
||||
id: preview_publish
|
||||
if: steps.clawhub_install.outcome == 'success' && steps.runtime_build.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
@@ -253,9 +267,129 @@ jobs:
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
- name: Write preview result
|
||||
if: always()
|
||||
env:
|
||||
PLUGIN_JSON: ${{ toJson(matrix.plugin) }}
|
||||
INSTALL_OUTCOME: ${{ steps.clawhub_install.outcome }}
|
||||
RUNTIME_BUILD_OUTCOME: ${{ steps.runtime_build.outcome }}
|
||||
PREVIEW_OUTCOME: ${{ steps.preview_publish.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local/clawhub-preview-results
|
||||
node --input-type=module <<'EOF'
|
||||
import { writeFileSync } from "node:fs";
|
||||
const plugin = JSON.parse(process.env.PLUGIN_JSON ?? "{}");
|
||||
const outcomes = {
|
||||
install: process.env.INSTALL_OUTCOME || "skipped",
|
||||
runtimeBuild: process.env.RUNTIME_BUILD_OUTCOME || "skipped",
|
||||
preview: process.env.PREVIEW_OUTCOME || "skipped",
|
||||
};
|
||||
const failed = Object.entries(outcomes).filter(([, outcome]) => outcome !== "success");
|
||||
const result = {
|
||||
status: failed.length === 0 ? "success" : "failure",
|
||||
failedSteps: failed.map(([step, outcome]) => ({ step, outcome })),
|
||||
plugin,
|
||||
};
|
||||
const id = String(plugin.extensionId ?? plugin.packageName ?? "plugin").replace(/[^A-Za-z0-9_.-]+/g, "-");
|
||||
writeFileSync(`.local/clawhub-preview-results/${id}.json`, `${JSON.stringify(result, null, 2)}\n`);
|
||||
EOF
|
||||
|
||||
- name: Upload preview result
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: plugin-clawhub-preview-${{ strategy.job-index }}
|
||||
path: .local/clawhub-preview-results/*.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Fail failed preview cell
|
||||
if: always() && (steps.clawhub_install.outcome != 'success' || steps.runtime_build.outcome != 'success' || steps.preview_publish.outcome != 'success')
|
||||
run: exit 1
|
||||
|
||||
collect_preview_results:
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
if: always() && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
passed_count: ${{ steps.collect.outputs.passed_count }}
|
||||
failed_count: ${{ steps.collect.outputs.failed_count }}
|
||||
passed_matrix: ${{ steps.collect.outputs.passed_matrix }}
|
||||
steps:
|
||||
- name: Download preview results
|
||||
id: download
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: plugin-clawhub-preview-*
|
||||
path: .local/clawhub-preview-results
|
||||
merge-multiple: true
|
||||
|
||||
- name: Collect preview results
|
||||
id: collect
|
||||
env:
|
||||
ORIGINAL_MATRIX: ${{ needs.preview_plugins_clawhub.outputs.matrix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'EOF' > .local/clawhub-preview-summary.json
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const original = JSON.parse(process.env.ORIGINAL_MATRIX || "[]");
|
||||
const resultDir = ".local/clawhub-preview-results";
|
||||
const results = [];
|
||||
try {
|
||||
for (const file of readdirSync(resultDir)) {
|
||||
if (file.endsWith(".json")) {
|
||||
results.push(JSON.parse(readFileSync(join(resultDir, file), "utf8")));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing artifacts are accounted for below.
|
||||
}
|
||||
|
||||
const keyFor = (plugin) => `${plugin.packageName ?? ""}@${plugin.version ?? ""}`;
|
||||
const resultByKey = new Map(results.map((result) => [keyFor(result.plugin ?? {}), result]));
|
||||
const passed = [];
|
||||
const failed = [];
|
||||
for (const plugin of original) {
|
||||
const result = resultByKey.get(keyFor(plugin));
|
||||
if (result?.status === "success") {
|
||||
passed.push(plugin);
|
||||
} else {
|
||||
failed.push({
|
||||
plugin,
|
||||
failedSteps: result?.failedSteps ?? [{ step: "preview-result", outcome: "missing" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ passed, failed }, null, 2));
|
||||
EOF
|
||||
|
||||
passed_matrix="$(jq -c '.passed' .local/clawhub-preview-summary.json)"
|
||||
passed_count="$(jq -r '.passed | length' .local/clawhub-preview-summary.json)"
|
||||
failed_count="$(jq -r '.failed | length' .local/clawhub-preview-summary.json)"
|
||||
{
|
||||
echo "passed_count=${passed_count}"
|
||||
echo "failed_count=${failed_count}"
|
||||
echo "passed_matrix=${passed_matrix}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### ClawHub preview results"
|
||||
echo
|
||||
echo "- Passed: \`${passed_count}\`"
|
||||
echo "- Failed: \`${failed_count}\`"
|
||||
if [[ "${failed_count}" != "0" ]]; then
|
||||
echo
|
||||
jq -r '.failed[] | "- \(.plugin.packageName)@\(.plugin.version): \(.failedSteps | map("\(.step)=\(.outcome)") | join(", "))"' .local/clawhub-preview-summary.json
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, collect_preview_results]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.collect_preview_results.outputs.passed_count != '0'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
@@ -263,9 +397,9 @@ jobs:
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 6
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
plugin: ${{ fromJson(needs.collect_preview_results.outputs.passed_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -297,19 +431,21 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: main
|
||||
ref: ${{ env.CLAWHUB_REF }}
|
||||
path: clawhub-source
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Checkout pinned ClawHub CLI revision
|
||||
working-directory: clawhub-source
|
||||
env:
|
||||
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
|
||||
run: git checkout --detach "${CLAWHUB_REF}"
|
||||
- name: Cache ClawHub CLI Bun artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: clawhub-cli-bun-${{ runner.os }}-${{ env.CLAWHUB_REF }}-${{ hashFiles('clawhub-source/bun.lock', 'clawhub-source/bun.lockb') }}
|
||||
restore-keys: |
|
||||
clawhub-cli-bun-${{ runner.os }}-${{ env.CLAWHUB_REF }}-
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: bun install --frozen-lockfile
|
||||
run: bash "$GITHUB_WORKSPACE/scripts/install-clawhub-cli-deps.sh"
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
@@ -392,3 +528,31 @@ jobs:
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
|
||||
verify_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, collect_preview_results, publish_plugins_clawhub]
|
||||
if: always() && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Verify expected ClawHub versions are published
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
PLAN_JSON: ${{ needs.preview_plugins_clawhub.outputs.plan_json }}
|
||||
PUBLISH_RESULT: ${{ needs.publish_plugins_clawhub.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local
|
||||
printf '%s\n' "${PLAN_JSON}" > .local/plugin-clawhub-release-plan.json
|
||||
if [[ "${PUBLISH_RESULT}" != "success" ]]; then
|
||||
echo "::warning::ClawHub publish job concluded with ${PUBLISH_RESULT}; verifying registry state before failing."
|
||||
fi
|
||||
node scripts/plugin-clawhub-verify-published.mjs .local/plugin-clawhub-release-plan.json
|
||||
|
||||
158
CHANGELOG.md
158
CHANGELOG.md
@@ -2,7 +2,147 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
## 2026.5.7
|
||||
|
||||
### Fixes
|
||||
|
||||
- Release/plugin publishing: retry transient ClawHub CLI dependency install failures, keep preview-passing plugins publishable when one preview cell flakes, and verify every expected ClawHub package version after publish so maintenance releases are faster to recover and less likely to hide partial plugin publishes.
|
||||
- OpenAI: support `openai/chat-latest` as an explicit direct API-key model override for trying the moving ChatGPT Instant API alias without changing the stable default model.
|
||||
- Cron CLI: include computed `status` in `cron list --json` and `cron show --json` output so external tooling can read disabled/running/ok/error/skipped/idle state without reimplementing cron status derivation. (#78701) Thanks @aweiker.
|
||||
- Channels CLI: make `openclaw channels list` channel-only, add `--all` for bundled and catalog channels, render installed/configured/enabled state, and move model auth/usage details to `openclaw models auth list`, `openclaw status`, and `openclaw models list`. (#78456) Thanks @sliverp.
|
||||
- Native commands: honor owner enforcement for native command handlers. (#78864) Thanks @pgondhi987.
|
||||
- Active Memory: require admin scope for global memory toggles. (#78863) Thanks @pgondhi987.
|
||||
- Gateway/sessions: clear cached skills snapshots during `/new` and `sessions.reset` so long-lived channel sessions rebuild the visible skill list after skills change. (#78873) Thanks @Evizero.
|
||||
- Auto-reply: gate inline skill tool dispatch through before-tool-call authorization hooks. (#78517) Thanks @pgondhi987.
|
||||
- Tavily: resolve dedicated `tavily_search` and `tavily_extract` tool credentials from the active runtime config snapshot, so `exec` SecretRef-backed API keys do not reach the tools unresolved. (#78610) Thanks @VACInc.
|
||||
- Plugins/install: use the same absolute POSIX npm lifecycle shell for managed plugin install, rollback, repair, and uninstall npm operations as staged package updates, preventing restricted PATH shells from breaking cleanup. Thanks @vincentkoc.
|
||||
- Agents/context engine: invalidate cached assembled context views when source history shrinks or assembly fails, preventing stale pre-reset history from being reused. Fixes #77968. (#78163) Thanks @brokemac79 and @ChrisBot2026.
|
||||
- Discord/message: parse provider-prefixed targets like `discord:channel:<id>` as channel sends instead of legacy Discord DM targets, so cross-channel agent `message(action="send")` calls no longer misroute channel IDs into misleading `Unknown Channel` failures. Fixes #78572.
|
||||
- Agents/compaction: clamp compaction summary reserve tokens to each model's output limit so high-context compaction no longer requests invalid `max_tokens` values. (#54392) Thanks @adzendo.
|
||||
- Commands/BTW: show the `/btw` missing-question usage placeholder with brackets so outbound channel sanitization keeps it visible. Fixes #62877. Thanks @RajvardhanPatil07.
|
||||
- Cron/doctor: repair persisted cron jobs whose `payload.model` was stored as `"default"`, `"null"`, blank, or JSON `null` by removing the bad override during `openclaw doctor --fix` while keeping cron runtime model validation strict. Fixes #78549. Thanks @bizzle12368239.
|
||||
- Telegram: honor `accessGroup:*` sender allowlists for DMs, groups, native commands, and callback authorization before applying Telegram's numeric sender-ID checks. Fixes #78660. Thanks @manugc.
|
||||
- Agent delivery: report `deliverySucceeded=false` when outbound delivery returns no adapter result, so claimed/empty delivery paths no longer masquerade as successful sends. Fixes #78532. Thanks @joeyfrasier.
|
||||
- Cron/isolated runs: fail implicit announce delivery before model execution when `delivery.channel=last` has no previous route, so recurring jobs do not spend tokens before hitting a permanent delivery-target error. Fixes #78608. Thanks @sallyom.
|
||||
- Gateway/sessions: persist a new generated transcript file when daily gateway-agent session rollover changes the session id, while preserving custom transcript paths. Fixes #78607. Thanks @nailujac, @zerone0x, and @sallyom.
|
||||
- Doctor/Codex OAuth: preserve working `openai-codex/*` PI routes during `doctor --fix` and recover 2026.5.5-rewritten `openai/*` GPT-5 routes when only Codex OAuth auth is available, so update repair does not break subscription-auth setups. Fixes #78407. Thanks @shakkernerd.
|
||||
- Telegram: keep the polling watchdog tied to `getUpdates` liveness so unrelated outbound Bot API calls cannot mask a wedged inbound poller. Fixes #78422. Thanks @ai-hpc.
|
||||
- Agents/subagents: have completed session-mode subagent registry rows honor `agents.defaults.subagents.archiveAfterMinutes` instead of a hardcoded 5-minute TTL, so registry-backed surfaces keep one retention knob across spawn modes. (#78263) Thanks @arniesaha.
|
||||
- Plugins/channel setup: forward `setChannelRuntime` from non-bundled external plugin setup entries so deferred external channel runtime initializers are installed before startup polling. Fixes #77779. (#77799) Thanks @openperf.
|
||||
- Telegram: treat successful same-chat `message` tool outbound sends during an inbound Telegram turn as delivered when deciding whether to emit the rewritten silent reply fallback. (#78685) Thanks @neeravmakwana.
|
||||
- Gateway/tasks: reconcile stale CLI run-context tasks whose live run context disappeared and bound channel hot-reload deferrals so stale task records cannot block Discord/Slack/Telegram reloads forever.
|
||||
- Discord/voice: audit Discord voice-channel permissions in `channels capabilities` and `channels status --probe`, including auto-join targets, so missing Connect/Speak/Read Message History permissions show up before `/vc join`.
|
||||
- Discord/voice: make voice capture less choppy by extending the default post-speech silence grace to 2.5s, add `voice.captureSilenceGraceMs` for noisy Discord sessions, and tighten the spoken-output prompt around live STT fragments. Thanks @vincentkoc.
|
||||
- WhatsApp: route proactive phone-number sends through Baileys LID forward mappings when available, so LID-addressed contacts receive agent messages instead of creating sender-only ghost chats. Fixes #67378. (#74925) Thanks @edenfunf.
|
||||
- WhatsApp: send captioned `MEDIA:` directive auto-replies once instead of emitting an empty media message before the captioned media reply. (#78770) Thanks @ai-hpc.
|
||||
- Codex/approvals: in Codex approval modes, stop installing the pre-guardian native `PermissionRequest` hook by default so Codex's reviewer can approve safe commands before OpenClaw surfaces an approval, remember `allow-always` decisions for identical Codex native `PermissionRequest` payloads within the active session window, and make plugin approval requests validate/render their actual allowed decisions so Telegram and other native approval UIs cannot offer stale actions. Thanks @shakkernerd.
|
||||
- Model providers: normalize APNG sniffed PNG uploads, preserve Gemini 3 tool-call thought-signature replay with fallback signatures, accept legacy `__env__:VAR` custom-provider keys, and repair snake_case tool-call transcript sanitization. Fixes #51881, #48915, #77566, and #42858.
|
||||
- Telegram/models: parse provider ids containing dots in `/models` callback buttons so `hf.co` model lists render as inline keyboard buttons. Fixes #38745.
|
||||
|
||||
## 2026.5.6
|
||||
|
||||
### Fixes
|
||||
|
||||
- Doctor/OpenAI config: keep the 2026.5.6 release branch clear of the legacy Codex route rewrite that could change OpenAI model config during `doctor --fix`, preserving existing OpenAI routes unless a supported repair path applies.
|
||||
- Plugins/runtime fetch: drop third-party symbol metadata from plain request header dictionaries before passing them into native `fetch` or `Headers`, so SDK and guarded/proxy fetch paths do not reject otherwise valid plugin requests. Fixes #77846. Thanks @shakkernerd.
|
||||
- Debug proxy: normalize captured fetch header dictionaries before replaying requests so symbol metadata from caller-owned header objects cannot make debug-proxy fetches fail.
|
||||
- Web fetch: bound guarded dispatcher cleanup after request timeouts so timed-out fetches return tool errors instead of leaving Gateway tool lanes active. (#78439) Thanks @obviyus.
|
||||
|
||||
## 2026.5.5
|
||||
|
||||
### Fixes
|
||||
|
||||
- Telegram/Codex: generate DM topic labels with Codex-compatible simple-completion requests so auto-created private topics can be renamed instead of staying `New Chat`.
|
||||
- Doctor/Codex OAuth: preserve working `openai-codex/*` PI routes during `doctor --fix`, recover 2026.5.5-rewritten `openai/*` GPT-5 routes when only Codex OAuth auth is available, and warn without rewriting mixed Codex OAuth plus direct OpenAI PI routes, so update repair does not break subscription-auth setups. Fixes #78407. Thanks @shakkernerd.
|
||||
- Plugins/runtime fetch: drop third-party symbol metadata from plain request header dictionaries before passing them into native `fetch` or `Headers`, so SDK and guarded/proxy fetch paths do not reject otherwise valid plugin requests. Fixes #77846. Thanks @shakkernerd.
|
||||
- Web fetch: bound guarded dispatcher cleanup after request timeouts so timed-out fetches return tool errors instead of leaving Gateway tool lanes active. (#78439) Thanks @obviyus.
|
||||
- Mattermost/setup: prompt for and persist the server base URL after the bot token in `openclaw setup --wizard`, instead of failing validation before `--http-url` is collected. Fixes #76670. Thanks @jacobtomlinson.
|
||||
- Gate Slack startup user allowlist resolution [AI]. (#77898) Thanks @pgondhi987.
|
||||
- OpenAI/Codex: suppress stale `openai-codex` GPT-5.1/5.2/5.3 model refs that ChatGPT/Codex OAuth accounts now reject, keeping model lists, config validation, and forward-compat resolution on current 5.4/5.5 routes. Fixes #67158. Thanks @drpau.
|
||||
- CLI/update: keep pnpm package updates on the running custom global install root and pass pnpm's `--global-dir` so `openclaw update` does not create a second default-prefix install when `OPENCLAW_HOME` or the shell points at a custom OpenClaw directory. Fixes #78377. Thanks @amknight.
|
||||
- Google Meet/Voice Call: wait longer before playing PIN-derived Twilio DTMF for Meet dial-in prompts and retire stale delegated phone sessions instead of reusing completed calls.
|
||||
- PDF/Codex: include extraction-fallback instructions for `openai-codex/*` PDF tool requests so Codex Responses receives its required system prompt. Fixes #77872. Thanks @anyech.
|
||||
- Gateway/startup: keep the Gateway running when a configured optional plugin-owned capability such as a web_search provider or channel points at a known installable plugin that is currently unavailable; startup now logs a config warning and leaves `openclaw doctor --fix` to install or enable the plugin. (#78642) Thanks @joshavant.
|
||||
- Onboard/channels: recover externalized channel plugins from stale `channels.<id>` config by falling back to `ensureChannelSetupPluginInstalled` via the trusted catalog when the plugin is missing on disk, so leftover `appId`/token entries no longer dead-end onboard with "<channel> plugin not available." (#78328) Thanks @sliverp.
|
||||
- Codex/app-server: forward the OpenClaw workspace bootstrap block through Codex `developerInstructions` instead of `config.instructions`, so persona/style guidance reaches the behavior-shaping app-server lane. Fixes #77363. Thanks @lonexreb.
|
||||
- MS Teams: route proactive channel sends with stored thread roots through the configured threaded reply path instead of forcing every CLI/message-tool send into a new top-level post. Fixes #78298. Thanks @amknight.
|
||||
- CLI/infer: pass minimal instructions to local `openai-codex/*` model probes and surface provider error details when `infer model run` returns no text. Fixes #76464. Thanks @lilesjtu.
|
||||
- Dependencies: override transitive `ip-address` to `10.2.0` so the runtime lockfile no longer includes the vulnerable `10.1.0` build flagged by Dependabot alert 109. Thanks @vincentkoc.
|
||||
- Plugins/install: apply OpenClaw's npm security overrides inside managed external plugin npm roots so hoisted plugin dependencies inherit the host package hardening. Thanks @vincentkoc.
|
||||
- Plugins/install: skip npm peer resolution in managed plugin roots so installing peer-based plugins such as Opik cannot pull a stale registry `openclaw` copy beside Codex/Discord/WhatsApp and trigger `ERESOLVE`. Thanks @vincentkoc.
|
||||
- Plugins/uninstall: run managed npm cleanup even when a plugin package directory is already missing, preventing stale package manifests from reinstalling removed plugins on the next npm install.
|
||||
- Feishu: hydrate missing native topic starter thread IDs before session routing so first turns and follow-ups stay in the same topic session. Fixes #78262. Thanks @joeyzenghuan.
|
||||
- LINE: reject `dmPolicy: "open"` configs without wildcard `allowFrom` so webhook DMs fail validation instead of being acknowledged and silently blocked before inbound processing. Fixes #78316.
|
||||
- Telegram/Codex: keep message-tool-only progress drafts visible and render native Codex tool progress once per tool instead of duplicating item/tool draft lines. Fixes #75641. (#77949)
|
||||
- Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`.
|
||||
- Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models.
|
||||
- Matrix/approvals: retry approval delivery up to 3 times with a short backoff so transient Matrix send failures do not strand pending approval prompts. (#78179) Thanks @Patrick-Erichsen.
|
||||
- Discord/gateway: measure heartbeat ACK timeouts from the actual heartbeat send, preventing late initial heartbeats from triggering false reconnect loops while the channel is still awaiting readiness. Fixes #77668. (#78087) Thanks @bryce-d-greybeard and @NikolaFC.
|
||||
- Discord/guilds: route plain text control commands such as `/steer` through the normal authorization and mention gate instead of silently dropping them before an agent session can see them. Fixes #78080. Thanks @ramitrkar-hash.
|
||||
- Control UI/Sessions: make the compaction count a compact `N Checkpoint(s)` disclosure and show expanded session-level details with modern checkpoint history cards across responsive table layouts. Thanks @BunsDev.
|
||||
- Control UI/performance: keep chat and channel tabs responsive while history payloads and channel probes are slow, label partial channel status, and record slow chat/config render timings in the event log. Thanks @BunsDev.
|
||||
- Control UI/sessions: fire the documented `/new` command and lifecycle hooks only for explicit Control UI session creation, restoring session-memory and custom hook capture without changing SDK parent-session creates. Fixes #76957. Thanks @BunsDev.
|
||||
- Exec approvals: fall back to a guarded copy when Windows rejects rename-overwrite for `exec-approvals.json`, while preserving symlink, hard-link, and owner-only permission safeguards. Fixes #77785. (#77907) Thanks @Alex-Alaniz and @MilleniumGenAI.
|
||||
- Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`.
|
||||
- iOS pairing: allow setup-code and manual `ws://` connects for private LAN and `.local` gateways while keeping Tailscale/public routes on `wss://`, and prefer explicit gateway passwords over stale bootstrap tokens in mixed-auth reconnects. Fixes #47887; carries forward #65185. Thanks @draix and @BunsDev.
|
||||
- Plugins/diagnostics: make source-only TypeScript package warnings actionable by explaining that missing compiled runtime output is a publisher packaging issue and pointing users to update/reinstall or disable/uninstall the plugin. Fixes #77835. Thanks @googlerest.
|
||||
- Control UI/chat: keep persisted assistant progress text visible when the same transcript turn also contains tool-use metadata, so chat.history reloads no longer make those replies vanish after the next user message. Fixes #77374. Thanks @BunsDev.
|
||||
- Cron: repair persisted future `nextRunAtMs` values that no longer line up with the cron schedule, so daily timezone-aware jobs do not stay jumped to stale future dates. Fixes #77867. Thanks @hongfangsong.
|
||||
- TUI: skip the generic CLI respawn wrapper for interactive launches, exit cleanly on terminal loss, and refuse to restore heartbeat sessions as the remembered chat session, preventing stale heartbeat history and orphaned `openclaw-tui` processes on first boot. Thanks @vincentkoc.
|
||||
- Doctor/sessions: move heartbeat-poisoned default main session store entries to recovery keys and clear stale TUI restore pointers, so `doctor --fix` can repair instances already stuck on `agent:main:main` heartbeat history. Thanks @vincentkoc.
|
||||
- Agents/context engines: keep hidden OpenClaw runtime-context custom messages out of context-engine assemble, afterTurn, and ingest hooks so transcript reconstruction plugins only see conversation messages. Thanks @vincentkoc.
|
||||
- Gateway/shutdown: cancel delayed post-ready maintenance during close and suppress maintenance/cron startup after quick restarts, preventing orphaned background timers. Thanks @vincentkoc.
|
||||
- Agents/generated media: treat attachment-style message tool actions as completed chat sends, preventing duplicate fallback media posts when generated files were already uploaded.
|
||||
- Control UI/sessions: show each session's agent runtime in the Sessions table and allow filtering by runtime labels, matching the Agents panel runtime wording. Thanks @vincentkoc.
|
||||
- Discord/streaming: show live reasoning text in progress drafts instead of a bare `Reasoning` status line.
|
||||
- Gateway/status: avoid marking fast repeated health/status samples as event-loop degraded from CPU/utilization alone until the Gateway has accumulated a sustained sampling window. Thanks @shakkernerd.
|
||||
- Plugins/update: keep installed official npm and ClawHub plugins such as Codex, Discord, WhatsApp, and diagnostics plugins synced during host updates even when disabled or previously exact-pinned, while preserving third-party plugin pins. Thanks @vincentkoc.
|
||||
- Doctor/status: warn when `OPENCLAW_GATEWAY_TOKEN` would shadow a different active `gateway.auth.token` source for local CLI commands, while avoiding false positives when config points at the same env token. Fixes #74271. Thanks @yelog.
|
||||
- Gateway/HTTP: avoid loading managed outgoing-image media handlers for unrelated requests, so disabled OpenAI-compatible routes return 404 without waiting on lazy media sidecars. Thanks @vincentkoc.
|
||||
- Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires.
|
||||
- Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754)
|
||||
- WebChat/Codex media: stage Codex app-server generated local images into managed media before Gateway display, so Codex-home image paths no longer hit `LocalMediaAccessError` while keeping Codex home out of the display allowlist. Thanks @frankekn.
|
||||
- TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc.
|
||||
- Doctor/gateway: report recent supervisor restart handoffs in `openclaw doctor --deep`, using the installed service environment when available so service-managed clean exits are visible in guided diagnostics. Thanks @shakkernerd.
|
||||
- Gateway/status: show recent supervisor restart handoffs in `openclaw gateway status --deep`, including JSON details, so clean service-managed restarts are reported as restart handoffs instead of opaque stopped-service diagnostics. Thanks @shakkernerd.
|
||||
- Providers/Fireworks: expose Kimi models as thinking-off-only and keep K2.5/K2.6 requests on `thinking: disabled`, so manual model switches do not send Fireworks-rejected `reasoning*` parameters. Refs #74289. Thanks @frankekn.
|
||||
- WhatsApp responsiveness: stop only verified stale local TUI clients when they degrade the Gateway event loop and delay replies. Thanks @vincentkoc.
|
||||
- Plugins/update: repair stale managed npm-root `openclaw` peer packages before plugin installs, so beta-channel official plugin updates are not downgraded by old core package-lock state. Thanks @vincentkoc.
|
||||
- Plugins/install: reassert managed npm plugin `openclaw` peer links after shared-root npm installs, updates, and uninstalls, so mutating one plugin does not leave previously installed SDK-using plugins unable to resolve `openclaw/plugin-sdk/*`.
|
||||
- Hooks/session-memory: add collision suffixes to fallback memory filenames so repeated `/new` or `/reset` captures in the same minute do not overwrite the earlier session archive. Thanks @vincentkoc.
|
||||
- Agents/config: remove the ambiguous legacy `main` agent dir helper from runtime paths; model, auth, gateway, bundled plugin, and test helpers now resolve default/session agent dirs through `agents.list`/agent-scope helpers while plugin SDK keeps a deprecated compatibility export.
|
||||
- CLI/status: show the selected agent runtime/harness in `openclaw status` session rows so terminal status matches the `/status` runtime line. Thanks @vincentkoc.
|
||||
- CLI/sessions: prune old unreferenced transcript, compaction checkpoint, and trajectory artifacts during normal `sessions cleanup`, so gateway restart or crash orphans do not accumulate indefinitely outside `sessions.json`. Fixes #77608. Thanks @slideshow-dingo.
|
||||
- Doctor/Codex: repair legacy `openai-codex/*` routes in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel overrides, and stale session pins to canonical `openai/*`, selecting `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth; otherwise select `agentRuntime.id: "pi"`. Thanks @vincentkoc.
|
||||
- Plugins/update: keep installed official npm and ClawHub plugins such as Codex, Discord, WhatsApp, and diagnostics plugins synced during host updates even when disabled or previously exact-pinned, while preserving third-party plugin pins. Thanks @vincentkoc.
|
||||
- Video generation: accept provider-specific aspect-ratio and resolution hints at the tool boundary, normalize `720P` to MiniMax's supported `768P`, and stop sending Google `generateAudio` on Gemini video requests so provider fallback can recover from model-specific parameter differences. Thanks @vincentkoc.
|
||||
- Status: show compact Gateway process uptime and host system uptime in `/status`, making restart and host-lifetime checks visible from chat. Thanks @vincentkoc.
|
||||
- WhatsApp responsiveness: stop only verified stale local TUI clients when they degrade the Gateway event loop and delay replies. Thanks @vincentkoc.
|
||||
- Hooks/session-memory: run reset memory capture off the command reply path and make model-generated memory filename slugs opt-in with `llmSlug: true`, so `/new` and `/reset` no longer block WhatsApp and other message-channel reset replies on hook housekeeping or a nested model call. Thanks @vincentkoc.
|
||||
- CLI/gateway: pause non-TTY stdin after full CLI command completion and stop `openclaw agent` from falling back to embedded mode after gateway request/auth failures, so parent help commands exit cleanly and scoped delivery probes surface the real Gateway error immediately. Thanks @vincentkoc.
|
||||
- Gateway/model catalog: cache empty read-only model catalog results until reload, so TUI and control-plane refresh loops cannot hammer plugin metadata reads when no usable models are currently discovered. Thanks @vincentkoc.
|
||||
- Hooks/session-memory: add collision suffixes to fallback memory filenames so repeated `/new` or `/reset` captures in the same minute do not overwrite the earlier session archive. Thanks @vincentkoc.
|
||||
- TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc.
|
||||
- Agents/context engines: keep hidden OpenClaw runtime-context custom messages out of context-engine assemble, afterTurn, and ingest hooks so transcript reconstruction plugins only see conversation messages. Thanks @vincentkoc.
|
||||
- TUI: skip the generic CLI respawn wrapper for interactive launches, exit cleanly on terminal loss, and refuse to restore heartbeat sessions as the remembered chat session, preventing stale heartbeat history and orphaned `openclaw-tui` processes on first boot. Thanks @vincentkoc.
|
||||
- Doctor/sessions: move heartbeat-poisoned default main session store entries to recovery keys and clear stale TUI restore pointers, so `doctor --fix` can repair instances already stuck on `agent:main:main` heartbeat history. Thanks @vincentkoc.
|
||||
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
|
||||
- CLI/update: make dev-channel preflight lint opt-in and constrained when enabled, so `openclaw update --channel dev` no longer walks back otherwise-good main commits when Ubuntu hosts OOM-kill or fail parallel oxlint shards. Thanks @vincentkoc.
|
||||
- CLI/channels: skip config, proxy, channel-option catalog, banner-config, and plugin startup bootstrap for the bare `openclaw channels` parent-help command, so it exits promptly after printing help instead of loading configured channel plugins. Thanks @vincentkoc.
|
||||
- Gateway/shutdown: cancel delayed post-ready maintenance during close and suppress maintenance/cron startup after quick restarts, preventing orphaned background timers. Thanks @vincentkoc.
|
||||
- CLI/status: show the selected agent runtime/harness in `openclaw status` session rows so terminal status matches the `/status` runtime line. Thanks @vincentkoc.
|
||||
- Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc.
|
||||
- Control UI/sessions: show each session's agent runtime in the Sessions table and allow filtering by runtime labels, matching the Agents panel runtime wording. Thanks @vincentkoc.
|
||||
- Docker/Gateway: harden the gateway container by dropping `NET_RAW` and `NET_ADMIN` capabilities and enabling `no-new-privileges` in the bundled `docker-compose.yml`. Thanks @VintageAyu.
|
||||
- OpenAI/Gateway: flush the initial chat stream chunk correctly so first-token streaming is visible instead of being delayed behind later chunks.
|
||||
- Gateway/media: skip media sidecar handling for unrelated HTTP routes so non-media requests do not pay the media route behavior.
|
||||
- Discord: show reasoning text in progress drafts so streaming replies expose useful thinking/progress instead of blank draft updates.
|
||||
- Auth profiles: avoid putting providers on cooldown for format-level rejections, so fallback profiles can still be tried when a model name is unsupported.
|
||||
- Update/plugins: tolerate corrupt managed plugin records during update so core package updates can still complete and report the plugin repair path.
|
||||
- Update: stop dev-channel updates cleanly after a fetch failure instead of continuing into later update steps.
|
||||
- Agents/generated media: treat attachment-style message tool actions as completed chat sends, preventing duplicate fallback media posts when generated files were already uploaded.
|
||||
|
||||
## 2026.5.4
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -10,6 +150,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/Windows: bind the default loopback gateway listener only to `127.0.0.1` on Windows so libuv's dual-stack `::1` behavior cannot wedge localhost HTTP requests. (#69701, fixes #69674) Thanks @SARAMALI15792.
|
||||
- Plugins/migration: emit catalog-backed install hints when `plugins.entries` or `plugins.allow` references an official external plugin that is not installed, so upgraded configs point operators to `openclaw plugins install <spec>` instead of telling them to remove valid plugin config. (#77483) Thanks @hclsys.
|
||||
- OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc.
|
||||
- Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure.
|
||||
@@ -59,22 +200,32 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/ClawHub: annotate 429 errors from ClawHub with the reset window from `RateLimit-Reset`/`Retry-After` and append a `Sign in for higher rate limits.` hint when the request was unauthenticated, so users can see when downloads will recover and how to lift the cap. Thanks @romneyda.
|
||||
- Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight.
|
||||
- Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin.
|
||||
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/install: honor the beta update channel for onboarding and doctor-managed plugin installs by requesting floating npm and ClawHub specs with `@beta` while keeping persistent install records on the catalog default. Thanks @vincentkoc.
|
||||
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
|
||||
- Gateway/startup: load provider plugins that own explicitly configured image, video, or music generation defaults so generation tools become live after gateway restart instead of remaining catalog-only. Fixes #77244. Thanks @buyuangtampan, @Nikoxx99, and @vincentkoc.
|
||||
- Slack/subagents: keep resumed parent `message.send` calls in the originating Slack thread when ambient session thread context is present, and suppress successful silent child completion rows from follow-up findings. Thanks @bek91.
|
||||
- Slack/mentions: record thread participation for successful visible threaded Slack sends, including message-tool and media delivery paths, so unmentioned replies in bot-participated threads can bypass mention gating as documented. Fixes #77648. Thanks @bek91.
|
||||
- Infra/Windows: skip the POSIX `/tmp/openclaw` preferred path on Windows in `resolvePreferredOpenClawTmpDir` so log files, TTS temp files, and other writes land in `%TEMP%\openclaw-<uid>` instead of `C:\tmp\openclaw`. Fixes #60713. Thanks @juan-flores077.
|
||||
- Media/Windows: open saved attachment temp files read/write before fsync so Windows WebChat and `chat.send` media offloads no longer fail with EPERM during durability flush. (#76593) Thanks @qq230849622-a11y.
|
||||
- Agents/tools: honor narrow runtime tool allowlists when constructing embedded-runner tool families and bundled MCP/LSP runtimes, so cron/subagent runs that request tools such as `update_plan`, `browser`, `x_search`, channel login tools, or `group:plugins` no longer start with missing tools or unrelated bootstrap work. (#77519, #77532)
|
||||
- Codex plugin: mirror the experimental upstream app-server protocol and format generated TypeScript before drift checks, keeping OpenClaw's `experimentalApi` bridge compatible with latest Codex while preserving formatter gates.
|
||||
- Telegram/media: derive no-caption inbound media placeholders from saved MIME metadata instead of the Telegram `photo` shape, so non-image and mixed attachments no longer reach the model as `<media:image>`. Fixes #69793. Thanks @aspalagin.
|
||||
- Telegram/streaming: reuse the active preview as the first chunk for long text finals, so multi-chunk replies no longer create a transient extra bubble that appears and then disappears. Thanks @vincentkoc.
|
||||
- Agents/cache: keep per-turn runtime context out of ordinary chat system prompts while still delivering hidden current-turn context, restoring prompt-cache reuse on chat continuations. Fixes #77431. Thanks @Udjin79.
|
||||
- Gateway/startup: include resolved thinking and fast-mode defaults in the `agent model` startup log line, defaulting unset startup thinking to `medium` without mixing in reasoning visibility.
|
||||
- Gateway/update: resolve local gateway probe auth from the installed config during post-update restart verification, so token/device-authenticated VPS gateways are not misreported as unhealthy port conflicts after a package swap. Thanks @vincentkoc.
|
||||
- Agents/Tools: add post-compaction loop guard in `pi-embedded-runner` that arms after auto-compaction-retry and aborts the run with `compaction_loop_persisted` when the agent emits the same `(tool, args, result)` triple `windowSize` times (default 3) within that window. Disable via existing `tools.loopDetection.enabled`; tune via `tools.loopDetection.postCompactionGuard.windowSize`. Targets the failure mode where context-overflow + compaction does not break a tool-call loop. Refs #77474; carries forward #21597. Thanks @efpiva.
|
||||
- Gateway/watch: suppress sync-I/O trace output during `pnpm gateway:watch --benchmark` unless explicitly requested, so CPU profiling no longer floods the terminal with stack traces.
|
||||
- Gateway/watch: when benchmark sync-I/O tracing is explicitly enabled, tee trace blocks to the benchmark output log and filter them from the terminal pane while keeping normal Gateway logs visible.
|
||||
- Plugins/runtime-deps: include `json5` in the memory-core plugin runtime dependency set so packaged `memory_search` sandboxes can resolve generated OpenClaw runtime chunks that parse JSON5 config. Fixes #77461.
|
||||
- Plugins/Windows: show a Git install hint when npm plugin installation fails with `spawn git ENOENT`, and document the WhatsApp plugin's Git-on-PATH requirement for Baileys/libsignal installs.
|
||||
- Codex harness: preserve app-server usage-limit reset details and deliver OpenClaw-owned runtime failure notices through tool-only source-reply mode, so Telegram and other chat channels tell users when Codex subscription limits or API failures block a turn instead of going silent. (#77557) Thanks @pashpashpash.
|
||||
- Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc.
|
||||
- Plugins/update: repair missing plugin-local `openclaw` peer links before skipping unchanged npm plugin updates, so current external Codex installs can recover `openclaw/plugin-sdk/*` resolution during OTA repair. (#77544) Thanks @ProspectOre.
|
||||
- Discord/replies: treat failed final reply delivery as a failed turn instead of counting it as a delivered automatic visible reply, so guild/channel turns no longer show done when the final message was dropped. Fixes #77520. Thanks @Patrick-Erichsen.
|
||||
- Discord: prefer IPv4 for Discord REST and gateway WebSocket startup paths so IPv4-only networks no longer stall before Gateway READY and inbound message dispatch. Fixes #77398; refs #77526. Thanks @Beandon13.
|
||||
- Channels/plugins: key bundled package-state probes, env/config presence, and read-only command defaults by channel id instead of manifest plugin id, preserving setup and native-command detection for channel plugins whose package id differs from the channel alias. Thanks @vincentkoc.
|
||||
@@ -216,6 +367,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Google Meet: make Twilio setup status require an enabled `voice-call` plugin entry instead of treating a missing entry as ready. Thanks @vincentkoc.
|
||||
- Telegram: render shared interactive reply buttons in reply delivery so plugin approval messages show inline keyboards. (#76238) Thanks @keshavbotagent.
|
||||
- Cron/sessions: keep cron metadata rows without an on-disk transcript non-resumable until a transcript exists, so doctor and `sessions cleanup --fix-missing` no longer report or prune pre-transcript cron rows as broken sessions. Refs #77011.
|
||||
- OpenAI Codex: recreate missing bound app-server threads once when a stale `/codex bind` sidecar survives a restart, preserving the selected auth profile and turn overrides before retrying the inbound turn. (#76936) Thanks @keshavbotagent.
|
||||
- Agents/cli-runner: drop a saved `claude-cli` resume sessionId at preparation time when its on-disk transcript no longer exists in `~/.claude/projects/`, so a stale binding from a half-installed `update.run` cannot trap follow-up runs (auto-reply / Telegram direct) in a `claude --resume` timeout loop; the run starts fresh and the new sessionId is written back through the existing post-run flow. (#77030; refs #77011) Thanks @openperf.
|
||||
- Release validation: install the cross-OS TypeScript harness through Windows-safe Node/npm shims so native Windows package checks reach the OpenClaw smoke suites instead of exiting before artifact capture. Thanks @vincentkoc.
|
||||
- Release validation: let Windows packaged-upgrade checks continue after the shipped 2026.5.2 updater hits its native-module swap cleanup fallback, verifying the fallback-installed candidate through package metadata and downstream smoke instead of crashing on the immediate update-status probe. Thanks @vincentkoc.
|
||||
@@ -304,6 +456,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions.
|
||||
- Doctor/config: `doctor --fix` now commits safe legacy migrations even when unrelated validation issues (e.g. a missing plugin) prevent full validation from passing, so `agents.defaults.llm` and other known-legacy keys are always cleaned up by `doctor --fix` regardless of other config problems. Fixes #76798. (#76800) Thanks @hclsys.
|
||||
- Agents/tools: skip optional media and PDF tool factories when the effective tool denylist already blocks them, avoiding unnecessary hot-path setup for tools that will be filtered out before model use. (#76773) Thanks @dorukardahan.
|
||||
- Agents/compaction: ignore pre-usage transcript metadata bytes when stale token snapshots estimate preflight compaction pressure, while still counting post-usage transcript tail pressure. Fixes #78604. Thanks @amknight.
|
||||
- Discord/status: let explicit reaction tool calls opt into tracking subsequent tool progress on the reacted message with `trackToolCalls: true`, and use the shared tool display emoji table for status reactions.
|
||||
- Gateway/config: stop Gateway startup and hot reload from auto-restoring invalid config; invalid config now fails closed and `openclaw doctor --fix` owns last-known-good repair.
|
||||
- Gateway/performance: lazy-load early runtime discovery and shutdown-hook helpers, defer maintenance timers until after readiness, and trim duplicate plugin auto-enable work during Gateway startup.
|
||||
@@ -324,6 +477,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Update: repair doctor-migratable legacy config before persisting `openclaw update --channel ...`, so old Slack/Telegram streaming keys do not block switching to beta after a package update. Thanks @vincentkoc.
|
||||
- Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc.
|
||||
- Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda.
|
||||
- Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals.
|
||||
@@ -583,6 +737,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/reply context: label replied-to messages as the current user message target in model-visible metadata, so short replies are grounded to their explicit reply target instead of nearby chat history. (#76817) Thanks @obviyus.
|
||||
- Doctor/plugins: install configured missing official plugins such as Discord and Brave during doctor/update repair, auto-enable repaired provider plugins, preserve config when a download fails, and stop auto-enable from inventing plugin entries when no manifest declares a configured channel. Fixes #76872. Thanks @jack-stormentswe.
|
||||
- Codex/app-server: stabilize transcript mirror dedupe across re-mirrored turns so reordered snapshots no longer drop reasoning entries or duplicate the assistant reply. Refs #77012. (#77046) Thanks @openperf.
|
||||
- Agents/auth-profiles: do not record request-shape (`format`) rejections as auth-profile health failures, so a single per-session transcript-shape error (such as a prefill-strict 400 "conversation must end with a user message") no longer triggers a profile-wide cooldown that blocks every other healthy session sharing the same auth profile. Refs #77228. (#77280) Thanks @openperf.
|
||||
|
||||
## 2026.5.2
|
||||
|
||||
@@ -1396,6 +1551,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/plugins: enable the native `require()` fast path on Windows for bundled plugin modules so plugin loading uses `require()` instead of Jiti's transform pipeline, reducing startup from ~39s to ~2s on typical 6-plugin setups. Fixes #68656. (#74173) Thanks @galiniliev.
|
||||
- macOS app: detect stale Gateway TLS certificate pins, automatically repair trusted Tailscale Serve rotations, and surface paired-but-disconnected Mac companion nodes so partial Gateway connections no longer look healthy. Thanks @guti.
|
||||
- Feishu: recreate WebSocket clients with monitor-owned backoff only after SDK reconnect exhaustion, preserving heartbeat defaults and shutdown cleanup without treating recoverable SDK callback errors as terminal, so persistent connections recover without manual gateway restart. Fixes #52618; duplicate evidence #59753; related #55532, #68766, #72411, and #73739. Thanks @vincentkoc, @schumilin, @alex-xuweilong, @120106835, @sirfengyu, and @tianhaocui.
|
||||
- Agents/skills: require exact `<location>` skill paths for both single-skill and multi-skill prompt selection, so agents do not guess or hard-code skill file paths. (#74161) Thanks @lanzhi-lee.
|
||||
|
||||
## 2026.4.27
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026050400
|
||||
versionName = "2026.5.4"
|
||||
versionCode = 2026050600
|
||||
versionName = "2026.5.6-beta.1"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.5.7 - 2026-05-06
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.6 - 2026-05-06
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.5.5 - 2026-05-05
|
||||
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
||||
## 2026.5.4 - 2026-05-04
|
||||
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.5.4
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.4
|
||||
OPENCLAW_IOS_VERSION = 2026.5.6
|
||||
OPENCLAW_MARKETING_VERSION = 2026.5.6
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
||||
- Gateway pairing now supports scanning QR codes from Settings and accepts full copied setup-code messages while keeping non-loopback `ws://` setup links blocked.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.5.4"
|
||||
"version": "2026.5.6"
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.5.4</string>
|
||||
<string>2026.5.6-beta.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026050400</string>
|
||||
<string>2026050600</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1812,6 +1812,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let label: String?
|
||||
public let model: String?
|
||||
public let parentsessionkey: String?
|
||||
public let emitcommandhooks: Bool?
|
||||
public let task: String?
|
||||
public let message: String?
|
||||
|
||||
@@ -1821,6 +1822,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
label: String?,
|
||||
model: String?,
|
||||
parentsessionkey: String?,
|
||||
emitcommandhooks: Bool?,
|
||||
task: String?,
|
||||
message: String?)
|
||||
{
|
||||
@@ -1829,6 +1831,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
self.label = label
|
||||
self.model = model
|
||||
self.parentsessionkey = parentsessionkey
|
||||
self.emitcommandhooks = emitcommandhooks
|
||||
self.task = task
|
||||
self.message = message
|
||||
}
|
||||
@@ -1839,6 +1842,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
case label
|
||||
case model
|
||||
case parentsessionkey = "parentSessionKey"
|
||||
case emitcommandhooks = "emitCommandHooks"
|
||||
case task
|
||||
case message
|
||||
}
|
||||
@@ -4673,6 +4677,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let severity: String?
|
||||
public let toolname: String?
|
||||
public let toolcallid: String?
|
||||
public let alloweddecisions: [String]?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let turnsourcechannel: String?
|
||||
@@ -4689,6 +4694,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
severity: String?,
|
||||
toolname: String?,
|
||||
toolcallid: String?,
|
||||
alloweddecisions: [String]?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
turnsourcechannel: String?,
|
||||
@@ -4704,6 +4710,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
self.severity = severity
|
||||
self.toolname = toolname
|
||||
self.toolcallid = toolcallid
|
||||
self.alloweddecisions = alloweddecisions
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
@@ -4721,6 +4728,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
case severity
|
||||
case toolname = "toolName"
|
||||
case toolcallid = "toolCallId"
|
||||
case alloweddecisions = "allowedDecisions"
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
|
||||
@@ -1812,6 +1812,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let label: String?
|
||||
public let model: String?
|
||||
public let parentsessionkey: String?
|
||||
public let emitcommandhooks: Bool?
|
||||
public let task: String?
|
||||
public let message: String?
|
||||
|
||||
@@ -1821,6 +1822,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
label: String?,
|
||||
model: String?,
|
||||
parentsessionkey: String?,
|
||||
emitcommandhooks: Bool?,
|
||||
task: String?,
|
||||
message: String?)
|
||||
{
|
||||
@@ -1829,6 +1831,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
self.label = label
|
||||
self.model = model
|
||||
self.parentsessionkey = parentsessionkey
|
||||
self.emitcommandhooks = emitcommandhooks
|
||||
self.task = task
|
||||
self.message = message
|
||||
}
|
||||
@@ -1839,6 +1842,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
case label
|
||||
case model
|
||||
case parentsessionkey = "parentSessionKey"
|
||||
case emitcommandhooks = "emitCommandHooks"
|
||||
case task
|
||||
case message
|
||||
}
|
||||
@@ -4673,6 +4677,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
public let severity: String?
|
||||
public let toolname: String?
|
||||
public let toolcallid: String?
|
||||
public let alloweddecisions: [String]?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let turnsourcechannel: String?
|
||||
@@ -4689,6 +4694,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
severity: String?,
|
||||
toolname: String?,
|
||||
toolcallid: String?,
|
||||
alloweddecisions: [String]?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
turnsourcechannel: String?,
|
||||
@@ -4704,6 +4710,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
self.severity = severity
|
||||
self.toolname = toolname
|
||||
self.toolcallid = toolcallid
|
||||
self.alloweddecisions = alloweddecisions
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.turnsourcechannel = turnsourcechannel
|
||||
@@ -4721,6 +4728,7 @@ public struct PluginApprovalRequestParams: Codable, Sendable {
|
||||
case severity
|
||||
case toolname = "toolName"
|
||||
case toolcallid = "toolCallId"
|
||||
case alloweddecisions = "allowedDecisions"
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case turnsourcechannel = "turnSourceChannel"
|
||||
|
||||
@@ -49,6 +49,11 @@ services:
|
||||
# Let bundled local-model providers reach host-side LM Studio/Ollama via
|
||||
# http://host.docker.internal:<port>. Docker Desktop usually provides this
|
||||
# alias; the host-gateway mapping makes it work on Linux Docker Engine too.
|
||||
cap_drop:
|
||||
- NET_RAW
|
||||
- NET_ADMIN
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
b4491e9b8ea5606cad18c1acf06f03d35301ebec1974d201ec9ee7582d2f6001 config-baseline.json
|
||||
65040b112912ccbc45e049bc6d9a877fe08f5a0daace120a3b98d8397bd9a325 config-baseline.json
|
||||
9c0c9369d49c2001f91ec030e3852ccdc2ac9084229f335804aa9141c13b4795 config-baseline.core.json
|
||||
cd7c0c7fb1435bc7e59099e9ac334462d5ad444016e9ab4512aae63a238f78dc config-baseline.channel.json
|
||||
463c45a79d02598184caccbc6f316692df962fe6b0e84d1a3e3cc1809f862b15 config-baseline.channel.json
|
||||
9832b30a696930a3da7efccf38073137571e1b66cae84e54d747b733fdafcc54 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
43c6f668cd8301f485c64e6a663dc1b19d38c146ce2572943e2dc961973e0c6f plugin-sdk-api-baseline.json
|
||||
1d877d94bebb634d90d929fe0581ba4bccf4d12d8342d179ae9bf1053e68c013 plugin-sdk-api-baseline.jsonl
|
||||
05f7e1db899277adbb77ee985ef09e21fc83bfb540096d1f0e74d17863cd8a69 plugin-sdk-api-baseline.json
|
||||
20b4d2401c38d6753e0b32cdfe69a63535b558283aa5a57c7b9e93b0347c9de8 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -133,6 +133,8 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
|
||||
|
||||
`--model` uses the selected allowed model as that job's primary model. It is not the same as a chat-session `/model` override: configured fallback chains still apply when the job primary fails. If the requested model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of silently falling back to the job's agent/default model selection.
|
||||
|
||||
If older or hand-edited `jobs.json` entries store `payload.model` as `"default"`, `"null"`, a blank string, or JSON `null`, run `openclaw doctor --fix`. Doctor removes those invalid persisted override sentinels; runtime does not support them as fallback aliases. Omit the model field to use the normal agent/default model selection.
|
||||
|
||||
Cron jobs can also carry payload-level `fallbacks`. When present, that list replaces the configured fallback chain for the job. Use `fallbacks: []` in the job payload/API when you want a strict cron run that tries only the selected model. If a job has `--model` but neither payload nor configured fallbacks, OpenClaw passes an explicit empty fallback override so the agent primary is not appended as a hidden extra retry target.
|
||||
|
||||
Model-selection precedence for isolated jobs is:
|
||||
|
||||
@@ -178,7 +178,7 @@ openclaw hooks enable <hook-name>
|
||||
|
||||
### session-memory details
|
||||
|
||||
Extracts the last 15 user/assistant messages, generates a descriptive filename slug via LLM, and saves to `<workspace>/memory/YYYY-MM-DD-slug.md` using the host local date. Requires `workspace.dir` to be configured.
|
||||
Extracts the last 15 user/assistant messages and saves to `<workspace>/memory/YYYY-MM-DD-HHMM.md` using the host local date. Memory capture runs in the background so `/new` and `/reset` acknowledgements are not delayed by transcript reads or optional slug generation. Set `hooks.internal.entries.session-memory.llmSlug: true` to generate descriptive filename slugs with the configured model. Requires `workspace.dir` to be configured.
|
||||
|
||||
<a id="bootstrap-extra-files"></a>
|
||||
|
||||
|
||||
@@ -151,11 +151,12 @@ Agent run completion is authoritative for active task records. A successful deta
|
||||
- Cron tasks: the cron runtime no longer tracks the job as active and durable
|
||||
cron run history does not show a terminal result for that run. Offline CLI
|
||||
audit does not treat its own empty in-process cron runtime state as authority.
|
||||
- CLI tasks: isolated child-session tasks use the child session; chat-backed
|
||||
CLI tasks use the live run context instead, so lingering
|
||||
channel/group/direct session rows do not keep them alive. Gateway-backed
|
||||
`openclaw agent` runs also finalize from their run result, so completed runs
|
||||
do not sit active until the sweeper marks them `lost`.
|
||||
- CLI tasks: tasks with a run id/source id use the live run context, so
|
||||
lingering child-session or chat-session rows do not keep them alive after the
|
||||
gateway-owned run disappears. Legacy CLI tasks without run identity still fall
|
||||
back to the child session. Gateway-backed `openclaw agent` runs also finalize
|
||||
from their run result, so completed runs do not sit active until the sweeper
|
||||
marks them `lost`.
|
||||
|
||||
## Delivery and notifications
|
||||
|
||||
@@ -249,7 +250,7 @@ openclaw tasks notify <lookup> state_changes
|
||||
- ACP/subagent tasks check their backing child session.
|
||||
- Subagent tasks whose child session has a restart-recovery tombstone are marked lost instead of being treated as recoverable backing sessions.
|
||||
- Cron tasks check whether the cron runtime still owns the job, then recover terminal status from persisted cron run logs/job state before falling back to `lost`. Only the Gateway process is authoritative for the in-memory cron active-job set; offline CLI audit uses durable history but does not mark a cron task lost solely because that local Set is empty.
|
||||
- Chat-backed CLI tasks check the owning live run context, not just the chat session row.
|
||||
- CLI tasks with run identity check the owning live run context, not just child-session or chat-session rows.
|
||||
|
||||
Completion cleanup is also runtime-aware:
|
||||
|
||||
@@ -316,7 +317,7 @@ A sweeper runs every **60 seconds** and handles four things:
|
||||
|
||||
<Steps>
|
||||
<Step title="Reconciliation">
|
||||
Checks whether active tasks still have authoritative runtime backing. ACP/subagent tasks use child-session state, cron tasks use active-job ownership, and chat-backed CLI tasks use the owning run context. If that backing state is gone for more than 5 minutes, the task is marked `lost`.
|
||||
Checks whether active tasks still have authoritative runtime backing. ACP/subagent tasks use child-session state, cron tasks use active-job ownership, and CLI tasks with run identity use the owning run context. If that backing state is gone for more than 5 minutes, the task is marked `lost`.
|
||||
</Step>
|
||||
<Step title="ACP session repair">
|
||||
Closes terminal or orphaned parent-owned one-shot ACP sessions, and closes stale terminal or orphaned persistent ACP sessions only when no active conversation binding remains.
|
||||
|
||||
@@ -1155,6 +1155,12 @@ Use `/vc join|leave|status` to control sessions. The command uses the account de
|
||||
/vc leave
|
||||
```
|
||||
|
||||
To inspect the bot's effective permissions before joining, run:
|
||||
|
||||
```bash
|
||||
openclaw channels capabilities --channel discord --target channel:<voice-channel-id>
|
||||
```
|
||||
|
||||
Auto-join example:
|
||||
|
||||
```json5
|
||||
@@ -1197,6 +1203,8 @@ Notes:
|
||||
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
|
||||
- `voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts. Default: `30000`.
|
||||
- `voice.reconnectGraceMs` controls how long OpenClaw waits for a disconnected voice session to begin reconnecting before destroying it. Default: `15000`.
|
||||
- Voice playback does not stop just because another user starts speaking. To avoid feedback loops, OpenClaw ignores new voice capture while TTS is playing; speak after playback finishes for the next turn.
|
||||
- `voice.captureSilenceGraceMs` controls how long OpenClaw waits after Discord reports a speaker has stopped before finalizing that audio segment for STT. Default: `2500`; raise this if Discord splits normal pauses into choppy partial transcripts.
|
||||
- OpenClaw also watches receive decrypt failures and auto-recovers by leaving/rejoining the voice channel after repeated failures in a short window.
|
||||
- If receive logs repeatedly show `DecryptionFailed(UnencryptedWhenPassthroughDisabled)` after updating, collect a dependency report and logs. The bundled `@discordjs/voice` line includes the upstream padding fix from discord.js PR #11449, which closed discord.js issue #11419.
|
||||
|
||||
|
||||
@@ -481,9 +481,10 @@ conversion fails, OpenClaw falls back to a file attachment and logs the reason.
|
||||
|
||||
For `groupSessionScope: "group_topic"` and `"group_topic_sender"`, native
|
||||
Feishu/Lark topic groups use the event `thread_id` (`omt_*`) as the canonical
|
||||
topic session key. Normal group replies that OpenClaw turns into threads keep
|
||||
using the reply root message ID (`om_*`) so the first turn and follow-up turn
|
||||
stay in the same session.
|
||||
topic session key. If a native topic starter event omits `thread_id`, OpenClaw
|
||||
hydrates it from Feishu before routing the turn. Normal group replies that
|
||||
OpenClaw turns into threads keep using the reply root message ID (`om_*`) so the
|
||||
first turn and follow-up turn stay in the same session.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -68,6 +68,22 @@ Minimal config:
|
||||
}
|
||||
```
|
||||
|
||||
Public DM config:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
line: {
|
||||
enabled: true,
|
||||
channelAccessToken: "LINE_CHANNEL_ACCESS_TOKEN",
|
||||
channelSecret: "LINE_CHANNEL_SECRET",
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Env vars (default account only):
|
||||
|
||||
- `LINE_CHANNEL_ACCESS_TOKEN`
|
||||
@@ -119,7 +135,7 @@ openclaw pairing approve line <CODE>
|
||||
Allowlists and policies:
|
||||
|
||||
- `channels.line.dmPolicy`: `pairing | allowlist | open | disabled`
|
||||
- `channels.line.allowFrom`: allowlisted LINE user IDs for DMs
|
||||
- `channels.line.allowFrom`: allowlisted LINE user IDs for DMs; `dmPolicy: "open"` requires `["*"]`
|
||||
- `channels.line.groupPolicy`: `allowlist | open | disabled`
|
||||
- `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups
|
||||
- Per-group overrides: `channels.line.groups.<groupId>.allowFrom`
|
||||
|
||||
@@ -344,6 +344,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
For text-only replies:
|
||||
|
||||
- short DM/group/topic previews: OpenClaw keeps the same preview message and performs a final edit in place, unless a visible non-preview message was sent after the preview appeared
|
||||
- long text finals that split into multiple Telegram messages reuse the existing preview as the first final chunk when possible, then send only the remaining chunks
|
||||
- previews followed by visible non-preview output: OpenClaw sends the completed reply as a fresh final message and cleans up the older preview, so the final answer appears after intermediate output
|
||||
- previews older than about one minute: OpenClaw sends the completed reply as a fresh final message and then cleans up the preview, so Telegram's visible timestamp reflects completion time instead of the preview creation time
|
||||
|
||||
|
||||
@@ -31,12 +31,13 @@ Healthy baseline:
|
||||
|
||||
### WhatsApp failure signatures
|
||||
|
||||
| Symptom | Fastest check | Fix |
|
||||
| ------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Connected but no DM replies | `openclaw pairing list whatsapp` | Approve sender or switch DM policy/allowlist. |
|
||||
| Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. |
|
||||
| QR login times out with 408 | Check gateway `HTTPS_PROXY` / `HTTP_PROXY` env | Set a reachable proxy; use `NO_PROXY` only for bypasses. |
|
||||
| Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Recent reconnects are flagged even when currently connected; watch logs, restart the gateway, then relink if flapping continues. |
|
||||
| Symptom | Fastest check | Fix |
|
||||
| ----------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Connected but no DM replies | `openclaw pairing list whatsapp` | Approve sender or switch DM policy/allowlist. |
|
||||
| Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. |
|
||||
| QR login times out with 408 | Check gateway `HTTPS_PROXY` / `HTTP_PROXY` env | Set a reachable proxy; use `NO_PROXY` only for bypasses. |
|
||||
| Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Recent reconnects are flagged even when currently connected; watch logs, restart the gateway, then relink if flapping continues. |
|
||||
| Replies arrive seconds/minutes late | `openclaw doctor --fix` | Doctor stops verified stale local TUI clients when they are degrading the Gateway event loop. |
|
||||
|
||||
Full troubleshooting: [WhatsApp troubleshooting](/channels/whatsapp#troubleshooting)
|
||||
|
||||
|
||||
@@ -26,6 +26,16 @@ openclaw plugins install @openclaw/whatsapp
|
||||
Use the bare package to follow the current official release tag. Pin an exact
|
||||
version only when you need a reproducible install.
|
||||
|
||||
On Windows, the WhatsApp plugin needs Git on `PATH` during npm install because
|
||||
one of its Baileys/libsignal dependencies is fetched from a git URL. Install
|
||||
Git for Windows, then restart the shell and rerun the install:
|
||||
|
||||
```powershell
|
||||
winget install --id Git.Git -e
|
||||
```
|
||||
|
||||
Portable Git also works if its `bin` directory is on `PATH`.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
Default DM policy is pairing for unknown senders.
|
||||
|
||||
@@ -19,13 +19,17 @@ Related docs:
|
||||
|
||||
```bash
|
||||
openclaw channels list
|
||||
openclaw channels list --all
|
||||
openclaw channels status
|
||||
openclaw channels capabilities
|
||||
openclaw channels capabilities --channel discord --target channel:123
|
||||
openclaw channels capabilities --channel discord --target channel:<voice-channel-id>
|
||||
openclaw channels resolve --channel slack "#general" "@jane"
|
||||
openclaw channels logs --channel all
|
||||
```
|
||||
|
||||
`channels list` shows chat channels only: configured accounts by default, with `installed`, `configured`, and `enabled` status tags per account. Pass `--all` to also surface bundled channels that have no configured account yet and installable catalog channels that are not yet on disk. Auth providers (OAuth + API keys) and model-provider usage/quota snapshots are no longer printed here; use `openclaw models auth list` for provider auth profiles and `openclaw status` or `openclaw models list` for usage.
|
||||
|
||||
## Status / capabilities / resolve / logs
|
||||
|
||||
- `channels status`: `--probe`, `--timeout <ms>`, `--json`
|
||||
@@ -108,7 +112,7 @@ openclaw channels logout --channel whatsapp
|
||||
|
||||
- Run `openclaw status --deep` for a broad probe.
|
||||
- Use `openclaw doctor` for guided fixes.
|
||||
- `openclaw channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude CLI.
|
||||
- `openclaw channels list` no longer prints model provider usage/quota snapshots. For those, use `openclaw status` (overview) or `openclaw models list` (per-provider).
|
||||
- `openclaw channels status` falls back to config-only summaries when the gateway is unreachable. If a supported channel credential is configured via SecretRef but unavailable in the current command path, it reports that account as configured with degraded notes instead of showing it as not configured.
|
||||
|
||||
## Capabilities probe
|
||||
@@ -124,7 +128,7 @@ Notes:
|
||||
|
||||
- `--channel` is optional; omit it to list every channel (including extensions).
|
||||
- `--account` is only valid with `--channel`.
|
||||
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
|
||||
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord. For Discord voice channels, the permission check flags missing `ViewChannel`, `Connect`, `Speak`, `SendMessages`, and `ReadMessageHistory`.
|
||||
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; Microsoft Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
|
||||
|
||||
## Resolve names to IDs
|
||||
|
||||
@@ -157,6 +157,8 @@ Retention and pruning are controlled in config:
|
||||
|
||||
<Note>
|
||||
If you have cron jobs from before the current delivery and store format, run `openclaw doctor --fix`. Doctor normalizes legacy cron fields (`jobId`, `schedule.cron`, top-level delivery fields including legacy `threadId`, payload `provider` delivery aliases) and migrates simple `notify: true` webhook fallback jobs to explicit webhook delivery when `cron.webhook` is configured.
|
||||
|
||||
Doctor also removes persisted cron `payload.model` sentinels such as `"default"`, `"null"`, blank strings, and JSON `null`. Cron runtime still treats any non-empty `payload.model` string as an explicit model override and validates it against `agents.defaults.models`; omit the model key when a job should use the agent/default model selection.
|
||||
</Note>
|
||||
|
||||
## Common edits
|
||||
@@ -217,6 +219,8 @@ openclaw cron run <job-id> --due
|
||||
openclaw cron runs --id <job-id> --limit 50
|
||||
```
|
||||
|
||||
`cron list --json` and `cron show <job-id> --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. This mirrors the human-readable status column so external tooling can read job state without re-deriving it.
|
||||
|
||||
`cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state.
|
||||
|
||||
Agent and session retargeting:
|
||||
|
||||
@@ -34,10 +34,11 @@ openclaw doctor --generate-gateway-token
|
||||
- `--force`: apply aggressive repairs, including overwriting custom service config when needed
|
||||
- `--non-interactive`: run without prompts; safe migrations and non-service repairs only
|
||||
- `--generate-gateway-token`: generate and configure a gateway token
|
||||
- `--deep`: scan system services for extra gateway installs
|
||||
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
|
||||
|
||||
Notes:
|
||||
|
||||
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive sessions still fully load plugins when a check needs their contribution.
|
||||
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
|
||||
@@ -45,6 +46,8 @@ Notes:
|
||||
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
- On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment.
|
||||
- When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops.
|
||||
- Doctor checks `openai-codex/*` model refs across primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale session route pins. `--fix` rewrites them to `openai/*` only when the native Codex runtime is installed, enabled, contributes the `codex` harness, and has usable OAuth, or when direct OpenAI auth is already available and no usable Codex OAuth route would be moved. When `openai-codex/*` is the working Codex OAuth route through OpenClaw PI, doctor preserves it. If an earlier repair left `openai/*` GPT-5 routes on PI while only Codex OAuth auth is available, `--fix` recovers them back to `openai-codex/*`; when direct OpenAI auth is also usable, doctor warns and leaves the ambiguous mixed-auth route unchanged for explicit confirmation.
|
||||
- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing downloadable plugins that are referenced by config, such as `plugins.entries`, configured channels, configured provider/search settings, or configured agent runtimes. During package updates, doctor skips package-manager plugin repair until the package swap is complete; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If the download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt.
|
||||
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy.
|
||||
- Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.<id>` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running.
|
||||
@@ -65,7 +68,7 @@ Notes:
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv OPENCLAW_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
If you previously ran `launchctl setenv OPENCLAW_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent "unauthorized" errors.
|
||||
|
||||
```bash
|
||||
launchctl getenv OPENCLAW_GATEWAY_TOKEN
|
||||
|
||||
@@ -282,7 +282,7 @@ Saves session context to memory when you issue `/new` or `/reset`.
|
||||
openclaw hooks enable session-memory
|
||||
```
|
||||
|
||||
**Output:** `~/.openclaw/workspace/memory/YYYY-MM-DD-slug.md`
|
||||
**Output:** `~/.openclaw/workspace/memory/YYYY-MM-DD-HHMM.md` by default. Set `hooks.internal.entries.session-memory.llmSlug: true` for model-generated filename slugs.
|
||||
|
||||
**See:** [session-memory documentation](/automation/hooks#session-memory)
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ is available, then fall back to `latest`.
|
||||
<Accordion title="Hook packs and npm specs">
|
||||
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
|
||||
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings.
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run project-local with `--ignore-scripts` for safety, even when your shell has global npm install settings. Managed plugin npm roots inherit OpenClaw's package-level npm `overrides`, so host security pins apply to hoisted plugin dependencies too.
|
||||
|
||||
Use `npm:<package>` when you want to make npm resolution explicit. Bare package specs also install directly from npm during the launch cutover.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ Notes:
|
||||
- Session status output separates `Execution:` from `Runtime:`. `Execution` is the sandbox path (`direct`, `docker/*`), while `Runtime` tells you whether the session is using `OpenClaw Pi Default`, `OpenAI Codex`, a CLI backend, or an ACP backend such as `codex (acp/acpx)`. See [Agent runtimes](/concepts/agent-runtimes) for the provider/model/runtime distinction.
|
||||
- MiniMax's raw `usage_percent` / `usagePercent` fields are remaining quota, so OpenClaw inverts them before display; count-based fields win when present. `model_remains` responses prefer the chat-model entry, derive the window label from timestamps when needed, and include the model name in the plan label.
|
||||
- When the current session snapshot is sparse, `/status` can backfill token and cache counters from the most recent transcript usage log. Existing nonzero live values still win over transcript fallback values.
|
||||
- `/status` includes compact Gateway process uptime and host system uptime.
|
||||
- Transcript fallback can also recover the active runtime model label when the live session entry is missing it. If that transcript model differs from the selected model, status resolves the context window against the recovered runtime model instead of the selected one.
|
||||
- For prompt-size accounting, transcript fallback prefers the larger prompt-oriented total when session metadata is missing or smaller, so custom-provider sessions do not collapse to `0` token displays.
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
|
||||
@@ -87,7 +87,9 @@ Previews or applies task and Task Flow reconciliation, cleanup stamping, and pru
|
||||
For cron tasks, reconciliation uses persisted run logs/job state before marking an
|
||||
old active task `lost`, so completed cron runs do not become false audit errors
|
||||
just because the in-memory Gateway runtime state is gone. Offline CLI audit is
|
||||
not authoritative for the Gateway's process-local cron active-job set.
|
||||
not authoritative for the Gateway's process-local cron active-job set. CLI tasks
|
||||
with a run id/source id are marked `lost` when their live Gateway run context is
|
||||
gone, even if an old child-session row remains.
|
||||
|
||||
### `flow`
|
||||
|
||||
|
||||
@@ -38,8 +38,9 @@ openclaw --update
|
||||
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
|
||||
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON, including
|
||||
`postUpdate.plugins.integrityDrifts` when npm plugin artifact drift is
|
||||
detected during post-update plugin sync.
|
||||
`postUpdate.plugins.warnings` when corrupt or unloadable managed plugins need
|
||||
repair after the core update succeeds, and `postUpdate.plugins.integrityDrifts`
|
||||
when npm plugin artifact drift is detected during post-update plugin sync.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1800s).
|
||||
- `--yes`: skip confirmation prompts (for example downgrade confirmation).
|
||||
|
||||
@@ -147,7 +148,7 @@ manually.
|
||||
Dev only.
|
||||
</Step>
|
||||
<Step title="Preflight build (dev only)">
|
||||
Runs lint and TypeScript build in a temp worktree. If the tip fails, walks back up to 10 commits to find the newest clean build.
|
||||
Runs the TypeScript build in a temp worktree. If the tip fails, walks back up to 10 commits to find the newest buildable commit. Set `OPENCLAW_UPDATE_PREFLIGHT_LINT=1` to also run lint during this preflight; lint runs in constrained serial mode because user update hosts are often smaller than CI runners.
|
||||
</Step>
|
||||
<Step title="Rebase">
|
||||
Rebases onto the selected commit (dev only).
|
||||
@@ -177,7 +178,7 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
Post-update plugin sync failures fail the update result and stop restart follow-up work. Fix the plugin install or update error, then rerun `openclaw update`.
|
||||
Post-update plugin sync failures that are scoped to a managed plugin are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
|
||||
|
||||
When the updated Gateway starts, plugin loading is verify-only: startup does not run package managers or mutate dependency trees. Package-manager `update.run` restarts bypass the normal idle deferral and restart cooldown after the package tree has been swapped, so the old process cannot keep lazy-loading removed chunks.
|
||||
|
||||
|
||||
@@ -232,6 +232,8 @@ Scenarios (`extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime
|
||||
- `telegram-tools-compact-command`
|
||||
- `telegram-whoami-command`
|
||||
- `telegram-context-command`
|
||||
- `telegram-long-final-reuses-preview`
|
||||
- `telegram-long-final-three-chunks`
|
||||
|
||||
Output artifacts:
|
||||
|
||||
|
||||
@@ -545,7 +545,7 @@ See [Multiple Gateways](/gateway/multiple-gateways).
|
||||
- `"hot"`: apply changes in-process without restarting.
|
||||
- `"hybrid"` (default): try hot reload first; fall back to restart if required.
|
||||
- `debounceMs`: debounce window in ms before config changes are applied (non-negative integer).
|
||||
- `deferralTimeoutMs`: optional maximum time in ms to wait for in-flight operations before forcing a restart. Omit it to use the default bounded wait (`300000`); set `0` to wait indefinitely and log periodic still-pending warnings.
|
||||
- `deferralTimeoutMs`: optional maximum time in ms to wait for in-flight operations before forcing a restart or channel hot reload. Omit it to use the default bounded wait (`300000`); set `0` to wait indefinitely and log periodic still-pending warnings.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ cat ~/.openclaw/openclaw.json
|
||||
- Matrix channel legacy state migration (in `--fix` / `--repair` mode).
|
||||
- Gateway runtime checks (service installed but not running; cached launchd label).
|
||||
- Channel status warnings (probed from the running gateway).
|
||||
- WhatsApp responsiveness checks for degraded Gateway event-loop health with local TUI clients still running; `--fix` stops only verified local TUI clients.
|
||||
- Codex route repair for `openai-codex/*` model refs in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` preserves working Codex OAuth PI routes, rewrites to `openai/*` only when the native Codex runtime or direct OpenAI auth path is usable, recovers prior `openai/*` GPT-5 PI rewrites when only Codex OAuth auth is available, and warns without rewriting when both Codex OAuth and direct OpenAI auth make an already-rewritten PI route ambiguous.
|
||||
- Supervisor config audit (launchd/systemd/schtasks) with optional repair.
|
||||
- Embedded proxy environment cleanup for gateway services that captured shell `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` values during install or update.
|
||||
- Gateway runtime best-practice checks (Node vs Bun, version-manager paths).
|
||||
@@ -164,7 +166,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
<Accordion title="1. Config normalization">
|
||||
If the config contains legacy value shapes (for example `messages.ackReaction` without a channel-specific override), doctor normalizes them into the current schema.
|
||||
|
||||
That includes legacy Talk flat fields. Current public Talk config is `talk.provider` + `talk.providers.<provider>`. Doctor rewrites old `talk.voiceId` / `talk.voiceAliases` / `talk.modelId` / `talk.outputFormat` / `talk.apiKey` shapes into the provider map.
|
||||
That includes legacy Talk flat fields. Current public Talk speech config is `talk.provider` + `talk.providers.<provider>`, and realtime voice config is `talk.realtime.*`. Doctor rewrites old `talk.voiceId` / `talk.voiceAliases` / `talk.modelId` / `talk.outputFormat` / `talk.apiKey` shapes into the provider map, and rewrites legacy top-level realtime selectors (`talk.mode`, `talk.transport`, `talk.brain`, `talk.model`, `talk.voice`) into `talk.realtime`.
|
||||
|
||||
Doctor also warns when `plugins.allow` is non-empty and tool policy uses
|
||||
wildcard or plugin-owned tool entries. `tools.allow: ["*"]` only matches tools
|
||||
@@ -183,7 +185,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
- Show the migration it applied.
|
||||
- Rewrite `~/.openclaw/openclaw.json` with the updated schema.
|
||||
|
||||
The Gateway also auto-runs doctor migrations on startup when it detects a legacy config format, so stale configs are repaired without manual intervention. Cron job store migrations are handled by `openclaw doctor --fix`.
|
||||
Gateway startup refuses legacy config formats and asks you to run `openclaw doctor --fix`; it does not rewrite `openclaw.json` on startup. Cron job store migrations are also handled by `openclaw doctor --fix`.
|
||||
|
||||
Current migrations:
|
||||
|
||||
@@ -197,6 +199,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
- `routing.bindings` → top-level `bindings`
|
||||
- `routing.agents`/`routing.defaultAgentId` → `agents.list` + `agents.list[].default`
|
||||
- legacy `talk.voiceId`/`talk.voiceAliases`/`talk.modelId`/`talk.outputFormat`/`talk.apiKey` → `talk.provider` + `talk.providers.<provider>`
|
||||
- legacy top-level realtime Talk selectors (`talk.mode`/`talk.transport`/`talk.brain`/`talk.model`/`talk.voice`) + `talk.provider`/`talk.providers` → `talk.realtime`
|
||||
- `routing.agentToAgent` → `tools.agentToAgent`
|
||||
- `routing.transcribeAudio` → `tools.media.audio.models`
|
||||
- `messages.tts.<provider>` (`openai`/`elevenlabs`/`microsoft`/`edge`) → `messages.tts.providers.<provider>`
|
||||
@@ -259,21 +262,24 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
<Accordion title="2e. Codex OAuth provider overrides">
|
||||
If you previously added legacy OpenAI transport settings under `models.providers.openai-codex`, they can shadow the built-in Codex OAuth provider path that newer releases use automatically. Doctor warns when it sees those old transport settings alongside Codex OAuth so you can remove or rewrite the stale transport override and get the built-in routing/fallback behavior back. Custom proxies and header-only overrides are still supported and do not trigger this warning.
|
||||
</Accordion>
|
||||
<Accordion title="2f. Codex plugin route warnings">
|
||||
When the bundled Codex plugin is enabled, doctor also checks whether `openai-codex/*` primary model refs still resolve through the default PI runner. That combination is valid when you want Codex OAuth/subscription auth through PI, but it is easy to confuse with the native Codex app-server harness. Doctor warns and points to the explicit app-server shape: `openai/*` plus `agentRuntime.id: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`.
|
||||
<Accordion title="2f. Codex route repair">
|
||||
Doctor checks for `openai-codex/*` model refs. Those refs are valid when you intentionally want Codex OAuth through OpenClaw PI. Native Codex harness routing uses canonical `openai/*` model refs plus `agentRuntime.id: "codex"` so the turn goes through the Codex app-server harness instead of the OpenClaw PI OpenAI path.
|
||||
|
||||
Doctor does not repair this automatically because both routes are valid:
|
||||
In `--fix` / `--repair` mode, doctor evaluates affected default-agent and per-agent refs, including primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale persisted session route state:
|
||||
|
||||
- `openai-codex/*` + PI means "use Codex OAuth/subscription auth through the normal OpenClaw runner."
|
||||
- `openai/*` + `agentRuntime.id: "codex"` means "run the embedded turn through native Codex app-server."
|
||||
- Working `openai-codex/*` Codex OAuth PI routes are preserved.
|
||||
- `openai-codex/*` becomes `openai/*` with `agentRuntime.id: "codex"` only when Codex is installed, enabled, contributes the `codex` harness, and has usable OAuth.
|
||||
- `openai-codex/*` becomes `openai/*` on PI only when direct OpenAI auth is already usable and no working Codex OAuth route would be moved.
|
||||
- Prior `openai/*` GPT-5 PI rewrites are recovered back to supported `openai-codex/*` refs when only Codex OAuth auth is available.
|
||||
- Prior `openai/*` GPT-5 PI rewrites warn and stay unchanged when direct OpenAI auth is also usable, so users can confirm whether the direct OpenAI API route is intentional before switching back to Codex OAuth through PI.
|
||||
- Existing model fallback lists are preserved when refs are rewritten or recovered; copied per-model settings move with the selected key.
|
||||
- Persisted session `modelProvider`/`providerOverride`, `model`/`modelOverride`, fallback notices, auth-profile pins, and Codex harness pins are repaired across all discovered agent session stores when the route can be moved safely.
|
||||
- `/codex ...` means "control or bind a native Codex conversation from chat."
|
||||
- `/acp ...` or `runtime: "acp"` means "use the external ACP/acpx adapter."
|
||||
|
||||
If the warning appears, choose the route you intended and edit config manually. Keep the warning as-is when PI Codex OAuth is intentional.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="2g. Session route cleanup">
|
||||
Doctor also scans the active sessions store for stale auto-created route state after you move the configured default/fallback model or runtime away from a plugin-owned route such as Codex.
|
||||
Doctor also scans discovered agent session stores for stale auto-created route state after you move configured models or runtime away from a plugin-owned route such as Codex.
|
||||
|
||||
`openclaw doctor --fix` can clear auto-created stale state such as `modelOverrideSource: "auto"` model pins, runtime model metadata, pinned harness ids, CLI session bindings, and auto auth-profile overrides when their owning route is no longer configured. Explicit user or legacy session model choices are reported for manual review and left untouched; switch them with `/model ...`, `/new`, or reset the session when that route is no longer intended.
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ targets the shipped npm package instead.
|
||||
Release checks call Package Acceptance with the package/update/plugin set:
|
||||
|
||||
```text
|
||||
doctor-switch update-channel-switch upgrade-survivor published-upgrade-survivor plugins-offline plugin-update
|
||||
doctor-switch update-channel-switch update-corrupt-plugin upgrade-survivor published-upgrade-survivor plugins-offline plugin-update
|
||||
```
|
||||
|
||||
They also pass:
|
||||
@@ -178,9 +178,10 @@ published_upgrade_survivor_scenarios=reported-issues
|
||||
telegram_mode=mock-openai
|
||||
```
|
||||
|
||||
This keeps package migration, update channel switching, stale plugin dependency
|
||||
cleanup, offline plugin coverage, plugin update behavior, and Telegram package
|
||||
QA on the same resolved artifact.
|
||||
This keeps package migration, update channel switching, corrupt managed-plugin
|
||||
tolerance, stale plugin dependency cleanup, offline plugin coverage, plugin
|
||||
update behavior, and Telegram package QA on the same resolved artifact without
|
||||
making the default release package gate walk every published release.
|
||||
|
||||
`all-since-2026.4.23` is the Full Release CI upgrade sample: every stable npm-published release from `2026.4.23` through `latest`. For exhaustive published
|
||||
update migration coverage, use `all-since-2026.4.23` in the separate Update
|
||||
|
||||
@@ -332,7 +332,7 @@ See [ClawDock](/install/clawdock) for the full helper guide.
|
||||
`openclaw-cli` uses `network_mode: "service:openclaw-gateway"` so CLI
|
||||
commands can reach the gateway over `127.0.0.1`. Treat this as a shared
|
||||
trust boundary. The compose config drops `NET_RAW`/`NET_ADMIN` and enables
|
||||
`no-new-privileges` on `openclaw-cli`.
|
||||
`no-new-privileges` on both `openclaw-gateway` and `openclaw-cli`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Permissions and EACCES">
|
||||
|
||||
@@ -87,9 +87,12 @@ If your config uses `plugins.allow`, include `codex` there too:
|
||||
}
|
||||
```
|
||||
|
||||
Do not use `openai-codex/gpt-*` when you mean native Codex runtime. That prefix
|
||||
is the explicit "Codex OAuth through PI" route. Config changes apply to new or
|
||||
reset sessions; existing sessions keep their recorded runtime.
|
||||
Use `openai-codex/gpt-*` only when you intentionally want Codex OAuth through
|
||||
OpenClaw PI. `openclaw doctor --fix` preserves that working subscription route,
|
||||
rewrites it to `openai/gpt-*` only when the native Codex runtime or direct
|
||||
OpenAI auth path is usable, can recover prior `openai/gpt-*` PI rewrites back
|
||||
to supported `openai-codex/gpt-*` refs when only Codex OAuth auth is available,
|
||||
and warns without rewriting when direct OpenAI auth makes the route ambiguous.
|
||||
|
||||
## What this plugin changes
|
||||
|
||||
@@ -106,7 +109,9 @@ The bundled `codex` plugin contributes several separate capabilities:
|
||||
Enabling the plugin makes those capabilities available. It does **not**:
|
||||
|
||||
- start using Codex for every OpenAI model
|
||||
- convert `openai-codex/*` model refs into the native runtime
|
||||
- convert a working `openai-codex/*` PI route into the native runtime without
|
||||
doctor verifying that Codex is installed, enabled, contributes the `codex`
|
||||
harness, and is OAuth-ready
|
||||
- make ACP/acpx the default Codex path
|
||||
- hot-switch existing sessions that already recorded a PI runtime
|
||||
- replace OpenClaw channel delivery, session files, auth-profile storage, or
|
||||
@@ -145,10 +150,13 @@ want native app-server execution. Legacy `codex/*` model refs still auto-select
|
||||
the harness for compatibility, but runtime-backed legacy provider prefixes are
|
||||
not shown as normal model/provider choices.
|
||||
|
||||
If the `codex` plugin is enabled but the primary model is still
|
||||
`openai-codex/*`, `openclaw doctor` warns instead of changing the route. That is
|
||||
intentional: `openai-codex/*` remains the PI Codex OAuth/subscription path, and
|
||||
native app-server execution stays an explicit runtime choice.
|
||||
If any configured model route is `openai-codex/*`, `openclaw doctor --fix`
|
||||
preserves it when it is the working Codex OAuth PI route. For matching agent
|
||||
routes, doctor rewrites it to `openai/*` and sets the agent runtime to `codex`
|
||||
only when the Codex plugin is installed, enabled, contributes the `codex`
|
||||
harness, and has usable OAuth. Doctor rewrites to direct `openai/*` on PI only
|
||||
when direct OpenAI auth is already usable and no working Codex OAuth route would
|
||||
be moved.
|
||||
|
||||
## Route map
|
||||
|
||||
@@ -158,15 +166,18 @@ Use this table before changing config:
|
||||
| ---------------------------------------------------- | -------------------------- | -------------------------------------- | ---------------------------- | ------------------------------ |
|
||||
| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` | `agentRuntime.id: "codex"` | Codex OAuth or Codex account | `Runtime: OpenAI Codex` |
|
||||
| OpenAI API through normal OpenClaw runner | `openai/gpt-*` | omitted or `runtime: "pi"` | OpenAI API key | `Runtime: OpenClaw Pi Default` |
|
||||
| ChatGPT/Codex subscription through PI | `openai-codex/gpt-*` | omitted or `runtime: "pi"` | OpenAI Codex OAuth provider | `Runtime: OpenClaw Pi Default` |
|
||||
| Codex subscription through PI | `openai-codex/gpt-*` | omitted or `runtime: "pi"` | Codex OAuth | `Runtime: OpenClaw Pi Default` |
|
||||
| Mixed providers with conservative auto mode | provider-specific refs | `agentRuntime.id: "auto"` | Per selected provider | Depends on selected runtime |
|
||||
| Explicit Codex ACP adapter session | ACP prompt/model dependent | `sessions_spawn` with `runtime: "acp"` | ACP backend auth | ACP task/session status |
|
||||
|
||||
The important split is provider versus runtime:
|
||||
|
||||
- `openai-codex/*` answers "which provider/auth route should PI use?"
|
||||
- `agentRuntime.id: "codex"` answers "which loop should execute this
|
||||
embedded turn?"
|
||||
- `openai-codex/*` is the explicit Codex OAuth through PI route; doctor preserves it when that route is working.
|
||||
- `agentRuntime.id: "codex"` requires the Codex harness and fails closed if it
|
||||
is unavailable.
|
||||
- `agentRuntime.id: "auto"` lets registered harnesses claim matching provider
|
||||
routes, but canonical OpenAI refs are still PI-owned unless a harness supports
|
||||
that provider/model pair.
|
||||
- `/codex ...` answers "which native Codex conversation should this chat bind
|
||||
or control?"
|
||||
- ACP answers "which external harness process should acpx launch?"
|
||||
@@ -175,33 +186,29 @@ The important split is provider versus runtime:
|
||||
|
||||
OpenAI-family routes are prefix-specific. For the common subscription plus
|
||||
native Codex runtime setup, use `openai/*` with `agentRuntime.id: "codex"`.
|
||||
Use `openai-codex/*` only when you intentionally want Codex OAuth through PI:
|
||||
For Codex OAuth through OpenClaw PI, use `openai-codex/*`:
|
||||
|
||||
| Model ref | Runtime path | Use when |
|
||||
| --------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `openai/gpt-5.4` | OpenAI provider through OpenClaw/PI plumbing | You want current direct OpenAI Platform API access with `OPENAI_API_KEY`. |
|
||||
| `openai-codex/gpt-5.5` | OpenAI Codex OAuth through OpenClaw/PI | You want ChatGPT/Codex subscription auth with the default PI runner. |
|
||||
| `openai-codex/gpt-5.5` | Codex OAuth through OpenClaw PI | You intentionally want subscription auth on the normal PI runner. |
|
||||
| `openai/gpt-5.5` + `agentRuntime.id: "codex"` | Codex app-server harness | You want ChatGPT/Codex subscription auth with native Codex execution. |
|
||||
|
||||
GPT-5.5 can appear on both direct OpenAI API-key and Codex subscription routes
|
||||
when your account exposes them. Use `openai/gpt-5.5` with the Codex app-server
|
||||
harness for native Codex runtime, `openai-codex/gpt-5.5` for PI OAuth, or
|
||||
`openai/gpt-5.5` without a Codex runtime override for direct API-key traffic.
|
||||
harness for native Codex runtime, or `openai/gpt-5.5` without a Codex runtime
|
||||
override for direct API-key traffic.
|
||||
|
||||
Legacy `codex/gpt-*` refs remain accepted as compatibility aliases. Doctor
|
||||
compatibility migration rewrites legacy primary runtime refs to canonical model
|
||||
refs and records the runtime policy separately, while fallback-only legacy refs
|
||||
are left unchanged because runtime is configured for the whole agent container.
|
||||
New PI Codex OAuth configs should use `openai-codex/gpt-*`; new native
|
||||
app-server harness configs should use `openai/gpt-*` plus
|
||||
`agentRuntime.id: "codex"`.
|
||||
compatibility migration rewrites legacy runtime refs to canonical model refs
|
||||
and records the runtime policy separately. New native app-server harness configs
|
||||
should use `openai/gpt-*` plus `agentRuntime.id: "codex"`.
|
||||
|
||||
`agents.defaults.imageModel` follows the same prefix split. Use
|
||||
`openai-codex/gpt-*` when image understanding should run through the OpenAI
|
||||
Codex OAuth provider path. Use `codex/gpt-*` when image understanding should run
|
||||
through a bounded Codex app-server turn. The Codex app-server model must
|
||||
advertise image input support; text-only Codex models fail before the media turn
|
||||
starts.
|
||||
`openai/gpt-*` for the normal OpenAI route and `codex/gpt-*` when image
|
||||
understanding should run through a bounded Codex app-server turn. The Codex
|
||||
app-server model must advertise image input support; text-only Codex models
|
||||
fail before the media turn starts.
|
||||
|
||||
Use `/status` to confirm the effective harness for the current session. If the
|
||||
selection is surprising, enable debug logging for the `agents/harness` subsystem
|
||||
@@ -211,22 +218,22 @@ in `auto` mode, each plugin candidate's support result.
|
||||
|
||||
### What doctor warnings mean
|
||||
|
||||
`openclaw doctor` warns when all of these are true:
|
||||
`openclaw doctor` warns when configured model refs or persisted session route
|
||||
state still use `openai-codex/*` so you can confirm whether PI subscription auth
|
||||
is intentional. `openclaw doctor --fix` uses the available auth/runtime state to
|
||||
choose one of these outcomes:
|
||||
|
||||
- the bundled `codex` plugin is enabled or allowed
|
||||
- an agent's primary model is `openai-codex/*`
|
||||
- that agent's effective runtime is not `codex`
|
||||
- preserve `openai-codex/*` when it is a working Codex OAuth PI route
|
||||
- rewrite to `openai/<model>` plus `agentRuntime.id: "codex"` when Codex is installed, enabled, contributes the `codex` harness, and has usable OAuth
|
||||
- rewrite to direct `openai/<model>` on PI only when direct OpenAI auth is already usable and no working Codex OAuth route would be moved
|
||||
- recover prior `openai/*` GPT-5 PI rewrites back to supported `openai-codex/*` refs when only Codex OAuth auth is available
|
||||
- warn without rewriting when a prior `openai/*` GPT-5 PI rewrite has both Codex OAuth and direct OpenAI auth available
|
||||
|
||||
That warning exists because users often expect "Codex plugin enabled" to imply
|
||||
"native Codex app-server runtime." OpenClaw does not make that leap. The warning
|
||||
means:
|
||||
|
||||
- **No change is required** if you intended ChatGPT/Codex OAuth through PI.
|
||||
- Change the model to `openai/<model>` and set
|
||||
`agentRuntime.id: "codex"` if you intended native app-server
|
||||
execution.
|
||||
- Existing sessions still need `/new` or `/reset` after a runtime change,
|
||||
because session runtime pins are sticky.
|
||||
The `codex` route forces the native Codex harness. The `pi` route keeps the
|
||||
agent on the default OpenClaw runner instead of enabling or installing Codex as
|
||||
a side effect of legacy-route cleanup.
|
||||
Doctor also repairs stale persisted session pins across discovered agent session
|
||||
stores so old conversations do not stay wedged on the removed route.
|
||||
|
||||
Harness selection is not a live session control. When an embedded turn runs,
|
||||
OpenClaw records the selected harness id on that session and keeps using it for
|
||||
@@ -273,9 +280,9 @@ filenames for persona files, because Codex fallbacks only apply when
|
||||
For OpenClaw workspace parity, the Codex harness resolves the other bootstrap
|
||||
files (`SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`,
|
||||
`BOOTSTRAP.md`, and `MEMORY.md` when present) and forwards them through Codex
|
||||
config instructions on `thread/start` and `thread/resume`. This keeps
|
||||
`SOUL.md` and related workspace persona/profile context visible without
|
||||
duplicating `AGENTS.md`.
|
||||
developer instructions on `thread/start` and `thread/resume`. This keeps
|
||||
`SOUL.md` and related workspace persona/profile context visible on the native
|
||||
Codex behavior-shaping lane without duplicating `AGENTS.md`.
|
||||
|
||||
## Add Codex alongside other models
|
||||
|
||||
@@ -349,7 +356,7 @@ Agents should route user requests by intent, not by the word "Codex" alone:
|
||||
| "File a support report for a bad Codex run" | `/diagnostics [note]` |
|
||||
| "Only send Codex feedback for this attached thread" | `/codex diagnostics [note]` |
|
||||
| "Use my ChatGPT/Codex subscription with Codex runtime" | `openai/*` plus `agentRuntime.id: "codex"` |
|
||||
| "Use my ChatGPT/Codex subscription through PI" | `openai-codex/*` model refs |
|
||||
| "Check or recover Codex OAuth PI routes" | `openclaw doctor --fix` |
|
||||
| "Run Codex through ACP/acpx" | ACP `sessions_spawn({ runtime: "acp", ... })` |
|
||||
| "Start Claude Code/Gemini/OpenCode/Cursor in a thread" | ACP/acpx, not `/codex` and not native sub-agents |
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ OpenClaw uses stable per-source roots:
|
||||
npm installs run in the npm root with:
|
||||
|
||||
```bash
|
||||
npm install --prefix ~/.openclaw/npm <spec> --omit=dev --ignore-scripts --no-audit --no-fund
|
||||
npm install --prefix ~/.openclaw/npm <spec> --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
```
|
||||
|
||||
npm may hoist transitive dependencies to `~/.openclaw/npm/node_modules` beside
|
||||
@@ -51,6 +51,14 @@ the plugin package. OpenClaw scans the managed npm root before trusting the
|
||||
install and uses npm to remove npm-managed packages during uninstall, so hoisted
|
||||
runtime dependencies stay inside the managed cleanup boundary.
|
||||
|
||||
Plugins that import `openclaw/plugin-sdk/*` declare `openclaw` as a peer
|
||||
dependency. OpenClaw does not let npm install a separate registry copy of the
|
||||
host package into the managed root, because stale host packages can affect npm
|
||||
peer resolution during later plugin installs. Managed npm installs skip npm peer
|
||||
resolution/materialization for the shared root and OpenClaw reasserts
|
||||
plugin-local `node_modules/openclaw` links for installed packages that declare
|
||||
the host peer after install, update, or uninstall.
|
||||
|
||||
git installs clone or refresh the repository, then run:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -18,6 +18,16 @@ Adds the WhatsApp channel surface for sending and receiving OpenClaw messages.
|
||||
|
||||
channels: whatsapp
|
||||
|
||||
## Windows install note
|
||||
|
||||
On Windows, the WhatsApp plugin needs Git on `PATH` during npm install because one of its Baileys/libsignal dependencies is fetched from a git URL. Install Git for Windows, then restart the shell and rerun the install:
|
||||
|
||||
```powershell
|
||||
winget install --id Git.Git -e
|
||||
```
|
||||
|
||||
Portable Git also works if its `bin` directory is on `PATH`.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [whatsapp](/channels/whatsapp)
|
||||
|
||||
@@ -32,6 +32,7 @@ changing config.
|
||||
| ---------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-5.5` plus `agentRuntime.id: "codex"` | Recommended Codex setup for most users. Sign in with `openai-codex` auth. |
|
||||
| Direct API-key billing | `openai/gpt-5.5` | Set `OPENAI_API_KEY` or run OpenAI API-key onboarding. |
|
||||
| Latest ChatGPT Instant API alias | `openai/chat-latest` | Direct API-key only. Moving alias for experiments, not the default. |
|
||||
| ChatGPT/Codex subscription auth through PI | `openai-codex/gpt-5.5` | Use only when you intentionally want the normal PI runner. |
|
||||
| Image generation or editing | `openai/gpt-image-2` | Works with either `OPENAI_API_KEY` or OpenAI Codex OAuth. |
|
||||
| Transparent-background images | `openai/gpt-image-1.5` | Use `outputFormat=png` or `webp` and `openai.background=transparent`. |
|
||||
@@ -165,6 +166,23 @@ Choose your preferred auth method and follow the setup steps.
|
||||
}
|
||||
```
|
||||
|
||||
To try ChatGPT's current Instant model from the OpenAI API, set the model
|
||||
to `openai/chat-latest`:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { OPENAI_API_KEY: "sk-..." },
|
||||
agents: { defaults: { model: { primary: "openai/chat-latest" } } },
|
||||
}
|
||||
```
|
||||
|
||||
`chat-latest` is a moving alias. OpenAI documents it as the latest Instant
|
||||
model used in ChatGPT and recommends `gpt-5.5` for production API usage, so
|
||||
keep `openai/gpt-5.5` as the stable default unless you explicitly want that
|
||||
alias behavior. The alias currently accepts only `medium` text verbosity, so
|
||||
OpenClaw normalizes incompatible OpenAI text-verbosity overrides for this
|
||||
model.
|
||||
|
||||
<Warning>
|
||||
OpenClaw does **not** expose `openai/gpt-5.3-codex-spark`. Live OpenAI API requests reject that model, and the current Codex catalog does not expose it either.
|
||||
</Warning>
|
||||
@@ -265,6 +283,14 @@ Choose your preferred auth method and follow the setup steps.
|
||||
intentional. Switch to `openai/<model>` plus `agentRuntime.id: "codex"` when
|
||||
you want native Codex app-server execution.
|
||||
|
||||
`doctor --fix` does not move a working `openai-codex/*` Codex OAuth route to
|
||||
direct `openai/*` API-key billing. If an earlier repair already rewrote a
|
||||
PI Codex OAuth setup to `openai/*` and no direct OpenAI auth is available,
|
||||
rerun `openclaw doctor --fix` to recover the route back to
|
||||
`openai-codex/*`. If direct OpenAI auth is also available, doctor warns and
|
||||
leaves the mixed-auth route unchanged until you confirm whether direct
|
||||
OpenAI API auth or Codex OAuth through PI is intended.
|
||||
|
||||
### Context window cap
|
||||
|
||||
OpenClaw treats model metadata and the runtime context cap as separate values.
|
||||
|
||||
@@ -77,10 +77,17 @@ the maintainer-only release runbook.
|
||||
prior evidence stale.
|
||||
9. For beta, tag `vYYYY.M.D-beta.N`, then run `OpenClaw Release Publish` from
|
||||
the matching `release/YYYY.M.D` branch. It verifies `pnpm plugins:sync:check`,
|
||||
publishes all publishable plugin packages to npm first, publishes the same
|
||||
set to ClawHub second as ClawPack npm-pack tarballs, and then promotes the
|
||||
prepared OpenClaw npm preflight artifact with the matching dist-tag. After
|
||||
publish, run post-publish package
|
||||
dispatches all publishable plugin packages to npm and the same set to
|
||||
ClawHub in parallel, and then promotes the prepared OpenClaw npm preflight
|
||||
artifact with the matching dist-tag as soon as plugin npm publish succeeds.
|
||||
ClawHub publishing may still be running while OpenClaw npm publishes, but the
|
||||
release publish workflow does not finish until both plugin publish paths and
|
||||
the OpenClaw npm publish path have completed successfully. The ClawHub path
|
||||
retries transient CLI dependency install failures, publishes preview-passing
|
||||
plugins even when one preview cell flakes, and ends with registry verification
|
||||
for every expected plugin version so partial publishes remain visible and
|
||||
retryable. After publish, run
|
||||
the post-publish package
|
||||
acceptance against the published `openclaw@YYYY.M.D-beta.N` or
|
||||
`openclaw@beta` package. If a pushed or published prerelease needs a fix,
|
||||
cut the next matching prerelease number; do not delete or rewrite the old
|
||||
|
||||
@@ -27,6 +27,14 @@ Child workflows use the trusted workflow ref for the harness and the input
|
||||
`ref` for the candidate under test. That keeps new validation logic available
|
||||
when validating an older release branch or tag.
|
||||
|
||||
Plugin publish validation is intentionally split from core package publication.
|
||||
`OpenClaw Release Publish` dispatches npm plugin publishing and ClawHub
|
||||
publishing in parallel, starts the core npm publish after plugin npm succeeds,
|
||||
and keeps waiting for ClawHub. The ClawHub child retries transient CLI
|
||||
dependency install failures, publishes preview-passing plugins when a single
|
||||
preview cell flakes, and then verifies every expected package/version through
|
||||
the ClawHub API so a partial publish still fails loudly and can be rerun.
|
||||
|
||||
By default, `release_profile=stable` runs the release-blocking lanes and skips
|
||||
the exhaustive live/Docker soak. Pass `run_release_soak=true` to include the
|
||||
soak lanes on a stable run. `release_profile=full` always enables soak lanes so
|
||||
|
||||
@@ -233,6 +233,8 @@ The config shape is identical to `approvals.exec`: `enabled`, `mode`, `agentFilt
|
||||
Channels that support shared interactive replies render the same approval buttons for both exec and
|
||||
plugin approvals. Channels without shared interactive UI fall back to plain text with `/approve`
|
||||
instructions.
|
||||
Plugin approval requests may restrict the available decisions. Approval surfaces use the request's
|
||||
declared decision set, and the Gateway rejects attempts to submit a decision that was not offered.
|
||||
|
||||
### Same-chat approvals on any channel
|
||||
|
||||
|
||||
@@ -133,6 +133,9 @@ managed npm root. After npm finishes, OpenClaw verifies the installed
|
||||
`package-lock.json` entry still matches the resolved version and integrity. If
|
||||
npm writes different package metadata, the install fails and the managed package
|
||||
is rolled back instead of accepting a different plugin artifact.
|
||||
Managed npm roots also inherit OpenClaw's package-level npm `overrides`, so
|
||||
security pins that protect the packaged host also apply to hoisted external
|
||||
plugin dependencies.
|
||||
|
||||
Source checkouts are pnpm workspaces. If you clone OpenClaw to hack on bundled
|
||||
plugins, run `pnpm install`; OpenClaw then loads bundled plugins from
|
||||
|
||||
@@ -152,7 +152,7 @@ Current source-of-truth:
|
||||
- `/help` shows the short help summary.
|
||||
- `/commands` shows the generated command catalog.
|
||||
- `/tools [compact|verbose]` shows what the current agent can use right now.
|
||||
- `/status` shows execution/runtime status, including `Execution`/`Runtime` labels and provider usage/quota when available.
|
||||
- `/status` shows execution/runtime status, Gateway and system uptime, plus provider usage/quota when available.
|
||||
- `/diagnostics [note]` is the owner-only support-report flow for Gateway bugs and Codex harness runs. It asks for explicit exec approval every time before running `openclaw gateway diagnostics export --json`; do not approve diagnostics with an allow-all rule. After approval, it sends a pasteable report with the local bundle path, manifest summary, privacy notes, and relevant session ids. In group chats, the approval prompt and report go to the owner privately. When the active session uses the OpenAI Codex harness, the same approval also sends relevant Codex feedback to OpenAI servers and the completed reply lists the OpenClaw session ids, Codex thread ids, and `codex resume <thread-id>` commands. See [Diagnostics Export](/gateway/diagnostics).
|
||||
- `/crestodian <request>` runs the Crestodian setup and repair helper from an owner DM.
|
||||
- `/tasks` lists active/recent background tasks for the current session.
|
||||
|
||||
@@ -198,9 +198,9 @@ role or use `first_frame` for single-image image-to-video.
|
||||
### Style controls
|
||||
|
||||
<ParamField path="aspectRatio" type="string">
|
||||
`1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9`, or `adaptive`.
|
||||
Aspect-ratio hint such as `1:1`, `16:9`, `9:16`, `adaptive`, or a provider-specific value. OpenClaw normalizes or ignores unsupported values per provider.
|
||||
</ParamField>
|
||||
<ParamField path="resolution" type="string">`480P`, `720P`, `768P`, or `1080P`.</ParamField>
|
||||
<ParamField path="resolution" type="string">Resolution hint such as `480P`, `720P`, `768P`, `1080P`, `4K`, or a provider-specific value. OpenClaw normalizes or ignores unsupported values per provider.</ParamField>
|
||||
<ParamField path="durationSeconds" type="number">
|
||||
Target duration in seconds (rounded to nearest provider-supported value).
|
||||
</ParamField>
|
||||
|
||||
@@ -231,12 +231,13 @@ fallbacks after its dedicated web-search config and `GEMINI_API_KEY`. See the
|
||||
provider pages for examples.
|
||||
|
||||
`tools.web.search.provider` is validated against the web-search provider ids
|
||||
declared by bundled and installed plugin manifests. A typo such as `"brvae"`
|
||||
fails config validation instead of silently falling back to auto-detection. If a
|
||||
configured provider only has stale plugin evidence, such as a leftover
|
||||
`plugins.entries.<plugin>` block after uninstalling a third-party plugin,
|
||||
OpenClaw keeps startup resilient and reports a warning so you can reinstall the
|
||||
plugin or run `openclaw doctor --fix` to clean up the stale config.
|
||||
declared by bundled and installed plugin manifests, plus known installable
|
||||
provider plugins. A typo such as `"brvae"` fails config validation instead of
|
||||
silently falling back to auto-detection. If the configured provider is known but
|
||||
the owning plugin is unavailable, OpenClaw keeps startup resilient and reports a
|
||||
warning so you can run `openclaw doctor --fix` to install or enable the plugin.
|
||||
The same warning behavior applies to stale plugin evidence, such as a leftover
|
||||
`plugins.entries.<plugin>` block after uninstalling a third-party plugin.
|
||||
|
||||
`web_fetch` fallback provider selection is separate:
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ Notes:
|
||||
|
||||
- Model picker: list available models and set the session override.
|
||||
- Agent picker: choose a different agent.
|
||||
- Session picker: shows only sessions for the current agent.
|
||||
- Session picker: shows up to 50 sessions for the current agent updated in the last 7 days. Use `/session <key>` to jump to an older known session.
|
||||
- Settings: toggle deliver, tool output expansion, and thinking visibility.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,10 +25,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.4"
|
||||
"pluginApi": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.4",
|
||||
"openclawVersion": "2026.5.6-beta.1",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./src/runtime-internals/mcp-proxy.mjs",
|
||||
|
||||
@@ -420,6 +420,108 @@ describe("active-memory plugin", () => {
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks gateway callers without admin scope from changing global active-memory config", async () => {
|
||||
const command = registeredCommands["active-memory"];
|
||||
|
||||
for (const { args, gatewayClientScopes } of [
|
||||
{ args: "off --global", gatewayClientScopes: ["operator.write"] },
|
||||
{ args: "on --global", gatewayClientScopes: ["operator.write"] },
|
||||
{ args: "disable --global", gatewayClientScopes: ["operator.write"] },
|
||||
{ args: "enable --global", gatewayClientScopes: ["operator.write"] },
|
||||
{ args: "disabled --global", gatewayClientScopes: ["operator.write"] },
|
||||
{ args: "enabled --global", gatewayClientScopes: ["operator.write"] },
|
||||
{ args: "off --global", gatewayClientScopes: [] },
|
||||
]) {
|
||||
const result = await command.handler({
|
||||
channel: "gateway",
|
||||
isAuthorizedSender: true,
|
||||
gatewayClientScopes,
|
||||
args,
|
||||
commandBody: `/active-memory ${args}`,
|
||||
config: {},
|
||||
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
|
||||
detachConversationBinding: async () => ({ removed: false }),
|
||||
getCurrentConversationBinding: async () => null,
|
||||
});
|
||||
|
||||
expect(result.text).toContain("global enable/disable changes require operator.admin");
|
||||
}
|
||||
|
||||
expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows admin-scoped gateway callers to change global active-memory config", async () => {
|
||||
const command = registeredCommands["active-memory"];
|
||||
|
||||
const result = await command.handler({
|
||||
channel: "gateway",
|
||||
isAuthorizedSender: true,
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
args: "off --global",
|
||||
commandBody: "/active-memory off --global",
|
||||
config: {},
|
||||
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
|
||||
detachConversationBinding: async () => ({ removed: false }),
|
||||
getCurrentConversationBinding: async () => null,
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Active Memory: off globally.");
|
||||
expect(api.runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(configFile).toMatchObject({
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
agents: ["main"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps write-scoped gateway callers on non-global-write active-memory paths", async () => {
|
||||
const command = registeredCommands["active-memory"];
|
||||
const sessionKey = "agent:main:write-scoped-active-memory";
|
||||
hoisted.sessionStore[sessionKey] = {
|
||||
sessionId: "s-write-scoped-active-memory",
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
const globalStatusResult = await command.handler({
|
||||
channel: "gateway",
|
||||
isAuthorizedSender: true,
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
args: "status --global",
|
||||
commandBody: "/active-memory status --global",
|
||||
config: {},
|
||||
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
|
||||
detachConversationBinding: async () => ({ removed: false }),
|
||||
getCurrentConversationBinding: async () => null,
|
||||
});
|
||||
|
||||
expect(globalStatusResult.text).toBe("Active Memory: on globally.");
|
||||
expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
|
||||
const sessionOffResult = await command.handler({
|
||||
channel: "gateway",
|
||||
isAuthorizedSender: true,
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
sessionKey,
|
||||
args: "off",
|
||||
commandBody: "/active-memory off",
|
||||
config: {},
|
||||
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
|
||||
detachConversationBinding: async () => ({ removed: false }),
|
||||
getCurrentConversationBinding: async () => null,
|
||||
});
|
||||
|
||||
expect(sessionOffResult.text).toBe("Active Memory: off for this session.");
|
||||
expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses live runtime config for before_prompt_build enablement", async () => {
|
||||
configFile = {
|
||||
plugins: {
|
||||
|
||||
@@ -783,6 +783,13 @@ function updateActiveMemoryGlobalEnabledInConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function requiresAdminToMutateActiveMemoryGlobal(gatewayClientScopes?: readonly string[]): boolean {
|
||||
return Array.isArray(gatewayClientScopes) && !gatewayClientScopes.includes("operator.admin");
|
||||
}
|
||||
|
||||
const ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT =
|
||||
"⚠️ /active-memory global enable/disable changes require operator.admin for gateway clients.";
|
||||
|
||||
function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPluginConfig {
|
||||
const raw = (
|
||||
pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {}
|
||||
@@ -2818,6 +2825,11 @@ export default definePluginEntry({
|
||||
text: `Active Memory: ${isActiveMemoryGloballyEnabled(currentConfig) ? "on" : "off"} globally.`,
|
||||
};
|
||||
}
|
||||
if (requiresAdminToMutateActiveMemoryGlobal(ctx.gatewayClientScopes)) {
|
||||
return {
|
||||
text: ACTIVE_MEMORY_GLOBAL_MUTATION_ADMIN_REQUIRED_TEXT,
|
||||
};
|
||||
}
|
||||
if (action === "on" || action === "enable" || action === "enabled") {
|
||||
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(currentConfig, true);
|
||||
await api.runtime.config.replaceConfigFile({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -257,6 +257,30 @@ describe("anthropic provider replay hooks", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not forward-compat case-mismatched Anthropic model ids", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
const resolved = provider.resolveDynamicModel?.({
|
||||
provider: "anthropic",
|
||||
modelId: "CLAUDE-OPUS-4-7",
|
||||
modelRegistry: createModelRegistry([
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 32_000,
|
||||
} as ProviderRuntimeModel,
|
||||
]),
|
||||
} as ProviderResolveDynamicModelContext);
|
||||
|
||||
expect(resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes exact claude opus 4.7 variants to 1M context", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -214,6 +214,9 @@ function resolveAnthropic46ForwardCompatModel(params: {
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const trimmedModelId = params.ctx.modelId.trim();
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmedModelId);
|
||||
if (trimmedModelId !== lower) {
|
||||
return undefined;
|
||||
}
|
||||
const is46Model =
|
||||
lower === params.dashModelId ||
|
||||
lower === params.dotModelId ||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Arcee provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/azure-speech",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Azure Speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -12,7 +12,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.4"
|
||||
"openclaw": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -53,10 +53,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.4"
|
||||
"pluginApi": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.4"
|
||||
"openclawVersion": "2026.5.6-beta.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bonjour",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw Bonjour/mDNS gateway discovery",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw Brave plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -20,10 +20,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.4"
|
||||
"pluginApi": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.4"
|
||||
"openclawVersion": "2026.5.6-beta.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cerebras-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cerebras provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw Codex harness and model provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -27,10 +27,10 @@
|
||||
"minHostVersion": ">=2026.5.1-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.4"
|
||||
"pluginApi": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.4"
|
||||
"openclawVersion": "2026.5.6-beta.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -660,6 +660,164 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.itemLifecycle).toMatchObject({ compactionCount: 1 });
|
||||
});
|
||||
|
||||
it("synthesizes normalized tool progress for Codex-native tool items", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: {
|
||||
type: "commandExecution",
|
||||
id: "cmd-1",
|
||||
command: "pnpm test extensions/codex",
|
||||
cwd: "/workspace",
|
||||
processId: null,
|
||||
source: "agent",
|
||||
status: "inProgress",
|
||||
commandActions: [],
|
||||
aggregatedOutput: null,
|
||||
exitCode: null,
|
||||
durationMs: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "commandExecution",
|
||||
id: "cmd-1",
|
||||
command: "pnpm test extensions/codex",
|
||||
cwd: "/workspace",
|
||||
processId: null,
|
||||
source: "agent",
|
||||
status: "completed",
|
||||
commandActions: [],
|
||||
aggregatedOutput: "ok",
|
||||
exitCode: 0,
|
||||
durationMs: 42,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "item",
|
||||
data: expect.objectContaining({
|
||||
phase: "start",
|
||||
kind: "command",
|
||||
name: "bash",
|
||||
itemId: "cmd-1",
|
||||
suppressChannelProgress: true,
|
||||
}),
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "tool",
|
||||
data: expect.objectContaining({
|
||||
phase: "start",
|
||||
name: "bash",
|
||||
itemId: "cmd-1",
|
||||
toolCallId: "cmd-1",
|
||||
args: { command: "pnpm test extensions/codex", cwd: "/workspace" },
|
||||
}),
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "tool",
|
||||
data: expect.objectContaining({
|
||||
phase: "result",
|
||||
name: "bash",
|
||||
itemId: "cmd-1",
|
||||
toolCallId: "cmd-1",
|
||||
status: "completed",
|
||||
isError: false,
|
||||
result: expect.objectContaining({ exitCode: 0, durationMs: 42 }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("marks declined Codex-native tool results as non-success", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/completed", {
|
||||
item: {
|
||||
type: "commandExecution",
|
||||
id: "cmd-declined",
|
||||
command: "pnpm test extensions/codex",
|
||||
cwd: "/workspace",
|
||||
processId: null,
|
||||
source: "agent",
|
||||
status: "declined",
|
||||
commandActions: [],
|
||||
aggregatedOutput: null,
|
||||
exitCode: null,
|
||||
durationMs: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "item",
|
||||
data: expect.objectContaining({
|
||||
phase: "end",
|
||||
kind: "command",
|
||||
name: "bash",
|
||||
itemId: "cmd-declined",
|
||||
status: "blocked",
|
||||
suppressChannelProgress: true,
|
||||
}),
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "tool",
|
||||
data: expect.objectContaining({
|
||||
phase: "result",
|
||||
name: "bash",
|
||||
itemId: "cmd-declined",
|
||||
toolCallId: "cmd-declined",
|
||||
status: "blocked",
|
||||
isError: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves Codex dynamic tool item progress to item/tool/call normalization", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: {
|
||||
type: "dynamicToolCall",
|
||||
id: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "send" },
|
||||
status: "inProgress",
|
||||
contentItems: null,
|
||||
success: null,
|
||||
durationMs: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stream: "item",
|
||||
data: expect.objectContaining({
|
||||
phase: "start",
|
||||
kind: "tool",
|
||||
name: "message",
|
||||
suppressChannelProgress: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(onAgentEvent).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stream: "tool",
|
||||
data: expect.objectContaining({ phase: "start", name: "message" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits verbose tool summaries through onToolResult", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const projector = await createProjector({
|
||||
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
|
||||
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
|
||||
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
||||
import {
|
||||
resolveCodexToolProgressDetailMode,
|
||||
sanitizeCodexAgentEventRecord,
|
||||
sanitizeCodexToolArguments,
|
||||
} from "./tool-progress-normalization.js";
|
||||
import { attachCodexMirrorIdentity } from "./transcript-mirror.js";
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
@@ -396,6 +401,7 @@ export class CodexAppServerEventProjector {
|
||||
});
|
||||
}
|
||||
this.emitStandardItemEvent({ phase: "start", item });
|
||||
this.emitNormalizedToolItemEvent({ phase: "start", item });
|
||||
this.emitToolResultSummary(item);
|
||||
this.emitAgentEvent({
|
||||
stream: "codex_app_server.item",
|
||||
@@ -449,6 +455,7 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
this.recordToolMeta(item);
|
||||
this.emitStandardItemEvent({ phase: "end", item });
|
||||
this.emitNormalizedToolItemEvent({ phase: "result", item });
|
||||
this.emitToolResultSummary(item);
|
||||
this.emitToolResultOutput(item);
|
||||
this.emitAgentEvent({
|
||||
@@ -656,6 +663,7 @@ export class CodexAppServerEventProjector {
|
||||
return;
|
||||
}
|
||||
const meta = itemMeta(item, this.toolProgressDetailMode());
|
||||
const suppressChannelProgress = shouldSuppressChannelProgressForItem(item);
|
||||
this.emitAgentEvent({
|
||||
stream: "item",
|
||||
data: {
|
||||
@@ -666,6 +674,42 @@ export class CodexAppServerEventProjector {
|
||||
status: params.phase === "start" ? "running" : itemStatus(item),
|
||||
...(itemName(item) ? { name: itemName(item) } : {}),
|
||||
...(meta ? { meta } : {}),
|
||||
...(suppressChannelProgress ? { suppressChannelProgress: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private emitNormalizedToolItemEvent(params: {
|
||||
phase: "start" | "result";
|
||||
item: CodexThreadItem | undefined;
|
||||
}): void {
|
||||
const { item } = params;
|
||||
if (!item || !shouldSynthesizeToolProgressForItem(item)) {
|
||||
return;
|
||||
}
|
||||
const name = itemName(item);
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const meta = itemMeta(item, this.toolProgressDetailMode());
|
||||
const args = params.phase === "start" ? itemToolArgs(item) : undefined;
|
||||
const status = params.phase === "result" ? itemStatus(item) : "running";
|
||||
this.emitAgentEvent({
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: params.phase,
|
||||
name,
|
||||
itemId: item.id,
|
||||
toolCallId: item.id,
|
||||
...(meta ? { meta } : {}),
|
||||
...(args ? { args } : {}),
|
||||
...(params.phase === "result"
|
||||
? {
|
||||
status,
|
||||
isError: isNonSuccessItemStatus(status),
|
||||
...itemToolResult(item),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -743,7 +787,7 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
|
||||
private toolProgressDetailMode(): ToolProgressDetailMode {
|
||||
return this.params.toolProgressDetail === "raw" ? "raw" : "explain";
|
||||
return resolveCodexToolProgressDetailMode(this.params.toolProgressDetail);
|
||||
}
|
||||
|
||||
private recordToolMeta(item: CodexThreadItem | undefined): void {
|
||||
@@ -1074,17 +1118,24 @@ function itemTitle(item: CodexThreadItem): string {
|
||||
}
|
||||
}
|
||||
|
||||
function itemStatus(item: CodexThreadItem): "completed" | "failed" | "running" {
|
||||
function itemStatus(item: CodexThreadItem): "completed" | "failed" | "running" | "blocked" {
|
||||
const status = readItemString(item, "status");
|
||||
if (status === "failed") {
|
||||
return "failed";
|
||||
}
|
||||
if (status === "declined") {
|
||||
return "blocked";
|
||||
}
|
||||
if (status === "inProgress" || status === "running") {
|
||||
return "running";
|
||||
}
|
||||
return "completed";
|
||||
}
|
||||
|
||||
function isNonSuccessItemStatus(status: ReturnType<typeof itemStatus>): boolean {
|
||||
return status === "failed" || status === "blocked";
|
||||
}
|
||||
|
||||
function itemName(item: CodexThreadItem): string | undefined {
|
||||
if (item.type === "dynamicToolCall" && typeof item.tool === "string") {
|
||||
return item.tool;
|
||||
@@ -1105,6 +1156,78 @@ function itemName(item: CodexThreadItem): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldSynthesizeToolProgressForItem(item: CodexThreadItem): boolean {
|
||||
switch (item.type) {
|
||||
case "commandExecution":
|
||||
case "fileChange":
|
||||
case "webSearch":
|
||||
case "mcpToolCall":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSuppressChannelProgressForItem(item: CodexThreadItem): boolean {
|
||||
if (shouldSynthesizeToolProgressForItem(item)) {
|
||||
return true;
|
||||
}
|
||||
// Dynamic OpenClaw tool requests are emitted at the item/tool/call request
|
||||
// boundary in run-attempt.ts. Re-emitting item notifications to channels can
|
||||
// duplicate start/result progress when the app-server sends both signals.
|
||||
return item.type === "dynamicToolCall";
|
||||
}
|
||||
|
||||
function itemToolArgs(item: CodexThreadItem): Record<string, unknown> | undefined {
|
||||
if (item.type === "commandExecution") {
|
||||
return sanitizeCodexAgentEventRecord({
|
||||
command: item.command,
|
||||
...(typeof item.cwd === "string" ? { cwd: item.cwd } : {}),
|
||||
});
|
||||
}
|
||||
if (item.type === "webSearch" && typeof item.query === "string") {
|
||||
return sanitizeCodexAgentEventRecord({ query: item.query });
|
||||
}
|
||||
if (item.type === "mcpToolCall") {
|
||||
return sanitizeCodexToolArguments(item.arguments);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function itemToolResult(item: CodexThreadItem): { result?: Record<string, unknown> } {
|
||||
if (item.type === "commandExecution") {
|
||||
return {
|
||||
result: sanitizeCodexAgentEventRecord({
|
||||
status: item.status,
|
||||
exitCode: item.exitCode,
|
||||
durationMs: item.durationMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (item.type === "fileChange") {
|
||||
return {
|
||||
result: sanitizeCodexAgentEventRecord({
|
||||
status: item.status,
|
||||
changes: item.changes.map((change) => ({ path: change.path, kind: change.kind })),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (item.type === "mcpToolCall") {
|
||||
return {
|
||||
result: sanitizeCodexAgentEventRecord({
|
||||
status: item.status,
|
||||
durationMs: item.durationMs,
|
||||
...(item.error ? { error: item.error } : {}),
|
||||
...(item.result ? { result: item.result } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (item.type === "webSearch") {
|
||||
return { result: sanitizeCodexAgentEventRecord({ status: "completed" }) };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function itemMeta(
|
||||
item: CodexThreadItem,
|
||||
detailMode: ToolProgressDetailMode = "explain",
|
||||
|
||||
@@ -180,6 +180,7 @@ function createAppServerHarness(
|
||||
) {
|
||||
const requests: Array<{ method: string; params: unknown }> = [];
|
||||
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
|
||||
let handleServerRequest: AppServerRequestHandler | undefined;
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
requests.push({ method, params });
|
||||
return requestImpl(method, params);
|
||||
@@ -194,11 +195,22 @@ function createAppServerHarness(
|
||||
notify = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: () => () => undefined,
|
||||
addRequestHandler: (handler: AppServerRequestHandler) => {
|
||||
handleServerRequest = handler;
|
||||
return () => undefined;
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
);
|
||||
|
||||
const waitForServerRequestHandler = async () => {
|
||||
await vi.waitFor(() => expect(handleServerRequest).toBeTypeOf("function"), {
|
||||
interval: 1,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return handleServerRequest!;
|
||||
};
|
||||
|
||||
return {
|
||||
request,
|
||||
requests,
|
||||
@@ -220,6 +232,11 @@ function createAppServerHarness(
|
||||
async notify(notification: CodexServerNotification) {
|
||||
await notify(notification);
|
||||
},
|
||||
waitForServerRequestHandler,
|
||||
async handleServerRequest(request: Parameters<AppServerRequestHandler>[0]) {
|
||||
const handler = await waitForServerRequestHandler();
|
||||
return handler(request);
|
||||
},
|
||||
async completeTurn(params: { threadId: string; turnId: string }) {
|
||||
await notify({
|
||||
method: "turn/completed",
|
||||
@@ -346,14 +363,35 @@ function createNamedDynamicTool(
|
||||
};
|
||||
}
|
||||
|
||||
type AppServerRequestHandler = (request: {
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
function extractRelayIdFromThreadRequest(params: unknown): string {
|
||||
const command = (
|
||||
params as {
|
||||
config?: {
|
||||
"hooks.PreToolUse"?: Array<{ hooks?: Array<{ command?: string }> }>;
|
||||
};
|
||||
const config = (params as { config?: Record<string, unknown> }).config;
|
||||
let command: string | undefined;
|
||||
for (const key of [
|
||||
"hooks.PreToolUse",
|
||||
"hooks.PostToolUse",
|
||||
"hooks.PermissionRequest",
|
||||
"hooks.Stop",
|
||||
]) {
|
||||
const entries = config?.[key];
|
||||
if (!Array.isArray(entries)) {
|
||||
continue;
|
||||
}
|
||||
).config?.["hooks.PreToolUse"]?.[0]?.hooks?.[0]?.command;
|
||||
for (const entry of entries as Array<{ hooks?: Array<{ command?: string }> }>) {
|
||||
command = entry.hooks?.find((hook) => typeof hook.command === "string")?.command;
|
||||
if (command) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (command) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const match = command?.match(/--relay-id ([^ ]+)/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`relay id missing from command: ${command}`);
|
||||
@@ -622,6 +660,93 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("emits normalized tool progress around app-server dynamic tool requests", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const onRunAgentEvent = vi.fn();
|
||||
const globalAgentEvents: AgentEventPayload[] = [];
|
||||
onAgentEvent((event) => globalAgentEvents.push(event));
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
);
|
||||
params.onAgentEvent = onRunAgentEvent;
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
await expect(
|
||||
harness.handleServerRequest({
|
||||
id: "request-tool-1",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-1",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: {
|
||||
action: "send",
|
||||
token: "plain-secret-value-12345",
|
||||
text: "hello",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
success: false,
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: expect.stringMatching(
|
||||
/^(Unknown OpenClaw tool: message|Action send requires a target\.)$/u,
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
const agentEvents = onRunAgentEvent.mock.calls.map(([event]) => event);
|
||||
expect(agentEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
stream: "tool",
|
||||
data: expect.objectContaining({
|
||||
phase: "start",
|
||||
name: "message",
|
||||
toolCallId: "call-1",
|
||||
args: expect.objectContaining({
|
||||
action: "send",
|
||||
token: "plain-…2345",
|
||||
text: "hello",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
stream: "tool",
|
||||
data: expect.objectContaining({
|
||||
phase: "result",
|
||||
name: "message",
|
||||
toolCallId: "call-1",
|
||||
isError: true,
|
||||
result: expect.objectContaining({ success: false }),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(agentEvents)).not.toContain("plain-secret-value-12345");
|
||||
expect(globalAgentEvents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
stream: "tool",
|
||||
data: expect.objectContaining({ phase: "start", name: "message" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("releases the session when Codex never completes after a dynamic tool response", async () => {
|
||||
let handleRequest:
|
||||
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
|
||||
@@ -1078,6 +1203,85 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lets Codex app-server approval modes own native permission requests by default", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
expect(startRequest?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
"features.codex_hooks": true,
|
||||
"hooks.PreToolUse": expect.any(Array),
|
||||
"hooks.PostToolUse": expect.any(Array),
|
||||
"hooks.Stop": expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(startRequest?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.not.objectContaining({
|
||||
"hooks.PermissionRequest": expect.anything(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({
|
||||
allowedEvents: ["pre_tool_use", "post_tool_use", "before_agent_finalize"],
|
||||
});
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves explicit native permission request relay events in app-server approval modes", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
mode: "guardian",
|
||||
},
|
||||
},
|
||||
nativeHookRelay: {
|
||||
enabled: true,
|
||||
events: ["permission_request"],
|
||||
},
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
const startRequest = harness.requests.find((request) => request.method === "thread/start");
|
||||
expect(startRequest?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
"features.codex_hooks": true,
|
||||
"hooks.PermissionRequest": expect.any(Array),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toMatchObject({
|
||||
allowedEvents: ["permission_request"],
|
||||
});
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses the Codex native hook relay id across runs for the same session", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -58,6 +58,7 @@ import { ensureCodexComputerUse } from "./computer-use.js";
|
||||
import {
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type CodexAppServerRuntimeOptions,
|
||||
type CodexPluginConfig,
|
||||
} from "./config.js";
|
||||
import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js";
|
||||
@@ -96,6 +97,12 @@ import {
|
||||
codexDynamicToolsFingerprint,
|
||||
startOrResumeThread,
|
||||
} from "./thread-lifecycle.js";
|
||||
import {
|
||||
inferCodexDynamicToolMeta,
|
||||
resolveCodexToolProgressDetailMode,
|
||||
sanitizeCodexToolArguments,
|
||||
sanitizeCodexToolResponse,
|
||||
} from "./tool-progress-normalization.js";
|
||||
import {
|
||||
createCodexTrajectoryRecorder,
|
||||
normalizeCodexTrajectoryError,
|
||||
@@ -113,6 +120,8 @@ const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
|
||||
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
|
||||
const LOG_FIELD_MAX_LENGTH = 160;
|
||||
const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
|
||||
const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS =
|
||||
CODEX_NATIVE_HOOK_RELAY_EVENTS.filter((event) => event !== "permission_request");
|
||||
const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map<string, number>([
|
||||
["soul.md", 10],
|
||||
["identity.md", 20],
|
||||
@@ -354,6 +363,10 @@ export async function runCodexAppServerAttempt(
|
||||
const attemptClientFactory = clientFactory;
|
||||
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const nativeHookRelayEvents = resolveCodexNativeHookRelayEvents({
|
||||
configuredEvents: options.nativeHookRelay?.events,
|
||||
appServer,
|
||||
});
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
const sandboxSessionKey =
|
||||
@@ -551,6 +564,7 @@ export async function runCodexAppServerAttempt(
|
||||
});
|
||||
nativeHookRelay = createCodexNativeHookRelay({
|
||||
options: options.nativeHookRelay,
|
||||
events: nativeHookRelayEvents,
|
||||
agentId: sessionAgentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: sandboxSessionKey,
|
||||
@@ -561,7 +575,7 @@ export async function runCodexAppServerAttempt(
|
||||
const nativeHookRelayConfig = nativeHookRelay
|
||||
? buildCodexNativeHookRelayConfig({
|
||||
relay: nativeHookRelay,
|
||||
events: options.nativeHookRelay?.events,
|
||||
events: nativeHookRelayEvents,
|
||||
hookTimeoutSec: options.nativeHookRelay?.hookTimeoutSec,
|
||||
})
|
||||
: options.nativeHookRelay?.enabled === false
|
||||
@@ -963,6 +977,19 @@ export async function runCodexAppServerAttempt(
|
||||
name: call.tool,
|
||||
arguments: call.arguments,
|
||||
});
|
||||
const toolProgressDetailMode = resolveCodexToolProgressDetailMode(params.toolProgressDetail);
|
||||
const toolMeta = inferCodexDynamicToolMeta(call, toolProgressDetailMode);
|
||||
const toolArgs = sanitizeCodexToolArguments(call.arguments);
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "start",
|
||||
name: call.tool,
|
||||
toolCallId: call.callId,
|
||||
...(toolMeta ? { meta: toolMeta } : {}),
|
||||
...(toolArgs ? { args: toolArgs } : {}),
|
||||
},
|
||||
});
|
||||
const response = await handleDynamicToolCallWithTimeout({
|
||||
call,
|
||||
toolBridge,
|
||||
@@ -986,6 +1013,17 @@ export async function runCodexAppServerAttempt(
|
||||
success: response.success,
|
||||
contentItems: response.contentItems,
|
||||
});
|
||||
emitCodexAppServerEvent(params, {
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "result",
|
||||
name: call.tool,
|
||||
toolCallId: call.callId,
|
||||
...(toolMeta ? { meta: toolMeta } : {}),
|
||||
isError: !response.success,
|
||||
result: sanitizeCodexToolResponse(response),
|
||||
},
|
||||
});
|
||||
return response as JsonValue;
|
||||
} finally {
|
||||
activeAppServerTurnRequests = Math.max(0, activeAppServerTurnRequests - 1);
|
||||
@@ -1395,11 +1433,11 @@ function createCodexNativeHookRelay(params: {
|
||||
options:
|
||||
| {
|
||||
enabled?: boolean;
|
||||
events?: readonly NativeHookRelayEvent[];
|
||||
ttlMs?: number;
|
||||
gatewayTimeoutMs?: number;
|
||||
}
|
||||
| undefined;
|
||||
events: readonly NativeHookRelayEvent[];
|
||||
agentId: string | undefined;
|
||||
sessionId: string;
|
||||
sessionKey: string | undefined;
|
||||
@@ -1422,7 +1460,7 @@ function createCodexNativeHookRelay(params: {
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
runId: params.runId,
|
||||
allowedEvents: params.options?.events ?? CODEX_NATIVE_HOOK_RELAY_EVENTS,
|
||||
allowedEvents: params.events,
|
||||
ttlMs: params.options?.ttlMs,
|
||||
signal: params.signal,
|
||||
command: {
|
||||
@@ -1431,6 +1469,22 @@ function createCodexNativeHookRelay(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCodexNativeHookRelayEvents(params: {
|
||||
configuredEvents?: readonly NativeHookRelayEvent[];
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy">;
|
||||
}): readonly NativeHookRelayEvent[] {
|
||||
if (params.configuredEvents?.length) {
|
||||
return params.configuredEvents;
|
||||
}
|
||||
// Codex emits PermissionRequest before the app-server approval reviewer has
|
||||
// resolved the command. In native approval modes, let Codex's app-server
|
||||
// approval bridge own the real escalation instead of surfacing a stale
|
||||
// pre-guardian OpenClaw plugin approval prompt.
|
||||
return params.appServer.approvalPolicy === "never"
|
||||
? CODEX_NATIVE_HOOK_RELAY_EVENTS
|
||||
: CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS;
|
||||
}
|
||||
|
||||
function buildCodexNativeHookRelayId(params: {
|
||||
agentId: string | undefined;
|
||||
sessionId: string;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
inferToolMetaFromArgs,
|
||||
type EmbeddedRunAttemptParams,
|
||||
type ToolProgressDetailMode,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { redactSensitiveFieldValue, redactToolPayloadText } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexDynamicToolCallParams,
|
||||
type CodexDynamicToolCallResponse,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
|
||||
export function resolveCodexToolProgressDetailMode(
|
||||
value: EmbeddedRunAttemptParams["toolProgressDetail"],
|
||||
): ToolProgressDetailMode {
|
||||
return value === "raw" ? "raw" : "explain";
|
||||
}
|
||||
|
||||
export function sanitizeCodexAgentEventValue(
|
||||
value: unknown,
|
||||
seen = new WeakSet<object>(),
|
||||
): unknown {
|
||||
if (typeof value === "string") {
|
||||
return redactToolPayloadText(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
return value.map((entry) => sanitizeCodexAgentEventValue(entry, seen));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] =
|
||||
typeof child === "string"
|
||||
? redactSensitiveFieldValue(key, child)
|
||||
: sanitizeCodexAgentEventValue(child, seen);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sanitizeCodexAgentEventRecord(
|
||||
value: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return sanitizeCodexAgentEventValue(value) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function sanitizeCodexToolArguments(
|
||||
value: JsonValue | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return sanitizeCodexAgentEventRecord(value);
|
||||
}
|
||||
|
||||
export function sanitizeCodexToolResponse(
|
||||
response: CodexDynamicToolCallResponse,
|
||||
): Record<string, unknown> {
|
||||
return sanitizeCodexAgentEventRecord(response as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function inferCodexDynamicToolMeta(
|
||||
call: Pick<CodexDynamicToolCallParams, "tool" | "arguments">,
|
||||
detailMode: ToolProgressDetailMode,
|
||||
): string | undefined {
|
||||
return inferToolMetaFromArgs(call.tool, call.arguments, { detailMode });
|
||||
}
|
||||
@@ -48,7 +48,10 @@ describe("codex conversation binding", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {} });
|
||||
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
|
||||
version: 1,
|
||||
profiles: {},
|
||||
});
|
||||
agentRuntimeMocks.resolveAuthProfileOrder.mockReturnValue([]);
|
||||
agentRuntimeMocks.resolveOpenClawAgentDir.mockReturnValue("/agent");
|
||||
agentRuntimeMocks.resolveProviderIdForAuth.mockImplementation((provider: string) => provider);
|
||||
@@ -56,7 +59,9 @@ describe("codex conversation binding", () => {
|
||||
|
||||
it("uses the default Codex auth profile and omits the public OpenAI provider for new binds", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const config = { auth: { order: { "openai-codex": ["openai-codex:default"] } } };
|
||||
const config = {
|
||||
auth: { order: { "openai-codex": ["openai-codex:default"] } },
|
||||
};
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
|
||||
version: 1,
|
||||
@@ -220,6 +225,142 @@ describe("codex conversation binding", () => {
|
||||
expect(result).toEqual({ handled: true });
|
||||
});
|
||||
|
||||
it("recreates a missing bound thread and preserves auth plus turn overrides", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
|
||||
version: 1,
|
||||
profiles: {
|
||||
work: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "access-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-old",
|
||||
cwd: tempDir,
|
||||
authProfileId: "work",
|
||||
model: "gpt-5.4-mini",
|
||||
modelProvider: "openai",
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "fast",
|
||||
}),
|
||||
);
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
const notificationHandlers: Array<(notification: Record<string, unknown>) => void> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
if (method === "turn/start" && requestParams.threadId === "thread-old") {
|
||||
throw new Error("thread not found: thread-old");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return {
|
||||
thread: { id: "thread-new", cwd: tempDir },
|
||||
model: "gpt-5.4-mini",
|
||||
};
|
||||
}
|
||||
if (method === "turn/start" && requestParams.threadId === "thread-new") {
|
||||
setImmediate(() => {
|
||||
for (const handler of notificationHandlers) {
|
||||
handler({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-new",
|
||||
turn: {
|
||||
id: "turn-new",
|
||||
status: "completed",
|
||||
items: [
|
||||
{
|
||||
id: "assistant-1",
|
||||
type: "agentMessage",
|
||||
text: "Recovered",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
return { turn: { id: "turn-new" } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: vi.fn((handler) => {
|
||||
notificationHandlers.push(handler);
|
||||
return () => undefined;
|
||||
}),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
const result = await handleCodexConversationInboundClaim(
|
||||
{
|
||||
content: "hi again",
|
||||
bodyForAgent: "hi again",
|
||||
channel: "telegram",
|
||||
isGroup: false,
|
||||
commandAuthorized: true,
|
||||
},
|
||||
{
|
||||
channelId: "telegram",
|
||||
pluginBinding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "5185575566",
|
||||
boundAt: Date.now(),
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 500 },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ handled: true, reply: { text: "Recovered" } });
|
||||
expect(requests.map((request) => request.method)).toEqual([
|
||||
"turn/start",
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
]);
|
||||
expect(sharedClientMocks.getSharedCodexAppServerClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authProfileId: "work" }),
|
||||
);
|
||||
expect(requests[1]?.params).toMatchObject({
|
||||
model: "gpt-5.4-mini",
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "fast",
|
||||
});
|
||||
expect(requests[1]?.params).not.toHaveProperty("modelProvider");
|
||||
expect(requests[2]?.params).toMatchObject({
|
||||
threadId: "thread-new",
|
||||
approvalPolicy: "on-request",
|
||||
serviceTier: "fast",
|
||||
});
|
||||
const savedBinding = JSON.parse(
|
||||
await fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8"),
|
||||
);
|
||||
expect(savedBinding).toMatchObject({
|
||||
threadId: "thread-new",
|
||||
authProfileId: "work",
|
||||
approvalPolicy: "on-request",
|
||||
sandbox: "workspace-write",
|
||||
serviceTier: "fast",
|
||||
});
|
||||
expect(savedBinding).not.toHaveProperty("modelProvider");
|
||||
});
|
||||
|
||||
it("returns a clean failure reply when app-server turn start rejects", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -10,8 +10,11 @@ import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
|
||||
import {
|
||||
codexSandboxPolicyForTurn,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
type CodexAppServerApprovalPolicy,
|
||||
type CodexAppServerSandboxMode,
|
||||
} from "./app-server/config.js";
|
||||
import {
|
||||
type CodexServiceTier,
|
||||
type CodexThreadResumeResponse,
|
||||
type CodexThreadStartResponse,
|
||||
type CodexTurnStartResponse,
|
||||
@@ -59,6 +62,9 @@ type CodexConversationStartParams = {
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
authProfileId?: string;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
};
|
||||
|
||||
type BoundTurnResult = {
|
||||
@@ -100,6 +106,9 @@ export async function startCodexConversationThread(
|
||||
model: params.model,
|
||||
modelProvider: params.modelProvider,
|
||||
authProfileId,
|
||||
approvalPolicy: params.approvalPolicy,
|
||||
sandbox: params.sandbox,
|
||||
serviceTier: params.serviceTier,
|
||||
config: params.config,
|
||||
});
|
||||
} else {
|
||||
@@ -110,6 +119,9 @@ export async function startCodexConversationThread(
|
||||
model: params.model,
|
||||
modelProvider: params.modelProvider,
|
||||
authProfileId,
|
||||
approvalPolicy: params.approvalPolicy,
|
||||
sandbox: params.sandbox,
|
||||
serviceTier: params.serviceTier,
|
||||
config: params.config,
|
||||
});
|
||||
}
|
||||
@@ -137,7 +149,7 @@ export async function handleCodexConversationInboundClaim(
|
||||
}
|
||||
try {
|
||||
const result = await enqueueBoundTurn(data.sessionFile, () =>
|
||||
runBoundTurn({
|
||||
runBoundTurnWithMissingThreadRecovery({
|
||||
data,
|
||||
prompt,
|
||||
event,
|
||||
@@ -177,9 +189,14 @@ async function attachExistingThread(params: {
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
authProfileId?: string;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
config?: CodexAppServerAuthProfileLookup["config"];
|
||||
}): Promise<void> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: params.pluginConfig,
|
||||
});
|
||||
const modelProvider = resolveThreadRequestModelProvider({
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: params.modelProvider,
|
||||
@@ -196,10 +213,12 @@ async function attachExistingThread(params: {
|
||||
threadId: params.threadId,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
approvalPolicy: runtime.approvalPolicy,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: runtime.sandbox,
|
||||
...(runtime.serviceTier ? { serviceTier: runtime.serviceTier } : {}),
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
...((params.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
{ timeoutMs: runtime.requestTimeoutMs },
|
||||
@@ -217,9 +236,9 @@ async function attachExistingThread(params: {
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
}),
|
||||
approvalPolicy: runtime.approvalPolicy,
|
||||
sandbox: runtime.sandbox,
|
||||
serviceTier: runtime.serviceTier,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
serviceTier: params.serviceTier ?? runtime.serviceTier,
|
||||
},
|
||||
{
|
||||
config: params.config,
|
||||
@@ -234,9 +253,14 @@ async function createThread(params: {
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
authProfileId?: string;
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
config?: CodexAppServerAuthProfileLookup["config"];
|
||||
}): Promise<void> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: params.pluginConfig,
|
||||
});
|
||||
const modelProvider = resolveThreadRequestModelProvider({
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: params.modelProvider,
|
||||
@@ -253,10 +277,12 @@ async function createThread(params: {
|
||||
cwd: params.workspaceDir,
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
approvalPolicy: runtime.approvalPolicy,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
sandbox: runtime.sandbox,
|
||||
...(runtime.serviceTier ? { serviceTier: runtime.serviceTier } : {}),
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
...((params.serviceTier ?? runtime.serviceTier)
|
||||
? { serviceTier: params.serviceTier ?? runtime.serviceTier }
|
||||
: {}),
|
||||
developerInstructions:
|
||||
"This Codex thread is bound to an OpenClaw conversation. Answer normally; OpenClaw will deliver your final response back to the conversation.",
|
||||
experimentalRawEvents: true,
|
||||
@@ -276,9 +302,9 @@ async function createThread(params: {
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: response.modelProvider ?? params.modelProvider,
|
||||
}),
|
||||
approvalPolicy: runtime.approvalPolicy,
|
||||
sandbox: runtime.sandbox,
|
||||
serviceTier: runtime.serviceTier,
|
||||
approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
|
||||
sandbox: params.sandbox ?? runtime.sandbox,
|
||||
serviceTier: params.serviceTier ?? runtime.serviceTier,
|
||||
},
|
||||
{
|
||||
config: params.config,
|
||||
@@ -293,7 +319,9 @@ async function runBoundTurn(params: {
|
||||
pluginConfig?: unknown;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BoundTurnResult> {
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const runtime = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: params.pluginConfig,
|
||||
});
|
||||
const binding = await readCodexAppServerBinding(params.data.sessionFile);
|
||||
const threadId = binding?.threadId;
|
||||
if (!threadId) {
|
||||
@@ -350,7 +378,10 @@ async function runBoundTurn(params: {
|
||||
"turn/start",
|
||||
{
|
||||
threadId,
|
||||
input: buildCodexConversationTurnInput({ prompt: params.prompt, event: params.event }),
|
||||
input: buildCodexConversationTurnInput({
|
||||
prompt: params.prompt,
|
||||
event: params.event,
|
||||
}),
|
||||
cwd: binding.cwd || params.data.workspaceDir,
|
||||
approvalPolicy: binding.approvalPolicy ?? runtime.approvalPolicy,
|
||||
approvalsReviewer: runtime.approvalsReviewer,
|
||||
@@ -389,6 +420,39 @@ async function runBoundTurn(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function runBoundTurnWithMissingThreadRecovery(params: {
|
||||
data: CodexConversationBindingData;
|
||||
prompt: string;
|
||||
event: PluginHookInboundClaimEvent;
|
||||
pluginConfig?: unknown;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BoundTurnResult> {
|
||||
try {
|
||||
return await runBoundTurn(params);
|
||||
} catch (error) {
|
||||
if (!isCodexThreadNotFoundError(error)) {
|
||||
throw error;
|
||||
}
|
||||
const binding = await readCodexAppServerBinding(params.data.sessionFile);
|
||||
await startCodexConversationThread({
|
||||
pluginConfig: params.pluginConfig,
|
||||
sessionFile: params.data.sessionFile,
|
||||
workspaceDir: binding?.cwd || params.data.workspaceDir,
|
||||
model: binding?.model,
|
||||
modelProvider: binding?.modelProvider,
|
||||
authProfileId: binding?.authProfileId,
|
||||
approvalPolicy: binding?.approvalPolicy,
|
||||
sandbox: binding?.sandbox,
|
||||
serviceTier: binding?.serviceTier,
|
||||
});
|
||||
return await runBoundTurn(params);
|
||||
}
|
||||
}
|
||||
|
||||
function isCodexThreadNotFoundError(error: unknown): boolean {
|
||||
return /\bthread not found:/iu.test(formatErrorMessage(error));
|
||||
}
|
||||
|
||||
function enqueueBoundTurn<T>(key: string, run: () => Promise<T>): Promise<T> {
|
||||
const state = getGlobalState();
|
||||
const previous = state.queues.get(key) ?? Promise.resolve();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/comfy-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw ComfyUI provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepgram-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Deepgram media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepinfra-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepInfra provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepSeek provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,10 +34,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.4"
|
||||
"pluginApi": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.4"
|
||||
"openclawVersion": "2026.5.6-beta.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-prometheus",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw diagnostics Prometheus exporter",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,10 +21,10 @@
|
||||
"minHostVersion": ">=2026.4.25"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.4"
|
||||
"pluginApi": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.4"
|
||||
"openclawVersion": "2026.5.6-beta.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -30,10 +30,10 @@
|
||||
"minHostVersion": ">=2026.4.30"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.4"
|
||||
"pluginApi": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.4",
|
||||
"openclawVersion": "2026.5.6-beta.1",
|
||||
"staticAssets": [
|
||||
{
|
||||
"source": "./assets/viewer-runtime.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.5.4",
|
||||
"version": "2026.5.6-beta.1",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,7 +21,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.5.4"
|
||||
"openclaw": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -65,10 +65,10 @@
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.4"
|
||||
"pluginApi": ">=2026.5.6-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.4"
|
||||
"openclawVersion": "2026.5.6-beta.1"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import type {
|
||||
DiscordGuildChannelConfig,
|
||||
DiscordGuildEntry,
|
||||
@@ -23,7 +24,21 @@ export type DiscordChannelPermissionsAudit = {
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
const REQUIRED_TEXT_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
const REQUIRED_VOICE_CHANNEL_PERMISSIONS = [
|
||||
"ViewChannel",
|
||||
"Connect",
|
||||
"Speak",
|
||||
"SendMessages",
|
||||
"ReadMessageHistory",
|
||||
] as const;
|
||||
|
||||
export function resolveRequiredDiscordChannelPermissions(channelType?: number): string[] {
|
||||
if (channelType === ChannelType.GuildVoice || channelType === ChannelType.GuildStageVoice) {
|
||||
return [...REQUIRED_VOICE_CHANNEL_PERMISSIONS];
|
||||
}
|
||||
return [...REQUIRED_TEXT_CHANNEL_PERMISSIONS];
|
||||
}
|
||||
|
||||
function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) {
|
||||
if (!config) {
|
||||
@@ -76,6 +91,27 @@ export function collectDiscordAuditChannelIdsForGuilds(
|
||||
return { channelIds, unresolvedChannels };
|
||||
}
|
||||
|
||||
export function collectDiscordAuditChannelIdsForAccount(config: {
|
||||
guilds?: Record<string, DiscordGuildEntry>;
|
||||
voice?: { autoJoin?: Array<{ guildId?: string; channelId?: string }> };
|
||||
}) {
|
||||
const collected = collectDiscordAuditChannelIdsForGuilds(config.guilds);
|
||||
const channelIds = new Set(collected.channelIds);
|
||||
let unresolvedVoiceChannels = 0;
|
||||
for (const entry of config.voice?.autoJoin ?? []) {
|
||||
const channelId = normalizeOptionalString(entry?.channelId) ?? "";
|
||||
if (/^\d+$/.test(channelId)) {
|
||||
channelIds.add(channelId);
|
||||
} else if (channelId) {
|
||||
unresolvedVoiceChannels++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
channelIds: [...channelIds].toSorted((a, b) => a.localeCompare(b)),
|
||||
unresolvedChannels: collected.unresolvedChannels + unresolvedVoiceChannels,
|
||||
};
|
||||
}
|
||||
|
||||
export async function auditDiscordChannelPermissionsWithFetcher(params: {
|
||||
cfg: OpenClawConfig;
|
||||
token: string;
|
||||
@@ -87,6 +123,7 @@ export async function auditDiscordChannelPermissionsWithFetcher(params: {
|
||||
params: { cfg: OpenClawConfig; token: string; accountId?: string },
|
||||
) => Promise<{
|
||||
permissions: string[];
|
||||
channelType?: number;
|
||||
}>;
|
||||
}): Promise<DiscordChannelPermissionsAudit> {
|
||||
const started = Date.now();
|
||||
@@ -101,7 +138,6 @@ export async function auditDiscordChannelPermissionsWithFetcher(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const required = [...REQUIRED_CHANNEL_PERMISSIONS];
|
||||
const channels: DiscordChannelPermissionsAuditEntry[] = [];
|
||||
|
||||
for (const channelId of params.channelIds) {
|
||||
@@ -111,6 +147,7 @@ export async function auditDiscordChannelPermissionsWithFetcher(params: {
|
||||
token,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const required = resolveRequiredDiscordChannelPermissions(perms.channelType);
|
||||
const missing = required.filter((p) => !perms.permissions.includes(p));
|
||||
channels.push({
|
||||
channelId,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
auditDiscordChannelPermissionsWithFetcher,
|
||||
collectDiscordAuditChannelIdsForAccount,
|
||||
collectDiscordAuditChannelIdsForGuilds,
|
||||
} from "./audit-core.js";
|
||||
|
||||
@@ -142,4 +144,59 @@ describe("discord audit", () => {
|
||||
expect(collected.channelIds).toEqual(["111"]);
|
||||
expect(collected.unresolvedChannels).toBe(1);
|
||||
});
|
||||
|
||||
it("includes configured voice auto-join channels in permission audits", () => {
|
||||
const collected = collectDiscordAuditChannelIdsForAccount({
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
"111": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
voice: {
|
||||
autoJoin: [
|
||||
{ guildId: "123", channelId: "222" },
|
||||
{ guildId: "123", channelId: "general" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(collected.channelIds).toEqual(["111", "222"]);
|
||||
expect(collected.unresolvedChannels).toBe(1);
|
||||
});
|
||||
|
||||
it.each([ChannelType.GuildVoice, ChannelType.GuildStageVoice])(
|
||||
"requires voice permissions for voice channel audit targets of type %s",
|
||||
async (channelType) => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "t",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
fetchChannelPermissionsDiscordMock.mockResolvedValueOnce({
|
||||
channelId: "222",
|
||||
permissions: ["ViewChannel", "SendMessages"],
|
||||
channelType,
|
||||
raw: "0",
|
||||
isDm: false,
|
||||
});
|
||||
|
||||
const audit = await auditDiscordChannelPermissionsWithFetcher({
|
||||
cfg,
|
||||
token: "t",
|
||||
accountId: "default",
|
||||
channelIds: ["222"],
|
||||
timeoutMs: 1000,
|
||||
fetchChannelPermissions: fetchChannelPermissionsDiscordMock,
|
||||
});
|
||||
|
||||
expect(audit.ok).toBe(false);
|
||||
expect(audit.channels[0]?.missing).toEqual(["Connect", "Speak", "ReadMessageHistory"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import {
|
||||
auditDiscordChannelPermissionsWithFetcher,
|
||||
collectDiscordAuditChannelIdsForGuilds,
|
||||
collectDiscordAuditChannelIdsForAccount,
|
||||
type DiscordChannelPermissionsAudit,
|
||||
} from "./audit-core.js";
|
||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||
@@ -15,7 +15,7 @@ export function collectDiscordAuditChannelIds(params: {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return collectDiscordAuditChannelIdsForGuilds(account.config.guilds);
|
||||
return collectDiscordAuditChannelIdsForAccount(account.config);
|
||||
}
|
||||
|
||||
export async function auditDiscordChannelPermissions(params: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -163,6 +164,33 @@ describe("discordPlugin outbound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves bare allowlisted Discord user IDs as message-tool DM targets", async () => {
|
||||
const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
|
||||
if (!resolveTarget) {
|
||||
throw new Error(
|
||||
"Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
|
||||
);
|
||||
}
|
||||
|
||||
await expect(
|
||||
resolveTarget({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: ["1439091261670948987"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
input: "1439091261670948987",
|
||||
normalized: "channel:1439091261670948987",
|
||||
preferredKind: "channel",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
to: "user:1439091261670948987",
|
||||
kind: "user",
|
||||
});
|
||||
});
|
||||
|
||||
it("honors per-account replyToMode overrides", () => {
|
||||
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
|
||||
if (!resolveReplyToMode) {
|
||||
@@ -379,6 +407,42 @@ describe("discordPlugin outbound", () => {
|
||||
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports missing voice permissions in targeted capabilities diagnostics", async () => {
|
||||
const fetchPermissionsSpy = vi
|
||||
.spyOn(sendModule, "fetchChannelPermissionsDiscord")
|
||||
.mockResolvedValueOnce({
|
||||
channelId: "222",
|
||||
guildId: "123",
|
||||
permissions: ["ViewChannel", "SendMessages"],
|
||||
raw: "0",
|
||||
isDm: false,
|
||||
channelType: ChannelType.GuildVoice,
|
||||
});
|
||||
try {
|
||||
const cfg = createCfg();
|
||||
const diagnostics = await discordPlugin.status!.buildCapabilitiesDiagnostics!({
|
||||
account: resolveAccount(cfg),
|
||||
timeoutMs: 5000,
|
||||
cfg,
|
||||
target: "channel:222",
|
||||
});
|
||||
|
||||
expect(fetchPermissionsSpy).toHaveBeenCalledWith(
|
||||
"222",
|
||||
expect.objectContaining({ token: "discord-token" }),
|
||||
);
|
||||
expect(diagnostics?.details?.permissions).toMatchObject({
|
||||
channelId: "222",
|
||||
missingRequired: ["Connect", "Speak", "ReadMessageHistory"],
|
||||
});
|
||||
expect(diagnostics?.lines?.map((line) => line.text).join("\n")).toContain(
|
||||
"Missing required: Connect, Speak, ReadMessageHistory",
|
||||
);
|
||||
} finally {
|
||||
fetchPermissionsSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses direct Discord startup helpers for async startup enrichment", async () => {
|
||||
const runtimeProbeDiscord = vi.fn(async () => {
|
||||
throw new Error("runtime Discord probe should not be used");
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { getDiscordApprovalCapability } from "./approval-native.js";
|
||||
import { resolveRequiredDiscordChannelPermissions } from "./audit-core.js";
|
||||
import { discordMessageActions as discordMessageActionsImpl } from "./channel-actions.js";
|
||||
import {
|
||||
buildTokenChannelStatusSummary,
|
||||
@@ -78,8 +79,8 @@ import { discordSetupAdapter } from "./setup-adapter.js";
|
||||
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
|
||||
import { collectDiscordStatusIssues } from "./status-issues.js";
|
||||
import { parseDiscordTarget } from "./target-parsing.js";
|
||||
import { resolveDiscordTarget } from "./target-resolver.js";
|
||||
|
||||
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
|
||||
|
||||
function startDiscordStartupProbe(params: {
|
||||
@@ -301,6 +302,21 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeDiscordTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
resolveTarget: async ({ cfg, accountId, input, preferredKind }) => {
|
||||
const target = await resolveDiscordTarget(
|
||||
input,
|
||||
{ cfg, accountId: accountId ?? undefined },
|
||||
{ defaultKind: preferredKind === "user" ? "user" : "channel" },
|
||||
);
|
||||
return target
|
||||
? {
|
||||
to: target.normalized,
|
||||
kind: target.kind,
|
||||
display: target.raw,
|
||||
source: "normalized",
|
||||
}
|
||||
: null;
|
||||
},
|
||||
},
|
||||
},
|
||||
approvalCapability: getDiscordApprovalCapability(),
|
||||
@@ -513,7 +529,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
token,
|
||||
accountId: account.accountId ?? undefined,
|
||||
});
|
||||
const missingRequired = REQUIRED_DISCORD_PERMISSIONS.filter(
|
||||
const requiredPermissions = resolveRequiredDiscordChannelPermissions(perms.channelType);
|
||||
const missingRequired = requiredPermissions.filter(
|
||||
(permission) => !perms.permissions.includes(permission),
|
||||
);
|
||||
details.permissions = {
|
||||
|
||||
@@ -152,11 +152,13 @@ describe("discord config schema", () => {
|
||||
voice: {
|
||||
connectTimeoutMs: 45_000,
|
||||
reconnectGraceMs: 20_000,
|
||||
captureSilenceGraceMs: 3_500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg.voice?.connectTimeoutMs).toBe(45_000);
|
||||
expect(cfg.voice?.reconnectGraceMs).toBe(20_000);
|
||||
expect(cfg.voice?.captureSilenceGraceMs).toBe(3_500);
|
||||
});
|
||||
|
||||
it("rejects invalid Discord voice timing overrides", () => {
|
||||
@@ -165,6 +167,8 @@ describe("discord config schema", () => {
|
||||
{ connectTimeoutMs: 120_001 },
|
||||
{ reconnectGraceMs: -1 },
|
||||
{ reconnectGraceMs: 1.5 },
|
||||
{ captureSilenceGraceMs: 0 },
|
||||
{ captureSilenceGraceMs: 30_001 },
|
||||
]) {
|
||||
expectInvalidDiscordConfig({ voice });
|
||||
}
|
||||
|
||||
@@ -201,6 +201,10 @@ export const discordChannelConfigUiHints = {
|
||||
label: "Discord Voice Reconnect Grace (ms)",
|
||||
help: "Grace period for a disconnected Discord voice session to enter Signalling or Connecting before OpenClaw destroys it. Default: 15000.",
|
||||
},
|
||||
"voice.captureSilenceGraceMs": {
|
||||
label: "Discord Voice Capture Silence Grace (ms)",
|
||||
help: "Silence window after Discord reports a speaker ended before OpenClaw finalizes the audio segment for transcription. Default: 2500.",
|
||||
},
|
||||
"voice.tts": {
|
||||
label: "Discord Voice Text-to-Speech",
|
||||
help: "Optional TTS overrides for Discord voice playback (merged with messages.tts).",
|
||||
|
||||
114
extensions/discord/src/internal/gateway-lifecycle.test.ts
Normal file
114
extensions/discord/src/internal/gateway-lifecycle.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayHeartbeatTimers } from "./gateway-lifecycle.js";
|
||||
|
||||
describe("GatewayHeartbeatTimers", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not false-timeout when the first heartbeat fires near the interval boundary", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const onHeartbeat = vi.fn();
|
||||
const onAckTimeout = vi.fn();
|
||||
const isAcked = vi.fn().mockReturnValue(false);
|
||||
const timers = new GatewayHeartbeatTimers();
|
||||
|
||||
timers.start({
|
||||
intervalMs: 45_000,
|
||||
isAcked,
|
||||
onAckTimeout,
|
||||
onHeartbeat,
|
||||
random: () => 0.95,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(42_750);
|
||||
expect(onHeartbeat).toHaveBeenCalledTimes(1);
|
||||
expect(onAckTimeout).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(2_250);
|
||||
expect(onAckTimeout).not.toHaveBeenCalled();
|
||||
|
||||
isAcked.mockReturnValue(true);
|
||||
vi.advanceTimersByTime(42_750);
|
||||
expect(onHeartbeat).toHaveBeenCalledTimes(2);
|
||||
expect(onAckTimeout).not.toHaveBeenCalled();
|
||||
|
||||
timers.stop();
|
||||
});
|
||||
|
||||
it("fires an ACK timeout when a heartbeat is genuinely not acknowledged", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const timers = new GatewayHeartbeatTimers();
|
||||
const onHeartbeat = vi.fn();
|
||||
const onAckTimeout = vi.fn();
|
||||
|
||||
timers.start({
|
||||
intervalMs: 45_000,
|
||||
isAcked: () => false,
|
||||
onAckTimeout,
|
||||
onHeartbeat,
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(onHeartbeat).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(45_000);
|
||||
expect(onAckTimeout).toHaveBeenCalledTimes(1);
|
||||
|
||||
timers.stop();
|
||||
});
|
||||
|
||||
it("sends heartbeats at regular intervals after the initial random delay", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const timers = new GatewayHeartbeatTimers();
|
||||
const onHeartbeat = vi.fn();
|
||||
const onAckTimeout = vi.fn();
|
||||
|
||||
timers.start({
|
||||
intervalMs: 10_000,
|
||||
isAcked: () => true,
|
||||
onAckTimeout,
|
||||
onHeartbeat,
|
||||
random: () => 0.5,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(onHeartbeat).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(onHeartbeat).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(onHeartbeat).toHaveBeenCalledTimes(3);
|
||||
expect(onAckTimeout).not.toHaveBeenCalled();
|
||||
|
||||
timers.stop();
|
||||
});
|
||||
|
||||
it("stop cancels all pending timers", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const timers = new GatewayHeartbeatTimers();
|
||||
const onHeartbeat = vi.fn();
|
||||
const onAckTimeout = vi.fn();
|
||||
|
||||
timers.start({
|
||||
intervalMs: 10_000,
|
||||
isAcked: () => true,
|
||||
onAckTimeout,
|
||||
onHeartbeat,
|
||||
random: () => 0.5,
|
||||
});
|
||||
|
||||
timers.stop();
|
||||
vi.advanceTimersByTime(100_000);
|
||||
|
||||
expect(onHeartbeat).not.toHaveBeenCalled();
|
||||
expect(onAckTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,24 @@ export class GatewayHeartbeatTimers {
|
||||
heartbeatInterval?: GatewayTimer;
|
||||
firstHeartbeatTimeout?: GatewayTimer;
|
||||
|
||||
private scheduleHeartbeatCycle(params: {
|
||||
intervalMs: number;
|
||||
isAcked: () => boolean;
|
||||
onAckTimeout: () => void;
|
||||
onHeartbeat: () => void;
|
||||
}): void {
|
||||
this.heartbeatInterval = setTimeout(() => {
|
||||
this.heartbeatInterval = undefined;
|
||||
if (!params.isAcked()) {
|
||||
params.onAckTimeout();
|
||||
return;
|
||||
}
|
||||
params.onHeartbeat();
|
||||
this.scheduleHeartbeatCycle(params);
|
||||
}, params.intervalMs);
|
||||
this.heartbeatInterval.unref?.();
|
||||
}
|
||||
|
||||
start(params: {
|
||||
intervalMs: number;
|
||||
isAcked: () => boolean;
|
||||
@@ -14,23 +32,19 @@ export class GatewayHeartbeatTimers {
|
||||
this.stop();
|
||||
const random = params.random ?? Math.random;
|
||||
this.firstHeartbeatTimeout = setTimeout(
|
||||
params.onHeartbeat,
|
||||
() => {
|
||||
this.firstHeartbeatTimeout = undefined;
|
||||
params.onHeartbeat();
|
||||
this.scheduleHeartbeatCycle(params);
|
||||
},
|
||||
Math.max(0, params.intervalMs * random()),
|
||||
);
|
||||
this.firstHeartbeatTimeout.unref?.();
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (!params.isAcked()) {
|
||||
params.onAckTimeout();
|
||||
return;
|
||||
}
|
||||
params.onHeartbeat();
|
||||
}, params.intervalMs);
|
||||
this.heartbeatInterval.unref?.();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
clearTimeout(this.heartbeatInterval);
|
||||
this.heartbeatInterval = undefined;
|
||||
}
|
||||
if (this.firstHeartbeatTimeout) {
|
||||
|
||||
@@ -82,6 +82,8 @@ export function createDiscordDraftPreviewController(params: {
|
||||
});
|
||||
let previewToolProgressSuppressed = false;
|
||||
let previewToolProgressLines: string[] = [];
|
||||
let reasoningProgressRawText = "";
|
||||
let lastReasoningProgressLine: string | undefined;
|
||||
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
|
||||
|
||||
const renderProgressDraft = async (options?: { flush?: boolean }) => {
|
||||
@@ -116,6 +118,8 @@ export function createDiscordDraftPreviewController(params: {
|
||||
draftChunker?.reset();
|
||||
previewToolProgressSuppressed = false;
|
||||
previewToolProgressLines = [];
|
||||
reasoningProgressRawText = "";
|
||||
lastReasoningProgressLine = undefined;
|
||||
};
|
||||
|
||||
const forceNewMessageIfNeeded = () => {
|
||||
@@ -163,8 +167,11 @@ export function createDiscordDraftPreviewController(params: {
|
||||
return;
|
||||
}
|
||||
const normalized = line?.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (discordStreamMode !== "progress") {
|
||||
if (!previewToolProgressEnabled || previewToolProgressSuppressed || !normalized) {
|
||||
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
|
||||
return;
|
||||
}
|
||||
const previous = previewToolProgressLines.at(-1);
|
||||
@@ -200,6 +207,36 @@ export function createDiscordDraftPreviewController(params: {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
},
|
||||
async pushReasoningProgress(text?: string) {
|
||||
if (!draftStream || discordStreamMode !== "progress" || !text) {
|
||||
return;
|
||||
}
|
||||
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text);
|
||||
const normalized = normalizeReasoningProgressLine(reasoningProgressRawText);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
|
||||
const priorIndex =
|
||||
lastReasoningProgressLine === undefined
|
||||
? -1
|
||||
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
|
||||
if (priorIndex >= 0) {
|
||||
previewToolProgressLines = [...previewToolProgressLines];
|
||||
previewToolProgressLines[priorIndex] = normalized;
|
||||
} else {
|
||||
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
|
||||
-resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
);
|
||||
}
|
||||
lastReasoningProgressLine = normalized;
|
||||
}
|
||||
const alreadyStarted = progressDraftGate.hasStarted;
|
||||
await progressDraftGate.noteWork();
|
||||
if (alreadyStarted && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
},
|
||||
resolvePreviewFinalText(text?: string) {
|
||||
if (typeof text !== "string") {
|
||||
return undefined;
|
||||
@@ -329,3 +366,29 @@ export function createDiscordDraftPreviewController(params: {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressLine(text: string): string {
|
||||
return text
|
||||
.replace(/^\s*(?:>\s*)?Reasoning:\s*/i, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function mergeReasoningProgressText(current: string, incoming: string): string {
|
||||
if (!current) {
|
||||
return incoming;
|
||||
}
|
||||
const normalizedCurrent = normalizeReasoningProgressLine(current);
|
||||
const normalizedIncoming = normalizeReasoningProgressLine(incoming);
|
||||
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) {
|
||||
return current;
|
||||
}
|
||||
if (isReasoningSnapshotText(incoming) || normalizedIncoming.startsWith(normalizedCurrent)) {
|
||||
return incoming;
|
||||
}
|
||||
return `${current}${incoming}`;
|
||||
}
|
||||
|
||||
function isReasoningSnapshotText(text: string): boolean {
|
||||
return /^\s*(?:>\s*)?Reasoning:\s*/i.test(text);
|
||||
}
|
||||
|
||||
@@ -65,9 +65,11 @@ export function createDiscordMessage(params: {
|
||||
mentionedEveryone?: boolean;
|
||||
attachments?: Array<Record<string, unknown>>;
|
||||
webhookId?: string;
|
||||
type?: import("../internal/discord.js").MessageType;
|
||||
}): import("../internal/discord.js").Message {
|
||||
return {
|
||||
id: params.id,
|
||||
type: params.type,
|
||||
content: params.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: params.channelId,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user