Compare commits

..

852 Commits

Author SHA1 Message Date
Peter Steinberger
7685a75fb6 test(opencode): avoid forced tool choice in live replay 2026-05-28 16:15:03 +01:00
Peter Steinberger
9e977d1590 test(opencode): add live DeepSeek replay probe 2026-05-28 16:15:03 +01:00
Pluviobyte
2474911e4d fix(agents): avoid spread-rebuild when iterating allowlist candidates
oxlint flagged the [...candidates] spread as an unnecessary array copy. Use an explicit baseCount loop bound instead so we still iterate the original entries while pushing tier-stripped variants onto the same array.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 16:15:03 +01:00
Pluviobyte
2acd256c9d fix(agents): preserve reasoning_content replay across DeepSeek tier suffixes
OpenCode Zen exposes DeepSeek V4 as `deepseek-v4-flash-free`, which keeps the upstream DeepSeek thinking-mode contract that requires `reasoning_content` to be passed back on follow-up requests. The existing replay allowlist only matched the bare ids (`deepseek-v4-flash`, `kimi-k2-thinking`, ...), so the tier-suffixed id missed every candidate and the sanitizer stripped `reasoning_content` from the assistant turn. DeepSeek then rejected the second API call with HTTP 400 and the session deadlocked.

Strip the well-known tier suffixes (`-free`, `-paid`, `-trial`) when generating allowlist candidates so the base model id matches and the reasoning replay survives. Existing matching for prefixed / colon-suffixed routes is unchanged.

Fixes #87575

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 16:15:03 +01:00
rain
ad1d8bf990 fix(openrouter): apply strict9 ids to Mistral routes
Fixes #58012.

Applies strict9 replay tool call id sanitization to OpenRouter Mistral-family model routes, including unprefixed Mistral/Codestral/Devstral aliases, while preserving existing passthrough behavior for Gemini and other OpenRouter-backed routes.

Adds focused unit coverage plus a live OpenRouter model catalog test so new Mistral-family routes are checked against the replay policy. Also keeps the current core lint gate green by switching the tool schema cache key sort to a non-mutating sorted array.

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 16:14:32 +01:00
Peter Steinberger
049c1158c9 perf: cache plugin module exports per loader 2026-05-28 16:12:13 +01:00
Peter Steinberger
81c90aab6b perf: prefer built bundled runtime surfaces 2026-05-28 16:03:02 +01:00
Michael Appel
85277c2db1 Block provider credentials from workspace dotenv [AI] (#83655)
* fix: block provider credentials from workspace dotenv

* addressing codex review

* fix(dotenv): document provider credential sources

---------

Co-authored-by: Agustin Rivera <agustin@rivera-web.com>
Co-authored-by: Devin Robison <drobison00@users.noreply.github.com>
2026-05-28 08:57:57 -06:00
Vincent Koc
9adbab05c6 fix(core): restore changed gate typecheck 2026-05-28 16:53:01 +02:00
Vincent Koc
83bb5fb994 fix(agents): quarantine compaction tool schemas 2026-05-28 16:52:44 +02:00
Peter Steinberger
b6ef874220 fix: reject partial numeric parsing 2026-05-28 10:51:32 -04:00
Peter Steinberger
68e6f03fd9 perf: reduce gateway runtime discovery overhead 2026-05-28 15:47:50 +01:00
Vincent Koc
7b5f0c23e5 fix(codex): bound sandbox http stream lines 2026-05-28 16:36:12 +02:00
Vincent Koc
3e2994b975 fix(ssh): bound config probe output 2026-05-28 16:33:12 +02:00
Agustin Rivera
2c3d7f5bad fix(msteams): bind bot framework service urls (#87160)
* fix(msteams): bind bot framework service urls

* fix(msteams): harden service url validation
2026-05-28 07:31:46 -07:00
Vincent Koc
dab3152e0e fix(telegram): bound proof command output 2026-05-28 16:31:05 +02:00
Andy Ye
3fea219692 fix(daemon): preserve explicit systemd unit during refresh
Preserve explicit gateway service identity when package/update refreshes the managed service environment. This keeps caller-selected systemd units ahead of stale persisted service env and applies the same precedence to launchd labels and Windows task names during service-state inspection.

Fixes #87490

Verification:
- node scripts/run-vitest.mjs src/daemon/service-env.test.ts src/daemon/service.test.ts src/cli/update-cli.test.ts src/cli/update-cli/restart-helper.test.ts src/cli/daemon-cli/install.test.ts src/daemon/systemd.test.ts
- git diff --check origin/main...pr/87556
- Crabbox AWS Linux systemd install/refresh proof: run_f3374bd610f7, lease cbx_754e69eb6c3a, provider aws, target linux
- autoreview --mode branch --base origin/main: clean, no accepted/actionable findings

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-28 15:27:51 +01:00
Nimrod Gutman
3f3ed5ec66 fix(gateway): preserve traced child sessions 2026-05-28 17:26:51 +03:00
Colin Johnson
f6e51ff99a feat(ios): refresh pro UI and gateway flows (#87367)
Summary:
- Replace the legacy iOS shell with Pro Command, Chat, Agents, and Settings tabs.
- Wire iOS chat/session/settings/diagnostics and realtime Talk flows through gateway-backed APIs.
- Add gateway/session and shared chat coverage for the new iOS flow.

Verification:
- git diff --check
- node scripts/run-vitest.mjs src/gateway/server.sessions.create.test.ts src/gateway/talk-realtime-relay.test.ts
- swift test --filter ChatViewModelTests (apps/shared/OpenClawKit)
- xcodebuild build for Nimrod's iPhone succeeded; install succeeded; launch was blocked because the phone was locked

Known follow-up:
- Preserve traceLevel in sessions.create parent runtime inheritance and keep the changelog credit in the follow-up patch.
2026-05-28 17:23:26 +03:00
Vincent Koc
65d47dc5d7 fix(imessage): bound cli output capture 2026-05-28 16:22:21 +02:00
Vincent Koc
b4741302c6 fix(auto-reply): bound scp staging stderr 2026-05-28 16:16:01 +02:00
Vincent Koc
76f447b250 fix(voice-call): ignore tailscale helper stderr 2026-05-28 16:13:59 +02:00
Vincent Koc
bc6ecc89d5 fix(voice-call): ignore ngrok probe output 2026-05-28 16:11:54 +02:00
Vincent Koc
47fdd6b88b fix(voice-call): drain tailscale tunnel output 2026-05-28 16:09:50 +02:00
Vincent Koc
80909b3265 fix(scripts): bound boundary check output 2026-05-28 16:09:12 +02:00
Vincent Koc
c7891ec67e fix(voice-call): bound tailscale status output 2026-05-28 16:07:19 +02:00
Peter Steinberger
910354b07f docs: point release process at public evidence repo 2026-05-28 15:04:33 +01:00
Ayaan Zaidi
844d263af0 test(telegram): cover long streamed final replay 2026-05-28 19:33:53 +05:30
Ayaan Zaidi
27d57af127 fix(telegram): retain streamed long final prefixes 2026-05-28 19:33:53 +05:30
Vincent Koc
b667bdd622 fix(release): bound command output capture 2026-05-28 16:01:25 +02:00
Ayaan Zaidi
3cb7ae5350 fix(docker): alias main images to latest release 2026-05-28 19:30:17 +05:30
Peter Steinberger
b58786ce9f perf: reduce agent turn CPU overhead 2026-05-28 14:59:09 +01:00
Vincent Koc
ff5886bba2 fix(matrix): bound bootstrap output capture 2026-05-28 15:58:34 +02:00
Vincent Koc
f2f18f5958 fix(agents): bound search tool stderr 2026-05-28 15:55:51 +02:00
Vincent Koc
8ba71e4aff fix(process): bound command output capture 2026-05-28 15:52:02 +02:00
Vincent Koc
44451eaa47 fix(ci): run CodeQL on main pushes 2026-05-28 15:49:18 +02:00
Vincent Koc
865678eb6b fix(backup): cap verify manifest extraction 2026-05-28 15:48:51 +02:00
Vincent Koc
38f3040384 fix(agents): normalize session tool limits 2026-05-28 15:44:44 +02:00
Ayaan Zaidi
bda924b639 fix(telegram): preserve final overflow state 2026-05-28 19:08:27 +05:30
Ayaan Zaidi
8677310fb5 fix(telegram): skip stopped draft finalization 2026-05-28 19:08:27 +05:30
Ayaan Zaidi
e856932600 fix(telegram): clamp partial draft overflow 2026-05-28 19:08:27 +05:30
Ayaan Zaidi
a048cbc4f0 test(telegram): cover draft preview overflow 2026-05-28 19:08:27 +05:30
Vincent Koc
8e3be0a705 fix(crestodian): bound local command probes 2026-05-28 15:37:05 +02:00
Vincent Koc
76ebc14956 fix(agents): detect signaled local service exits 2026-05-28 15:25:32 +02:00
Peter Steinberger
3d89f493ba fix(release): port 2026.5.27 fixes 2026-05-28 14:25:01 +01:00
Vincent Koc
a5eddb91bb fix(msteams): bound service error bodies 2026-05-28 15:22:03 +02:00
Vincent Koc
56302f79a8 fix(test): keep btw fs promises mock partial 2026-05-28 15:19:09 +02:00
Ayaan Zaidi
dc31f73b39 ci(docker): publish browser release images 2026-05-28 18:48:45 +05:30
Vincent Koc
5809bdf0cb fix(test): detect signaled memory fd gateway exits 2026-05-28 15:17:23 +02:00
Vincent Koc
97ed582f1c fix(test): detect signaled kitchen sink gateway exits 2026-05-28 15:09:16 +02:00
Peter Steinberger
6eedc8331b docs: add release verification skill 2026-05-28 14:07:24 +01:00
Vincent Koc
6835f05cd0 fix(test): detect signaled test gateway exits 2026-05-28 15:02:01 +02:00
Peter Steinberger
d7e62a87f2 test: stabilize code mode wait timeout
Increase the code-mode wait-timeout test timeout so CI shard load does not trip the worker startup guard before the test reaches the intended pending-tool wait path.
2026-05-28 08:56:57 -04:00
Vincent Koc
f48a89cb1c fix(test): detect signaled cross-os gateway exits 2026-05-28 14:52:47 +02:00
Vincent Koc
aa82b43c9f fix(test): detect signaled bundled smoke exits 2026-05-28 14:46:39 +02:00
Vincent Koc
a854331c4c fix(test): hard kill boundary prep timeouts 2026-05-28 14:40:52 +02:00
Vincent Koc
3fb67467fa fix(test): hard kill boundary step timeouts 2026-05-28 14:34:14 +02:00
Peter Steinberger
51e57d46cf docs: tune clawdtributor refresh summaries 2026-05-28 13:33:12 +01:00
Vincent Koc
e5a687f115 fix(test): handle extension memory spawn errors 2026-05-28 14:27:59 +02:00
Peter Steinberger
17c1b06cc7 chore(release): update appcast for 2026.5.27
Updates production Sparkle appcast for v2026.5.27 from the private macOS publish workflow.
2026-05-28 13:27:54 +01:00
Vincent Koc
bda3531560 fix(test): bound startup build helpers 2026-05-28 14:25:06 +02:00
Peter Steinberger
aab5410bd5 test: speed up slow test suite (#87611)
* test: speed up slow test suite

* test: preserve fake timer cleanup hooks

* test: avoid timeout readiness race

* test: satisfy reply test types

* test: restore runner and image coverage

* test: restore final media runner path

* test: make cli auth status fixture deterministic

* test: repair runtime alias fixtures
2026-05-28 13:20:19 +01:00
Vincent Koc
e0635eb6fd fix(release): bound npm release checks 2026-05-28 14:20:01 +02:00
Peter Steinberger
4252f07ff0 fix: reduce gateway warning noise
Reduce repeated gateway warning noise in startup/auth retry paths while preserving credential mismatch and rate-limit audit visibility.

Also hardens empty embedded-assistant retry handling by carrying lifecycle state through the missing-assistant guard, and keeps the relevant regression coverage in gateway and agent tests.
2026-05-28 13:17:57 +01:00
Vincent Koc
4ce3c3e36c fix(test): rebuild startup memory artifacts 2026-05-28 14:14:34 +02:00
Vincent Koc
653e8d1ea5 fix(release): bound prepack subprocesses 2026-05-28 14:14:13 +02:00
Vincent Koc
98d6331d10 fix(release): bound release check commands 2026-05-28 14:11:55 +02:00
Vincent Koc
2b0e399da1 fix(release): bound npm verifier commands 2026-05-28 14:06:46 +02:00
Vincent Koc
b234aa0085 fix(e2e): bound bundled plugin selection 2026-05-28 14:03:24 +02:00
Vincent Koc
cee364e2f6 fix(docker): bound package capture output 2026-05-28 14:01:02 +02:00
Vincent Koc
da551463e3 fix(agent-sessions): fail oversized exec output 2026-05-28 13:53:17 +02:00
Vincent Koc
2252cf6f03 fix(supervisor): bound captured process output 2026-05-28 13:43:36 +02:00
Vincent Koc
9a7f808953 fix(file-transfer): bound dir fetch tar listings 2026-05-28 13:39:55 +02:00
Vincent Koc
eb273a8a4a fix(brave): bound search error bodies 2026-05-28 13:28:27 +02:00
Vincent Koc
259796dc3d fix(test): bound package candidate command output 2026-05-28 13:26:16 +02:00
Vincent Koc
d64b394537 fix(test): bound extension memory profiler output 2026-05-28 13:22:57 +02:00
Vincent Koc
88c395c83c fix(test): wait for credential timeout cleanup 2026-05-28 13:17:35 +02:00
Vincent Koc
9085d17ab6 fix(qa-lab): bound plugin tools stderr tail 2026-05-28 13:07:46 +02:00
Vincent Koc
4a2b02e86f fix(qa-lab): bound child process output 2026-05-28 13:04:09 +02:00
Vincent Koc
beb25d60f7 fix(test): escalate e2e watchdog termination 2026-05-28 13:03:29 +02:00
Vincent Koc
4bd711e1c4 fix(security): avoid fetching untrusted proof refs 2026-05-28 12:39:12 +02:00
Vincent Koc
3844e035bb fix(security): avoid CodeQL legacy auth patterns 2026-05-28 12:32:49 +02:00
Vincent Koc
9fef53c3b1 fix(test): keep upgrade survivor runtime state local 2026-05-28 12:30:58 +02:00
Pavan Kumar Gondhi
91a4635bdc Tighten phone-control mutation authorization [AI] (#87150)
* fix: require admin authorization for phone control mutations

* addressing codex review

* addressing codex review

* addressing ci

* addressing ci

* test: restore provider registry mock isolation

* docs: add changelog entry for PR merge
2026-05-28 16:00:01 +05:30
Vincent Koc
629fc2f8f0 fix(voice-call): bound ngrok diagnostics 2026-05-28 12:16:44 +02:00
Vincent Koc
1bc32e53ab fix(qa): expose credential fingerprints in admin list 2026-05-28 12:04:20 +02:00
Vincent Koc
93577ad587 fix(memory): bound remote error bodies 2026-05-28 11:51:26 +02:00
Pavan Kumar Gondhi
bb418a857e Clarify directive persistence authorization policy [AI] (#86369)
* fix: require admin scope for persisted directive defaults

* addressing codex review

* fix: complete directive persistence scope gate

* addressing review-skill

* fix: preserve channel directive persistence

* fix: require admin scope for directive default persistence

* addressing codex review

* fix: complete directive persistence scope handling

* addressing codex review

* fix: complete directive persistence gate

* addressing review-skill

* fix: complete directive persistence gate

* addressing review-skill

* clarify directive persistence policy

* docs: add changelog entry for PR merge
2026-05-28 15:20:47 +05:30
Vincent Koc
dc5671edae fix(install): harden Windows git installs 2026-05-28 11:47:05 +02:00
Vincent Koc
f9aec04167 fix(qa): stabilize live transport lanes
Wire QA fallback models into live gateway config, fix Slack allowlist-block coverage, and keep WhatsApp live artifacts useful while redacting raw credential metadata.\n\nVerification: focused QA Vitest; autoreview clean; AWS Crabbox pnpm check:changed run_0207de7d47aa; QA-Lab branch-defined transport run 26565521272 with Matrix transport 56/56 and Slack/Discord/Telegram/parity clear. WhatsApp remains blocked by stale shared Convex WhatsApp Web credentials returning Baileys 401 before scenarios.
2026-05-28 10:38:09 +01:00
Vincent Koc
b008989bef fix(security): address OpenClaw CodeQL alerts 2026-05-28 11:34:32 +02:00
Peter Steinberger
7275304793 fix(parallels): guard release target harness mismatch 2026-05-28 10:11:40 +01:00
Peter Steinberger
9ebf51efe9 docs(skills): refine beta release announcement guidance 2026-05-28 10:11:34 +01:00
Peter Steinberger
98052028aa docs(skills): add OpenClaw release announcement guide 2026-05-28 10:11:34 +01:00
Vincent Koc
13dcded7c8 fix(release): bound cross-os fetch bodies 2026-05-28 10:38:08 +02:00
Josh Avant
4c3a0292ff Fix Claude live tool progress for watchdog recovery (#87546)
* fix: keep claude live tools fresh for watchdog

* fix: avoid claude live active tool spread
2026-05-28 01:37:40 -07:00
Peter Steinberger
bd02977e29 test: avoid platform-specific transcript stat assertion 2026-05-28 04:29:31 -04:00
Vincent Koc
9f7006407f fix(scripts): bound audit advisory error bodies 2026-05-28 10:22:44 +02:00
Peter Steinberger
b005f01c13 fix: ignore leading transcript bytes in tail scan 2026-05-28 04:20:01 -04:00
Peter Steinberger
e397636051 fix: avoid direct transcript stat fallback 2026-05-28 04:05:36 -04:00
Vincent Koc
23f494cba9 fix(scripts): bound docker preflight capture 2026-05-28 09:59:51 +02:00
Vincent Koc
744da7e6bd fix(scripts): bound gh read error bodies 2026-05-28 09:47:07 +02:00
Peter Steinberger
5da34a982b perf: avoid runtime catalog load for reasoning defaults 2026-05-28 08:43:49 +01:00
Peter Steinberger
a0cf1858a2 fix(release): pin ClawHub publish workdir 2026-05-28 08:37:06 +01:00
Peter Steinberger
8d5f6c8ae4 perf: reuse preflight transcript scan size 2026-05-28 08:31:06 +01:00
Vincent Koc
1395d71821 fix(scripts): bound labeler error bodies 2026-05-28 09:24:40 +02:00
Peter Steinberger
39bc43cb60 perf: skip recent transcript read after final usage 2026-05-28 08:19:47 +01:00
Vincent Koc
05f357b13b fix(scripts): bound memory fd ready output 2026-05-28 09:05:47 +02:00
Peter Steinberger
bd6a404aa3 perf: reuse transcript scan size 2026-05-28 07:59:25 +01:00
Vincent Koc
0ade360da5 fix(scripts): bound gateway watch log capture 2026-05-28 08:45:18 +02:00
Vincent Koc
00fb15253c fix(agents): cancel failed skill download bodies 2026-05-28 08:13:31 +02:00
Peter Steinberger
ea48ac7da8 fix(agents): suppress abandoned requester completion handoff (#87541) 2026-05-28 07:10:17 +01:00
Vincent Koc
50a708c5f9 fix(qa): keep live transport artifacts local 2026-05-28 08:04:53 +02:00
Peter Steinberger
02b1a2168c test(release): satisfy cross-os socket lint 2026-05-28 07:01:55 +01:00
Peter Steinberger
13427276b8 fix(release): speed windows upgrade fallback 2026-05-28 07:01:55 +01:00
Peter Steinberger
97717277c4 fix(release): close cross-os artifact sockets 2026-05-28 07:01:55 +01:00
Peter Steinberger
ca1829c3f4 fix(ci): bound optional performance report publishing 2026-05-28 07:01:55 +01:00
github-actions[bot]
43deaf4621 chore(ui): refresh nl control ui locale 2026-05-28 05:55:02 +00:00
github-actions[bot]
c16620cb07 chore(ui): refresh fa control ui locale 2026-05-28 05:55:00 +00:00
github-actions[bot]
55e1878e57 chore(ui): refresh vi control ui locale 2026-05-28 05:54:49 +00:00
github-actions[bot]
47c67e31ab chore(ui): refresh th control ui locale 2026-05-28 05:54:36 +00:00
github-actions[bot]
062d429d9c chore(ui): refresh pl control ui locale 2026-05-28 05:54:29 +00:00
github-actions[bot]
580e95fad1 chore(ui): refresh id control ui locale 2026-05-28 05:54:24 +00:00
github-actions[bot]
dcb00f3d8e chore(ui): refresh tr control ui locale 2026-05-28 05:54:07 +00:00
github-actions[bot]
748015b42f chore(ui): refresh uk control ui locale 2026-05-28 05:54:02 +00:00
github-actions[bot]
ae0f46927d chore(ui): refresh it control ui locale 2026-05-28 05:53:55 +00:00
github-actions[bot]
5f3012bc70 chore(ui): refresh ar control ui locale 2026-05-28 05:53:52 +00:00
github-actions[bot]
b0517f1f54 chore(ui): refresh fr control ui locale 2026-05-28 05:53:28 +00:00
github-actions[bot]
5058fc94b3 chore(ui): refresh ja-JP control ui locale 2026-05-28 05:53:26 +00:00
github-actions[bot]
d4ffac4597 chore(ui): refresh ko control ui locale 2026-05-28 05:53:24 +00:00
github-actions[bot]
384dd1216e chore(ui): refresh es control ui locale 2026-05-28 05:53:15 +00:00
github-actions[bot]
6c858ac65f chore(ui): refresh de control ui locale 2026-05-28 05:52:52 +00:00
github-actions[bot]
d3751e409f chore(ui): refresh pt-BR control ui locale 2026-05-28 05:52:50 +00:00
github-actions[bot]
831bb456f7 chore(ui): refresh zh-CN control ui locale 2026-05-28 05:52:46 +00:00
github-actions[bot]
71781b82b4 chore(ui): refresh zh-TW control ui locale 2026-05-28 05:52:43 +00:00
Dallin Romney
127c0ad418 test(cron): speed up isolated fallback tests (#87520) 2026-05-27 22:45:15 -07:00
Dallin Romney
e805ffd2eb refactor(openai): centralize codex oauth flow (#87411) 2026-05-27 22:32:08 -07:00
Dallin Romney
53704b26e8 perf(ci): instrument build artifacts phases (#87514) 2026-05-27 22:31:32 -07:00
Vincent Koc
44027e72d0 test(agents): narrow bounded error assertions 2026-05-28 07:17:21 +02:00
Vincent Koc
d1bca0c32c test(agents): prove active live subagent steering 2026-05-28 07:17:21 +02:00
joshavant
8f6a2f0f6b chore: clarify bug report issue scope 2026-05-27 22:07:44 -07:00
Josh Avant
4a45a259ec fix(agents): preserve signed thinking payloads (#87493) 2026-05-27 21:57:41 -07:00
Vincent Koc
d10d30c5fa fix(test): harden startup benchmark harness 2026-05-28 06:53:58 +02:00
Vincent Koc
4f26cc9090 fix(agents): bound minimax vlm error bodies 2026-05-28 06:50:55 +02:00
amittell
f7c32fc8be fix(telegram): lower polling keepalive delay (#83304)
* fix(telegram): enable TCP keepalive on getUpdates connections to prevent NAT timeout stalls

Long-polling connections to api.telegram.org stay idle for up to the
getUpdates timeout (~900 s). Most home/office NAT tables expire idle TCP
entries after 60–1800 s (commonly ~1000 s). When the NAT entry is
silently dropped the connection hangs rather than returning an error,
leaving the grammY runner stuck until the 90 s stall watchdog fires and
forces a restart cycle.

Fix: unconditionally set `keepAlive: true` and
`keepAliveInitialDelay: 30_000` (30 s) on the undici Agent `connect`
options built in `buildTelegramConnectOptions`. OS-level TCP keepalive
probes sent every ~75 s (OS default) will:
1. Refresh the NAT table entry before it expires.
2. Surface dead connections immediately with ETIMEDOUT instead of
   hanging forever.

The `return Object.keys(connect).length > 0 ? connect : null` guard is
also removed; `connect` is now always non-empty so it always returns the
object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 92e454c0614256201cdf6f0f73c7897d006616d4)

* fix(telegram): stop self-flagging disconnected on poll-cycle start; widen channel connect grace to 300s

(cherry picked from commit 1ca963a05dac0d9d605e9a15dc97fced9cf7725e)

* fix(telegram): catch hung polling startups that preserve inherited connected:true

The widened 300s channel connect grace and the removal of connected:false from
notePollingStart left a path where a polling restart could hang forever
looking healthy. notePollingStart clears lastConnectedAt, lastEventAt, and
lastTransportActivityAt but deliberately omits connected, so server-channels'
patch-merge inherits a connected:true from the previous lifecycle. After grace,
evaluateChannelHealth's stale-socket branch requires lastTransportActivityAt
to be non-null and the connected:false branch is masked, so the channel sits
healthy with no first getUpdates.

Add a post-grace branch to evaluateChannelHealth that flags polling channels
as stale-socket when connected:true is paired with null lastConnectedAt and
null lastTransportActivityAt and a non-null lastStartAt. Scoped to mode:polling
so webhook channels and channels without continuous transport tracking are
not falsely flagged. Align TELEGRAM_POLLING_CONNECT_GRACE_MS in the Telegram
status diagnostic with DEFAULT_CHANNEL_CONNECT_GRACE_MS so openclaw channels
status agrees with the shared health monitor on the grace window. Refresh
the notePollingStart comment to point at the new evaluateChannelHealth branch.

Addresses clawsweeper review on #83304 (P1 connect-grace startup-hang, P2
diagnostic grace drift). Tests cover the new flagged path, the in-grace happy
path, and the prior-successful-connect happy path.

* fix(telegram): clear polling connected state on startup

* fix(gateway): add defense-in-depth health-policy branch for hung polling startups

Defense in depth on top of 87db46c576's notePollingStart connected:false fix.
The primary path (notePollingStart writes connected:false explicitly so
evaluateChannelHealth's existing connected===false branch catches a hung
restart) is unchanged. This adds a defensive post-grace branch that catches
the same hang via a different signature -- inherited connected:true paired
with null lastConnectedAt and null lastTransportActivityAt -- in case a
future code path forgets to clear the inherited connected flag on lifecycle
start. Scoped to mode:polling so webhook channels and channels without
continuous transport tracking are not falsely flagged.

Also bump lastStartAt: Date.now() - 121_000 to 301_000 in the spool-handler
timeout test added by upstream #83505 so it falls past the widened 300s
TELEGRAM_POLLING_CONNECT_GRACE_MS suppression window (mirroring the same
fixup already applied to the two adjacent polling-startup tests).

* revert(telegram,gateway): keep connect grace at 120s

Drop the 120s -> 300s widening from this PR after maintainer feedback that
the extra grace masks real startup bugs. The defense-in-depth checks added
in earlier commits (notePollingStart clearing inherited connected state,
the stale-socket policy branch, the per-snapshot startup grace test) all
work fine at 120s and remain valuable on their own.

Reverts in:
- src/gateway/channel-health-policy.ts: DEFAULT_CHANNEL_CONNECT_GRACE_MS 300 -> 120
- extensions/telegram/src/status-issues.ts: TELEGRAM_POLLING_CONNECT_GRACE_MS 300 -> 120
- extensions/telegram/src/status.test.ts: lastStartAt 301_000 -> 121_000 (3 cases)

The new channel-health-policy.test.ts cases use explicit channelConnectGraceMs:
10_000 in the policy, so they are unaffected by the default constant change.

* fix(telegram): narrow polling keepalive fix

---------

Co-authored-by: Yibei Ou <yibeiou@Yibeis-Mac-mini.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-05-28 10:13:13 +05:30
Ayaan Zaidi
51d7f3c143 ci(mantis): route telegram proof runs to us-east-1 2026-05-28 10:10:32 +05:30
Vincent Koc
c841218ace fix(agents): bound native pdf error bodies 2026-05-28 06:39:55 +02:00
Dallin Romney
647e18aa04 test: deflake agent image root checks (#87499) 2026-05-27 21:32:04 -07:00
Ayaan Zaidi
771ddcf184 fix(android): trust private LAN credentials 2026-05-28 10:00:32 +05:30
Ayaan Zaidi
5f3d6cde19 fix(android): keep LAN cleartext untrusted 2026-05-28 10:00:32 +05:30
Ayaan Zaidi
633c40aa65 fix(android): preserve private LAN TLS pins 2026-05-28 10:00:32 +05:30
Ayaan Zaidi
ec3ac182c5 fix(android): allow private LAN pairing 2026-05-28 10:00:32 +05:30
Vincent Koc
6ae4a00a66 fix(qa): reject loose openwebui probe timeouts 2026-05-28 06:27:04 +02:00
Vincent Koc
a0ba9f2b72 fix(media): cancel oversized fetch responses 2026-05-28 06:20:23 +02:00
Masato Hoshino
313d6ae1b3 fix(whatsapp): strip control characters from outbound document fileName (#77114)
Merged via squash.

Prepared head SHA: 5417a8ee2c
Co-authored-by: masatohoshino <246810661+masatohoshino@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-05-28 01:17:52 -03:00
Dallin Romney
8d21ac3f6e refactor: share QA runtime helpers (#87412)
* refactor: share QA runtime helpers

* refactor: keep QA helpers private

* refactor: keep QA helpers on private runtime seam

* chore: prune stale QA duplicate ignores

* fix: align qa runtime boundary alias

* fix: avoid startup memory lint conversion
2026-05-27 21:16:24 -07:00
Vincent Koc
96b8df75d5 fix(media): cancel ignored input fetch bodies 2026-05-28 06:13:24 +02:00
Vincent Koc
6adf2340fb fix(qa): parse kitchen sink rpc guardrails strictly 2026-05-28 06:05:24 +02:00
Vincent Koc
736e04cb90 fix(media): drain ignored download responses 2026-05-28 05:53:09 +02:00
Vincent Koc
6a324f6400 fix(perf): keep abort leak thresholds active 2026-05-28 05:29:40 +02:00
Agustin Rivera
b860a0d4d0 fix: harden qqbot direct media uploads
Harden QQBot direct media URL uploads by downloading through the local SSRF guard before QQ upload, disabling redirects, bounding fetch/setup and body reads, and routing downloaded buffers through the existing one-shot/chunked size gate.

Co-authored-by: Agustin Rivera <agustin@rivera-web.com>
2026-05-28 04:21:46 +01:00
Vincent Koc
751cd0c9b8 fix(doctor): validate normalized tool schemas 2026-05-28 05:09:58 +02:00
Vincent Koc
f5e48f767f fix(perf): keep startup memory budgets active 2026-05-28 05:07:34 +02:00
Dallin Romney
d165100c93 perf(tests): refactor embedded attempt runner helpers (#87410)
* refactor: extract embedded attempt runner helpers

* fix: remove unused attempt queue type import

* fix: restore attempt helper coverage

* fix: clear attempt cleanup ci

* fix: restore model prompt transform extraction
2026-05-27 20:04:36 -07:00
Dallin Romney
5887119e8d chore: stop tracking generated diffs viewer runtime (#87405)
* chore: stop tracking generated diffs viewer runtime

* test(diffs): generate viewer runtime fixture when missing
2026-05-27 19:59:35 -07:00
Vincent Koc
bf22893cb6 fix(perf): reject loose extension memory numeric flags 2026-05-28 04:57:51 +02:00
Peter Steinberger
edd4c62da1 perf: dedupe persisted skill prompts (#87458)
* perf: dedupe persisted skills prompts

* fix: account for blobbed skill prompts

* fix: prune unreferenced skill prompt blobs

* fix: refresh skill prompt blob lifecycle

* fix: prune skill prompt blob temp files

* chore: rerun ci

* fix: keep blobbed store serialized cache

* fix: preserve blobbed store cache fast paths

* fix: protect in-flight session prompt blobs

* fix: revalidate session prompt blob cleanup

* test: avoid bundled channel load in image tool tests

* fix: revalidate session prompt blobs before commit

* fix: keep CI guard and media root tests lean
2026-05-28 03:52:03 +01:00
Vincent Koc
6fe7dddcf2 fix(qa): reject loose Docker scheduler numeric env 2026-05-28 04:48:56 +02:00
Vincent Koc
3ef34702c8 fix(qa): reject loose gateway CPU numeric flags 2026-05-28 04:38:41 +02:00
bladin
e0d003b372 fix(whatsapp): support pluginHooks.messageReceived in channel/account config schema (#86426)
Merged via squash.

Prepared head SHA: 27003a8d5a
Co-authored-by: bladin <1740879+bladin@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-05-27 23:31:47 -03:00
Peter Steinberger
2229122077 fix: keep private SDK declarations local 2026-05-28 03:28:27 +01:00
Vincent Koc
8b78ded074 test(agents): cover tool schema quarantine in turns 2026-05-28 04:26:00 +02:00
Vincent Koc
ac28c0611d fix(qa): reject loose gauntlet numeric flags 2026-05-28 04:24:13 +02:00
Dallin Romney
3005b62242 perf(plugins) refactor plugin SDK declarations for flat package types (#87165)
* refactor: flatten plugin sdk declarations

* fix: align package inventory with flat sdk declarations

* refactor: move packed sdk smoke to fixture

* test: simplify packed sdk type smoke

* fix(canvas): use focused number runtime helpers

* fix(ci): stabilize sdk boundary checks

* test: guard private sdk declaration leaks

Co-authored-by: Peter Steinberger <steipete@gmail.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 19:22:32 -07:00
Vincent Koc
b6e354f6ca fix(file-transfer): handle late tar pipe errors 2026-05-28 04:14:57 +02:00
Vincent Koc
d1577a2ff2 fix(perf): reject invalid startup bench counts 2026-05-28 03:48:55 +02:00
Andy
d2319d718c fix(status): keep default JSON scan lean
Default `openclaw status --json` stays on the lean health-probe path while preserving the JSON task summary, local update/install metadata, explicit probe timeouts, and configured gateway handshake timeouts. Deeper memory, registry, remote git, and local status-RPC diagnostics remain behind `status --json --all`.

Also keeps generated diffs viewer output in its built form and ignores it in oxfmt so `pnpm build` leaves a clean tree.

Proof:
- `node scripts/run-vitest.mjs src/commands/status.scan.fast-json.test.ts src/commands/status-json-payload.test.ts src/commands/status.scan.shared.test.ts`
- `OPENCLAW_LOCAL_CHECK=0 node scripts/run-oxlint-shards.mjs --threads=8`
- `node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo`
- `node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- GitHub checks green for head `47a63f87ea7c2351994fdb71e8cc18041aa0b64e`

Thanks @andyylin.

Co-authored-by: Andy <andyylin@users.noreply.github.com>
2026-05-28 02:28:49 +01:00
Vincent Koc
5846878924 fix(auth): honor OAuth login cancellation 2026-05-28 03:12:40 +02:00
Vincent Koc
a20c091411 test(reply): avoid redundant settled hook return unions 2026-05-28 02:55:01 +02:00
Vincent Koc
069f33b410 test(openai): type malformed context window fixture 2026-05-28 02:55:01 +02:00
Vincent Koc
28a719f3da fix(agents): allow steering yielded subagents 2026-05-28 02:55:01 +02:00
Peter Steinberger
7c7fb7df67 chore(release): refresh plugin sdk baseline 2026-05-28 01:51:27 +01:00
Peter Steinberger
cee2a50fe6 chore(release): prepare 2026.5.28 2026-05-28 01:48:07 +01:00
Peter Steinberger
0e262d20e7 fix(discord): fence tool warning fallback delivery (#87465)
* fix(discord): fence recovered tool warning fallback

* fix(discord): keep warning fallback after failed final

* fix(reply): keep settled cleanup unconditional
2026-05-28 01:39:14 +01:00
Vincent Koc
748510b7a3 fix(doctor): validate tool schemas for configured agents 2026-05-28 02:17:43 +02:00
Peter Steinberger
45e6af5e57 fix: reject partial numeric runtime values 2026-05-27 20:10:01 -04:00
Peter Steinberger
d1aa3cb925 fix: reject partial numeric command values 2026-05-27 20:10:01 -04:00
WarrenJones
65e2120f8c fix(hooks): pass media metadata to received hook
Forward canonical inbound media metadata to plugin message_received hooks so plugins can inspect the same mediaPath, mediaUrl, mediaType, mediaPaths, mediaUrls, and mediaTypes fields already available to inbound_claim.

Verification:
- node scripts/run-vitest.mjs src/hooks/message-hook-mappers.test.ts
- /Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Refs: https://github.com/openclaw/openclaw/pull/87297
Co-authored-by: WarrenJones <8704779+WarrenJones@users.noreply.github.com>
2026-05-28 01:06:00 +01:00
Martin Kessler
d00e764e66 fix(heartbeat): stop pending final replay
Stop heartbeat runs from directly returning non-ack durable pending final text. Heartbeats now only clear ack-only pending state and otherwise continue the heartbeat turn, so stale prior final answers cannot be replayed through a later heartbeat/default route.

Keep the isolated heartbeat active-run guard so an immediate/manual heartbeat cannot overwrite an isolated heartbeat session that is still running.

Proof:
- node scripts/run-vitest.mjs src/auto-reply/reply/get-reply.fast-path.test.ts src/infra/heartbeat-runner.skips-busy-session-lane.test.ts
- git diff --check
- autoreview --mode local
- autoreview --mode branch --base origin/main
- GitHub CI 26543804437, CodeQL 26543804438, Critical Quality 26543804441, OpenGrep PR Diff 26543804440 rerun job 78197443511, Real behavior proof 26544027357

Refs #74257.

Co-authored-by: kesslerio <martin@kessler.io>
2026-05-28 00:58:57 +01:00
Peter Steinberger
c86667c5cf test(discord): use reply payload SDK test helper (#87454)
* test(discord): use reply payload SDK test helper

* build(plugin-sdk): exclude reply payload test helper
2026-05-28 00:57:22 +01:00
Peter Steinberger
ff0990d800 fix: accept uncommitted autoreview mode 2026-05-28 00:55:08 +01:00
Edward Abrams
05db911775 fix(outbound): thread session keys into outbound hooks (#73706)
Thread the canonical outbound session key into plugin message_sending and message_sent hook contexts, and align native command redirect routed delivery with the agent runtime session key. This lets plugins correlate agent_end with outbound delivery hooks without seeing missing or divergent session keys.

Verification:
- gh pr checks 73706 --repo openclaw/openclaw --watch=false
- Real behavior proof: https://github.com/openclaw/openclaw/actions/runs/26526635074/job/78131933497

Thanks @zeroaltitude.

Co-authored-by: Edward Abrams <zeroaltitude@gmail.com>
2026-05-28 00:43:27 +01:00
Vincent Koc
c9151ba902 fix(provider): bound local service startup 2026-05-28 01:38:35 +02:00
Peter Steinberger
1f1cdd84ea chore: forward gateway profiling env 2026-05-28 00:35:35 +01:00
Peter Steinberger
da279041ab fix(discord): suppress recovered tool warnings (#87451) 2026-05-28 00:32:28 +01:00
Fermin Quant
3f9d2415ac fix(cron): stabilize isolated prompt cache affinity
Stabilize isolated cron prompt cache affinity by deriving a stable prompt cache key per cron job/session/model and forwarding it separately from the rotating run session id.

Thread the key through embedded runs, stream resolution, provider options, proxy forwarding, custom streams, and prompt-cache observability. Keep OpenAI-compatible payloads valid by using hyphen-safe keys, clamping upstream prompt_cache_key values, and omitting affinity when cache retention is disabled.

Thanks @ferminquant.

Co-authored-by: Fermin Quant <ferminquant@hotmail.com>
2026-05-28 00:31:19 +01:00
Alix-007
8b7a4826a1 fix(agents): keep hook context prompt-local (#86875)
Fixes embedded agent prompt handling so before_prompt_build prepend/append context stays prompt-local: visible transcripts keep the user prompt, provider/model prompts keep hook context, and runtime/system context stays separate.

Local verification:
- git diff --check
- fnm exec --using v22.22.2 pnpm exec oxfmt --check src/agents/embedded-agent-runner/tool-result-context-guard.ts src/agents/embedded-agent-runner/tool-result-context-guard.test.ts
- fnm exec --using v22.22.2 node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json src/agents/embedded-agent-runner/tool-result-context-guard.ts src/agents/embedded-agent-runner/tool-result-context-guard.test.ts
- fnm exec --using v22.22.2 pnpm tsgo:test:src
- autoreview clean: no accepted/actionable findings

CI verification:
- GitHub CI run 26544578760 passed on rebased head 9715d3a01a

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-28 00:29:31 +01:00
alkor2000
603aa8a2ed fix(doctor): rewrite non-canonical api_key auth profiles
Rewrites non-canonical api_key fields in auth-profiles.json to canonical key via openclaw doctor --fix, with backups, while preserving canonical key/keyRef credentials and active-agent auth stores.

Fixes #57389.

Co-authored-by: alkor2000 <200923177@qq.com>
2026-05-28 00:29:28 +01:00
lukeboyett
b5bd6e8828 fix(sessions): preserve Matrix room-id case in session keys (#75670) (#87366)
* fix(sessions): preserve Matrix room-id case in session keys (#75670)

Matrix room IDs (and thread event IDs) are opaque, case-sensitive per the
Matrix spec, but session-key canonicalization lowercased them. That forked
one room into duplicate sessions and produced 403 M_FORBIDDEN on recovery /
delivery paths that reconstruct the target from the (lowercased) session key,
even though deliveryContext.to stayed correct.

Introduce a generic, opt-in case-preservation registry (CASE_PRESERVING_PEERS)
consulted at all three lowercasing sites:
- construction: normalizeSessionPeerId
- store canonicalization: normalizeSessionKeyPreservingOpaquePeerIds
- gateway send: explicit request.sessionKey

Signal group preservation is encoded to match prior behavior exactly (segment
span, unscoped, thread suffix still lowercased). Matrix channel/group enrolls
the opaque tail (room id with embedded :server + any 🧵<event> suffix).
Exact mixed-case keys now win over folded legacy aliases in
resolveSessionStoreEntry and delivery-info lookup; existing lowercased rows
collapse on the next write. Matrix DM/MXID and non-enrolled channels keep the
default lowercase behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sessions): guard Matrix folded alias delivery proof

* test(agents): cover cold OpenAI gpt-5.5 fallback

* fix(sessions): preserve non-opaque alias freshness

* fix(sessions): prevent Matrix cross-room thread recovery

* build(protocol): refresh tools effective Swift models

* test(codex): include effective cwd in startup fixture

* test(codex): align startup failure cleanup expectation

* fix(sessions): keep Signal folded aliases fresh

* fix(sessions): preserve unscoped Matrix room keys

* fix(sessions): recover legacy Matrix thread aliases

* fix(sessions): preserve Matrix keys in state migrations

* fix(sessions): keep Matrix structural alias freshness

* fix(sessions): preserve unscoped Matrix migration keys

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-28 00:26:49 +01:00
Vincent Koc
92043f7547 test(gateway): retry live exec read probe wording 2026-05-28 01:20:56 +02:00
Peter Steinberger
59c3ee7c45 fix(imessage): continue polling after denied reactions
(cherry picked from commit 6cc534af9b859301f9ff814bdd8672fa910141e3)
2026-05-28 00:17:52 +01:00
Chunyue Wang
65fb56513f fix(agents): release session lock on timeout abort
Fixes #86816.

Co-authored-by: Chunyue Wang <16864032@qq.com>
2026-05-28 00:16:40 +01:00
Vincent Koc
c20a055341 fix(provider): honor Codex response timeouts 2026-05-28 01:03:21 +02:00
Vincent Koc
da5fe990d8 fix(codex): report quarantined dynamic tools 2026-05-28 00:56:30 +02:00
Kevin Lin
40bca6d8bb fix(imessage): suppress duplicate native exec approvals
Fix iMessage native exec approval routing so approval prompts bind to the sent GUID without duplicate sends after RPC timeout. Also keeps chat.db GUID recovery on the local imsg path while avoiding local DB recovery for configured or detected SSH wrappers.

Thanks @kevinslin.
2026-05-27 23:55:28 +01:00
Andy Ye
d8641a661b fix(sessions): avoid stale restart continuation reuse
Avoid stale restart continuation reuse after a session key has rotated.

Queued restart agent turns now carry the session id they were queued for and fall back to a system wake if the key points at a different session by delivery time. Normal completed-run lifecycle fields stay reusable for fresh sessions, while new-session creation clears stale lifecycle markers.

Closes #86593.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 23:55:24 +01:00
Andy Ye
cc72519053 fix(gateway): drain probe client close
Closes #87210.

Gateway probe now waits for GatewayClient.stopAndWait() before resolving so callers do not observe a successful probe while the client socket is still draining. If the drain fails, probe falls back to stop().

Adds mocked probe coverage plus a real WebSocket regression test that verifies no client socket handle remains when probeGateway() resolves.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 23:55:14 +01:00
Peter Steinberger
550a9b459a test(ci): bound image tool iMessage fixtures 2026-05-27 18:50:49 -04:00
simplyclever914
169effacc2 feat(status): show active subagent details
Show active subagent detail rows in /status with labels and elapsed runtime while keeping completed-subagent summary behavior. Thanks @simplyclever914.
2026-05-27 23:49:46 +01:00
Sarah Fortune
6ac3561c69 fix(codex): format skills command output (#87400) 2026-05-27 15:43:05 -07:00
Paul Frederiksen
77fe36bb98 Improve stale Codex auth recovery guidance
Fixes #83935.

Summary:
- clear stale legacy openai-codex auto route pins only when the canonical OpenAI provider is still using the Codex harness for the same model
- preserve usable Codex auth profiles while clearing stale route state
- keep explicit/custom OpenAI API route pins intact

Verification:
- git diff --check
- pnpm exec oxfmt --check --threads=1 src/auto-reply/reply/model-selection.ts src/auto-reply/reply/model-selection.test.ts src/auto-reply/reply/agent-runner-execution.ts src/auto-reply/reply/agent-runner-execution.test.ts
- fnm exec --using 24.15.0 node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo
- .agents/skills/autoreview/scripts/autoreview --mode local
- CI: https://github.com/openclaw/openclaw/actions/runs/26542490863

Co-authored-by: Paul Frederiksen <paul@paulfrederiksen.com>
2026-05-27 23:35:48 +01:00
samzong
316fd5b625 [Fix] Warm provider auth off main thread (#86281)
* fix(agents): warm provider auth off main thread

Signed-off-by: samzong <samzong.lu@gmail.com>

* fix(agents): keep provider auth warm read-only

* fix(ci): unblock provider auth landing

* ci: serialize gateway watch artifact check

* fix(ci): stabilize diffs viewer asset generation

* fix(agents): avoid stale plugin auth warm results

* fix(agents): keep partial auth warm cache

---------

Signed-off-by: samzong <samzong.lu@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 23:24:55 +01:00
Peter Steinberger
5cef288d65 fix(agents): resolve Codex runtime models first
* fix(agents): resolve Codex runtime models first

* test(agents): align Codex runtime resolution fixtures
2026-05-27 23:23:22 +01:00
Gio Della-Libera
f3e285126a fix(doctor): make restart follow-up actionable (#87361) 2026-05-27 23:23:19 +01:00
Vincent Koc
53ad531df9 fix(crabbox): preserve sparse run artifacts 2026-05-28 00:20:39 +02:00
Peter Steinberger
78c5eeab01 docs(changelog): require contributor thanks 2026-05-27 23:20:03 +01:00
Peter Steinberger
5d437de70f fix(web-search): preserve runtime-only provider config
Fixes #87191. Keeps Brave and Gemini runtime-injected web search provider config readable by providers without re-exposing legacy tools.web.search provider objects to config validation.
2026-05-27 23:17:07 +01:00
xiaotian
fb1dfd486b fix(slack): retain delivered final replies during late cleanup
Fix Slack draft cleanup after final-visible delivery.

Track when Slack has already delivered a visible final reply and stop reusing the draft finalizer for later same-turn final/error payloads. This keeps the first fallback cleanup for transient previews while preventing late cleanup from deleting a visible answer.

Fixes #87363

Co-authored-by: tianxiaochannel-oss88 <tianxiaochannel@gmail.com>
2026-05-27 23:16:17 +01:00
Peter Steinberger
cf47580a45 test(ci): align startup and model fixtures 2026-05-27 18:09:03 -04:00
Peter Steinberger
efbd00f282 fix: preserve retry-after fallback 2026-05-27 18:03:13 -04:00
Peter Steinberger
f24844d801 fix: reject partial numeric parsing 2026-05-27 18:00:19 -04:00
Peter Steinberger
db549137d3 fix(agents): bound compaction wake retry timeouts 2026-05-27 22:57:51 +01:00
alkor2000
ea2e9ce8bd fix(agents): clamp compaction steer retry wait to remaining delivery window
The compaction retry loop checked the delivery-timeout deadline before
choosing a fixed backoff delay, then slept that whole delay. When the
remaining window was shorter than the next backoff entry, the final
retry could sleep past the deadline, overrunning the delivery timeout
the retry is meant to stay within. Clamp the wait to the remaining
window (min(scheduledDelay, deadline - now)) and stop retrying once no
time remains, so compaction waiting never exceeds the delivery timeout.

Addresses the near-deadline overrun raised in ClawSweeper review of #86606.
2026-05-27 22:57:51 +01:00
alkor2000
a7b8e6a5a9 fix(agents): wait for compaction before requester steering fallback
Follow-up to #85489. Active requester steering treated a `compacting`
outcome from queueEmbeddedPiMessageWithOutcome as a terminal wake
failure and fell through to the requester-agent/direct fallback, even
though the active run becomes steerable again as soon as compaction
finishes.

Introduce a shared resolveActiveWakeWithRetries helper used by both the
steer path (maybeSteerSubagentAnnounce) and the generated-completion
active wake (sendSubagentAnnounceDirectly). The helper treats
`compacting` as transient and waits through compaction, retrying the
same wake. Waiting is bounded by the active wake's delivery timeout (not
just the backoff schedule): the backoff schedule controls the gap
between attempts, and once it is exhausted its last delay is reused until
the delivery deadline, so a compaction that finishes after the schedule
but within the delivery timeout still re-steers. The best-effort
transcript-commit retry and the compaction retry share one loop, so a
run that compacts and then reports transcript_commit_wait_unsupported
still gets the best-effort retry. Other wake failures keep their
existing single-attempt fallback.

Fixes #86566
2026-05-27 22:57:51 +01:00
Mariano
7299c56953 Fix sub-agent cwd/workspace separation (#87218)
Merged via squash.

Prepared head SHA: f47b073830
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-05-27 23:55:24 +02:00
martingarramon
039fcbaa4c fix(agent-job): preserve grace for pending error diagnostics
Preserve pending agent-job error diagnostics as non-terminal timeout snapshots so the retry grace path can still recover when the lifecycle later starts and completes.

Local proof:
- node scripts/run-vitest.mjs packages/sdk/src/index.test.ts src/gateway/server-methods/server-methods.test.ts src/gateway/server.chat.gateway-server-chat.test.ts src/agents/run-wait.test.ts src/agents/openclaw-tools.sessions.test.ts
- node scripts/run-oxlint.mjs packages/sdk/src/client.ts packages/sdk/src/index.test.ts src/gateway/server-methods/agent-job.ts src/gateway/server-methods/agent.ts src/gateway/server-methods/agent-wait-dedupe.ts src/agents/run-wait.ts src/agents/tools/sessions-send-tool.ts src/gateway/server-methods/server-methods.test.ts src/gateway/server.chat.gateway-server-chat.test.ts src/agents/run-wait.test.ts src/agents/openclaw-tools.sessions.test.ts
- autoreview --mode local: no accepted/actionable findings
- CI run 26536599850: success

Co-authored-by: Martin Garramon <martin@yulicreative.ai>
2026-05-27 22:51:11 +01:00
Kevin Lin
bb752c2b47 Revert "feat: expose plugin approval action metadata" (#87419)
This reverts commit 0c867eef75.

# Conflicts:
#	docs/.generated/plugin-sdk-api-baseline.sha256
2026-05-27 14:48:06 -07:00
Vincent Koc
dfcf211232 test(agents): clarify live subagent steering prompt 2026-05-27 23:45:35 +02:00
Vincent Koc
5ad8036bda test(openai): stabilize live audio transcription 2026-05-27 23:45:35 +02:00
Vincent Koc
7b967c5701 fix(oauth): bound GitHub Copilot requests 2026-05-27 23:27:27 +02:00
Patrick Erichsen
b4e5038692 fix(cli): respect subcommand version options (#87398)
* fix(cli): respect subcommand version options

* test: stabilize model directive auth status
2026-05-27 16:26:11 -05:00
Vincent Koc
67277088eb fix(oauth): bound Codex token requests 2026-05-27 23:20:15 +02:00
Peter Steinberger
5f68291f4f fix(agents): move session write lock into owned session runtime (#87409)
* fix(agents): move session write lock into owned session runtime

* test(agents): use typed tool-call fixtures
2026-05-27 22:17:35 +01:00
Vincent Koc
21d9609866 fix(gateway): quarantine unsupported effective tool schemas 2026-05-27 23:15:24 +02:00
Mariano Belinky
a7d2d9c6df fix: migrate legacy memory auto provider 2026-05-27 23:03:32 +02:00
Vincent Koc
09d2682cd8 fix(openai): resolve gpt-5.5 without cached catalog 2026-05-27 22:57:30 +02:00
Vincent Koc
00004ca798 fix(cli): wait for respawn child shutdown 2026-05-27 22:57:30 +02:00
Peter Steinberger
7f7eca1ad2 fix(codex): preserve shared app-server after startup app errors (#87399)
* fix(codex): preserve shared app-server after startup app errors

* fix(codex): align startup cleanup tests with current types

* test(config): isolate installed plugin ledger cache
2026-05-27 21:50:41 +01:00
Dallin Romney
87944c0d80 chore(ui): mark generated locale artifacts (#87406) 2026-05-27 13:48:21 -07:00
Vincent Koc
f39f1a4712 fix(e2e): bound MCP channel harness buffers 2026-05-27 22:34:08 +02:00
Vincent Koc
1eb27da55d fix(testing): bound openclaw instance logs 2026-05-27 22:29:36 +02:00
Peter Steinberger
9ff071f646 test(config): clear install record cache in validation fixture 2026-05-27 16:23:01 -04:00
GarlicGo
2900c1c25c fix(inbound-meta): include seconds in timestamps
Include second-level precision in inbound metadata and auto-reply envelope timestamps, matching the timestamp helper contract used by providers and channel adapters.

Docs now show the weekday plus seconds form in date-time and timezone examples.

Verification:
- node scripts/run-vitest.mjs src/auto-reply/envelope.test.ts src/auto-reply/reply/inbound-meta.test.ts
- pnpm docs:list >/tmp/openclaw-docs-list-87360.log
- git diff --check origin/main...HEAD
- pnpm format:docs:check
- pnpm lint:docs
- pnpm lint:extensions:bundled
- pnpm lint
- PR CI green on 495bb6c10f

Fixes #87257

Co-authored-by: GarlicGo <582149912@qq.com>
2026-05-27 21:18:08 +01:00
Alix-007
f4329fe0d6 fix(agents): bound plugin system context
* fix(agents): bound plugin system context

* test(agents): align wrapped system context expectations

* style(agents): format hook context helper

* test(codex): expect plugin system context boundary

---------

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 21:16:15 +01:00
Peter Steinberger
b257b988a1 perf(plugins): trust install records cache between reloads 2026-05-27 21:13:39 +01:00
Pavan Kumar Gondhi
c923b07784 fix(gateway): expire browser tokens after auth rotation
Expire browser-origin Control UI/WebChat device tokens when shared gateway auth rotates by tagging those tokens with the shared-auth generation and enforcing it during verification.

Preserve the issuer tag when a shared-auth-derived device token reconnects through a non-browser client, so reconnect rotation cannot turn it into an untagged long-lived token.

Proof:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs src/gateway/server.shared-auth-rotation.test.ts src/infra/device-pairing.test.ts src/gateway/control-ui.http.test.ts
- GitHub CI run 26535632102: relevant build/runtime/test-type checks green; inherited lint reds match origin/main.
- GitHub CodeQL Critical Quality run 26535631610: network-runtime-boundary green.

Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com>
2026-05-27 21:13:20 +01:00
Vincent Koc
d9051151d7 fix(gateway): scope assistant idempotency dedupe 2026-05-27 22:09:31 +02:00
Vincent Koc
4ff944c0e8 fix(ci): stabilize model picker and release checks 2026-05-27 22:05:38 +02:00
Peter Steinberger
171675b54b docs: clarify backport target 2026-05-27 21:05:25 +01:00
Peter Steinberger
d30ba7f961 fix(ci): satisfy codex extension lint 2026-05-27 16:05:06 -04:00
Dallin Romney
cc2948d1e1 fix(codex): narrow legacy hook generation grace (#87386) 2026-05-27 13:01:44 -07:00
Peter Steinberger
4da2b5f4d9 perf(config): prefer native JSON parsing 2026-05-27 20:56:58 +01:00
Peter Steinberger
c71c49c460 fix(ci): address lint and test type failures 2026-05-27 15:56:12 -04:00
Sebastien Tardif
60e8e60306 fix(tool-search): reuse unchanged catalogs
Fixes repeated Tool Search catalog registration for unchanged effective tool sets by reusing a fingerprinted catalog snapshot across embedded-agent run cleanup.

The reusable catalog is guarded by catalog-affecting fields, parameters, and executable identity, and reuse now rebinds the current run/session refs before returning. Embedded-agent prep logging only suppresses the catalog line when reuse actually happened.

Verification:
- pnpm test src/agents/tool-search.test.ts -- --reporter=verbose
- pnpm check:changed, Testbox tbx_01ksney4f00wgk9n39yv7jsh4m
- Real behavior proof, GitHub Actions run 26534896284
- CI rerun for unrelated model-picker timeout passed, GitHub Actions run 26534489215
- autoreview clean: no accepted/actionable findings

Closes #86887
Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-27 20:56:06 +01:00
Peter Steinberger
d93524d1cc fix(codex): route workspace memory through tools (#87383)
* fix(codex): route workspace memory through tools

* fix(codex): preserve extra memory bootstrap files

* fix(codex): support memory_get-only context routing

* fix(codex): only tool-route canonical workspace memory

* fix(codex): keep memory fallback for sandbox workspaces
2026-05-27 20:55:27 +01:00
Yuval Dinodia
74f9d6b96d fix(codex): preserve shared app-server when spawned helper run fails logically (#72574) (#87375)
* fix(codex): preserve shared app-server when spawned helper run fails logically

* fix(codex): widen spawnedBy param to match EmbeddedRunAttemptParams

* fix(codex): align spawnedBy startup typing

* fix(codex): retire shared client on spawned startup timeout

* fix(codex): narrow spawned thread-start preservation

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 20:48:18 +01:00
Peter Steinberger
15b1e99df3 perf(sessions): add precomputed patch writer 2026-05-27 20:45:27 +01:00
Peter Steinberger
26a8432ee1 fix(ci): align release and image tests 2026-05-27 15:44:56 -04:00
Peter Steinberger
94749b0a45 fix(cli): reject malformed numeric inputs 2026-05-27 15:43:12 -04:00
狼哥
b789e71e57 fix(agents): avoid session event queue self-wait (#86123)
Avoids a self-wait in embedded agent session event hooks by skipping the queue drain only for hooks running inside the current session event processing chain. Detached or external hook work still drains the queue before taking the session write lock.

Verification:
- node scripts/run-vitest.mjs run --config test/vitest/vitest.agents-embedded-agent.config.ts src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts
- node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts src/agents/embedded-agent-runner/run/attempt.session-lock.ts --threads=8
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI: https://github.com/openclaw/openclaw/actions/runs/26533883763

Thanks @luoyanglang.

Co-authored-by: luoyanglang <hanwanlonga@gmail.com>
2026-05-27 20:38:01 +01:00
keshavbotagent
e339586750 fix(plugin-state): evict current namespace on plugin row cap
Make plugin-state enforce the plugin-wide live-row fuse by evicting only from the namespace currently being written, preserving sibling namespace rows and still failing atomically when the current namespace cannot free enough rows.

Raise the plugin-wide cap to 6,000 rows, keep Telegram's persistent message-cache namespace at 3,000 entries, and document the updated SDK runtime contract. Harden legacy plugin-state import so capacity pressure cannot archive a source after losing imported keys, with focused regression coverage for Telegram-shaped namespaces and migration rollback.

Also restore the Docker runtime-assets preflight step in full release validation so release workflow contract tests stay aligned.

Verification: focused plugin-state, migration, Telegram, workflow-contract, lint, deprecated-API, diff-check, Blacksmith Testbox, CI, CodeQL, Workflow Sanity, OpenGrep, and autoreview all passed on PR head fee021cfa6.

Co-authored-by: Keshav's Bot <keshavbotagent@gmail.com>
2026-05-27 20:33:40 +01:00
Shubhankar Tripathy
90f30075aa fix(channels): preserve Telegram SecretRef prompt config
Use read-only Telegram account inspection for prompt-time channel actions, inline buttons, and reaction guidance so unresolved SecretRef tokens retain configured non-secret behavior before runtime snapshot hydration.

Match runtime Telegram account lookup for normalized config keys and multi-account fallback guards, while keeping sends/actions on the existing strict credential resolution path.

Fixes #75433.

Co-authored-by: Shubhankar Tripathy <reach2shubhankar@gmail.com>
2026-05-27 20:25:41 +01:00
Patrick Erichsen
ee57f341f0 Add ClawHub skill verification and trust surfaces (#86699)
* feat(skills): fetch ClawHub skill verification

* feat(skills): resolve ClawHub verification targets

* feat(skills): add ClawHub verify command

* docs(skills): document ClawHub verification

* test(skills): type verify CLI fixture

* fix(skills): fetch verified skill card URL

* fix(skills): bound verified card downloads

* fix(skills): fail closed on malformed verification

* fix(skills): corroborate ClawHub install origins

* feat(skills): surface ClawHub trust in control UI

* chore(protocol): refresh generated gateway models

* chore(ui): refresh i18n raw copy baseline

* docs: clarify skills verify wording

* fix: fail closed on skill trust mismatches

* fix: reject incomplete clawhub provenance

* fix: satisfy trust verdict lint

* fix: restore ci checks after main merge
2026-05-27 14:23:24 -05:00
Peter Steinberger
431eb9cda4 perf(sessions): skip unchanged store serialization 2026-05-27 20:22:22 +01:00
Vincent Koc
bde1bad2c1 fix(gateway): bound webchat image data scans 2026-05-27 21:00:38 +02:00
Peter Steinberger
2f710f5604 fix(ci): avoid deprecated sdk import in canvas cli 2026-05-27 14:57:00 -04:00
Alex Knight
42e9504114 fix(codex): preserve native hook relay across restarts
Fixes #87331.\n\nPersist Codex native hook relay generations for real app-server resumes, keep a bounded legacy-binding grace path, and rotate generation on fresh-thread fallback so stale hook commands stay rejected.\n\nCo-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
2026-05-27 19:55:19 +01:00
Peter Steinberger
6727985365 docs: add macOS gateway sleep troubleshooting
Refs: #87337
Co-authored-by: Arunjeet Singh <arunjeetsingh@gmail.com>
2026-05-27 19:52:56 +01:00
Peter Steinberger
da1a3434f4 docs: document native Codex hook relay recovery 2026-05-27 19:46:56 +01:00
Vincent Koc
fdbf3cf4e7 fix(qa): make matrix block streaming deterministic 2026-05-27 20:43:33 +02:00
Peter Steinberger
9755241b56 fix(cli): reject partial numeric options 2026-05-27 14:36:07 -04:00
Peter Steinberger
163df2578b fix(diffs): use root viewer runtime builder 2026-05-27 14:36:07 -04:00
Peter Steinberger
0f5ea87244 fix(cli): reject partial numeric options 2026-05-27 14:36:06 -04:00
Vincent Koc
ac176d496b fix(gateway): bound artifact transcript scans 2026-05-27 20:32:54 +02:00
Peter Steinberger
bb46b79d3c refactor: internalize OpenClaw agent runtime (#85341)
* refactor: extract agent core package

Introduce packages/agent-core as the OpenClaw-owned home for reusable agent loop, harness, session, prompt, and runtime dependency contracts.

* refactor: extract shared llm runtime

Move provider model registries, stream wrappers, OAuth helpers, and LLM utilities into src/llm with plugin-sdk barrels instead of depending on the old embedded runtime layout.

* refactor: remove pi runtime internals

Rename remaining Pi-shaped agent surfaces to OpenClaw agent runtime names, delete obsolete Pi docs and package graph checks, and add the third-party notice for incorporated code.

* refactor: tighten agent session runtime

Make agent-core/runtime dependencies explicit, consolidate compaction and session transcript helpers, and move model/session helpers behind OpenClaw-owned contracts.

* refactor: remove static model and pi auth paths

Drop static model catalogs and Pi auth bridges, move model/provider facts to manifest-owned runtime contracts, and harden internal embedded-agent utilities.

* refactor: remove legacy provider compat paths

* docs: remove agent parity notes

* fix: skip provider wildcard metadata parsing

* refactor: share session extension sdk loading

* refactor: inline acpx proxy error formatter

* refactor: fold edit recovery into edit tool

* fix: accept extension batch separator

* test: align startup provider plugin expectations

* fix: restore provider-scoped release discovery

* test: align static asset packaging expectations

* fix: run static provider catalogs during scoped discovery

* fix: add provider entry catalogs for scoped live discovery

* fix: load lightweight provider catalog entries

* fix: refresh provider-scoped plugin metadata

* fix: keep provider catalog entries on release live path

* fix: keep static manifest models in release live checks

* fix: harden release model discovery

* fix: reduce OpenAI live cache probe reasoning

* fix: disable OpenAI cache probe reasoning

* ci: extend OpenAI gateway live timeout

* fix: extend live gateway model budget

* fix: stabilize release validation regressions

* fix: honor provider aliases in model rows

* fix: stabilize release validation lanes

* fix: stabilize release memory qa

* ci: stabilize release validation lanes

* ci: prefer ipv4 for live docker node calls

* fix: restore shared tool-call stream wrapper

* ci: remove legacy pi test shard alias

* fix: clean up embedded agent test drift

* fix: stabilize runtime alias status

* fix: clean up embedded agent ci drift

* fix: restore release ci invariants

* fix: clean up post-rebase runtime drift

* fix: restore release ci checks

* fix: restore release ci after rebase

* fix: remove stale pi runtime path

* test: align compaction runtime expectations

* test: update plugin prerelease expectations

* fix: handle claude live tool approvals

* fix: stabilize release validation gates

* fix: finish agent runtime import

* test: finish post-rebase agent runtime mocks

* fix: keep codex compaction native

* fix: stabilize codex app-server hook tests

* test: isolate codex diagnostic active run

* test: remove codex diagnostic completion race

# Conflicts:
#	extensions/codex/src/app-server/run-attempt.test.ts

* ci: fix full release manifest performance run id

* refactor: narrow llm plugin sdk boundary

* chore: drop generated google boundary stamps

* fix: repair rebase fallout

* fix: clean up rebased runtime references

* fix: decode codex jwt payloads as base64url

* fix: preserve shipped pi runtime alias

* fix: add scoped sdk virtual modules

* fix: decode llm codex oauth jwt as base64url

* fix: avoid stale vertex adc negative cache

* fix: harden tool arg decoding and codeql path

* fix: keep vertex adc negative checks live

* refactor: consolidate codex jwt and edit helpers

* fix: await codex oauth node runtime imports

* fix: preserve sdk tool and notice contracts

* fix: preserve shipped compat config boundaries

* fix: align codex oauth callback host

* fix: terminate agent-core loop streams on failure

* fix: keep codex oauth callback alive during fallback

* ci: include session tools in critical codeql scans

* fix: keep Cloudflare Anthropic provider auth header

* docs: redirect legacy pi runtime pages

* fix: honor bundled web provider compat discovery

* fix: protect session output spill files

* fix: keep legacy agent dir env blocked

* fix: contain auto-discovered skill symlinks

* fix: harden agent core sdk proxy surfaces

* fix: restore approval reaction sdk compat

* fix: keep live docker runs bounded

* fix: keep codex oauth redirect host aligned

* fix: resolve post-rebase agent runtime drift

* fix: redact anthropic oauth parse failures

* fix: preserve responses strict tool shaping

* fix: repair agent runtime rebase cleanup

* docs: redirect retired parity pages

* fix: bound auto-discovered resources to roots

* fix: repair post-rebase agent test drift

* fix: preserve bundled provider allowlist migration

* fix: preserve manifest-owned provider aliases

* fix: declare photon image dependency

* fix: keep provider headers out of proxy body

* fix: preserve shipped env aliases

* fix: refresh control ui i18n generated state

* fix: quote read fallback paths

* fix: preview edits through configured backend

* test: satisfy core test typecheck

* fix: preserve ZAI usage auth fallback

* test: repair codex diagnostic test

* fix: repair agent runtime rebase drift

* test: finish embedded runner import rename

* fix: repair agent runtime rebase integrations

* test: align compaction oauth fallback expectations

* fix: allow sdk-auth session models

* fix: update doctor tool schema import

* fix: preserve bedrock plugin region

* fix: stream harmony-like prose immediately

* ci: include session runtime in codeql shards

* fix: repair latest rebase integrations

* fix: honor explicit codex websocket transport

* fix: keep openai-compatible credentials provider-scoped

* fix: refresh sdk api baseline after rebase

* fix: route cli runtime aliases through openclaw harness

* test: rename stale harness mock expectation

* test: rename embedded agent overflow calls

* test: clean embedded auth test wording

* test: use openclaw stream types in deepinfra cache test

* fix: refresh sdk api baseline on latest main

* fix: honor bundled discovery compat allowlists

* fix: refresh sdk api baseline after latest rebase

* fix: remove stale rebase imports

* test: rename stale model catalog mock

* test: mock renamed doctor runtime modules

* fix: map canonical kimi env auth

* fix: use internal model registry in bench script

* fix: migrate deepinfra provider catalog entry

* fix: enforce builtin tool suppression

* fix: route compaction auth and proxy payloads safely

* refactor: prune unused llm registry leftovers

* test: update codex hooks session import

* test: fix model picker ci coverage

* test: align model picker auth mock types
2026-05-27 19:24:04 +01:00
Peter Steinberger
99b27cde64 perf(sessions): reduce store clone allocations 2026-05-27 19:20:17 +01:00
Peter Steinberger
f40275ce26 test(agents): make live subagent steering explicit 2026-05-27 14:17:22 -04:00
Peter Steinberger
dfe49ae2f4 test(matrix): quarantine live block-streaming scenario 2026-05-27 14:15:49 -04:00
Dallin Romney
cff8e4383c expand default diffs languages (#87372) 2026-05-27 11:14:16 -07:00
Dallin Romney
2c95752c1e fix(diffs): align language pack host floor (#87370) 2026-05-27 11:13:50 -07:00
Vincent Koc
140cede2e2 fix(qa): make matrix block streaming deterministic 2026-05-27 20:11:37 +02:00
Peter Steinberger
c0f16460d7 ci(release): smoke Docker runtime templates in full validation 2026-05-27 19:08:56 +01:00
Vincent Koc
11ca150a1b fix(testing): bound plugin gauntlet relay logs 2026-05-27 20:04:56 +02:00
Peter Steinberger
a4d33fd81b docs: clarify container model auth 2026-05-27 19:04:04 +01:00
Peter Steinberger
b01c6d4eaa test(matrix): force block-streaming marker separation 2026-05-27 13:58:42 -04:00
Peter Steinberger
97eba607b5 perf(gateway): skip concrete jiti alias rewrites 2026-05-27 18:58:12 +01:00
Peter Steinberger
659b5dce79 fix(docker): package runtime workspace templates 2026-05-27 18:54:22 +01:00
Vincent Koc
8e8445905f fix(release): stream cross-os served artifacts 2026-05-27 19:51:51 +02:00
Agustin Rivera
62550710bf fix(msteams): block untrusted Teams service URLs (#87334) 2026-05-27 10:48:39 -07:00
Andi Liao
085228c961 fix(auth): document paste-token stdin setup (#63050)
Document that automation should pipe `models auth paste-token` credentials over stdin instead of passing token material in argv, keeping the existing secret-handling path explicit in the CLI docs.

Also include accepted auth-profile credential types in invalid-profile warning logs so malformed local auth stores are easier to repair.

Fixes #63042.

Thanks @liaoandi.
2026-05-27 18:44:44 +01:00
Shadow
1806b152a9 fix: add ClawHub plugin display names (#87354) 2026-05-27 12:37:35 -05:00
Vincent Koc
c571652487 fix(e2e): stream live plugin transcripts 2026-05-27 19:36:43 +02:00
Peter Steinberger
c3b7e91f26 docs: clarify device token admin gate 2026-05-27 18:35:27 +01:00
Ben Badejo
7691a8a9a3 docs(codex): clarify computer use setup (#87313)
Clarify the Codex Computer Use docs around inferred opt-in, read-only status checks, and marketplace root versus marketplace JSON path setup.

The docs now match current source-backed behavior: autoInstall opts Computer Use in, status does not mutate plugin setup, and marketplacePath is for a local marketplace JSON file while source registers a marketplace root.

Verification:
- pnpm docs:list
- GitHub CI check-docs passed
- Real behavior proof passed via maintainer proof override for this docs-only PR

Thanks @bdjben.

Co-authored-by: Benjamin Badejo <ben@benbadejo.com>
Co-authored-by: Sally O'Malley <somalley@redhat.com>
2026-05-27 18:35:21 +01:00
Peter Steinberger
fe91ada730 fix: reflect lazy plugin runtime surfaces 2026-05-27 18:35:04 +01:00
Peter Steinberger
1577e8f10e fix(diffs): avoid extension build-script boundary import 2026-05-27 13:33:58 -04:00
Peter Steinberger
05b23e9b7e refactor(codex): extract app-server notification state 2026-05-27 18:31:06 +01:00
Vincent Koc
b182b71d74 fix(e2e): align prerelease and google live guards 2026-05-27 19:30:27 +02:00
Peter Steinberger
04880ab250 fix(gateway): avoid viewer asset watch loops 2026-05-27 18:29:42 +01:00
Vincent Koc
e93cf52782 fix(e2e): stream release scenario log checks 2026-05-27 19:23:11 +02:00
Peter Steinberger
f80f47d408 fix(status): show configured fast-status channels 2026-05-27 13:22:03 -04:00
Vincent Koc
32d9caf662 test(matrix): clarify block streaming QA prompt 2026-05-27 19:14:54 +02:00
Peter Steinberger
d84cbfa50e perf(gateway): cache manifest model catalog rows 2026-05-27 18:12:47 +01:00
Peter Steinberger
a4c2e7f5cf refactor(codex): split app-server attempt seams 2026-05-27 18:11:16 +01:00
Vincent Koc
1a34c4833e fix(e2e): stream OpenAI web search request logs 2026-05-27 19:09:32 +02:00
Dallin Romney
d638611684 feat: split diffs language pack
Split the diffs viewer Shiki language pack into an external publishable plugin.

The diffs plugin keeps the default curated syntax set, while the new @openclaw/diffs-language-pack package carries the extended Shiki languages for npm and ClawHub distribution. The install metadata includes the external ClawHub spec, and the curated C# alias set keeps both c# and cs supported without the language pack.

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-05-27 18:08:40 +01:00
Peter Steinberger
5f7e21e26a fix(cli): reject malformed timeout values 2026-05-27 13:06:17 -04:00
Peter Steinberger
de5971eedc fix(onboard): preserve rerun config migrations
Fix non-interactive and wizard onboarding reruns so existing agent lists and bindings are preserved unless the user explicitly resets config.

Isolate legacy `plugins.installs` migration into its own write so the config size-drop allowance cannot mask unrelated config loss, while preserving new or repaired install records for the final plugin-index commit. Also keep shrinkwrap generation pinned to pnpm-locked transitive patch versions only when the dependency edge still allows that version, and isolate the tooling Vitest shard that mutates process state.

Fixes #84692.
Replaces #84748.

Co-authored-by: yetval <yetvald@gmail.com>
2026-05-27 18:05:07 +01:00
Vincent Koc
11dfef201f fix(e2e): keep doctor loader mock current 2026-05-27 18:57:13 +02:00
Shadow
930d9f63ad docs: expand install deployment cards 2026-05-27 11:54:12 -05:00
Vincent Koc
5c20ff93e0 fix(e2e): isolate kitchen sink log scans 2026-05-27 18:45:11 +02:00
Peter Steinberger
ca7b6be7cf perf(gateway): cache auto-enabled plugin config 2026-05-27 17:42:34 +01:00
Vincent Koc
c285766d62 fix(ci): merge nested shrinkwrap override pins 2026-05-27 18:37:00 +02:00
Vincent Koc
8ee767baa7 fix(ci): pin aged lru cache lock entry 2026-05-27 18:17:09 +02:00
Vincent Koc
d2a1f62d23 fix(matrix): keep fallback tool warnings mention-inert 2026-05-27 18:07:24 +02:00
Vincent Koc
98a9a523e6 fix(ci): preserve forked shrinkwrap pins 2026-05-27 18:07:24 +02:00
Vincent Koc
162a79b170 fix(e2e): bound agent turn assertion logs 2026-05-27 18:04:43 +02:00
Peter Steinberger
97a8c09b0a perf(gateway): slim current metadata identity cache 2026-05-27 16:54:57 +01:00
Peter Steinberger
7aaca4a8a6 chore(release): prepare 2026.5.27 2026-05-27 16:53:50 +01:00
Vincent Koc
46f5905498 fix(e2e): zero log tail buffers 2026-05-27 17:48:56 +02:00
Onur Solmaz
b7a5bcba78 fix(memory): salvage qmd search JSON after nonzero exit (#87225)
Merged via squash.

Prepared head SHA: 964617b224
Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
Reviewed-by: @osolmaz
2026-05-27 23:44:43 +08:00
Pavan Kumar Gondhi
0314d67d87 Harden hostname normalization for repeated trailing dots [AI] (#87305)
* fix: canonicalize trailing hostname dots

* test: reuse shared hostname normalization

* docs: add changelog entry for PR merge
2026-05-27 21:08:29 +05:30
Vincent Koc
12dc398267 fix(e2e): harden kitchen sink log tailing 2026-05-27 17:35:51 +02:00
Pavan Kumar Gondhi
8e41c118fa fix: block side-effecting command wrappers [AI] (#87292)
* fix: block side-effecting command wrappers

* docs: add changelog entry for PR merge
2026-05-27 20:56:53 +05:30
Vincent Koc
694907d01e fix(e2e): bound bundled runtime log scans 2026-05-27 17:22:46 +02:00
Vincent Koc
5574f7518a fix(matrix): ignore filename-embedded mxids 2026-05-27 17:19:23 +02:00
Vincent Koc
20eab65ff4 fix(e2e): relax kitchen sink plugin memory guard 2026-05-27 17:10:01 +02:00
Agustin Rivera
0d0bddf032 fix(gateway): require admin for device role approvals (#87146)
* fix(gateway): require admin for device role approvals

* fix(gateway): add trusted-proxy approval proof
2026-05-27 08:08:51 -07:00
Pavan Kumar Gondhi
91590132f6 Block unsafe Node runtime env overrides [AI] (#87308)
* fix: block unsafe node runtime env overrides

* fix: block node env path redirects

* docs: add changelog entry for PR merge
2026-05-27 20:34:12 +05:30
Vincent Koc
d242774ec6 fix(matrix): await shared DM notices 2026-05-27 17:03:13 +02:00
Vincent Koc
a2f714cd44 fix(e2e): bound Telegram proof log polling 2026-05-27 16:59:35 +02:00
Vincent Koc
0d565833e1 fix(matrix): send mention finals normally 2026-05-27 16:48:09 +02:00
Vincent Koc
bca2501c7f fix(matrix): preserve final mention delivery 2026-05-27 16:44:02 +02:00
Vincent Koc
96eec2aab6 fix(matrix): keep draft previews mention-inert 2026-05-27 16:36:55 +02:00
Vincent Koc
5eeaa5603f fix(e2e): bound Open WebUI control probes 2026-05-27 16:31:16 +02:00
Vincent Koc
b8cf83aeb3 fix(qa): keep Matrix mention preview finals strict 2026-05-27 16:15:34 +02:00
Vincent Koc
2f1e314211 test(qa): assert final-first Matrix mention previews 2026-05-27 16:15:34 +02:00
Vincent Koc
bf5fef857a fix(qa): use read failure for Matrix mention progress 2026-05-27 16:15:34 +02:00
Vincent Koc
5bf1f168d4 fix(e2e): bound ClawHub preflight waits 2026-05-27 16:14:51 +02:00
Vincent Koc
101c83448b fix(qa): relax Matrix artifact modes on Windows 2026-05-27 16:10:34 +02:00
zunkai Zhao
0c493a161f fix(auto-reply): suppress reasoning-prefixed NO_REPLY
Suppress reasoning-prefixed silent replies before outbound delivery while preserving substantive replies that merely end with the silent token.\n\nFixes #66701.\n\nThanks @zuoanCo for the PR and @Cavadus for the report.\n\nProof: focused Vitest and pnpm check:changed passed on Testbox-through-Crabbox tbx_01ksmvfw0gk9xwh10ra1cyhzfw; CI passed for head a014eb0d91.
2026-05-27 15:09:56 +01:00
Vincent Koc
4d099c354b fix(e2e): bound kitchen sink log scans 2026-05-27 15:50:11 +02:00
Vincent Koc
e2f6734dac fix(qa): force Matrix mention progress search 2026-05-27 15:35:01 +02:00
Mariano
c9d4f7e35c Deprecate memory-specific embedding provider registration (#85072)
Merged via squash.

Prepared head SHA: 661eb99066
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-05-27 15:24:17 +02:00
Vincent Koc
4a8d89f8b5 fix(ci): bound real behavior proof API waits 2026-05-27 15:12:53 +02:00
Peter Steinberger
dc5954b0f8 fix(gateway): reject no-auth tailscale exposure
Fixes #50630.
Replaces stale PR #50631.

Behavior: reject gateway auth mode none when Tailscale Serve or Funnel exposes the gateway, across config validation, install-token preflight, and runtime startup.

Proof:
- node scripts/run-vitest.mjs src/config/config.gateway-tailscale-bind.test.ts src/gateway/server-runtime-config.test.ts src/commands/doctor-gateway-auth-token.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode local
- node scripts/crabbox-wrapper.mjs run --shell -- "pnpm check:changed" (run_5a999c1e11c0, exit 0)
- GitHub PR checks clean on 0b306e8e00ebfe2856e672fbd1964a51a69bfe58; prior checkout/diff failures were GitHub infrastructure and cleared after rebase.
2026-05-27 14:11:17 +01:00
Marvinthebored
04774071b1 fix(agents): avoid false Codex runtime live switches
Fixes #87226.

Preserve the already-applied `openai` to `openai-codex` Codex runtime promotion when the persisted selection is canonical `openai` with the same model, while keeping explicit runtime provider changes switchable.

Verification:
- `node scripts/run-vitest.mjs src/agents/live-model-switch.test.ts`
- `/Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- `pnpm check:changed` via Testbox `tbx_01ksmr59zdaqj3617w8w53xv4t` / Actions run `26512418770`
- Real behavior proof override gate: Actions run `26513059970`

Co-authored-by: Peter Lindsey <peter@lindsey.jp>
2026-05-27 14:11:11 +01:00
Peter Steinberger
128262fbc1 perf(gateway): trust current metadata lifecycle cache 2026-05-27 14:07:17 +01:00
openclaw-release-bot
c95b51f0cc chore(release): update appcast for 2026.5.26 2026-05-27 13:06:26 +00:00
Peter Steinberger
8e5183c60d refactor: move channel message sdk compat into core 2026-05-27 13:59:33 +01:00
Peter Steinberger
ef17bbaabf ci(release): harden postpublish verification 2026-05-27 13:58:14 +01:00
Peter Steinberger
1e67af7006 ci(release): accept main full-validation proof 2026-05-27 13:58:14 +01:00
Peter Steinberger
e61f175203 fix(cli): reject malformed gateway timeouts 2026-05-27 08:57:03 -04:00
Vincent Koc
b12bd3fc98 fix(dev): bound issue labeler OpenAI waits 2026-05-27 14:56:10 +02:00
Yuval Dinodia
ef77428c95 fix(openai): normalize responses replay tool ids
Fixes #74665.

Normalize replayed OpenAI Responses tool call identifiers before same-model pi-ai replay sends them back to the provider. This keeps canonical `call_*|fc_*` pairs intact when valid, rewrites overlong or malformed `function_call.call_id`, `function_call.id`, and matching `function_call_output.call_id` values into OpenAI's accepted 64-character shape, and preserves the reasoning replay path.

Verification:
- Live OpenAI E2E with maintainer key: raw replay payload failed with HTTP 400 for overlong `input[1].id`; normalized replay succeeded with HTTP 200 and `LIVE_OK`.
- `pnpm exec oxfmt --check --threads=1 src/agents/pi-embedded-helpers/openai.ts`
- `CI=1 OPENCLAW_VITEST_FS_MODULE_CACHE_PATH="$(mktemp -d /tmp/openclaw-vitest-cache.XXXXXX)" fnm exec --using 24.15.0 -- pnpm test src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts -- --run --reporter=verbose --maxWorkers=1`
- `CI=1 OPENCLAW_VITEST_FS_MODULE_CACHE_PATH="$(mktemp -d /tmp/openclaw-vitest-cache.XXXXXX)" fnm exec --using 24.15.0 -- pnpm test src/agents/pi-embedded-runner.openai-tool-id-preservation.test.ts src/agents/openai-responses.reasoning-replay.test.ts -- --run --reporter=verbose --maxWorkers=2`
- `autoreview --mode branch --base origin/main`: clean, no accepted/actionable findings.
- GitHub CI green on c675b35ade.

Co-authored-by: Yuval Dinodia <yetvald@gmail.com>
2026-05-27 13:51:57 +01:00
xin zhuang
7121f674ba fix(status): keep default status fast path bounded
Keeps plain `openclaw status` on a bounded fast path while preserving local status metadata. The default text scan now avoids network update fetches, live channel checks, setup fallback work, and unbounded session hydration; deep/all status keeps the fuller behavior.

Behavior addressed: default status latency from update, channel, setup, and session scans
Real environment tested: GitHub Actions on PR head 98f589a35df74a7abb8327984d0103bb9f31af3e; local focused lint; autoreview
Exact steps or command run after this patch: CI workflow 26510790999; CodeQL workflow 26510790924; CodeQL Critical Quality workflow 26510791058; OpenGrep workflow 26510791138; autoreview branch against origin/main
Evidence after fix: all current-SHA workflows completed successfully; autoreview clean; local focused core oxlint passed on touched status files
Observed result after fix: default status hydrates only visible recent sessions, keeps local update metadata, and shows intentionally skipped SecretRef credentials as unknown instead of warning
What was not tested: live provider/channel roundtrip

Co-authored-by: 1052326311 <1052326311@users.noreply.github.com>
2026-05-27 13:49:51 +01:00
Bob
4d89e00c50 feat(embeddings): add OpenAI-compatible core provider (#85269)
Merged via squash.

Prepared head SHA: dc9a5d5397
Co-authored-by: dutifulbob <261991368+dutifulbob@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-05-27 14:37:17 +02:00
Mariano
f3fe48e8b7 Make Telegram sendMessage actions durable (#87261)
Route Telegram sendMessage action replies through durable outbound delivery so completed agent responses remain retryable when the gateway send path times out.

Verified with focused Telegram/outbound tests, extension test typecheck, prepare build/check/full test gates, and green CI rerun for head 20b45687e1.
2026-05-27 14:34:47 +02:00
Vincent Koc
5fb57b533e fix(dev): bound gh-read API waits 2026-05-27 14:33:06 +02:00
rendrag-git
e153eceea5 fix(vllm): wire configured thinking params
Move vLLM Qwen thinking control onto configured model compat metadata and carry it through catalog/model-selection/runtime thinking contexts.

Also migrate legacy provider/default request params in doctor and keep Pi/runtime model rows buildable with explicit reasoning defaults.

Thanks @rendrag-git.

Co-authored-by: rendrag-git <253747599+rendrag-git@users.noreply.github.com>
2026-05-27 13:32:18 +01:00
Mason Huang
75221e0550 fix(agents): separate heartbeat runtime template (#85416)
Summary:
- The PR moves the runtime `HEARTBEAT.md` bootstrap template into `src/agents/templates`, keeps docs templates ... or other workspace files, adds a legacy heartbeat-template doctor repair, and updates package guards/tests.
- PR surface: Source +281, Tests +283, Docs +11, Config +1, Other 0. Total +576 across 15 files.
- Reproducibility: yes. from source inspection: current main loads `HEARTBEAT.md` from the docs template, and  ... pty heartbeat file non-empty to the runtime. I did not run a live heartbeat repro in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(doctor): recognize heartbeat docs boilerplate
- PR branch already contained follow-up commit before automerge: fix(agents): update heartbeat workspace test
- PR branch already contained follow-up commit before automerge: fix(doctor): tighten heartbeat template repair

Validation:
- ClawSweeper review passed for head e34e85864c.
- Required merge gates passed before the squash merge.

Prepared head SHA: e34e85864c
Review: https://github.com/openclaw/openclaw/pull/85416#issuecomment-4519851630

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-05-27 12:30:22 +00:00
Peter Steinberger
3e351b718e fix(agents): honor OpenAI-compatible cache retention
Carry over #82973 and fix #81281 by preserving explicit cacheRetention for OpenAI-compatible completions providers that opt into prompt-cache-key support.

The change keeps explicit cacheRetention suppressed for OpenAI-compatible providers without compat.supportsPromptCacheKey, adds regression coverage for both paths, and updates prompt-caching docs for prompt_cache_key / prompt_cache_retention behavior.

Fixes #81281.
Supersedes #82973.

Co-authored-by: lonexreb <reach2shubhankar@gmail.com>
2026-05-27 13:21:23 +01:00
Peter Steinberger
517ce3df75 fix: require admin for node device approvals 2026-05-27 13:18:50 +01:00
Vincent Koc
983759b9b8 fix(qa): create Matrix mention progress target 2026-05-27 14:14:17 +02:00
Vincent Koc
d09eb437f2 fix(dev): bound Claude usage debug fetches 2026-05-27 14:10:15 +02:00
Peter Steinberger
5fdaf6b49b fix(cli): validate message numeric options 2026-05-27 08:05:03 -04:00
Peter Steinberger
7efbaf7dba perf(gateway): cache current plugin metadata fingerprints 2026-05-27 12:59:23 +01:00
Vincent Koc
e2cebe88ca fix(dev): bound realtime smoke HTTP waits 2026-05-27 13:46:42 +02:00
Vincent Koc
a275ce8611 fix(qa): accept Matrix tool error final races 2026-05-27 13:43:17 +02:00
Vincent Koc
099b0f816a fix(qa): cap Matrix readiness polling 2026-05-27 13:39:14 +02:00
Peter Steinberger
513a223c15 fix(cli): validate directory limits before resolution 2026-05-27 07:35:02 -04:00
Peter Steinberger
0889106cb2 fix(cli): reject loose webhook and directory numeric options 2026-05-27 07:35:02 -04:00
Syu
0503853c29 fix(agents): keep runtime context before active user turns
Fix runtime context placement so hidden runtime context is model-visible before the active user turn without persisting as a visible/session message.

Verification:
- git diff --check origin/main...origin/pr/86995-merge
- gh pr checks 86995 --repo openclaw/openclaw --watch=false
- gh run rerun 26493979156 --repo openclaw/openclaw --failed
- gh run watch 26493979156 --repo openclaw/openclaw --exit-status
- CodeQL run 26493979156 attempt 2, Security High (mcp-process-tool-boundary) job 78066719467 passed
2026-05-27 12:31:56 +01:00
Vincent Koc
f4b9d24621 fix(qa): stop Matrix phases after run timeout 2026-05-27 13:22:18 +02:00
Andy Ye
66965f5008 fix(agents): strip stale Anthropic thinking
Preserve replayability for direct Anthropic sessions whose stored assistant thinking blocks have empty or blank signatures after a newer user turn. Older invalid thinking-only assistant turns are replaced with the existing omitted-reasoning placeholder so the turn shape survives provider replay.

Also keep active tool-use continuations safe: when an assistant tool call is followed by tool results, preserve the latest assistant thinking block so signed-thinking providers can replay the current tool turn unchanged.

Proof:
- node scripts/run-vitest.mjs src/agents/pi-embedded-runner.sanitize-session-history.test.ts src/agents/pi-embedded-runner/thinking.test.ts test/scripts/openclaw-e2e-instance.test.ts
- pnpm check:changed via Blacksmith Testbox through Crabbox, tbx_01ksmfypqet50et92vdm5mmv5v, run https://github.com/openclaw/openclaw/actions/runs/26505947008
- Live Anthropic Messages replay accepted the OpenClaw-sanitized active tool-turn history with a real thinking signature.
- PR CI on 37c2e72d82 completed successfully for relevant checks.

Fixes #86886.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 12:20:27 +01:00
Vincent Koc
a02fe525f1 fix(doctor): validate bundled MCP tool schemas 2026-05-27 13:15:15 +02:00
Vincent Koc
b8fc2f6587 fix(qa): kill timed out Matrix CLI runs 2026-05-27 13:01:36 +02:00
Vincent Koc
545ad7f256 fix(dev): bound discord smoke waits 2026-05-27 12:56:39 +02:00
Vincent Koc
53662094c3 chore(pixverse): publish as external plugin 2026-05-27 12:41:10 +02:00
Vincent Koc
b3083de4f2 feat(pixverse): add api region selection 2026-05-27 12:41:10 +02:00
Vincent Koc
c18370574e feat(pixverse): add video generation provider 2026-05-27 12:41:10 +02:00
Vincent Koc
a46e839f7c fix(qa): tolerate fast Matrix tool replies 2026-05-27 12:41:04 +02:00
Peter Steinberger
6c3740255f refactor: remove channel turn runtime aliases 2026-05-27 11:37:23 +01:00
Vincent Koc
83ab0ba99f fix(test): bound qa otel receiver bodies 2026-05-27 12:26:49 +02:00
Peter Steinberger
fd648edfa9 fix(lint): clean manifest registry installed checks 2026-05-27 06:26:15 -04:00
Vincent Koc
7a7d9dedc1 fix(scripts): resolve npm package candidates through npm runner 2026-05-27 12:12:32 +02:00
Vincent Koc
42f3550f7e fix(qa): hide Matrix tool progress marker in workspace 2026-05-27 12:09:35 +02:00
caz0075
12e5876903 fix(usage): forward cached token usage in chat completions (#82062)
Forward cache-read token counts through the OpenAI-compatible chat-completions usage shape as prompt_tokens_details.cached_tokens so clients can price cached turns correctly.

Align internal gateway usage typing with the expanded wire shape.

Thanks @caz0075.
2026-05-27 11:07:37 +01:00
Peter Steinberger
42387aff59 test(codex): align provider claim expectation 2026-05-27 11:03:50 +01:00
Peter Steinberger
2babe03bf5 perf(gateway): cache stable plugin index fingerprints 2026-05-27 11:03:50 +01:00
Vincent Koc
1d4537add3 fix(test): scan kitchen rpc readiness logs incrementally 2026-05-27 11:51:03 +02:00
Vincent Koc
8c6da93fdf fix(test): fail startup bench on bad samples 2026-05-27 11:46:02 +02:00
Yuval Dinodia
bbdff39b6a fix(onboard): preserve agents.list and bindings on rerun
Preserve existing `agents.list` and top-level `bindings` during ordinary onboarding reruns so rerunning `openclaw onboard` cannot silently wipe configured agents or routing bindings.

Keep config size-drop allowances scoped to explicit reset/import/plugin-install migration flows, validate binding agent ids with normalized agent ids, and add doctor repair coverage for dangling bindings that is still best-effort around malformed agent lists.

Closes #84692.

Co-authored-by: yetval <yetvald@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 10:45:14 +01:00
Andy Ye
aa0a29099f fix: send bare direct Anthropic model ids
Closes #87181.

Direct Anthropic Messages requests now send bare Claude model ids even when OpenClaw stores them with the `anthropic/` provider prefix. Anthropic-compatible proxy and custom endpoint routes keep slash-bearing model ids unchanged so configured proxy models do not regress.

Also preserves the original parse error as `cause` in the JSONL request tail helper to keep the current CI lint gate green.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 10:44:20 +01:00
Georgi Atsev
9e7c2b356b fix(deepinfra): load all DeepInfra models when user wants to browse t… (#84549)
* fix(deepinfra): load all DeepInfra models when user wants to browse them during onboarding

* docs(deepinfra): align TTS default

* fix(deepinfra): refresh video fallbacks

* fix(deepinfra): share credential-aware catalog discovery

* test(deepinfra): narrow catalog regression types

* test(deepinfra): keep catalog narrowing across callback

* fix(deepinfra): preserve default model in live catalog

* fix(deepinfra): align default model pricing

* fix(deepinfra): keep pixverse as video default

* docs(deepinfra): match video fallback default

* fix(deepinfra): honor config api keys for live catalog

* test(e2e): wait for watchdog stdio close

* test(media): align live harness provider expectation

* fix(deepinfra): always augment custom catalogs

* test(e2e): resolve watchdog commands before spawning

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 10:43:37 +01:00
Vincent Koc
32b3fb698d fix(qa): harden Matrix tool progress scenario 2026-05-27 11:37:55 +02:00
Vincent Koc
f4bcd61c9b fix(package): match npm globstar exclusions 2026-05-27 11:37:55 +02:00
Vincent Koc
296fbde279 fix(package): honor dist package exclusions in inventory 2026-05-27 11:37:55 +02:00
Peter Steinberger
c89298f9f8 fix: preserve channel runResolved mock compatibility 2026-05-27 10:36:33 +01:00
Vincent Koc
329dad23f5 fix(test): bound config reload log polling 2026-05-27 11:23:50 +02:00
Peter Steinberger
d6949d5951 fix(lint): preserve JSONL parse cause 2026-05-27 05:18:46 -04:00
Peter Steinberger
5eba76531b test(e2e): preserve macos smoke entrypoint path 2026-05-27 10:07:36 +01:00
Vincent Koc
109ba23083 fix(test): await mcp timeout cleanup 2026-05-27 11:04:09 +02:00
Vincent Koc
a4a75a8694 fix(test): harden mcp channel ws timeout 2026-05-27 11:01:52 +02:00
Vincent Koc
e50b20fe7b fix(test): harden gateway network ws timeout 2026-05-27 10:59:50 +02:00
Cathryn Lavery
730ac1a68d fix(agents/harness): validate forced plugin harness support before pinning (#74341)
Validates forced plugin harness support for the requested provider/model before pinning Codex or any other plugin harness. This prevents an explicitly forced Codex runtime from accepting unsupported OpenAI-like providers through a hardcoded bypass while preserving implicit PI fallback and CLI runtime alias passthrough.

Regression coverage covers forced Codex rejection for unsupported openai/openai-codex support, Codex provider support declarations, CLI attempt routing, pi-embedded auth/profile forwarding fakes, Testbox scenario probes, and live Docker Codex plugin E2E.

Thanks @cathrynlavery.
2026-05-27 09:59:04 +01:00
Vincent Koc
40a2600544 fix(test): bound codex media path log polling 2026-05-27 10:55:00 +02:00
Peter Steinberger
98c0ad8b42 test: align extension inbound context assertions 2026-05-27 04:52:02 -04:00
Sebastien Tardif
527b7c2eed fix(install): skip Homebrew until macOS packages need it
Keep macOS Homebrew setup lazy so users with supported Node and Git can install without admin/Homebrew, while still installing Homebrew before macOS Node or Git package installs.

Updates installer docs and adds focused install.sh coverage for the lazy Git path. Also aligns the live-media provider expectation with current main so built-artifact checks stay green.

Fixes #83232

Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-27 09:48:04 +01:00
Vincent Koc
351aac9f57 fix(ci): bound additional boundary checks 2026-05-27 10:42:22 +02:00
Vincent Koc
4dfc2cf14a fix(release): reject empty beta smoke runs 2026-05-27 10:34:06 +02:00
Vincent Koc
158bc697c4 fix(lint): split source lint shards 2026-05-27 10:32:53 +02:00
Vincent Koc
ecdc925698 fix(crabbox): reinitialize invalid changed-gate git dirs 2026-05-27 10:32:53 +02:00
Vincent Koc
1ba4448a60 fix(lint): shard core lint checks 2026-05-27 10:32:53 +02:00
Vincent Koc
8caa44fba3 fix(lint): cap oxlint helper memory locally 2026-05-27 10:32:53 +02:00
Vincent Koc
6c42fea2d8 fix(package): omit unpacked test helpers from inventory 2026-05-27 10:32:53 +02:00
Vincent Koc
cc662ba7d3 fix(docker): skip declarations in runtime packages 2026-05-27 10:32:53 +02:00
Vincent Koc
e8dde305e2 fix(build): cap tsdown heap in containers 2026-05-27 10:32:53 +02:00
Vincent Koc
b3e3b1b659 fix(crabbox): full-sync local sparse container runs 2026-05-27 10:32:52 +02:00
Vincent Koc
72c681396e fix(test): enable live cache script gates 2026-05-27 10:31:27 +02:00
Vincent Koc
51dd548a59 fix(test): reject unknown live media providers 2026-05-27 10:29:24 +02:00
Peter Steinberger
6b391efa4e fix(cli): reject loose model and gateway numeric options 2026-05-27 04:27:02 -04:00
Peter Steinberger
1507a9701b refactor: centralize inbound supplemental context
* refactor: centralize inbound supplemental context

* refactor: trim supplemental finalizer typing

* docs: clarify supplemental context projection

* refactor: move inbound finalization into core

* refactor: simplify channel inbound facts

* refactor: fold supplemental media into inbound finalizer

* refactor: migrate channel inbound callers to builder

* docs: mark inbound finalizer compat types deprecated

* refactor: wire runtime turn context builder

* refactor: replace channel turn runtime API

* fix: respect discord quote visibility

* fix: avoid deprecated line dispatch helper

* refactor: deprecate channel message SDK seams

* docs: trim channel outbound SDK page

* test: migrate irc inbound assertion

* refactor: deprecate outbound SDK facades

* refactor: deprecate channel helper SDK facades

* refactor: deprecate channel streaming SDK facade

* refactor: move direct dm helpers into inbound SDK

* chore: mark legacy test-utils SDK alias deprecated

* refactor: remove unused allow-from read helper

* refactor: route remaining channel dispatch through core

* refactor: enforce modern extension SDK imports

* test: give slow image root tests more time

* ci: support node fallback on windows

* fix: add transcripts tool display metadata

* refactor: trim legacy channel test seams

* fix: preserve channel compat after rebase

* fix: keep deprecated channel inbound aliases

* fix: preserve discord thread context visibility

* fix: clean final rebase conflicts

* fix: preserve channel message dispatch aliases

* fix: sync channel refactor after rebase

* fix: sync channel refactor after latest main

* fix: dedupe memory-core subagent mock

* test: align clickclack inbound dispatch assertions

* fix: sync plugin sdk api hash after rebase

* fix: sync channel refactor after latest main

* fix: sync plugin sdk api hash after rebase

* fix: sync plugin sdk api hash after latest main

* test: remove stale inbound context awaits
2026-05-27 09:26:06 +01:00
Vincent Koc
ad3d197c68 fix(test): reject empty gateway cpu runs 2026-05-27 10:19:57 +02:00
Vincent Koc
b460ee48a6 fix(test): fail empty plugin gauntlet runs 2026-05-27 10:16:16 +02:00
Josh Avant
cc704caa08 fix: load Claude CLI OAuth for PI auth profiles (#87167)
* test: cover auth profile SecretRef regressions

* docs: note auth profile regression coverage

* test: satisfy auth profile regression lint

* fix: load Claude CLI OAuth overlay for PI runs

* fix(agents): share external CLI auth selection with btw

* chore: remove release-owned changelog entry
2026-05-27 01:15:41 -07:00
Vincent Koc
cefa6777e2 fix(qa): keep fallback delivery on latest targets 2026-05-27 10:06:09 +02:00
Vincent Koc
31ecbbd5bf fix(agents): ignore failed subagent placeholders 2026-05-27 10:06:09 +02:00
Vincent Koc
2c3190d9de fix(agents): preserve bridge hook context 2026-05-27 10:06:09 +02:00
Vincent Koc
88bbc5b84b fix(agents): report approval resolutions in bridge mode 2026-05-27 10:06:09 +02:00
Vincent Koc
79f7b9348e fix(agents): classify direct fallback targets by channel grammar 2026-05-27 10:06:08 +02:00
Vincent Koc
35248be6b0 fix(qa): isolate mock bridge hook state 2026-05-27 10:06:08 +02:00
Vincent Koc
c2d059dc29 fix(qa): scope mock image prompts to latest turn 2026-05-27 10:06:08 +02:00
Vincent Koc
14198a1c66 fix(qa): close remaining mock qa e2e regressions 2026-05-27 10:06:08 +02:00
Vincent Koc
81c1892c9a fix(qa): stabilize mock QA scenario contracts 2026-05-27 10:06:08 +02:00
ToToKr
7e702bb43d fix(agents): suppress Write/Edit failed warning on response-timeout false-failure (#55424) (#86855)
* fix(agents): suppress Write/Edit failed warning on response-timeout false-failure (#55424)

Reporter sees '⚠️ Write failed' / '⚠️ Edit failed' warnings on Feishu (and other channels) even though the file was 100% saved successfully (8 of 8 verified writes succeeded; warning shown for all 8). Source path: tool-mutation records lastToolError.timedOut=true with a fileTarget when a write/edit tool ack reply times out after the disk mutation has already completed, then resolveToolErrorWarningPolicy goes through the default mutating-tool branch and emits the misleading failure summary.

Add a narrow gate inside resolveToolErrorWarningPolicy that suppresses the warning only when both lastToolError.timedOut is true AND lastToolError.fileTarget is defined. fileTarget is set by tool-mutation.ts only for the write/edit family (FILE_MUTATING_TOOL_NAMES), so this branch never matches exec/message/cron/gateway mutating-tool timeouts where the disk-write idempotency reasoning does not apply. Real file failures (no timeout) and timeouts without recorded fileTarget keep their visible warnings.

* fix: recover completed write timeouts safely

* fix: bound write timeout recovery precheck

* fix: type write recovery precheck fallback

* test: complete write recovery result mock

* test: isolate e2e timeout fixture shims

* test: stabilize e2e timeout fixture path

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 09:03:58 +01:00
Yoshikazu Terashi
3104f36329 fix(cron): surface classified run failure causes
Surface classified cron failure causes without changing raw cron JSON error text.

- add additive CLI `cause` output for finished run entries with `errorReason`
- persist/backfill full `FailoverReason` values on cron run-log entries
- thread provider context through cron finalization so provider-specific failure causes stay accurate
- extend protocol/Swift models and regression coverage for CLI JSON, run-log parsing/search, alerts, and protocol conformance

Verification:
- `pnpm lint --threads=8`
- `pnpm protocol:check`
- `pnpm exec oxfmt --check src/cli/cron-cli/shared.ts src/cli/cron-cli/shared.cause-display.test.ts src/cron/run-log.ts src/cron/run-log.error-reason.test.ts src/cron/cron-protocol-conformance.test.ts src/cron/service.failure-alert.test.ts src/cron/service/timer.ts src/cron/service/ops.ts src/gateway/protocol/schema/cron.ts scripts/protocol-gen-swift.ts`
- `git diff --check`
- AWS Crabbox `cbx_8a6a65ab83b0` / `run_42b73a4a9750`: 4 files, 20 tests passed
- autoreview clean, no accepted/actionable findings
- GitHub CI/CodeQL/OpenGrep/Workflow Sanity green/skipped/neutral on `aa29b087b2587d0aed3d409de5e7a2c706c32cdf`

Co-authored-by: Yoshikazu Terashi <yterashi@peperon-works.jp>
2026-05-27 09:03:17 +01:00
Vincent Koc
57b1c0b3d9 fix(test): fail empty extension test requests 2026-05-27 09:57:18 +02:00
Peter Steinberger
c95d348bb5 fix(cli): reject loose numeric options 2026-05-27 03:52:40 -04:00
Shubhankar Tripathy
717003aaff docs(providers/openai): clarify OpenAI Realtime Platform credits
Clarify that OpenAI Realtime voice is billed through OpenAI Platform credits, not Codex/ChatGPT subscription quota, for Voice Call and Control UI Talk.

Document the direct Platform API key path, the `openai-codex` OAuth client-secret path, the quota symptom, and the Platform billing fix. Keep the changelog note crediting @lonexreb.

Closes #76498.

Co-authored-by: lonexreb <reach2shubhankar@gmail.com>
2026-05-27 08:51:26 +01:00
Vincent Koc
ca990f2ce1 fix(codex): keep attempt watchdog for queued terminal turns
Keep the Codex app-server full attempt watchdog armed after a terminal turn notification is queued, so a wedged notification projector cannot leave a run stuck indefinitely.

Proof:
- `git diff --check origin/main...HEAD`
- `node scripts/run-oxlint.mjs extensions/codex/src/app-server/run-attempt.ts extensions/codex/src/app-server/run-attempt.test.ts`
- `node scripts/run-vitest.mjs run extensions/codex/src/app-server/run-attempt.test.ts --testNamePattern "keeps the attempt watchdog armed"` passed in PR proof (`1 passed | 232 skipped`)
- `OPENCLAW_TESTBOX=1 pnpm check:changed` passed in `tbx_01kskyg44ej461k574jee8ffjc`
- CI required checks green after `build-artifacts` rerun job `78031279635` passed

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-27 08:50:59 +01:00
Agustin Rivera
08a73dbe4b fix(qqbot): gate fallback approval buttons (#87154)
QQBot fallback approval buttons now reuse the same slash-command authorization path as real commands, including access groups and default-account config merging.

Verification:
- node scripts/test-extension.mjs qqbot
- node --max-old-space-size=8192 --import tsx scripts/generate-plugin-sdk-api-baseline.ts --check && git diff --check
- pnpm lint --threads=8
- node scripts/run-vitest.mjs src/agents/agent-command.live-model-switch.test.ts
- GitHub PR checks for 7cc0f15031: passed

Thanks @eleqtrizit.

Co-authored-by: Agustin Rivera <agustin@rivera-web.com>
2026-05-27 08:44:55 +01:00
Vincent Koc
7615c3137d fix(test): fail explicit empty vitest runs 2026-05-27 09:41:53 +02:00
Peter Steinberger
8d990378a6 ci: fall back from stale workflow dispatch refs 2026-05-27 03:39:28 -04:00
Vincent Koc
c93b7d8bbc fix(lint): serialize oxlint shards on constrained hosts 2026-05-27 09:36:56 +02:00
Peter Steinberger
d2d5010aec fix: reject partial numeric CLI options 2026-05-27 03:34:44 -04:00
clawsweeper[bot]
f4e20f806e fix(agents): avoid duplicate Claude CLI skill prompts
Fix Claude CLI skill prompt handling so native skill plugin materialization is prepared before prompt suppression, with the prompt fallback preserved when plugin args are unavailable. Also keeps direct prepared-run callers covered by an execute-time fallback.

Fixes #87063.

Co-authored-by: uday <udaymanish.thumma@gmail.com>
2026-05-27 08:34:34 +01:00
Peter Steinberger
cf399d65d8 test: harden e2e instance package fixture 2026-05-27 03:30:57 -04:00
Peter Steinberger
e718d471f2 test(codex): mirror raw reasoning event order 2026-05-27 08:29:18 +01:00
Peter Steinberger
4314eadc79 fix(codex): keep raw assistant release path intact 2026-05-27 08:29:18 +01:00
Peter Steinberger
284098d2d8 fix(codex): preserve raw reasoning source-reply guard 2026-05-27 08:29:18 +01:00
Username
4d6bcf9f17 test(codex): verify completion idle watch arms after non-assistant rawResponseItem/completed
Regression test for the binary stall fix: when rawResponseItem/completed
arrives with a non-assistant type (e.g. "reasoning") and all tracked
items have completed, the completion idle watch must stay armed so the
stall is caught in 60s, not 30 minutes.

Refs openclaw/openclaw#87071

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 08:29:18 +01:00
Username
a36c82ba8b fix(codex): arm completion idle watch after rawResponseItem/completed with no active items
When the codex binary emits rawResponseItem/completed and all tracked
items have completed (activeTurnItemIds empty, no active requests), the
binary should deliver turn/completed imminently. Previously, a
rawResponseItem/completed that didn't qualify as a post-tool assistant
completion would actively disarm the completion idle watch, leaving only
the 30-minute terminal timeout to catch a stalled binary. This caused
turns to hang for up to 30 minutes when the OpenAI Responses API fails
to deliver response.completed to the binary.

Now, rawResponseItem/completed with no active items arms the 60s
completion idle watch and is excluded from the disarm path, so stalled
binaries are detected in 60s instead of 30 minutes.

Refs openclaw/openclaw#87071

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-27 08:29:18 +01:00
scotthuang
819fd9fbe9 fix(node-host): restart stale node host on version mismatch
Restart stale local node-host processes when they reconnect to a newer gateway with a released-version mismatch, so launchd/systemd can restart them with updated code instead of leaving old dynamic imports alive.

Adds gateway mismatch detail propagation, node-host terminal pause handling, and regression coverage for the GatewayClient reconnect-pause path.

Verification:
- node scripts/run-vitest.mjs run src/gateway/client.test.ts -t 'CLIENT_VERSION_MISMATCH' --reporter=verbose
- node scripts/run-vitest.mjs run src/gateway/server.node-version-mismatch.test.ts src/node-host/runner.credentials.test.ts src/gateway/client.test.ts --reporter=verbose
- /Users/steipete/Projects/agent-skills/skills/autoreview/scripts/autoreview --mode local
- Crabbox AWS run_292dcbfd78d9: focused GatewayClient mismatch regression plus server/node-host mismatch tests passed

Co-authored-by: scotthuang <scotthuang@tencent.com>
2026-05-27 08:25:02 +01:00
Vincent Koc
761c802c2a fix(e2e): bound tool search gateway proof 2026-05-27 09:23:57 +02:00
Peter Steinberger
9ed1b02134 fix(discord): harden requester checks for guild actions 2026-05-27 08:22:25 +01:00
Peter Steinberger
482018e536 fix: mark plugin command groups in root help 2026-05-27 08:22:01 +01:00
Peter Steinberger
2bbef6caac fix: route nested root help targets 2026-05-27 08:22:01 +01:00
Peter Steinberger
5f6293a902 fix: route root help targets to command help 2026-05-27 08:22:01 +01:00
Peter Steinberger
b31c9e9810 fix: preserve root options in generated help 2026-05-27 08:22:01 +01:00
Peter Steinberger
ec377dd079 fix: support plugin generated help targets 2026-05-27 08:22:01 +01:00
Peter Steinberger
1de98487cc fix: route generated help targets to subcommands 2026-05-27 08:22:01 +01:00
Peter Steinberger
9015d0c582 fix: normalize generated help self-help 2026-05-27 08:22:01 +01:00
Peter Steinberger
f407e4e498 fix: validate gateway call timeouts 2026-05-27 08:22:01 +01:00
Peter Steinberger
84b11237f2 fix: let skills JSON output flush naturally 2026-05-27 08:22:01 +01:00
Peter Steinberger
ef2ebeef89 fix: keep root help plugin descriptor loading quiet 2026-05-27 08:22:01 +01:00
Super Zheng
6790b0f792 perf(secrets): propagate snapshots and eliminate esm side-effects in auth env vars (#86439)
* perf(secrets): propagate snapshots and eliminate esm side-effects in auth env vars

* perf(secrets): reuse provider auth lookup maps

* test(auth): update provider env var mocks

* test(auth): cover rebased provider env mocks

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 08:19:07 +01:00
Peter Steinberger
f327df866c test: stabilize main ci lanes 2026-05-27 08:07:13 +01:00
Peter Steinberger
54eca3fb56 perf(gateway): keep agent session working store active-only 2026-05-27 08:00:24 +01:00
Peter Steinberger
e6937f9f01 test(e2e): harden shell helper env assertions 2026-05-27 07:57:27 +01:00
Vincent Koc
497685111b fix(doctor): warn on unsupported active tool schemas 2026-05-27 08:55:49 +02:00
Peter Steinberger
368469688b test: fix main ci expectations 2026-05-27 07:55:01 +01:00
Vincent Koc
9b2860324b fix(e2e): bound Telegram credential setup 2026-05-27 08:49:56 +02:00
Peter Steinberger
3b9fa16862 perf(gateway): borrow agent session lookup safely 2026-05-27 07:44:56 +01:00
Vincent Koc
6afe3e8952 fix(crabbox): prefer Azure for Windows targets (#87186) 2026-05-27 07:37:19 +01:00
Peter Steinberger
b28f9e0df3 test(e2e): isolate shell helper env 2026-05-27 07:36:55 +01:00
Vincent Koc
aa40174f0a fix(e2e): bound MCP channel connect 2026-05-27 08:33:49 +02:00
Peter Steinberger
8c8162f1f7 perf(gateway): borrow read-only session metadata 2026-05-27 07:32:29 +01:00
xin zhuang
152f68d037 fix(gateway): persist model auth profile suffixes
Persist trailing `/model ...@profile` suffixes through the gateway session patch path so documented per-session credential pinning reaches the session entry. Strip the suffix before model resolution so bare allowlisted model IDs still infer their configured provider, and mark same-model profile-only changes as pending live model switches.

Closes #87099.

Verification:
- `npx oxfmt --check src/sessions/model-overrides.ts src/sessions/model-overrides.test.ts src/gateway/sessions-patch.ts src/gateway/sessions-patch.test.ts`
- `node scripts/run-vitest.mjs src/gateway/sessions-patch.test.ts src/sessions/model-overrides.test.ts`
- `npx oxlint src/sessions/model-overrides.ts src/sessions/model-overrides.test.ts src/gateway/sessions-patch.ts src/gateway/sessions-patch.test.ts`
- `/Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- `gh pr checks 87123 --watch --fail-fast`

Co-authored-by: xin zhuang <65798732+1052326311@users.noreply.github.com>
2026-05-27 07:29:02 +01:00
Javier Ailbirt
da822dd28b fix(googlechat): suppress thread sends in DMs
Guard Google Chat DM delivery so direct-space messages that include thread metadata do not request threaded sends. The monitor now derives one group-only reply thread and reuses it for both turn reply context and typing indicator messages.

Adds regression coverage for a DM event carrying `message.thread.name`, proving reply metadata and typing sends omit the thread while the turn still runs.

Verification:
- `node scripts/run-vitest.mjs extensions/googlechat/src/monitor.test.ts`
- `pnpm check:changed` (Blacksmith Testbox `tbx_01ksm18yck1zy35k0adgf66hax`, run https://github.com/openclaw/openclaw/actions/runs/26494371192)
- `/Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode local`
- PR CI run https://github.com/openclaw/openclaw/actions/runs/26494573295
- Critical Quality run https://github.com/openclaw/openclaw/actions/runs/26494573221
- Real behavior proof override run https://github.com/openclaw/openclaw/actions/runs/26494609491

Co-authored-by: Javier Ailbirt <jailbirt@theeye.io>
2026-05-27 07:28:09 +01:00
Josh Avant
3349fe21bb Fix embedded session file ownership race (#87159)
* fix: serialize embedded session file attempts

* test: update reply runtime mock for session file lookup

* fix: thread session files into diagnostic recovery

* fix: attach causes to session owner abort errors
2026-05-26 23:18:27 -07:00
Peter Steinberger
ebe09be500 ci(ui): refresh raw copy baseline 2026-05-27 07:14:57 +01:00
Sanjay Santhanam
1710dac5eb fix(pi-embedded): route Codex OAuth compaction through OpenAI-Codex
Fix Codex OAuth-backed OpenAI compaction routing by separating the configured provider from the runtime auth provider, preserving same-provider fallback auth, and keeping OpenAI context policy lookup intact. Also preserves the original cause when sessions.send reports A2A fallback failure. Fixes #86373.
2026-05-27 07:14:02 +01:00
Coy Geek
ce64d74e5a fix(commands): enforce /allowlist configWrites origin policy
Summary:
- Enforces /allowlist config and pairing-store writes against the real command origin plus the selected target.
- Adds regressions for disabled Telegram-origin commands targeting an enabled Discord allowlist.

Verification:
- node scripts/run-vitest.mjs src/auto-reply/reply/commands-allowlist.test.ts
- pnpm check:changed via Blacksmith Testbox tbx_01ksm06e82dnpxmnj00hrt6xzd
- autoreview --mode local clean, no accepted/actionable findings
- GitHub PR checks green on 42a38d2b00

Closes #72360.
Thanks @coygeek.

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
Co-authored-by: opencode <opencode@users.noreply.github.com>
2026-05-27 07:10:50 +01:00
Dallin Romney
780bc79147 fix(plugin-sdk): stop exporting vitest test helpers (#87120) 2026-05-26 23:10:41 -07:00
Peter Steinberger
9c2a6a8df5 perf(gateway): borrow session reads on turn hot paths 2026-05-27 07:02:00 +01:00
keshavbotagent
455d5e807c fix(reply): keep visible turn admission unbounded (#87044)
Remove the hidden 15s default from reply-run idle waits so visible user turns do not inherit cleanup-settle behavior while waiting behind an active same-session reply operation.

Keep the 15s timeout explicit for queued follow-up retry/defer paths and interrupt/reset cleanup waits, and add reply-admission regressions for both visible and queued follow-up behavior. Also preserve the original cause on a nearby sessions-send fallback error to keep current lint green after rebasing onto main.

Thanks @keshavbotagent.

Co-authored-by: Keshav's Bot <keshavbotagent@gmail.com>
2026-05-27 07:01:46 +01:00
Vincent Koc
4b40197eae fix(e2e): bound Telegram proof Bot API calls 2026-05-27 07:58:34 +02:00
Vincent Koc
798691779b fix(agents): preserve sessions fallback errors 2026-05-27 07:54:24 +02:00
Vincent Koc
e7214efbb7 test(core): isolate provider and approval tests 2026-05-27 07:54:24 +02:00
Vincent Koc
8c644ee611 fix(agents): quarantine unsupported tool schemas 2026-05-27 07:54:24 +02:00
Agustin Rivera
2c88547254 fix(prompt): route untrusted group prompts outside system prompt [AI] (#87144)
* fix(prompt): route untrusted group prompts outside system prompt

* fix(prompt): align untrusted group prompt helpers
2026-05-26 22:47:54 -07:00
Pablo Guardiola
0c867eef75 feat: expose plugin approval action metadata
Expose plugin approval action metadata so plugins can describe richer approval actions across gateway, SDK, channel, and UI surfaces.
2026-05-26 22:46:09 -07:00
Peter Steinberger
e74a039035 docs(changelog): refresh 2026.5.26 notes 2026-05-27 06:42:53 +01:00
Peter Steinberger
b9f6abf5e8 fix: bind plugin command llm auth to host agent 2026-05-27 06:38:35 +01:00
Vincent Koc
96bd939995 fix(e2e): bound kitchen sink RPC probes 2026-05-27 07:33:25 +02:00
Peter Steinberger
95c8fc9678 ci: support native Windows Crabbox daemon hydration (#87175)
* ci: support native Windows Crabbox hydration

* ci: use Windows PowerShell for Crabbox hydrate

* ci: reduce Windows pnpm install handle pressure

* ci: narrow native Windows hydrate install scope

* ci: keep Windows hydrate workspace-complete

* ci: persist Windows hydrate tool paths

* ci: split native Windows Crabbox hydration job

* ci: scope native Windows hydrate to daemon proof

* ci: use PowerShell for Windows hydrate fetch

* ci: write Windows hydrate command files as UTF-8

* ci: document Windows hydrate runner mode

* ci: preserve custom Crabbox hydrate markers

* ci: avoid forced copy imports in Windows hydrate
2026-05-27 06:32:09 +01:00
Andy Ye
81e7e8ef24 fix: handle sessions_send active fallback failures (#86638)
Fix run-scoped sessions_send active-run fallback handling.

- surface active queue rejection plus durable fallback admission failures instead of returning accepted too early
- return fallback run/session metadata so normal A2A announcement waits on the fallback run
- retry active steering without transcript-commit waiting when the active runtime does not support it

Thanks @TurboTheTurtle.

Verification:
- node scripts/run-vitest.mjs src/agents/openclaw-tools.sessions.test.ts
- pnpm check:test-types
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-05-27 06:30:56 +01:00
Agustin Rivera
ae972fe1fe fix(gateway): enable default auth rate limiting (#87148)
* fix(gateway): enable default auth rate limiting

* fix(gateway): update auth rate limit changelog
2026-05-26 22:29:33 -07:00
Dallin Romney
9772cf202c test(media): isolate generation provider registry mocks (#87173) 2026-05-26 22:27:58 -07:00
Peter Steinberger
0762acae86 perf(gateway): skip lifecycle session cache clone 2026-05-27 06:26:53 +01:00
Vincent Koc
fdf58c1998 fix(e2e): backstop Parallels update jobs 2026-05-27 07:20:19 +02:00
Peter Steinberger
f348284fa9 fix(daemon): avoid workgroup schtasks user prompts 2026-05-27 06:17:27 +01:00
MoerAI
d92a33306e fix(daemon): keep Windows Scheduled Task running on battery power (#59299)
The Windows Gateway daemon crashes (or rather is killed by Task Scheduler) every time the laptop unplugs from AC power. Reporter on Windows 10 22H2 documented a 100% failure rate.

Root cause: `activateScheduledTask` in `src/daemon/schtasks.ts` used `schtasks /Create` with CLI flags (`/SC ONLOGON /RL LIMITED /TR ...`). That CLI surface cannot set `<DisallowStartIfOnBatteries>` or `<StopIfGoingOnBatteries>`, so the task inherits the Task Scheduler defaults (both `true`), which prevent the task from starting on battery and stop it when AC power is lost mid-run.

This change switches `/Create` to `/Create /XML <tempfile>` and emits a Task Scheduler XML payload that mirrors the prior CLI flags (ONLOGON trigger, LeastPrivilege run level, InteractiveToken logon when a `taskUser` is resolved, single-instance policy, no idle restrictions, exec action wired to the existing `gateway.cmd` / `gateway.vbs` launcher) AND sets:

  <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
  <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>

The XML is written as UTF-16 LE with a BOM, which is what `schtasks /XML` expects on all Windows locales. The temp file is cleaned up in a `finally` block.

The same XML re-apply is also issued from `updateExistingScheduledTask` after the existing `/Change /TR` call, so users upgrading from older versions inherit the new battery flags on the next gateway install/refresh instead of staying broken until a full uninstall+reinstall.

This follows clawsweeper's direction on #59299: "Land a narrow Windows Scheduled Task settings repair that lets the Gateway task start and continue on battery while preserving the current Startup-folder fallback, hidden launcher, quoting, and update behavior."

Preserved unchanged:
- Startup-folder fallback when `/Create` is denied or times out
- Hidden launcher (.vbs) selection via `OPENCLAW_WINDOWS_TASK_HIDDEN_LAUNCHER`
- `quoteSchtasksArg` quoting strategy for the script launch path
- `/Change` update path semantics (still updates `/TR` first)
- All `runScheduledTaskOrThrow` and fallback launch behavior downstream

Verification:
- `node scripts/run-vitest.mjs src/daemon/schtasks.install.test.ts` — 12 passed (incl. 2 new battery-flag regression tests)
- `node scripts/run-vitest.mjs src/daemon/schtasks.test.ts src/daemon/schtasks.startup-fallback.test.ts src/daemon/schtasks.stop.test.ts src/daemon/schtasks-exec.test.ts` — 54 passed (sibling daemon coverage)
- `pnpm tsgo:core` — passed (production typecheck)

Closes #59299
2026-05-27 06:17:27 +01:00
Peter Steinberger
b75f70bc04 perf(gateway): avoid cloning live switch store reads 2026-05-27 06:15:18 +01:00
Vincent Koc
586a6ce03b fix(crabbox): use host-visible local work roots 2026-05-27 07:06:19 +02:00
Peter Steinberger
15c0dfa61b docs(changelog): refresh 2026.5.26 notes 2026-05-27 05:59:20 +01:00
Jesse Merhi
42f0822bfa fix(exec): hide unavailable durable approval actions (#86359)
* fix(macos): align ask always approval actions

* fix(macos): harden approval prompt decisions

* fix(ui): satisfy approval action lint

* fix(infra): settle jsonl sockets on close

* fix(ui): explain unavailable durable approvals

* test(macos): document legacy approval fallback
2026-05-27 14:58:11 +10:00
Alex Knight
2899560a6b fix(reply): derive explicit control command turns
Derive explicit source-reply command turns from authorized control-command bodies when legacy command source metadata is missing.

Preserve native/text structured command semantics, keep unauthorized native commands and structured normal command bodies on plugin-owned fallback paths, and pass bot username normalization through the derived detection.

Co-authored-by: Alex Knight <aknight@atlassian.com>
2026-05-27 05:57:04 +01:00
Vincent Koc
44c1cc8285 fix(e2e): check onboarding systemd noise 2026-05-27 06:48:27 +02:00
Andy Ye
2e3b4b58a1 test(agents): cover cold default model alias resolution
Adds regression coverage for provider-qualified default models with aliasless configured model entries.

Refs #86635

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 05:47:14 +01:00
Peter Steinberger
5371b96af1 fix: prefer trusted argv runtime fallback roots 2026-05-27 05:46:51 +01:00
Peter Steinberger
e71b6f7e57 fix: expand startup argv runtime fallback hints 2026-05-27 05:46:51 +01:00
Peter Steinberger
2b9be22c0b fix: keep plugin runtime fallback on startup root 2026-05-27 05:46:51 +01:00
Peter Steinberger
78b2aeeae4 test: cover plugin runtime diagnostic context 2026-05-27 05:46:51 +01:00
Andy Ye
66a8262028 Fix runtime fallback startup argv default 2026-05-27 05:46:51 +01:00
Andy Ye
41fa603aa8 Fix plugin runtime module resolution diagnostics 2026-05-27 05:46:51 +01:00
Andy Tien
8246e91e92 fix(ui): show config open failure feedback (#87108)
Fixes #87020.

Summary:
- Surface config.openFile failures in the Control UI instead of silently doing nothing.
- Return actionable gateway errors for headless opener failures, including the config path.
- Add gateway and UI controller regression coverage for the failed-open path.

Verification:
- node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-methods.config.ts src/gateway/server-methods/config.test.ts --reporter=dot
- node scripts/run-vitest.mjs run --config test/vitest/vitest.ui.config.ts ui/src/ui/controllers/config.test.ts --reporter=dot
- pnpm check:changed via Blacksmith Testbox tbx_01ksktydqx6mk3n20yevcbkwtn
- autoreview --mode local

Thanks @Linux2010.

Co-authored-by: Linux2010 <35169750+Linux2010@users.noreply.github.com>
2026-05-27 05:45:45 +01:00
Vincent Koc
59818226a9 fix(e2e): bound Telegram RTT bot API calls 2026-05-27 06:44:14 +02:00
Gio Della-Libera
bf1a5c3303 fix(install): bound finalization probes (#86997)
Bounds nonessential installer finalization probes so npm prefix and daemon-status checks warn and fall back instead of hanging setup.

Thanks @giodl73-repo!
2026-05-27 05:39:05 +01:00
Agustin Rivera
119d2359f3 fix(memory): reject prompt-like memory stores (#87142)
* fix(memory): reject prompt-like memory stores

* fix(changelog): mention memory store rejection
2026-05-26 21:37:29 -07:00
Vincent Koc
6b68d05fdc fix(e2e): bound release user journey fixture probes 2026-05-27 06:33:08 +02:00
Vincent Koc
d88681662b fix(e2e): bound bundled runtime HTTP probes 2026-05-27 06:30:15 +02:00
Peter Steinberger
8fa4fad3a7 perf(gateway): skip duplicate turn session touch 2026-05-27 05:28:10 +01:00
Peter Steinberger
1c8a11265b test: avoid repeated module reloads in unit tests 2026-05-27 05:24:40 +01:00
zhang-guiping
608fa52c80 fix(media): keep explicit workspace roots scoped
Fixes MEDIA delivery for agent workspaces named `workspace-*` by carrying the explicit resolved workspace directory into scoped outbound media local roots. The unscoped default local media boundary remains closed for `workspace-*` sibling directories.

Proof:
- node scripts/run-vitest.mjs src/media/read-capability.test.ts src/media/local-media-access.test.ts
- pnpm exec oxfmt --write --threads=1 src/media/read-capability.ts src/media/read-capability.test.ts src/media/local-media-access.test.ts
- node scripts/run-vitest.mjs src/media/read-capability.test.ts src/media/local-media-access.test.ts src/auto-reply/reply/reply-media-paths.test.ts
- /Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Fixes #86879.
2026-05-27 05:24:07 +01:00
Vincent Koc
fca77dcb19 fix(e2e): bound bundled runtime smoke commands 2026-05-27 06:21:17 +02:00
Peter Steinberger
bbfcdea202 test: route more command tests through light suite 2026-05-27 05:20:51 +01:00
Vincent Koc
4b23b36f20 fix(scripts): short-circuit helper help 2026-05-27 06:20:39 +02:00
Peter Steinberger
10056c9346 test: harden docker smoke portability 2026-05-27 00:19:07 -04:00
Sebastien Tardif
4980c32846 fix(agents): recover failed subagent lifecycle completions
Recover failed subagent lifecycle completions through a shared retry/resume recovery path.

Proof:
- node scripts/run-vitest.mjs src/agents/subagent-registry.test.ts src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts
- pnpm changed:lanes --json
- pnpm check:changed (Blacksmith Testbox tbx_01ksksytyrfxscxs78e8f3eegk)
- .agents/skills/autoreview/scripts/autoreview --mode local

Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-27 05:18:58 +01:00
Vincent Koc
dd44a47ba3 fix(e2e): hard kill timed out host commands 2026-05-27 06:16:02 +02:00
Peter Steinberger
2831d697ce test: move lightweight command tests to light suite 2026-05-27 05:13:11 +01:00
Vincent Koc
2cc6871553 fix(scripts): handle helper cli help 2026-05-27 06:11:57 +02:00
Vincent Koc
6d5c15a744 fix(gateway): bound loopback preflight calls 2026-05-27 06:11:19 +02:00
Agustin Rivera
e72621e566 fix(hooks): enforce default hook agent allowlist
Enforce hook allowedAgentIds against the effective default agent when hook payloads omit or blank agentId, while preserving omitted-agent dispatch semantics for default/global routing.

Also updates the affected generated hook config docs from the contributor change and fixes the current-main memory-core test mock after rebasing the PR branch.

Verification:
- pnpm format:check extensions/memory-core/src/dreaming.test.ts src/gateway/hooks.ts src/gateway/hooks.test.ts src/gateway/server/hooks-request-handler.ts src/gateway/server.hooks.test.ts && git diff --check
- node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-server.config.ts src/gateway/hooks.test.ts src/gateway/server.hooks.test.ts --reporter=dot --pool=forks --no-file-parallelism --testTimeout=120000
- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test-local-pr87124.tsbuildinfo
- pnpm check:test-types
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub PR merge state CLEAN; CodeQL Critical Quality rerun succeeded after first runner checkout wedged

Co-authored-by: Agustin Rivera <agustin@rivera-web.com>
2026-05-27 05:05:18 +01:00
Vincent Koc
2814ab66fd fix(e2e): handle docker helper cli help 2026-05-27 06:04:53 +02:00
Steady-ai
eb8f9b46da fix(codex): avoid native compaction on budget triggers (#86772)
* fix(codex): avoid native compaction on budget triggers

* fix(codex): require manual trigger for native compaction

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 05:04:35 +01:00
Peter Steinberger
05ff771010 test: speed up plugin test fixtures 2026-05-27 05:01:57 +01:00
Vincent Koc
de94217774 fix(e2e): bound openai chat tools client 2026-05-27 05:58:15 +02:00
Vincent Koc
981ae137f5 fix(e2e): bound upgrade survivor probes 2026-05-27 05:57:18 +02:00
Gio Della-Libera
371c4d621a fix(doctor): keep hooks model checks read-only (#86101)
Behavior addressed: doctor hooks model validation now loads the model catalog read-only, so lint/doctor can warn without writable catalog side effects.
Real environment tested: local temp merged tree on current origin/main.
Exact steps or command run after this patch: node scripts/run-vitest.mjs src/flows/doctor-core-checks.test.ts src/flows/doctor-health-contributions.test.ts --reporter=dot; ./node_modules/.bin/oxfmt --check --threads=1 src/flows/doctor-core-checks.ts src/flows/doctor-health-contributions.ts src/flows/doctor-core-checks.test.ts src/flows/doctor-health-contributions.test.ts; ./node_modules/.bin/oxlint src/flows/doctor-core-checks.ts src/flows/doctor-health-contributions.ts src/flows/doctor-core-checks.test.ts src/flows/doctor-health-contributions.test.ts; git diff --check origin/main <merged-tree>
Evidence after fix: 2 test files passed, 30 tests passed; oxfmt passed; oxlint passed; diff check passed.
Observed result after fix: hooks.gmail.model doctor paths call loadModelCatalog with readOnly true in both structured and legacy health surfaces.
What was not tested: GitHub Actions run details could not be refreshed because the Actions API was rate-limited; gh reported no required checks for the branch.

Thanks @giodl73-repo.

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-27 04:55:39 +01:00
Vincent Koc
340f480a7b fix(installer): tighten nonroot smoke node preflight 2026-05-27 05:52:37 +02:00
Vincent Koc
d58f864e23 fix(e2e): bound HTTP readiness probes 2026-05-27 05:52:01 +02:00
Gio Della-Libera
a4e0b6ef47 fix(daemon): keep node tasks off gateway listener cleanup
Keep Windows node service stop/restart/status from treating the gateway listener port as node-owned runtime evidence. Node Scheduled Task and Startup fallback paths now match the installed node host command line before reporting or terminating a node runtime, so WSL2 gateway loopback connectivity is not disturbed by node lifecycle commands.

Fixes #85289.

Verification:
- node scripts/run-vitest.mjs src/daemon/schtasks.startup-fallback.test.ts src/daemon/schtasks.stop.test.ts
- git diff --check

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-27 04:51:51 +01:00
Peter Steinberger
d2711c900d perf(gateway): reuse prepared auth stores 2026-05-27 04:51:43 +01:00
Peter Steinberger
1ce363743a test: speed up codex app server run attempts 2026-05-27 04:51:20 +01:00
Peter Steinberger
231a812276 build(codex): update Codex CLI to 0.134.0 2026-05-27 04:42:12 +01:00
Peter Steinberger
989a369112 docs(skills): omit advisory ids from changelog notes 2026-05-27 04:41:58 +01:00
Peter Steinberger
140892ce3d test: speed up test project routing 2026-05-27 04:41:30 +01:00
Jesse Merhi
5297eebe88 Fix stale approval prompts in Control UI (#86270)
* fix(ui): clear stale approval prompts

* fix(ui): keep approval prompt state current

* test: update approval controller mocks

* fix(ui): keep escape denying approvals

* refactor(ui): keep approval decisions in app
2026-05-27 13:38:52 +10:00
Vincent Koc
49d605ece7 fix(installer): reject stale cli node runtimes 2026-05-27 05:31:03 +02:00
Peter Steinberger
acbb06e266 test: harden e2e harness isolation 2026-05-26 23:20:42 -04:00
Peter Steinberger
96c576674d fix: keep approval runtime token local-only
Follow-up to #86771. Keep approval runtime authority source-based instead of loopback-host-based.\n\nProof: autoreview clean; Crabbox AWS run_5f28c413194d on cbx_ec9ef82cf95a passed 5 focused files / 68 tests plus formatter.\n\nCo-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-27 04:20:38 +01:00
Peter Steinberger
145b57c734 perf(gateway): defer skipped-channel sidecars 2026-05-27 04:20:26 +01:00
Peter Steinberger
d606881807 docs(changelog): omit advisory id from release notes 2026-05-27 04:16:17 +01:00
Peter Steinberger
26c0c19352 docs(changelog): refresh 2026.5.26 notes 2026-05-27 04:15:52 +01:00
Peter Steinberger
c8d20aeb48 docs(skills): add release changelog update workflow 2026-05-27 04:14:48 +01:00
Vincent Koc
c965b3a1ae fix(e2e): bound upgrade survivor cli checks 2026-05-27 05:13:55 +02:00
Peter Steinberger
5177180376 test: speed up doctor config flow tests 2026-05-27 04:11:02 +01:00
Agustin Rivera
c1151ea899 fix(events): sanitize queued system markers (#87094)
* fix(events): sanitize queued system markers

* fix(changelog): record system event sanitization
2026-05-26 20:07:39 -07:00
Peter Steinberger
f393ebe54e fix(gateway): remove redundant unknown union 2026-05-26 23:06:26 -04:00
Peter Steinberger
e7f644c7b1 test: speed up model fallback tests 2026-05-27 04:06:03 +01:00
Andy Ye
ae52be9f32 fix(imessage): stage remote media before understanding
Stage remote iMessage attachments before media understanding so the image pipeline receives local remote-cache paths instead of raw macOS Messages paths.

Fixes #87089

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 04:05:36 +01:00
Chunyue Wang
982e88821c fix(gateway): drop stale subagent announce history
Fix stale `subagent_announce` history hydration after `/new` by filtering pre-session-start announce/user reply pairs before `chat.history` projection.

Maintainer fixups added:
- require the adjacent assistant reply to carry a pre-session timestamp before dropping it
- preserve record timestamps for oversized transcript placeholders
- run the filter after Claude CLI history import and support imported timestamp/text fallback
- overread one local transcript message only as boundary context so limit-window edges do not leak stale assistant replies

Verification:
- `git diff --check`
- `node scripts/run-vitest.mjs src/gateway/server-methods/server-methods.test.ts src/gateway/session-utils.fs.test.ts src/gateway/session-history-state.test.ts src/gateway/cli-session-history.test.ts src/gateway/server.chat.gateway-server-chat-b.test.ts` -> 11 files, 463 tests passed
- `/Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode branch --base origin/main` -> clean, no accepted/actionable findings

Thanks @openperf.
2026-05-27 03:59:08 +01:00
Jason (Json)
13cfb77c10 fix: repair local approval resolution (#86771) 2026-05-26 19:56:30 -07:00
Vincent Koc
f89fcdd5b3 fix(e2e): bound codex media plugin setup 2026-05-27 04:55:21 +02:00
Val Alexander
b4f69286fd fix(gateway): stop chat timeout fallback cascade
Fix gateway/chat timeout abort propagation so timed-out runs do not cascade through fallbacks. Preserve provider timeout errors when the gateway abort signal did not fire, and keep timeout stop reasons in async gateway agent results. Includes regression coverage for chat, follow-up, memory flush, fallback classification, and gateway agent timeout results. Fixes #83962.
2026-05-27 03:54:44 +01:00
Peter Steinberger
b74cd69c6f perf(gateway): defer scheduled service imports 2026-05-27 03:52:15 +01:00
Peter Steinberger
0126aba57f test: speed up capability cli tests 2026-05-27 03:48:59 +01:00
Peter Steinberger
0ee4ccf02c perf(gateway): defer startup warning fallback imports 2026-05-27 03:45:42 +01:00
Vincent Koc
7014bd0ff1 fix(gateway): bound watch regression teardown 2026-05-27 04:45:11 +02:00
Peter Steinberger
a43cf2b5db test: type current plugin metadata snapshot mock 2026-05-27 03:44:27 +01:00
Peter Steinberger
1242931ba8 test: align WebChat delivery hint expectations 2026-05-27 03:44:27 +01:00
Peter Steinberger
0cfccdb0c7 fix(codex): keep WebChat delivery hints out of user requests
Land PR #87003 from @ragesaq with a maintainer fix for routed room events.

Co-authored-by: Forge <forge@psiclawops.dev>
2026-05-27 03:44:27 +01:00
Peter Steinberger
657f9d1422 test: speed up command secret gateway tests 2026-05-27 03:43:52 +01:00
Sarah Fortune
41962ed369 fix(status): show explicit fast mode state (#87115) 2026-05-26 19:43:14 -07:00
Josh Lehman
9119492f15 fix: preserve plugin LLM command auth (#85936)
Merged via squash.

Prepared head SHA: e61c724708
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-05-26 22:41:52 -04:00
Peter Steinberger
f7a39f487c test: align loopback prompt metadata 2026-05-26 22:38:22 -04:00
Peter Steinberger
166097e564 perf(gateway): reuse metadata for startup warnings 2026-05-27 03:36:00 +01:00
Peter Steinberger
53f36a8ee6 fix(plugin-sdk): stabilize diagnostic event root alias
Fixes #87082.

Co-authored-by: Kaspre <kaspre@gmail.com>
2026-05-27 03:34:54 +01:00
Neerav Makwana
6842d72a9c fix(tui): queue prompts submitted while busy (#86722)
* fix(tui): queue busy prompt submissions

* fix(tui): queue local busy sends

* fix(tui): keep gateway busy gate

* fix(tui): treat injected backends as local

* fix(tui): preserve stop interrupts

* fix(tui): satisfy queue readiness typing

* fix(tui): keep stop aborting active runs

* fix(tui): limit embedded stop shortcut

* fix(tui): stop active and queued runs

* fix(tui): block gateway busy slash sends

* fix(tui): let stop text pass busy gate

* fix(tui): allow queued stop text

* fix(tui): clear queued abort state

* fix(tui): let stop abort finishing local runs

* fix(tui): abort terminal local maintenance on stop

* fix(tui): emit aborted after stopped maintenance

* fix(tui): preserve stop fallback and queue order

* fix(tui): let idle local stop finish

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 03:34:36 +01:00
Peter Steinberger
f34a527f61 test: speed up tooling tests 2026-05-27 03:33:36 +01:00
Kaspre
b3f8a0edf3 fix(plugin-sdk): use Function.name to find onDiagnosticEvent export (#87084)
* fix(plugin-sdk): use Function.name to find onDiagnosticEvent export

normalizeDiagnosticEventsModule hardcodes `mod.r` as the fallback alias
for onDiagnosticEvent, but the bundler reassigns export aliases across
builds. On 2026.5.25-beta.1, `r` is emitFailoverEvent — calling it as
onDiagnosticEvent returns a non-function, so the combo unsubscribe
closure throws TypeError on every gateway stop.

Replace the hardcoded letter with Function.name introspection. JS
functions retain their original .name regardless of export aliasing,
so this survives bundler alias changes.

Fixes #87082

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(plugin-sdk): cover diagnostic event alias shifts

* fix(plugin-sdk): harden diagnostic alias cleanup

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:31:41 -07:00
Sarah Fortune
df6ec2822f Suppress transient runner failures in channels (#87069) 2026-05-26 19:30:43 -07:00
Vincent Koc
698c40ef9d fix(e2e): bound telegram live hot path 2026-05-27 04:29:06 +02:00
Peter Steinberger
5aaad5f492 test: speed up crabbox wrapper tests 2026-05-27 03:26:50 +01:00
Peter Steinberger
df659d124d refactor(telegram): encode conversation binding mode 2026-05-27 03:26:31 +01:00
Fermin Quant
cecb07655a fix(agents): correlate pathless read diagnostics (#86977)
* fix(agents): correlate pathless read diagnostics

* fix(agents): trace embedded tool starts

* fix(agents): honor read aliases in trace diagnostics

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 03:23:55 +01:00
Peter Steinberger
cdfb1b4bf1 perf: trim gateway session cache churn 2026-05-27 03:23:26 +01:00
Peter Steinberger
90653775a9 test: speed up update cli tests 2026-05-27 03:16:21 +01:00
Peter Steinberger
27ad3d7eeb fix(doctor): map runtime tool schema health 2026-05-26 22:12:04 -04:00
Vincent Koc
8fa5ecb81d fix(e2e): bound update channel CLI checks 2026-05-27 04:11:31 +02:00
Peter Steinberger
c8364b43de test: speed up run-node tests 2026-05-27 03:11:21 +01:00
Agustin Rivera
06047005ef fix(browser): validate current tab before snapshots (#78526)
* fix(browser): validate current tab before snapshots

* fix(browser): reject snapshot selector before SSRF guard

* fix(test): stabilize plugin activation normalization

* fix(ci): fetch opengrep base history

* fix(snapshot): enforce snapshot ssrf policy

* docs(changelog): add unreleased entry for snapshot SSRF fix

* Revert "docs(changelog): add unreleased entry for snapshot SSRF fix"

This reverts commit 4f3031ff65.

* fix(changelog): record snapshot ssrf entry
2026-05-26 19:11:01 -07:00
Peter Steinberger
42d6cf66d3 fix(media): require staged sandbox media refs 2026-05-27 03:08:50 +01:00
Peter Steinberger
8d6b599737 perf: trim gateway startup planning 2026-05-27 03:04:15 +01:00
Vincent Koc
d7d037b46f fix(codex): quarantine unsupported dynamic tool schemas 2026-05-27 04:02:07 +02:00
Vincent Koc
d0cb7ba55b fix(e2e): bound package cli scenarios 2026-05-27 04:00:55 +02:00
Peter Steinberger
716d719d4c ci: prepare pnpm for crabbox hydrate 2026-05-26 21:58:49 -04:00
Vincent Koc
81d22e8f53 fix(e2e): bound kitchen sink gateway teardown 2026-05-27 03:58:14 +02:00
Peter Steinberger
97541170ca test: speed up test routing and parallels smoke tests 2026-05-27 02:56:47 +01:00
Gio Della-Libera
5304682593 fix(onboard): preserve configured default model (#87000)
Preserve user-configured default model settings when provider onboarding preset helpers merge provider models and aliases.

Fixes #75720.

Thanks @giodl73-repo.
2026-05-27 02:52:41 +01:00
kesslerio
b8ea6d2aee fix(telegram): route plugin-bound topic messages 2026-05-27 02:52:25 +01:00
Vincent Koc
1baab3bef5 fix(gateway): bound benchmark teardown waits 2026-05-27 03:49:41 +02:00
Samuel Soares da Silva
286964cd6a fix(diagnostics): recover orphaned session activity
Recover idle queued sessions whose diagnostic activity retained stale ownerless model or tool calls by classifying them as recoverable session.stuck after the usual recovery gates. Yield the event loop before stale session-lock process inspection so sync process lookup cannot monopolize lock contention paths.

Docs now describe the widened session.stuck telemetry contract for recoverable stale bookkeeping, including ownerless activity. Thanks @samuelsoaress.

Refs #84903.

Co-authored-by: samuelsoaress <samuelsoares177778@gmail.com>
2026-05-27 02:47:42 +01:00
Peter Steinberger
a67ee0f7a2 perf: avoid redundant runtime postbuild sync 2026-05-27 02:44:47 +01:00
Peter Steinberger
6290ed52ff fix(media): resolve inbound media refs consistently
Summary:
- Resolve inbound media references through the shared media-reference path before workspace-relative handling.
- Reuse the same sandbox rewrite for Pi native images and sandbox media bridge paths.
- Add regression coverage for managed inbound images, sandbox-staged media references, and invalid media IDs.
- Fix current lint by using non-mutating cpuprofile sorting.

Verification:
- node scripts/run-vitest.mjs src/media/media-reference.test.ts src/agents/sandbox-media-paths.test.ts src/agents/pi-embedded-runner/run/images.test.ts src/agents/tools/image-tool.test.ts src/media/web-media.test.ts src/agents/tools/pdf-tool.test.ts src/agents/tools/image-generate-tool.test.ts src/agents/tools/video-generate-tool.test.ts src/agents/tools/music-generate-tool.test.ts
- node scripts/run-oxlint-shards.mjs --threads=8
- git diff --check
- /Users/steipete/Projects/agent-skills/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI rollup passed for eceea707a7

Fixes #87024.
Supersedes #87055; thanks @TurboTheTurtle for the report and initial fix direction.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-27 02:44:30 +01:00
Vincent Koc
b74984dd50 fix(e2e): bound logged onboard commands 2026-05-27 03:41:52 +02:00
Vincent Koc
dfadc7b704 fix(ollama): normalize greedy top_p (#87049) 2026-05-27 02:41:30 +01:00
Peter Steinberger
1d2bf82461 test: speed up crabbox config shim 2026-05-27 02:41:14 +01:00
Peter Steinberger
1954468efc test: speed up crabbox wrapper tests 2026-05-27 02:41:14 +01:00
Michael Appel
10546e57dd clickclack: enforce inbound sender allowlist [AI] (#83741)
* fix: enforce clickclack sender allowlist

* addressing codex review

* test(clickclack): drop removed senderIsOwner from inbound test fixture
2026-05-26 18:41:12 -07:00
Shakker
223655dfc4 fix: preserve provenance through user turn hooks 2026-05-27 02:38:58 +01:00
Shakker
2e8f1d439d fix: preserve user turn provenance metadata 2026-05-27 02:38:58 +01:00
Shakker
91cb04265b fix: keep user turn replay hooks idempotent 2026-05-27 02:38:58 +01:00
Shakker
e4c42ae786 fix: use selected user transcript text 2026-05-27 02:38:58 +01:00
Shakker
fafed256a6 fix: isolate chat transcript fallback failures 2026-05-27 02:38:58 +01:00
Shakker
b9c2590151 fix: use cleaned user turn transcript text 2026-05-27 02:38:58 +01:00
Shakker
c0f8224109 fix: resolve final codex mirror prompt 2026-05-27 02:38:58 +01:00
Shakker
2bd38da4b0 fix: mark final codex mirror user persistence 2026-05-27 02:38:58 +01:00
Shakker
ffb8350478 test: trim duplicate user turn persistence coverage 2026-05-27 02:38:58 +01:00
Shakker
00ab2f2cba test: wait for initial session task cleanup 2026-05-27 02:38:58 +01:00
Shakker
9263e3887e fix: preserve inline image routing with staged media 2026-05-27 02:38:58 +01:00
Shakker
c86214345f fix: keep user turn enrichment off dispatch 2026-05-27 02:38:58 +01:00
Shakker
696fb41c5b fix: restore user turn persistence checks 2026-05-27 02:38:58 +01:00
Shakker
848c38907d refactor: drop unused user turn update mode 2026-05-27 02:38:58 +01:00
Shakker
20d7bf7525 refactor: remove duplicate user turn handoff 2026-05-27 02:38:58 +01:00
Shakker
fe44ecd8f0 refactor: trim duplicated transcript tests 2026-05-27 02:38:58 +01:00
Shakker
8bbd4baa9a refactor: trim user turn transcript API 2026-05-27 02:38:58 +01:00
Shakker
d55fe4b6ae fix: persist cli user turns to admitted session target 2026-05-27 02:38:58 +01:00
Shakker
44bdc521f7 refactor: carry prepared user turns on recorder 2026-05-27 02:38:58 +01:00
Shakker
481f432e27 refactor: centralize prepared user turn merge 2026-05-27 02:38:58 +01:00
Shakker
0fd8c507bf test: cover cli recorder-owned user persistence 2026-05-27 02:38:58 +01:00
Shakker
33b24d6f2e refactor: reuse user turn recorder in cli persistence 2026-05-27 02:38:58 +01:00
Shakker
ce465d4422 refactor: let recorder track runtime persistence pending 2026-05-27 02:38:58 +01:00
Shakker
1679b2f14c refactor: drop unused inline user turn persistence wrappers 2026-05-27 02:38:58 +01:00
Shakker
d3465756f6 refactor: remove reply option user persistence callbacks 2026-05-27 02:38:58 +01:00
Shakker
1310c92be7 test: cover user turn transcript recorder lifecycle 2026-05-27 02:38:58 +01:00
Shakker
e9a2f10900 refactor: mark user turn persistence inside runtimes 2026-05-27 02:38:58 +01:00
Shakker
05001e102e refactor: carry user turn recorder into embedded runs 2026-05-27 02:38:58 +01:00
Shakker
e9d0ac2aba refactor: pass user turn recorder through reply options 2026-05-27 02:38:58 +01:00
Shakker
f3a43a90d3 refactor: route cli user turn persistence through recorder 2026-05-27 02:38:58 +01:00
Shakker
8a1b7710d7 refactor: add user turn transcript recorder 2026-05-27 02:38:58 +01:00
Shakker
00e68b195e perf: keep transcript idempotency scans explicit 2026-05-27 02:38:58 +01:00
Shakker
6510aecfb4 fix: infer later user turn media types 2026-05-27 02:38:58 +01:00
Shakker
662e5b67d5 fix: persist user turns after runtime mirror failures 2026-05-27 02:38:58 +01:00
Shakker
953fe4d6e1 fix: forward pending user turn persistence 2026-05-27 02:38:58 +01:00
Shakker
48034a5cc7 fix: preserve user turn idempotency after hooks 2026-05-27 02:38:58 +01:00
Shakker
51d3e363e3 fix: return persisted codex mirror user messages 2026-05-27 02:38:58 +01:00
Shakker
8caed9d66d fix: honor transcript hooks in user turn fallbacks 2026-05-27 02:38:58 +01:00
Shakker
8f2200777a fix: fail cli runs on user turn persistence errors 2026-05-27 02:38:58 +01:00
Shakker
b1b533c627 fix: prepare text chat send user turns 2026-05-27 02:38:58 +01:00
Shakker
d241a996de fix: keep exact assistant idempotency locked 2026-05-27 02:38:58 +01:00
Shakker
5d64ebe1de fix: resolve staged transcript media paths 2026-05-27 02:38:58 +01:00
Shakker
dc692aa6f6 perf: avoid duplicate transcript idempotency scans 2026-05-27 02:38:58 +01:00
Shakker
a9e51732db fix: keep chat send transcript text clean 2026-05-27 02:38:58 +01:00
Shakker
209eadcd2d fix: notify codex prompt mirror persistence 2026-05-27 02:38:58 +01:00
Shakker
7d3eabdee8 fix: harden chat send transcript fallback 2026-05-27 02:38:58 +01:00
Shakker
10f4096f11 fix: persist chat send user turns after hooked startup failures 2026-05-27 02:38:58 +01:00
Shakker
52b127b9fb test: avoid transcript filename assumptions 2026-05-27 02:38:58 +01:00
Shakker
0f5ce05753 fix: dedupe user turn transcript appends 2026-05-27 02:38:58 +01:00
Shakker
cf265732c7 fix: mirror prepared codex user turns 2026-05-27 02:38:58 +01:00
Shakker
98c01585b7 fix: isolate reply persistence notifications 2026-05-27 02:38:58 +01:00
Shakker
956a967047 fix: isolate cli persistence notifications 2026-05-27 02:38:58 +01:00
Shakker
8ad308d3e9 fix: keep pre-start chat send fallback persistence 2026-05-27 02:38:58 +01:00
Shakker
1c35ec6cd7 fix: preserve chat send user turns on started failures 2026-05-27 02:38:58 +01:00
Shakker
ce5adbd2c2 fix: keep gateway fallback tied to user persistence 2026-05-27 02:38:58 +01:00
Shakker
e1ff653ade fix: preserve queued media user turns for pi followups 2026-05-27 02:38:58 +01:00
Shakker
d9b5bdada1 refactor: persist cli user turns after hook approval 2026-05-27 02:38:58 +01:00
Shakker
1878662a91 refactor: add inline user turn append helper 2026-05-27 02:38:58 +01:00
Shakker
bf3dad63aa refactor: keep inline transcript error options separate 2026-05-27 02:38:58 +01:00
Shakker
38b0984d33 refactor: centralize inline user turn persistence 2026-05-27 02:38:58 +01:00
Shakker
41ad8f00eb refactor: persist followup cli user turns through sessions 2026-05-27 02:38:58 +01:00
Shakker
982c0aaa77 refactor: route chat send user transcripts through sessions 2026-05-27 02:38:58 +01:00
Shakker
5268bf900e refactor: persist cli user turns through sessions 2026-05-27 02:38:58 +01:00
Shakker
12adc30ac8 refactor: centralize user turn transcript persistence 2026-05-27 02:38:58 +01:00
Shakker
7b27c0495e test: cover text-only media followups 2026-05-27 02:38:58 +01:00
Shakker
840cea5d6e refactor: use shared user turn builder for command transcripts 2026-05-27 02:38:58 +01:00
Shakker
91aee9cd51 fix: keep media transcript text clean 2026-05-27 02:38:58 +01:00
Shakker
928a75a365 refactor: route chat send media through user turn input 2026-05-27 02:38:58 +01:00
Shakker
e5e65431fd refactor: prepare media user turns for replies 2026-05-27 02:38:58 +01:00
Shakker
833520b13a refactor: derive user turn media from fields 2026-05-27 02:38:58 +01:00
Shakker
56e461b76a refactor: thread prepared user turn through embedded runs 2026-05-27 02:38:58 +01:00
Shakker
b9f6c96d18 refactor: support prepared user turn persistence 2026-05-27 02:38:58 +01:00
Shakker
5c69853cd6 refactor: use shared user turn message for chat send updates 2026-05-27 02:38:58 +01:00
Shakker
cc4dca69eb refactor: build persisted user turn messages 2026-05-27 02:38:58 +01:00
Shakker
4a4ef7be5e fix: keep user turn media fields aligned 2026-05-27 02:38:58 +01:00
Shakker
f65fec27a2 refactor: add user turn media field builder 2026-05-27 02:38:58 +01:00
Peter Steinberger
47f7ec7631 perf: reduce session store clone churn 2026-05-27 02:35:53 +01:00
Peter Steinberger
b9ade75fec test(agents): deflake code mode guest error check 2026-05-27 02:34:17 +01:00
Peter Steinberger
0fe7479752 fix(agents): fence yield abort lock release 2026-05-27 02:32:51 +01:00
OpenClaw Assistant
a7eab7467f fix(agents): release yield abort session lock
Release the embedded attempt session lock before sessions_yield abort cleanup waits for session events and rewrites yielded-parent artifacts.

This keeps the existing bounded settle wait while preventing child completion callbacks from contending on the coarse parent transcript lock.

Adds focused session-lock lifecycle coverage.
2026-05-27 02:32:51 +01:00
Agustin Rivera
42b8898e8e fix(filefetch): wrap fetched text as external content (#87062)
* fix(filefetch): wrap fetched text as external content

* fix(release): add file transfer changelog entry
2026-05-26 18:29:48 -07:00
Peter Steinberger
ffe1213bf8 fix(ci): satisfy script oxlint sort rule 2026-05-27 02:27:33 +01:00
Peter Steinberger
a3e7473df2 ci: tolerate gateway status help probe hangs 2026-05-27 02:23:11 +01:00
Zee Zheng
e9823023f4 fix(memory-core): close providers created during shutdown
Refactor memory close provider draining so providers created during shutdown are closed through the same bounded retry path.

Co-authored-by: spacegeologist <zheng.zuo0@gmail.com>
2026-05-27 02:22:05 +01:00
Vincent Koc
6509da7555 fix(gateway): bound e2e HTTP helper responses 2026-05-27 03:21:03 +02:00
NVIDIAN
bba429831c fix(agents): honor per-agent thinking defaults for ingress runs (#86689)
Honor the selected session agent's thinkingDefault for ingress agent runs before global fallback.

Also keep session store cache object-clone writes parse-free while matching persisted JSON shape when cloning values.

Fixes #86669

Co-authored-by: ai-hpc <mail.speedy.hpc@hotmail.com>
2026-05-27 02:18:57 +01:00
Peter Steinberger
2035f38ab2 perf: trim gateway runtime hotspots 2026-05-27 02:17:29 +01:00
Peter Steinberger
f6599ede0d fix(sessions): avoid parsing object cache writes 2026-05-26 21:16:21 -04:00
Peter Steinberger
978cb6ac20 test(cli): allow mac startup memory overhead 2026-05-26 21:16:21 -04:00
Vincent Koc
d5b5eaccc2 fix(crabbox): show broker url in auth guard 2026-05-27 02:15:56 +01:00
Vincent Koc
7c432d2bd8 fix(crabbox): require broker auth for aws proof 2026-05-27 02:13:59 +01:00
Vincent Koc
d353dc128f fix(docker): bound kitchen sink plugin commands 2026-05-27 03:08:13 +02:00
Vincent Koc
2b5fba1519 fix(cli): bound startup memory probes 2026-05-27 03:06:46 +02:00
Peter Steinberger
049d6c9683 test: skip claude resume live proof without cli 2026-05-26 21:04:46 -04:00
Chunyue Wang
71d24f98a8 fix(agents): force SIGKILL for stuck MCP stdio children (#86739)
Guarantee MCP stdio child cleanup during Gateway shutdown by sending a synchronous SIGKILL when the child survives the existing stdin and SIGTERM waits. This prevents SIGTERM-ignoring local MCP processes from outliving the Gateway when killProcessTree's unref'd SIGKILL timer would otherwise lose the shutdown race.

Fixes #86412.

Verification:
- GitHub CI green on relevant agent/runtime, lint/type, CodeQL/security, OpenGrep, and Real behavior proof checks.
- Real behavior proof: https://github.com/openclaw/openclaw/actions/runs/26430512156/job/77802651894
- Maintainer manual review: no blocking findings.

Thanks @openperf.

Co-authored-by: openperf <16864032@qq.com>
2026-05-27 02:04:29 +01:00
Peter Steinberger
1dbd9a3154 fix(codex): avoid false queued terminal idle timeout (#87096) 2026-05-27 01:57:08 +01:00
Vincent Koc
bfddd45e25 fix(gateway): fail hot cpu scenario checks 2026-05-27 02:55:45 +02:00
Alix-007
c9ca7fc0d2 fix(cron): preview no-deliver message targets
Fix cron delivery previews for no-delivery jobs that still provide explicit message-tool targets.

- Reuse one cron delivery-plan explicit-target predicate across preview and isolated-agent runtime paths.
- Treat numeric threadId 0 as an explicit delivery target.
- Avoid fail-closed wording for unresolved message-tool-only targets.

Thanks @Alix-007 for the fix.

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-27 01:53:11 +01:00
Peter Steinberger
a43da0c8c5 perf: reduce gateway cpu churn 2026-05-27 01:52:27 +01:00
Vincent Koc
80749b3bdf fix(gateway): harden runtime smoke checks 2026-05-27 02:49:22 +02:00
Vincent Koc
86ff2cf820 fix(docker): bound plugin sweep reads 2026-05-27 02:48:36 +02:00
Peter Steinberger
94cd364a00 test: make docker package timeout proof robust 2026-05-27 01:43:13 +01:00
JanusAsmussen
84e62824f6 fix(anthropic): pass system prompt on resumed claude-cli sessions
Summary:
- send Claude CLI system prompt files on resumed turns when backend policy is always
- set Claude CLI default systemPromptWhen to always
- add argv/unit coverage plus live ALPHA-to-BRAVO resume proof for #80374

Verification:
- pnpm test src/agents/cli-runner/helpers.system-prompt-resume.test.ts extensions/anthropic/cli-shared.test.ts src/agents/cli-backends.test.ts test/scripts/test-live-shard.test.ts -- --reporter=verbose
- OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_USE_REAL_HOME=1 OPENCLAW_LIVE_CLI_BACKEND=true OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-haiku-4-5 node scripts/run-vitest.mjs run --config test/vitest/vitest.live.config.ts src/gateway/gateway-cli-backend.system-prompt-resume.live.test.ts --reporter=verbose
- /Users/steipete/Projects/agent-scripts/skills/autoreview/scripts/autoreview --mode local
- git diff --check
- gitcrawl gh pr checks 86433 --repo openclaw/openclaw --watch=false --required

Co-authored-by: JanusAsmussen <jjasmussen@outlook.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 01:31:13 +01:00
Enjou
d8f6d65525 fix(skills): sync plugin skills to sandbox workspaces
Copy plugin-provided skills from their validated real target into sandbox workspaces while keeping prompt-visible skill paths sandbox-local.

Adds regression coverage for symlinked plugin skills, multiple plugin skill roots, escaped symlink targets, and sandbox prompt paths that must not leak host plugin-skill locations.

Refs #86190
2026-05-27 01:27:10 +01:00
Peter Steinberger
8b8e088620 docs: show PR LOC in maintainer reviews 2026-05-27 01:26:56 +01:00
uday
0f18d52f16 fix(codex): raise dynamic tool timeout 2026-05-27 01:25:48 +01:00
Peter Steinberger
a1934e9d0e fix(cli): handle Bun launcher module misses
Fixes #86198.

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-27 01:20:14 +01:00
Vincent Koc
e46b92cc58 fix(docker): bound plugin sweep commands 2026-05-27 02:19:55 +02:00
Peter Steinberger
ebfcddbaed docs: improve PR blame provenance 2026-05-27 01:17:54 +01:00
Jason (Json)
ee655f4d94 fix: scrub serialized tool-call text from replies (#86924)
* fix: scrub serialized tool-call text from replies

* fix: consume xmlish tool parameters
2026-05-27 01:16:58 +01:00
Peter Steinberger
eac918d69b test: fix CI type checks 2026-05-26 20:13:03 -04:00
Vincent Koc
b65411740e fix(e2e): resolve mac update smoke commands from PATH 2026-05-27 02:10:32 +02:00
Peter Steinberger
61fa2b285e test(docs): avoid URL default stringification 2026-05-26 20:04:33 -04:00
Peter Steinberger
9f7584c385 test: speed up plugin runtime tests 2026-05-27 01:02:46 +01:00
Peter Steinberger
69d84d775b fix(docs): use Cloudflare docs search API 2026-05-27 00:58:09 +01:00
Peter Steinberger
7e913c08f8 test: speed up run-node infra tests 2026-05-27 00:57:44 +01:00
Vincent Koc
6ef0cbb94f fix(docker): bound e2e image builds 2026-05-27 01:53:22 +02:00
Ted Li
030861e5d1 fix(agents): unwrap standalone message tool JSON (#86626)
* fix(agents): unwrap standalone message tool JSON

* fix(agents): guard message JSON unwrap

* fix(agents): gate message JSON recovery

* fix(agents): treat to as routed message JSON
2026-05-27 00:53:02 +01:00
Peter Steinberger
9cd1d27a89 fix(slack): fast-path wildcard open DM policy 2026-05-27 00:50:48 +01:00
Peter Steinberger
d122839eb7 ci: retry corepack pnpm activation 2026-05-27 00:49:26 +01:00
Peter Steinberger
dc1e6fb02b test: bound gateway live model discovery 2026-05-26 19:47:07 -04:00
Peter Steinberger
75fc0bce0f test: speed up plugin install suites 2026-05-27 00:46:44 +01:00
Steven
bf8be79b88 fix(irc): use channel routes for group inbound targets
Fix IRC group inbound metadata so `To` uses the same `channel:#name` route shape as `From` and `OriginatingTo`.

This keeps IRC group message context consistent for reply/session routing metadata.

Verification:
- `git diff --check origin/main...FETCH_HEAD`
- `git merge-tree origin/main FETCH_HEAD`
- `node scripts/run-vitest.mjs extensions/irc/src/inbound.behavior.test.ts --run` (1 file / 4 tests passed)
- `gh pr checks 86721 --repo openclaw/openclaw --json name,state,link,bucket,workflow` (pass/skip only; no required checks reported)
2026-05-27 00:44:12 +01:00
Jason (Json)
532494b12a Preserve xAI usage limit errors in local TUI (#86614)
* fix: preserve xai usage limit errors

* fix: classify actual xai credit errors

* fix: classify xai 429 billing exhaustion

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 00:43:55 +01:00
Peter Steinberger
fa384d4de0 fix: filter claude autoreview streaming 2026-05-27 00:41:34 +01:00
Abdel Gomez-Perez
474b1e0386 fix(cli-runner): scale Claude CLI reseed history automatically
Remove the proposed public `maxReseedHistoryChars` config surface and scale Claude CLI reseed history automatically from the resolved context tier instead.

Claude CLI 200K-context runs now keep a 64K-character reseed slice, 1M Opus/Sonnet runs use the bounded 256KiB cap, and non-Claude CLI backends keep the existing 12KiB default. This preserves the intended long-context behavior without adding another config option.

Verification:
- `node scripts/run-vitest.mjs src/agents/cli-runner/session-history.test.ts src/agents/cli-runner/prepare.test.ts`
- `node scripts/run-vitest.mjs src/agents/cli-runner/prepare.test.ts -t "automatic Claude CLI cap"`
- `node scripts/run-oxlint.mjs src/agents/cli-runner/prepare.ts src/agents/cli-runner/prepare.test.ts src/agents/cli-runner/session-history.ts src/agents/cli-runner/session-history.test.ts src/config/types.agent-defaults.ts src/config/zod-schema.core.ts`
- `pnpm check:changed` via Testbox `tbx_01kska2twjxb925xft9dj82hvb`
- GitHub PR checks green

Closes #83985
Co-authored-by: Abdel Gomez-Perez <nabdel07@icloud.com>
2026-05-27 00:41:01 +01:00
Peter Steinberger
8592352c24 test: speed up infra test hotspots 2026-05-27 00:39:27 +01:00
Vincent Koc
3e701449ff fix(e2e): keep mac smoke commands bounded without timeout 2026-05-27 01:37:57 +02:00
Peter Steinberger
693f06d811 fix(live): classify Z.ai plan denials as billing drift 2026-05-27 00:36:54 +01:00
Eric Milgram, PhD
678a0ee944 fix(config): render transform-backed config schema inputs (#67328)
Generate the public config JSON Schema from accepted input shapes so transform-backed fields remain renderable in the Control UI. Keep transform output schemas representable with explicit string pipes, align analyzer metadata handling, and cover the generated schema plus browser-safe UI render shapes.

Co-authored-by: Altay <altay@hey.com>

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-27 00:36:13 +01:00
Peter Steinberger
980d73dc5a perf: speed up test hotspots 2026-05-27 00:30:51 +01:00
Peter Steinberger
322ceb36ce feat: stream autoreview progress 2026-05-27 00:22:05 +01:00
Peter Steinberger
8f1fb675aa test: improve full-suite failure summaries 2026-05-27 00:21:12 +01:00
Vincent Koc
0028c2f793 fix(e2e): require bounded helper timeouts 2026-05-27 01:18:48 +02:00
Brian Potter
068d88c142 fix(ui): eliminate double scrollbar on Logs view
Keep the Logs page from rendering competing outer page and inner log-stream scrollbars. The Logs route now opts into an explicit content class for desktop fill-height layout, while mobile keeps the single-page scroll behavior with the capped log panel.

Also adds regression coverage for the route class and CSS ownership selectors.

Co-authored-by: Brian potter <brian@potterdigital.com>
2026-05-27 00:14:48 +01:00
Peter Steinberger
0f608bc497 test: speed up hot test fixtures 2026-05-27 00:11:23 +01:00
Alix-007
8ec2b2d09b fix(auto-reply): suppress repeated silent tokens (#86848)
* fix(auto-reply): suppress repeated silent tokens

* test(plugin-sdk): cover repeated silent token exports

* test(plugin-sdk): cover custom repeated silent token export

* fix(lint): drop redundant image registry casts

---------

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-27 00:04:57 +01:00
Peter Steinberger
1313e15241 fix(commands): preserve async skill commands
Preserve native slash-command laziness while allowing `/skill` to load workspace skill commands asynchronously when needed. The loaded command list is reused for downstream native skill dispatch so valid `/skill <name>` calls do not get misclassified as unknown.

Verification:
- git diff --check
- fnm exec --using v24.15.0 -- pnpm changed:lanes --json
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub CI rollup success for c0d778d512

Co-authored-by: Keshav's Bot <keshavbotagent@gmail.com>
2026-05-27 00:03:25 +01:00
Vincent Koc
130464e797 fix(docker): bound telegram npm installs 2026-05-27 00:53:53 +02:00
Vincent Koc
728b61a0a4 fix(mac): use corepack pnpm for app packaging 2026-05-27 00:53:09 +02:00
joshavant
1600bcd44d fix: mark ios watch app as watchkit app 2026-05-26 15:52:08 -07:00
fuller-stack-dev
40fa750b4f docs: explain bundled plugin npm override 2026-05-26 23:51:53 +01:00
fuller-stack-dev
669bfdd9b0 test: fix bundled install mock typing 2026-05-26 23:51:53 +01:00
fuller-stack-dev
771675e826 fix: keep bundled OpenClaw plugins image-owned 2026-05-26 23:51:53 +01:00
Peter Steinberger
84a33c743e fix: preserve whatsapp inbound batch order 2026-05-26 18:51:18 -04:00
Peter Steinberger
3f524a6423 perf: cache npm globalconfig lookups 2026-05-26 23:45:17 +01:00
狼哥
126a3363a3 fix(daemon): ignore recursive Windows gateway wrapper
Fixes #86007.

Release note: Windows gateway install/update now ignores a persisted OPENCLAW_WRAPPER when it points back at the generated gateway.cmd task script, preventing recursive gateway startup while keeping valid wrapper installs intact.

Credit: thanks @luoyanglang for the fix and proof.
2026-05-26 23:42:25 +01:00
Vincent Koc
eb15c443fc fix(docker): bound live setup commands 2026-05-27 00:38:17 +02:00
joshavant
1daef79f80 fix: restore ios build stability 2026-05-26 15:37:32 -07:00
Kevin Lin
7d6b7f434c feat(plugin-sdk): add reaction approval helpers (#86735)
* feat(plugin-sdk): add reaction approval helpers

* fix(signal): register target approval reactions

* Remove legacy WhatsApp approval reaction appender

* refactor(plugin-sdk): share native exec prompt suppression

* revert(discord): keep exec prompt suppression local

* refactor(plugin-sdk): share native approval fallback suppression

* fix(whatsapp): bind outbound approval reactions

* chore(plugin-sdk): refresh api baseline

* revert(imessage): defer reaction approval changes
2026-05-26 15:28:50 -07:00
Peter Steinberger
4f83cd6528 test(auto-reply): type manifest catalog harness mock
(cherry picked from commit 64e01ef97a)
2026-05-26 23:26:52 +01:00
Vincent Koc
96307ca9b4 fix(docker): bound live docker runs 2026-05-27 00:26:27 +02:00
Peter Steinberger
989d449404 test(auto-reply): mock manifest model catalog in trigger harness
(cherry picked from commit 7135e34520)
2026-05-26 23:22:46 +01:00
Vincent Koc
2f7bfdbd10 fix(crabbox): scope env-wrapped macOS bootstrap 2026-05-27 00:12:31 +02:00
Frederic David blum
1e1cf14da2 fix(gateway): reject RPCs from invalidated device-token clients durin… (#70707)
* fix(gateway): reject RPCs from invalidated device-token clients during rotation/revoke race

device.token.rotate, device.token.revoke and device.pair.remove all
respond 200 OK to the admin, then schedule disconnectClientsForDevice
via queueMicrotask so the response can flush before the socket close.
That microtask window plus the absence of a per-RPC re-check for
device-token auth (unlike shared-auth, which gets checked at
message-handler.ts:1444-1458) created a race: an attacker with RPCs
already pipelined in the WS socket buffer could land a few more
authenticated operations with the rotated/revoked token before the
socket actually closed.

Fix: add a cheap in-memory 'invalidated' flag on GatewayWsClient and
mark it synchronously *before* responding in the three handlers. Add
a mirror check at the start of the per-RPC dispatch that force-closes
the client if the flag is set, regardless of whether socket.close()
has taken effect yet. Disconnect still happens via queueMicrotask so
the admin's rotate/revoke response flushes normally.

Introduces context.invalidateClientsForDevice(deviceId, opts) as a
sync companion to the existing disconnectClientsForDevice. Also
defense-in-depth: disconnectClientsForDevice now sets the flag too,
so any other caller of the hard-disconnect path gets the per-RPC
gate for free.

* test(gateway): use vi.mocked instead of direct Mock casts in devices tests

check-test-types failed on the PR because direct 'as ReturnType<typeof vi.fn>' casts from RespondFn (or the optional context methods) don't structurally overlap with the Mock type — Mock has mockImplementation/mockReturnValue that RespondFn lacks, so strict tsgo rejects the conversion. vi.mocked() is the intended helper for reinterpreting an already-mocked function, and drops through to the Mock surface cleanly.

* test(gateway): align tests with upstream type/shape changes after rebase

After rebasing onto upstream main, two test surfaces drifted:

1. GatewayRequestContextParams gained two required fields upstream
   (getRuntimeConfig, broadcastVoiceWakeRoutingChanged). The
   makeContextParams test helper was missing them, so every consumer
   tripped tsgo with a missing-field error. Add both as vi.fn()
   stubs.

2. revokeDeviceToken's return shape changed upstream from a bare
   entry record to a discriminated union {ok: true, entry: ...} | {ok:
   false, reason}. The new device.token.revoke synchronous-invalidate
   test still mocked the old shape, so the production handler took the
   !revoked.ok branch and never reached the invalidateClientsForDevice
   call the test asserted. Update the mock to the new union shape.

Also fix three new Set([...] as never) sites in server-request-
context.test.ts that produced Set<unknown> rather than Set<never>.
Move the cast outside the Set constructor so the literal stays
inferred while the wrapper is type-erased to never, which is
assignable to the Partial<GatewayRequestContextParams> clients field.

* fix(gateway): export GatewayRequestContextParams for test access

* fix(ci): resolve check-test-types and lint failures from PR #70707 branch

- server-request-context.test.ts: hasConnectedMobileNode → hasConnectedTalkNode
  (field renamed in server-request-context.ts but test fixture not updated)
- status.summary.redaction.test.ts: add configuredModel/selectedModel/
  modelSelectionReason to createRecentSessionRow fixture
  (SessionStatus gained these fields in a13468320c; test was not updated)
- video-generation-providers.live.test.ts: replace empty {} fallbacks in
  conditional spreads with undefined (oxlint 1.65.0, 5 occurrences)
- music-generation-providers.live.test.ts: same fix for 4 occurrences

Remaining CI failures (FsSafeError/Python helper, media tests, Windows ACL,
session-memory hooks) are pre-existing infra failures unrelated to this PR.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): add missing GatewayRequestContextParams fields to test fixture

chatDeltaLastBroadcastText, agentDeltaSentAt, and bufferedAgentEvents are
required fields in GatewayRequestContextParams but were absent from the
makeContextParams fixture, causing TS2322 in check-test-types.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(gateway): serialize credential invalidating RPCs

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 23:09:56 +01:00
Peter Steinberger
6158742f80 fix(channel): handle plugin channel markdown fallback
(cherry picked from commit 8824a8de47)
2026-05-26 23:04:49 +01:00
Vincent Koc
3736d7b60b fix(docker): require bounded e2e docker commands 2026-05-27 00:03:00 +02:00
Thesaranshn8n
6729dea36f fix(codex): share native hook relay registry (#73950)
Co-authored-by: Sar Jeeves <sar-jeeves@example.com>
Co-authored-by: Kaspre <kaspre@gmail.com>
Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-05-26 15:02:03 -07:00
Peter Steinberger
5a684c4553 fix(release): stabilize plugin prerelease tests
(cherry picked from commit ea42c1db8a)
2026-05-26 22:54:12 +01:00
Vincent Koc
c4b9f54b46 fix(diagnostics): flush OTel trace batches
Apply diagnostics.otel.flushIntervalMs to OpenTelemetry trace batching so short-lived Windows and QA runs do not lose late lifecycle/model spans. Also make the OTel QA smoke wait for required telemetry and print bounded failure diagnostics.
2026-05-26 22:46:39 +01:00
Peter Steinberger
d569e41c58 fix(memory): reject invalid CLI numeric options
Fixes memory CLI numeric parsing bugs found by clawpatch.

- memory CLI numeric options now reject non-finite values before command runtime.
- wiki apply `--confidence` now enforces the documented 0..1 range before metadata mutation.
- Commander parse-error UX is preserved without importing `commander` at bundled plugin runtime.

Proof:
- `node scripts/run-vitest.mjs extensions/memory-core/src/cli.test.ts extensions/memory-wiki/src/cli.test.ts`
- `pnpm exec oxfmt --check --threads=1 extensions/memory-core/src/cli.ts extensions/memory-core/src/cli.test.ts extensions/memory-wiki/src/cli.ts extensions/memory-wiki/src/cli.test.ts`
- `git diff --check`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Real CLI proof: invalid memory `--max-results` and wiki `--confidence` both fail with Commander parse errors before actions run.
- GitHub PR checks green: 67 success, 29 skipped, 1 neutral.
2026-05-26 22:42:48 +01:00
Peter Steinberger
5a7d5c6def fix(codex): bound app-server timeout fallout
Retire timed-out Codex app-server clients with lease-aware cleanup and keep harness-owned timeouts out of provider fallback.
2026-05-26 22:41:02 +01:00
Peter Steinberger
9fc71e9076 fix(agents): keep model browse normalization bounded
Keep model browse/list visibility consistent with runtime-normalized allowlist entries while keeping unrestricted default browse off plugin/runtime hydration. Add regression coverage for catalog visibility, `/models` browse data, and the replay sanitizer mock isolation that made the agents shard order-sensitive.

Verification:
- pnpm test src/agents/pi-embedded-runner.sanitize-session-history.test.ts src/agents/model-catalog-visibility.test.ts src/auto-reply/reply/commands-models.test.ts src/auto-reply/reply/model-selection.test.ts src/agents/model-selection.plugin-runtime.test.ts -- --reporter=verbose
- OPENCLAW_VITEST_MAX_WORKERS=2 pnpm exec node scripts/test-projects.mjs test/vitest/vitest.agents-core.config.ts
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub Actions CI run 26476126784
2026-05-26 22:34:37 +01:00
Peter Steinberger
a818556dd9 fix: stabilize media-related tests 2026-05-26 17:30:34 -04:00
Vincent Koc
be2213e46e fix(ci): preserve docker pull retry failures 2026-05-26 23:30:07 +02:00
Peter Steinberger
bb48fcf36a ci: support windows node download fallback 2026-05-26 22:29:46 +01:00
Peter Steinberger
acd3ce00ea test(agents): pin native anthropic replay policy 2026-05-26 22:29:46 +01:00
Peter Steinberger
538b537cc5 fix(build): stabilize shrinkwrap generation 2026-05-26 22:29:46 +01:00
Peter Steinberger
17051894d0 fix(ui): ignore stale running session rows 2026-05-26 22:29:46 +01:00
Fermin Quant
0a085bf15e fix(status): surface systemd gateway hygiene (#86976) 2026-05-26 22:29:20 +01:00
Chengjie Wang
950007dd9c fix(ui): show failed tool results as errors (#85786)
Merged via squash.

Prepared head SHA: c0c4fb5917
Co-authored-by: chengjiew <75600865+chengjiew@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-27 00:27:57 +03:00
Peter Steinberger
1d972af69d ci: enforce Node 22 floor in setup helper 2026-05-26 22:26:08 +01:00
Peter Steinberger
ce4db4f9f3 ci: allow Windows Node 22 patch range 2026-05-26 22:26:08 +01:00
Andy Ye
f3e61580bd Fix status JSON plugin scan (#87001)
* fix status json plugin scan

* fix status json metadata imports

* fix channel metadata repair fallback

* fix runtime channel id normalization fallback

* fix status json env channel detection

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* fix signed thinking legacy tool repair

* fix: preserve first signed replay turn

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 22:24:25 +01:00
吴杨帆
77505daa85 fix(telegram): preserve command slots for aliases (#85270)
* fix(telegram): preserve command slots for aliases

* fix: report Telegram alias command overflow

* fix: preserve Telegram alias menu order

* docs: drop release-owned changelog entry

---------

Co-authored-by: wuyangfan <yangfan.wu@succaiss.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 22:24:03 +01:00
Peter Steinberger
94fb547fe2 fix(agents): handle deferred maintenance drain
Ensure deferred context-engine maintenance rejects cleanly when the gateway command queue is draining, including coalesced active-run requests. This prevents budget compaction from treating an unscheduled deferred maintenance run as successful and leaving the context engine alive.

Verification:
- pnpm exec oxfmt --check --threads=1 src/process/command-queue.ts src/agents/pi-embedded-runner/compact.queued.ts src/agents/pi-embedded-runner/context-engine-maintenance.ts src/agents/pi-embedded-runner/context-engine-maintenance.test.ts
- pnpm test src/auto-reply/reply/agent-runner-memory.test.ts src/agents/pi-embedded-runner/compact.hooks.test.ts src/agents/pi-embedded-runner/context-engine-maintenance.test.ts src/tasks/task-flow-registry.store.test.ts src/auto-reply/reply/commands-compact.test.ts src/agents/pi-embedded-runner/compact-reasons.test.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub Actions CI run 26475226442: relevant Node/Linux, lint, type, security, CodeQL, OpenGrep, Socket, Real behavior proof, and build jobs passed; Windows job failed before tests due current runner image Node 22.19.0 vs required 24.x, matching current main infra failure.
2026-05-26 22:17:19 +01:00
Peter Steinberger
72bc429f60 test: keep legacy tool-result error proof 2026-05-26 22:13:19 +01:00
Peter Steinberger
b546998b9b ci: fix post-merge Rastermill checks 2026-05-26 22:11:50 +01:00
Peter Steinberger
8523d3268a fix(agents): mark repaired legacy tool results errored 2026-05-26 17:01:12 -04:00
Peter Steinberger
b414020bef docs(changelog): note rastermill exif fix 2026-05-26 21:58:29 +01:00
Peter Steinberger
a6973ab9b4 docs(changelog): regroup 2026.5.26 release notes 2026-05-26 21:57:49 +01:00
Peter Steinberger
acb942f634 fix: keep EXIF normalization best-effort (#86923) 2026-05-26 21:55:57 +01:00
Peter Steinberger
cee8c8773b build: use rastermill 0.3.0 2026-05-26 21:55:57 +01:00
Peter Steinberger
e6edccad3a build: update rastermill dependency 2026-05-26 21:55:57 +01:00
Peter Steinberger
a3325c9fb4 refactor: use unified rastermill encode API 2026-05-26 21:55:57 +01:00
Peter Steinberger
03ae999a1a ci: normalize Windows toolcache paths 2026-05-26 21:55:57 +01:00
Peter Steinberger
16d06aa112 ci: satisfy opengrep git add guard 2026-05-26 21:55:57 +01:00
Peter Steinberger
4f728f8321 refactor: delegate image limits to Rastermill 2026-05-26 21:55:57 +01:00
Peter Steinberger
4e84229e82 fix: infer realtime smoke dev server type 2026-05-26 21:55:57 +01:00
Peter Steinberger
7d4d7512e4 build: update rastermill pin 2026-05-26 21:55:57 +01:00
Peter Steinberger
50b98a1878 refactor: delegate image processing to Rastermill 2026-05-26 21:55:57 +01:00
Peter Steinberger
4e45b11983 fix(agents): repair legacy tool results before replay 2026-05-26 16:53:32 -04:00
Josh Avant
3c16648ad7 fix(config): narrow profiled tool section doctor repair (#87030)
* fix(config): repair profiled tool section grants

* fix(config): narrow profiled tool section doctor repair

* fix(config): satisfy doctor warning lint

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-26 13:50:22 -07:00
Peter Steinberger
80655fe955 test: fix current suite drift 2026-05-26 16:40:08 -04:00
Alix-007
daa7b1d06b fix(lock): require owner identity proof before stale removal
Fixes #86814.

Reclaims stale plugin lock files only when the previous owner is provably gone or the recorded process start time proves PID reuse. Timestamp age alone now stays fail-closed for PID-owned locks, preserving mutual exclusion for long-running writers while still allowing pidless expired locks to expire.

Verification:
- pnpm test src/infra/stale-lock-file.test.ts src/plugin-sdk/file-lock.test.ts
- pnpm tool-display:check
- git diff --check
- autoreview --mode branch --base origin/main

Known CI note: check-guards failed in deps:shrinkwrap:check because npm resolved newer AWS transitive versions than pnpm-lock.yaml contains; no package or lock files are changed in this PR.

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-26 21:38:35 +01:00
Peter Steinberger
d8a14e77c3 fix(deps): pin shrinkwrap patch drift to pnpm lock 2026-05-26 16:35:10 -04:00
joshavant
e09f89d37b revert: 60bec8c duplicate tool display guard 2026-05-26 13:32:50 -07:00
Vincent Koc
38edae7df7 fix(e2e): bound docker package preparation 2026-05-26 22:32:25 +02:00
Peter Steinberger
5e8f4981a5 fix(cli): add Windows stack-size respawn (#87031)
Add a Windows-only CLI respawn with `--stack-size=8192` so stack-heavy startup paths can run with a larger V8 stack.

The respawn path normalizes duplicated Windows `node.exe` launcher argv before handoff, preserves real non-launcher argv values containing `node.exe`, and treats both `--stack-size` and `--stack_size` as already configured.

Fixes #62055.
Supersedes #86307.
Thanks @giodl73-repo for the original fix.

Verification:
- `node --v8-options | rg -n "stack-size|stack_size"`
- `node --stack-size=8192 -e "console.log('ok')"`
- `node --stack_size=8192 -e "console.log('ok')"`
- `pnpm format:check src/cli/windows-argv.ts src/cli/windows-argv.test.ts src/entry.respawn.ts src/entry.respawn.test.ts`
- `node scripts/run-vitest.mjs src/entry.respawn.test.ts src/cli/windows-argv.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode local`
- `pnpm check:changed` via Testbox `tbx_01ksjzf06pcgx29qrctjrn4rhr`, GitHub Actions run https://github.com/openclaw/openclaw/actions/runs/26473172664

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-05-26 21:31:58 +01:00
martingarramon
ef86d8c95c fix(agents): preserve sessions_spawn transcript payloads (#82203)
Remove the transcript redaction path for sessions_spawn arguments and inline attachments. OpenClaw transcripts are local trusted-operator state, and streamTo/resumeSessionId are runtime routing fields that must not be rewritten before replay or dispatch.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 21:30:01 +01:00
Josh Avant
60bec8c020 fix(agents): guard duplicate tool display metadata (#87025) 2026-05-26 13:28:38 -07:00
Peter Steinberger
f7e2d9bb47 ci(release): port 2026.5.25 release gate fixes 2026-05-26 21:19:51 +01:00
Peter Steinberger
ad71c427fa chore: update tool display snapshot 2026-05-26 16:17:51 -04:00
狼哥
4a85cd76f6 fix(web-search): keep runtime legacy merge out of validation (#86818)
Runtime-injected web_search provider config from plugins.entries.<plugin>.config.webSearch now stays available to provider execution without being validated as user-authored legacy tools.web.search.<provider> config.

Co-authored-by: luoyanglang <hanwanlonga@gmail.com>
2026-05-26 21:15:44 +01:00
Vincent Koc
3127808473 fix(cli): default logs to local timestamps (#85387) 2026-05-26 21:14:47 +01:00
Peter Steinberger
8788ae1a8e fix(agents): dedupe transcripts tool display config 2026-05-26 16:12:03 -04:00
Mark
e070519f43 fix(updater): exclude prerelease tags from stable git channel (#86559)
Preserve legacy numeric stable git tags while excluding named semver prerelease tags from stable git channel detection and status display.

Thanks @goldmar.
2026-05-26 21:11:38 +01:00
Chunyue Wang
c430fcde1c fix(agents): memoize session lock owner args
Memoize owner process argv lookups per PID during `cleanStaleLockFiles`, and yield between lock entries so startup cleanup does not monopolize the event loop while inspecting many session locks.

This keeps lock classification semantics unchanged while avoiding repeated synchronous process-args reads for lock clusters owned by the same PID, especially the Windows PowerShell path.

Fixes #86509.

Verification:
- `git diff --check origin/main...HEAD`
- focused TSX harness against the current-main merge result: `session-lock memo regression harness passed`

Thanks @openperf.

Co-authored-by: openperf <16864032@qq.com>
2026-05-26 21:10:19 +01:00
Shakker
0f49bbbeb2 fix: dedupe transcripts tool display metadata 2026-05-26 21:09:18 +01:00
Peter Steinberger
abb85ccc86 fix(cli): validate timeout and banner TTY state
Fixes two CLI edge cases found by clawpatch.

- `emitCliBanner` now honors injected TTY state before writing to stdout.
- Nodes RPC timeout handling now rejects malformed `--timeout` values with the existing timeout parser instead of forwarding `NaN` into gateway transport calls.

Proof:
- `node scripts/run-vitest.mjs src/cli/banner.test.ts src/cli/nodes-cli/register.invoke.approval-transport-timeout.test.ts`
- `pnpm exec oxfmt --check --threads=1 src/cli/banner.ts src/cli/banner.test.ts src/cli/nodes-cli/rpc.runtime.ts src/cli/nodes-cli/register.invoke.approval-transport-timeout.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Real CLI proof: `pnpm openclaw nodes list --timeout nope --json` exits 1 with `Invalid --timeout`.
- Runtime banner proof: injected `isTty:false` with `stdout.isTTY=true` produced `writes=0`, `emitted=false`.
2026-05-26 21:08:11 +01:00
Andy Ye
bf0228b5c2 fix(codex): project newer history on app-server resume (#86677)
Project newer external OpenClaw chat history into resumed Codex app-server threads when the saved binding is older than user-visible transcript messages, while filtering Codex-owned mirror records on consecutive resumes.

Thanks @TurboTheTurtle!
2026-05-26 21:07:07 +01:00
pashpashpash
3a64dc7623 fix(codex): keep turn timeouts inside Codex (#86476)
Keep Codex app-server turn timeouts within the Codex runtime boundary so they interrupt the active turn without retiring the shared app-server client, poisoning auth-profile cooldowns, or falling through to generic provider/model fallback.

Preserve concrete non-timeout provider failures for auth-profile rotation and fallback, and add regression coverage for prompt-stage timeouts, assistant idle timeouts, auth-profile cooldowns, and app-server timeout handling.

Thanks @pashpashpash.
2026-05-26 21:06:19 +01:00
mjamiv
f22c3a518e fix(auto-reply): stage sandboxed workspace media
Fixes #74061.

Stages absolute final-reply MEDIA paths that already live under the agent workspace before sandbox path translation runs, so Telegram/local delivery can attach generated workspace media instead of dropping it as Media failed. Outside-workspace host-local paths remain blocked, and host-read HTML stays denied pending separate security-boundary review.

Verification:
- git diff --check origin/main...refs/remotes/pull/86531
- git merge-tree --write-tree origin/main refs/remotes/pull/86531
- reviewed src/auto-reply/reply/reply-media-paths.ts, src/media/web-media.ts, and focused tests

Co-authored-by: mjamiv <74088820+mjamiv@users.noreply.github.com>
2026-05-26 21:05:08 +01:00
Vincent Koc
2fcf990cee fix(e2e): support plain telegram install timeouts 2026-05-26 22:03:50 +02:00
Vincent Koc
639e7ff997 fix(mac): harden restart and dSYM packaging 2026-05-26 22:01:35 +02:00
Vincent Koc
4d6593642e fix(exec): avoid default approval store writes (#86964)
* fix(exec): avoid default approval store writes

* fix(exec): harden token approvals on default policy

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 20:59:24 +01:00
Vincent Koc
9b1b6d02fd fix(agents): restore current guard checks (#86934) 2026-05-26 20:59:03 +01:00
Peter Steinberger
983b33867e docs(changelog): prepare 2026.5.26 notes 2026-05-26 20:52:04 +01:00
Peter Steinberger
29a1dc2249 docs(changelog): note reply latency fixes 2026-05-26 20:51:00 +01:00
Keshav's Bot
699c047c7d fix(reply): reduce visible reply delivery latency 2026-05-26 20:51:00 +01:00
Keshav's Bot
ed3ae0da43 fix(reply): defer context compaction safely 2026-05-26 20:51:00 +01:00
Keshav's Bot
21c25bbb9d fix(codex): gate profiler timing and startup setup 2026-05-26 20:51:00 +01:00
Keshav's Bot
7951cc0c8a fix(agents): avoid runtime model hydration on hot paths 2026-05-26 20:51:00 +01:00
Keshav's Bot
c2b56ded61 fix(commands): keep slash handling off reply startup 2026-05-26 20:51:00 +01:00
Keshav's Bot
0afccc62ab fix(telegram): refine typing and progress drafts 2026-05-26 20:51:00 +01:00
Vincent Koc
5c1ecda0ca fix(e2e): support plain timeout wrappers 2026-05-26 21:49:04 +02:00
Pavel Ganson
e7500417c8 fix(channels): preserve direct native progress callbacks
Preserve native direct-message progress callbacks for quiet Telegram/Codex turns while keeping text tool summaries behind verbose visibility.

The fix keeps source-delivery suppression and sendPolicy denial intact, so quiet native progress is allowed only for direct chat progress callbacks and does not leak when delivery is denied.

Verification:
- node scripts/run-vitest.mjs run --config test/vitest/vitest.auto-reply-reply.config.ts src/auto-reply/reply/dispatch-from-config.test.ts -t "direct native progress callbacks|channel-owned group progress callbacks|delivers text-only tool summaries when verbose overrides preview suppression|delivers verbose tool summaries despite message-tool-only source suppression"
- node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.core.json src/auto-reply/reply/dispatch-from-config.ts src/auto-reply/reply/dispatch-from-config.test.ts
- git diff --check
- /Users/steipete/Projects/agent-skills/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Thanks @PashaGanson.
2026-05-26 20:41:38 +01:00
Peter Steinberger
174cd49f78 fix: tighten parser edge cases (#86999)
* fix: tighten parser edge cases

* fix: dedupe lsof listener records

* fix: recognize ipv6 wildcard model URLs
2026-05-26 20:40:13 +01:00
Vincent Koc
39682889f9 fix(e2e): clean stale docker lane containers 2026-05-26 21:25:16 +02:00
Vincent Koc
71cb60706b fix(e2e): bound docker lifecycle hangs 2026-05-26 21:22:01 +02:00
Peter Steinberger
0ea7871e53 fix(gateway): bound live agent model probes 2026-05-26 20:20:01 +01:00
Vincent Koc
b36fa1d8f1 fix(e2e): bound plugin binding docker smoke 2026-05-26 21:09:37 +02:00
Vincent Koc
c0641eb3ad fix(e2e): preserve docker run failure status 2026-05-26 20:55:51 +02:00
rendrag-git
e9dd1c43c4 feat(discord): bucket large model picker menus
Summary:
- Add alpha-bucket selects when the Discord provider/model picker exceeds select-menu limits.
- Split bucket/runtime lookup helpers and keep compact recents runtime decoding provider-scoped.

Verification:
- node scripts/run-vitest.mjs --config test/vitest/vitest.extension-discord.config.ts extensions/discord/src/monitor/model-picker.test.ts extensions/discord/src/monitor/native-command.model-picker.test.ts
- node scripts/run-tsgo.mjs
- git diff --check origin/main...HEAD
- autoreview --mode local: no accepted/actionable findings
- CI run 26468173320, OpenGrep run 26468171525, CodeQL Critical Quality run 26468171885

Co-authored-by: rendrag-git <253747599+rendrag-git@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 19:52:50 +01:00
alexph-dev
aa117ec4de fix(telegram): derive DM topics from bot capability
Remove the Telegram DM thread reply policy config and use Telegram bot capability as the single source of truth for DM topic session splitting.

DM messages with message_thread_id now split into thread-scoped sessions only when Telegram getMe reports has_topics_enabled for the bot. Doctor removes retired dm.threadReplies and direct.*.threadReplies keys, docs explain the upgrade behavior, and startup keeps cached bot info as a non-auth fallback when a fresh probe fails.

Refs #86513.
Thanks @alexph-dev.

Verification:
- pnpm docs:list
- pnpm exec oxfmt --check --threads=1 extensions/telegram/src/channel.ts extensions/telegram/src/channel.gateway.test.ts extensions/telegram/src/doctor-contract.ts extensions/telegram/src/doctor.test.ts
- git diff --check
- node scripts/run-vitest.mjs extensions/telegram/src/channel.gateway.test.ts extensions/telegram/src/doctor.test.ts extensions/telegram/src/bot/helpers.test.ts extensions/telegram/src/bot-message-context.dm-threads.test.ts extensions/telegram/src/config-schema.test.ts
- pnpm config:channels:check
- pnpm config:docs:check
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub Actions: CI 26468039803, Workflow Sanity 26468040057, OpenGrep 26468039472, Real behavior proof 26468036483, CodeQL 26468039466, CodeQL Critical Quality 26468039473

Known CI caveat: checks-windows-node-test failed before tests because Windows runner setup left Node 22.19.0 active while the job requested Node 24.x; the same setup failure is present on current main CI run 26468063947.
2026-05-26 19:52:17 +01:00
Peter Steinberger
4007df7f60 fix: improve discord voice playback and wake replies 2026-05-26 19:40:12 +01:00
Vincent Koc
23aeb58eaa fix(e2e): kill timed kitchen rpc command groups 2026-05-26 20:39:44 +02:00
Peter Steinberger
b56ddcc6ff ci: use supported codex mini live target 2026-05-26 19:04:43 +01:00
Vincent Koc
8c6537b8c1 fix(ci): kill wedged bun smoke commands 2026-05-26 20:04:12 +02:00
4230 changed files with 223442 additions and 68948 deletions

View File

@@ -27,10 +27,11 @@ Use when:
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
- Never switch or override the requested review engine/model. If the review hits model capacity, retry the same command a few times with the same engine/model.
- Be patient with large bundles. Structured review can take up to 30 minutes while the model call is active, especially with Codex tools or web search.
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing.
- Treat heartbeat lines like `review still running: ... elapsed=... pid=...` as healthy progress, not a hang. Let the helper continue while heartbeats are advancing. Pass `--stream-engine-output` when live engine text is useful; Codex and Claude filter tool/file chatter, other engines pass raw output through.
- Do not kill a review just because it has been quiet for 2-5 minutes, or because it is still running under the 30-minute window. Inspect the process only after missing multiple expected heartbeats, after 30 minutes, or after an obviously failed subprocess; prefer letting the same helper command finish.
- Tools are useful in review mode. The helper allows read-only inspection tools and web search by default so reviewers can check dependency contracts, upstream docs, and current behavior.
- Security perspective is always included, but it should not cripple legitimate functionality. Report security findings only when the change creates a concrete, actionable risk or removes an important safety check.
- For regression provenance, if no blamed PR is traceable, use the blamed commit as the provenance: commit SHA, date, and author username. Do not guess a merger or frame missing PR metadata as a separate finding.
- Do not invoke built-in `codex review`, nested reviewers, or reviewer panels from inside the review. The helper builds one bundle, calls one selected engine, validates one structured result, and stops.
- Stop as soon as the helper exits 0 with no accepted/actionable findings. Do not run an extra review just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
@@ -49,8 +50,9 @@ Dirty local work:
```
Use this only when the patch is actually unstaged/staged/untracked in the
current checkout. For committed, pushed, or PR work, point the helper at the commit
or branch diff instead; do not force `--mode local` / `--uncommitted` just
current checkout. `--mode uncommitted` is accepted as an alias for `--mode local`.
For committed, pushed, or PR work, point the helper at the commit
or branch diff instead; do not force dirty modes just
because the helper docs mention dirty work first. A clean local review
only proves there is no local patch.
@@ -163,16 +165,18 @@ If installed from `agent-scripts`, path is:
The helper:
- chooses dirty local changes first
- accepts `--mode uncommitted` as an alias for `--mode local`
- otherwise uses current PR base if `gh pr view` works
- otherwise uses `origin/main` for non-main branches
- supports `--engine codex`, `claude`, `droid`, and `copilot`; default is `AUTOREVIEW_ENGINE` or `codex`; Codex should remain the default when nothing is set
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- writes only to stdout unless `--output` or `--json-output` is set
- writes only to stdout unless `--output`, `--json-output`, or live streamed engine stderr is set
- supports `--dry-run`, `--parallel-tests`, `--prompt`, `--prompt-file`, `--dataset`, `--no-tools`, `--no-web-search`, and commit refs
- supports `--stream-engine-output` or `AUTOREVIEW_STREAM_ENGINE_OUTPUT=1` for live engine text while preserving structured validation; Codex and Claude hide tool/file event details, emit compact activity summaries, and report usage at turn completion
- supports opt-in review panels with `--panel` / `--reviewers`, plus per-engine `--model` and `--thinking`
- allows read-only tools and web search by default where the selected CLI supports them; forbids nested review in the prompt; Codex is run through `codex exec` with read-only sandbox and structured output
- prints `review still running: <engine> elapsed=<seconds>s pid=<pid>` to stderr at long-running intervals while waiting for the selected review engine
- prints `review still running: <engine> elapsed=<seconds>s pid=<pid>` to stderr at long-running intervals while waiting for the selected review engine, unless streamed output or compact Codex activity has been visible recently
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
- exits nonzero when accepted/actionable findings are present

View File

@@ -6,13 +6,15 @@ import concurrent.futures
import copy
import json
import os
import queue
import subprocess
import sys
import tempfile
import textwrap
import threading
import time
from pathlib import Path
from typing import Any
from typing import Any, Callable
ENGINES = ("codex", "claude", "droid", "copilot")
@@ -100,7 +102,18 @@ def run_with_heartbeat(
input_text: str | None = None,
label: str,
heartbeat_seconds: int = 60,
stream_output: bool = False,
stream_display: Callable[[str, str], str | None] | None = None,
) -> subprocess.CompletedProcess[str]:
if stream_output:
return run_with_stream(
args,
cwd,
input_text=input_text,
label=label,
heartbeat_seconds=heartbeat_seconds,
stream_display=stream_display,
)
started = time.monotonic()
proc = subprocess.Popen(
args,
@@ -124,6 +137,82 @@ def run_with_heartbeat(
print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
def run_with_stream(
args: list[str],
cwd: Path,
*,
input_text: str | None,
label: str,
heartbeat_seconds: int,
stream_display: Callable[[str, str], str | None] | None,
) -> subprocess.CompletedProcess[str]:
started = time.monotonic()
proc = subprocess.Popen(
args,
cwd=cwd,
stdin=subprocess.PIPE if input_text is not None else None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
events: queue.Queue[tuple[str, str | None]] = queue.Queue()
stdout_parts: list[str] = []
stderr_parts: list[str] = []
def read_stream(name: str, stream: Any) -> None:
try:
for line in iter(stream.readline, ""):
events.put((name, line))
finally:
events.put((name, None))
def write_stdin() -> None:
if proc.stdin is None or input_text is None:
return
try:
proc.stdin.write(input_text)
proc.stdin.close()
except BrokenPipeError:
return
threads = [
threading.Thread(target=read_stream, args=("stdout", proc.stdout), daemon=True),
threading.Thread(target=read_stream, args=("stderr", proc.stderr), daemon=True),
]
for thread in threads:
thread.start()
stdin_thread = threading.Thread(target=write_stdin, daemon=True)
stdin_thread.start()
open_streams = 2
while open_streams:
try:
name, line = events.get(timeout=heartbeat_seconds)
except queue.Empty:
elapsed = int(time.monotonic() - started)
print(f"review still running: {label} elapsed={elapsed}s pid={proc.pid}", file=sys.stderr, flush=True)
continue
if line is None:
open_streams -= 1
continue
if name == "stdout":
stdout_parts.append(line)
else:
stderr_parts.append(line)
display = stream_display(name, line) if stream_display else line
if display:
target = sys.stdout if name == "stdout" else sys.stderr
target.write(display)
target.flush()
for thread in threads:
thread.join()
stdin_thread.join(timeout=1)
returncode = proc.wait()
return subprocess.CompletedProcess(args, returncode, "".join(stdout_parts), "".join(stderr_parts))
def git(repo: Path, *args: str, check: bool = True) -> str:
return run(["git", *args], repo, check=check).stdout
@@ -149,6 +238,7 @@ def is_dirty(repo: Path) -> bool:
def choose_target(repo: Path, mode: str, base_ref: str | None) -> tuple[str, str | None]:
mode = "local" if mode == "uncommitted" else mode
branch = current_branch(repo)
if mode == "local" or (mode == "auto" and is_dirty(repo)):
return "local", None
@@ -336,9 +426,11 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
cmd.extend(["--model", args.model])
if args.thinking:
cmd.extend(["-c", f'model_reasoning_effort="{args.thinking}"'])
cmd.append("exec")
if args.stream_engine_output:
cmd.append("--json")
cmd.extend(
[
"exec",
"--ephemeral",
"-C",
str(repo),
@@ -351,7 +443,14 @@ def run_codex(args: argparse.Namespace, repo: Path, prompt: str) -> str:
"-",
]
)
result = run_with_heartbeat(cmd, repo, input_text=prompt, label="codex")
result = run_with_heartbeat(
cmd,
repo,
input_text=prompt,
label="codex",
stream_output=args.stream_engine_output,
stream_display=CodexStreamDisplay() if args.stream_engine_output else None,
)
try:
output = output_path.read_text()
finally:
@@ -368,7 +467,7 @@ def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
"--print",
"--no-session-persistence",
"--output-format",
"json",
"stream-json" if args.stream_engine_output else "json",
"--json-schema",
json.dumps(SCHEMA),
]
@@ -376,11 +475,20 @@ def run_claude(args: argparse.Namespace, repo: Path, prompt: str) -> str:
cmd.extend(["--allowedTools", claude_allowed_tools(args)])
else:
cmd.extend(["--tools", ""])
if args.stream_engine_output:
cmd.append("--verbose")
if args.model:
cmd.extend(["--model", args.model])
if args.thinking:
cmd.extend(["--effort", args.thinking])
result = run_with_heartbeat(cmd, repo, input_text=prompt, label="claude")
result = run_with_heartbeat(
cmd,
repo,
input_text=prompt,
label="claude",
stream_output=args.stream_engine_output,
stream_display=ClaudeStreamDisplay() if args.stream_engine_output else None,
)
if result.returncode != 0:
raise SystemExit(f"claude engine failed ({result.returncode})\n{result.stderr or result.stdout}")
return result.stdout
@@ -405,7 +513,7 @@ def run_droid(args: argparse.Namespace, repo: Path, prompt: str) -> str:
cmd.extend(["--model", args.model])
if not args.tools:
cmd.extend(["--disabled-tools", "*"])
result = run_with_heartbeat(cmd, repo, label="droid")
result = run_with_heartbeat(cmd, repo, label="droid", stream_output=args.stream_engine_output)
prompt_path.unlink(missing_ok=True)
if result.returncode != 0:
raise SystemExit(f"droid engine failed ({result.returncode})\n{result.stderr or result.stdout}")
@@ -430,7 +538,7 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
"--output-format",
"json",
"--stream",
"off",
"on" if args.stream_engine_output else "off",
"--no-ask-user",
"--disable-builtin-mcps",
]
@@ -447,12 +555,142 @@ def run_copilot(args: argparse.Namespace, repo: Path, prompt: str) -> str:
)
if args.web_search:
cmd.append("--allow-all-urls")
result = run_with_heartbeat(cmd, Path(tempdir), label="copilot")
result = run_with_heartbeat(cmd, Path(tempdir), label="copilot", stream_output=args.stream_engine_output)
if result.returncode != 0:
raise SystemExit(f"copilot engine failed ({result.returncode})\n{result.stderr or result.stdout}")
return result.stdout
class CodexStreamDisplay:
def __init__(self, *, activity_seconds: int = 20) -> None:
self.activity_seconds = activity_seconds
self.hidden_events = 0
self.last_visible = time.monotonic()
def __call__(self, name: str, line: str) -> str | None:
if name != "stdout":
return line
try:
event = json.loads(line)
except json.JSONDecodeError:
return self.visible(line)
event_type = event.get("type")
if event_type == "thread.started":
return self.visible(f"codex thread: {event.get('thread_id', '<unknown>')}\n")
if event_type == "turn.started":
return self.visible("codex turn started\n")
if event_type == "turn.completed":
usage = event.get("usage")
message = format_codex_usage(usage) + "\n" if isinstance(usage, dict) else "codex turn completed\n"
return self.visible(self.flush_hidden() + message)
item = event.get("item")
if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str):
return self.visible(self.flush_hidden() + item["text"].rstrip() + "\n")
return self.hidden_activity()
def hidden_activity(self) -> str | None:
self.hidden_events += 1
if time.monotonic() - self.last_visible < self.activity_seconds:
return None
return self.visible(self.flush_hidden())
def flush_hidden(self) -> str:
if not self.hidden_events:
return ""
count = self.hidden_events
self.hidden_events = 0
return f"codex activity: {count} hidden tool/status events\n"
def visible(self, text: str) -> str:
self.last_visible = time.monotonic()
return text
class ClaudeStreamDisplay:
def __init__(self, *, activity_seconds: int = 20) -> None:
self.activity_seconds = activity_seconds
self.hidden_events = 0
self.last_visible = time.monotonic()
self.started = False
def __call__(self, name: str, line: str) -> str | None:
if name != "stdout":
return line
try:
event = json.loads(line)
except json.JSONDecodeError:
return self.visible(line)
event_type = event.get("type")
if event_type == "system" and not self.started:
self.started = True
return self.visible("claude turn started\n")
if event_type == "assistant":
return self.assistant_message(event)
if event_type == "result":
return self.visible(self.flush_hidden() + self.result_summary(event))
return self.hidden_activity()
def assistant_message(self, event: dict[str, Any]) -> str | None:
message = event.get("message")
if not isinstance(message, dict):
return self.hidden_activity()
chunks: list[str] = []
for item in message.get("content", []):
if not isinstance(item, dict):
continue
if item.get("type") == "text" and isinstance(item.get("text"), str):
chunks.append(item["text"].rstrip())
if chunks:
return self.visible(self.flush_hidden() + "\n".join(chunks) + "\n")
return self.hidden_activity()
def result_summary(self, event: dict[str, Any]) -> str:
usage = event.get("usage")
fields: list[str] = []
if isinstance(usage, dict):
for key in (
"input_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
"output_tokens",
):
value = usage.get(key)
if isinstance(value, int):
fields.append(f"{key}={value}")
cost = event.get("total_cost_usd")
if isinstance(cost, (int, float)) and not isinstance(cost, bool):
fields.append(f"cost_usd={cost:.6f}")
return "claude usage: " + " ".join(fields) + "\n" if fields else "claude turn completed\n"
def hidden_activity(self) -> str | None:
self.hidden_events += 1
if time.monotonic() - self.last_visible < self.activity_seconds:
return None
return self.visible(self.flush_hidden())
def flush_hidden(self) -> str:
if not self.hidden_events:
return ""
count = self.hidden_events
self.hidden_events = 0
return f"claude activity: {count} hidden tool/status events\n"
def visible(self, text: str) -> str:
self.last_visible = time.monotonic()
return text
def format_codex_usage(usage: dict[str, Any]) -> str:
fields = [
"input_tokens",
"cached_input_tokens",
"output_tokens",
"reasoning_output_tokens",
]
parts = [f"{field}={usage[field]}" for field in fields if isinstance(usage.get(field), int)]
return "codex usage: " + " ".join(parts) if parts else "codex usage: unavailable"
def claude_allowed_tools(args: argparse.Namespace) -> str:
tools = [tool.strip() for tool in args.claude_allowed_tools.split(",") if tool.strip()]
if not args.web_search:
@@ -490,7 +728,7 @@ def extract_json(text: str) -> dict[str, Any]:
def extract_json_from_jsonl(text: str) -> dict[str, Any] | None:
candidates: list[str] = []
candidates: list[str | dict[str, Any]] = []
for line in text.splitlines():
line = line.strip()
if not line:
@@ -509,7 +747,13 @@ def extract_json_from_jsonl(text: str) -> dict[str, Any] | None:
candidates.append(data["content"])
if isinstance(event.get("result"), str):
candidates.append(event["result"])
if isinstance(event.get("structured_output"), dict):
candidates.append(event["structured_output"])
for candidate in reversed(candidates):
if isinstance(candidate, dict):
if "findings" in candidate:
return candidate
continue
parsed = parse_json_candidate(candidate)
if isinstance(parsed, dict) and "findings" in parsed:
return parsed
@@ -646,7 +890,7 @@ def finish_parallel_tests(proc: subprocess.Popen, started: float) -> int:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Bundle-driven AI code review.")
parser.add_argument("--mode", choices=["auto", "local", "branch", "commit"], default="auto")
parser.add_argument("--mode", choices=["auto", "local", "uncommitted", "branch", "commit"], default="auto")
parser.add_argument("--base")
parser.add_argument("--commit", default="HEAD")
parser.add_argument("--engine", choices=ENGINES, default=os.environ.get("AUTOREVIEW_ENGINE", "codex"))
@@ -673,6 +917,12 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--dataset", action="append", help="Extra evidence file to include in the review bundle.")
parser.add_argument("--output", help="Write human output to a file as well as stdout.")
parser.add_argument("--json-output", help="Write validated structured review JSON.")
parser.add_argument(
"--stream-engine-output",
action="store_true",
default=os.environ.get("AUTOREVIEW_STREAM_ENGINE_OUTPUT") == "1",
help="Stream review engine output while preserving buffered output for validation. Codex output is filtered to hide tool/file chatter.",
)
parser.add_argument("--parallel-tests", help="Run a test command concurrently with review; failure fails the helper.")
parser.add_argument("--require-finding", action="append", default=[], help="Require finding text to contain this substring.")
parser.add_argument("--expect-findings", action="store_true", help="Treat findings as success; for harness acceptance tests.")

View File

@@ -98,7 +98,7 @@ Do not close from title alone. If closing as done on main or nonsensical, prove
When asked for `5 new`, exclude refs already surfaced in the session and refill from the archive until there are 5 live-open candidates. If fewer than 5 remain open, list all open ones and say how many short.
When asked to `update`, `refresh`, `recheck`, `check again`, or similar, return an updated live-open candidate list. Do not fill the main list with items that merely merged/closed since the last pass; put those numbers in a short bottom line.
When asked to `update`, `refresh`, `recheck`, `check again`, or similar, return an updated live-open candidate list. Sort by maintainer importance, not recency: high-impact ready fixes first, then useful-but-review-first, then open/not-ready items. Do not include a "changed since last pass" section or bottom-line merged/closed summary unless the user explicitly asks for churn.
Prefer:
@@ -142,18 +142,20 @@ No Markdown tables. Compact bullets. Use color/risk markers:
Required line shape:
```markdown
- **PR #81244** `@whatsskill.` `+118/-1` `bug` 🟢 verifiable: yes. This prevents chat action buttons from overlapping short assistant replies. Blast: web chat rendering, low.
- **Issue #81245** `@alice` `LOC n/a` `bug` 🟡 verifiable: partial. This reports duplicate Telegram replies when reconnecting after gateway restart. Blast: Telegram channel runtime, medium.
- **PR #81244** `@whatsskill.` `+118/-1` `bug` 🟢 https://github.com/openclaw/openclaw/pull/81244 - Prevents chat action buttons from overlapping short assistant replies. Verifiable: yes. Blast: web chat rendering, low.
- **Issue #81245** `@alice` `LOC n/a` `bug` 🟡 https://github.com/openclaw/openclaw/issues/81245 - Reports duplicate Telegram replies when reconnecting after gateway restart. Verifiable: partial. Blast: Telegram channel runtime, medium.
```
Rules:
- Bold the `PR #n` or `Issue #n` marker.
- Use `@handle`, not author bio text.
- Always include the full GitHub URL.
- Include a one-line description after the URL, separated with `-`.
- PR LOC is `+additions/-deletions`; issue LOC is `LOC n/a`.
- Type: `bug`, `feature`, `perf`, `security`, `docs`, `test`, `chore`, or `refactor`.
- Write a full sentence for what it does.
- Always include blast radius in one phrase.
- Always include `verifiable: yes|partial|no` plus the shortest proof hint when helpful.
- If status is not open, still show it only when the user asked for all surfaced refs; use ✅ or ⚪ and state merged/closed.
- For refresh-style asks, bottom line: `Merged/closed since last pass: #81016 merged, #81026 closed.` Omit if none.
- For refresh-style asks, prefer section order: `Best Open Now`, `Useful But Review First`, `Still Open / Not Ready`. Omit merged/closed churn by default.

View File

@@ -44,7 +44,9 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
- OpenClaw scripts prefer `../crabbox/bin/crabbox` when present. The user PATH
shim can be stale.
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
means brokered AWS today.
means brokered AWS for normal Linux/macOS paths; the wrapper selects Azure
for unqualified Windows/WSL2 runs when the local Crabbox binary advertises
Azure.
- The brokered AWS default is a Linux developer image in `eu-west-1`; the repo
config pins hot `eu-west-1a/b/c` placement so Fast Snapshot Restore can apply.
If warmup drifts well past the minute-scale path, verify image promotion,
@@ -82,18 +84,16 @@ Use these only when the task needs an existing non-Linux host. OpenClaw broad
Linux validation uses the repo Crabbox config unless a provider is explicitly
requested.
Native brokered Windows is available for Windows-specific proof. Use the AWS
developer image in `us-west-2` on demand; it has the expected OpenClaw developer
toolchain and Docker image cache. Keep broad Linux gates on Linux/Testbox unless
the bug is Windows-specific:
Native brokered Windows is available for Windows-specific proof. Prefer Azure
for Windows/WSL2 when the subscription has quota or credits and the local
Crabbox binary advertises Azure. Keep broad Linux gates on Linux/Testbox unless
the bug is Windows-specific, and only force AWS when the operator asks for the
older AWS developer image/cache path or Azure is unavailable:
```sh
../crabbox/bin/crabbox warmup \
--provider aws \
pnpm crabbox:warmup -- \
--target windows \
--windows-mode normal \
--region us-west-2 \
--market on-demand \
--windows-mode wsl2 \
--timing-json
```
@@ -160,9 +160,14 @@ pnpm crabbox:run -- \
--ttl 240m \
--timing-json \
--shell -- \
"pnpm test"
"pnpm verify"
```
Use `pnpm verify` when you need check plus full Vitest proof. It emits
`CRABBOX_PHASE:check` and `CRABBOX_PHASE:test`, making Crabbox summaries show
which stage failed. Use plain `pnpm test` only when check proof is already
covered or intentionally skipped.
Focused rerun:
```sh

View File

@@ -0,0 +1,89 @@
---
name: openclaw-changelog-update
description: Regenerate OpenClaw release changelog sections from git history before beta or stable releases.
---
# OpenClaw Changelog Update
Use this for release changelog rewrites and GitHub release-note source text.
Use it with `release-openclaw-maintainer`; this skill owns changelog content,
ordering, and audit discipline.
## Goal
Rewrite the target `CHANGELOG.md` version section from history, not from stale
draft notes. Produce user-facing release notes sorted by user interest while
preserving issue/PR refs and thanks.
## Inputs
- Target base version: `YYYY.M.D`, without beta suffix.
- Base tag: last reachable shipped release tag, usually the previous stable or
the previous beta train requested by the operator.
- Target ref: exact branch/SHA being released.
## Workflow
1. Start on `main` before branching when possible:
- `git fetch --tags origin`
- `git pull --ff-only`
- confirm clean `git status -sb`
2. Audit history, including direct commits:
- `git log --first-parent --date=iso-strict --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
- `git log --first-parent --grep='(#' --date=short --pretty=format:'%h%x09%ad%x09%s' <base-tag>..<target-ref>`
- also inspect `--since='24 hours ago'` when main moved during the release.
3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter;
infer notes from subject, body, touched files, tests, and nearby commits.
4. Rewrite one stable-base section only:
- use `## YYYY.M.D`
- do not create beta-specific headings
- do not leave a stale `## Unreleased` section above the target release
- if `Unreleased` contains release-bound notes, fold them into the target
section instead of deleting them
5. Section shape:
- `### Highlights`: 5-8 bullets, broad user wins first
- `### Changes`: new capabilities and behavior changes
- `### Fixes`: user-facing fixes first, grouped by impact and surface
6. Preserve attribution:
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
- every human-authored merged PR represented by a user-facing entry needs
its PR ref and `Thanks @author`, even when the PR had no linked issue
- do not add GHSA references, advisory IDs, or security advisory slugs to
changelog entries or GitHub release-note text unless explicitly requested
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
- if grouping multiple entries, carry all relevant refs and thanks into the
grouped bullet
7. Sorting preference:
- security/data-loss and content-boundary fixes
- transcript/replay/reply delivery correctness
- channels and mobile integrations
- providers/Codex/local model reliability
- install/update/release path reliability
- performance and observability
- docs and contributor-only/internal details last or omitted
8. Keep bullets single-line unless existing file style forces otherwise. Avoid
internal release-process noise unless it changes user install/update safety.
9. Check release-note side conditions:
- inspect `src/plugins/compat/registry.ts`
- inspect `src/commands/doctor/shared/deprecation-compat.ts`
- if any compatibility `removeAfter` is on/before release date, resolve it
or explicitly record the blocker before shipping
10. Validate and ship:
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.D notes" CHANGELOG.md`
- push, pull/rebase if needed, then branch/rebase release from latest `main`
## Quota / API Outage Rule
If GitHub API quota is exhausted, do not idle. Continue work that does not need
GitHub API:
- local changelog rewrite and release-note extraction
- local pretag checks and package/build sanity
- git push/tag checks over git protocol
- npm registry `npm view` checks
- exact workflow-dispatch command preparation
Only GitHub Release creation, workflow dispatch, run polling, artifact download,
and issue/PR mutation need API quota.

View File

@@ -168,13 +168,22 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
- Start every PR review with 1-3 plain sentences explaining what the change does and why it matters. Put this before `Findings`.
- Then list findings first. If none, say `No blocking findings` or `No findings`.
- Show size near the top as `LOC: +<additions>/-<deletions> (<changedFiles> files)`, using live PR stats or local diff stats.
- Always answer: bug/behavior being fixed, PR/issue URL and affected surface, provenance for regressions when traceable, and best-fix verdict.
- For bug/regression fixes, include a compact `Provenance:` line after cause/root-cause when a bounded history pass can identify it. Use `git log -S/-G`, `git blame`, linked PRs/issues, and tests.
- Provenance must separate roles when they differ: blamed code author username, blamed PR merger/committer username, current PR author username, PR number, and date. Do not collapse them into one "introduced by" actor.
- Provenance must separate roles when they differ: blamed code author username, blamed PR author username, blamed PR merger/committer username, automerge trigger when known, current PR author username, PR number, and date. Do not collapse them into one "introduced by" actor.
- If the blamed PR was merged by `clawsweeper[bot]` or another automation, identify the human trigger when practical. Check live PR timeline/comments first; if rate-limited, use gitcrawl/cache or public PR HTML. Look for maintainer command comments such as `@clawsweeper automerge`, `/landpr`, labels/events that armed automerge, and ClawSweeper status comments. Report `automerge triggered by @login`; if not found, say trigger unknown rather than naming the bot as the human decision-maker.
- For any confirmed bug, run `git blame` on the implicated line(s) after identifying the root cause. Report who broke it as the blamed PR merger/committer, and also name the blamed code author. Include the PR number. If no PR is traceable, use the blamed commit as the provenance: commit SHA, date, and author username. Do not guess a merger or frame missing PR metadata as a separate finding.
- Phrase provenance as `introduced by`, `made visible by`, or `carried forward by`, with confidence (`clear`, `likely`, `unknown`). If unclear, say what evidence is missing instead of guessing. For features, docs, and refactors, use `Provenance: N/A` or omit it when no broken behavior is being fixed.
- Keep summaries compact, but include enough proof that the verdict is auditable without rereading the PR.
LOC proof:
```bash
gh pr view <number> --json additions,deletions,changedFiles \
--jq '"LOC: +\(.additions)/-\(.deletions) (\(.changedFiles) files)"'
```
## Read beyond the diff
- Review the surrounding code path, not just changed lines. Open the caller, callee, data contracts, adjacent tests, and owner module.
@@ -194,7 +203,7 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
- Before landing, require:
1. symptom evidence such as a repro, logs, or a failing test
2. a verified root cause in code with file/line
3. blame-backed provenance for regressions when traceable, including blamed PR merger and date, or commit SHA/date when no PR is traceable
3. blame-backed provenance for regressions when traceable, including blamed PR merger and automerge trigger when known, or commit SHA/date when no PR is traceable
4. a fix that touches the implicated code path
5. a regression test when feasible, or explicit manual verification plus a reason no test was added
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.

View File

@@ -68,6 +68,7 @@ scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
pnpm changed:lanes --json
pnpm check:changed # changed typecheck/lint/guards; no Vitest
pnpm test:changed # cheap smart changed Vitest targets
pnpm verify # full check, then full Vitest
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
pnpm test <path-or-filter> -- --reporter=verbose
OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
@@ -89,6 +90,8 @@ status checks or install reconciliation in a linked worktree.
- `pnpm check` and `pnpm check:changed` do not run Vitest tests. They are for
typecheck, lint, and guard proof.
- `pnpm test` and `pnpm test:changed` run Vitest tests.
- `pnpm verify` runs `pnpm check`, then `pnpm test`, with Crabbox phase markers
so remote summaries show which half failed.
- `pnpm test:changed` is intentionally cheap by default: direct test edits,
sibling tests, explicit source mappings, and import-graph dependents.
- `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` is the explicit broad
@@ -210,7 +213,7 @@ workflow only spends setup and queue time on that suite.
### Release Evidence
After release-candidate validation or before a release decision, record the
important run ids in the private `openclaw/releases-private` evidence ledger.
important run ids in the public `openclaw/releases` evidence ledger.
Use the manual `OpenClaw Release Evidence`
(`openclaw-release-evidence.yml`) workflow there. It writes durable summaries
under `evidence/<release-id>/` and commits:
@@ -233,13 +236,13 @@ short release-manager notes there. Do not store raw logs, provider
prompts/responses, channel transcripts, signing material, or secret-bearing
config in git; raw logs stay in Actions artifacts.
When `Full Release Validation` completes and
`OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN` is configured in the public repo, it
requests the private `OpenClaw Release Evidence From Full Validation` workflow.
That private workflow reads the parent full-validation run, extracts the child
CI/release-checks/Telegram run ids from the parent logs, and opens the evidence
PR automatically. If the token is absent or the run predates this wiring, trigger
that private workflow manually with the full-validation run id.
When `Full Release Validation` completes and `OPENCLAW_RELEASES_DISPATCH_TOKEN`
is configured in the source repo, it requests the public
`OpenClaw Release Evidence From Full Validation` workflow. That workflow reads
the parent full-validation run, extracts the child CI/release-checks/Telegram
run ids from the parent logs, and opens the evidence PR automatically. If the
token is absent or the run predates this wiring, trigger that workflow manually
with the full-validation run id.
### Release Checks

View File

@@ -0,0 +1,85 @@
---
name: release-openclaw-announcement
description: "Draft or post OpenClaw beta/stable Discord release announcements from changelog, GitHub release, registry, and validation evidence. Use when announcing a beta, stable release, release candidate, or asking what users should test after an OpenClaw release."
---
# OpenClaw Release Announcement
Use with `release-openclaw-maintainer` after a beta or stable release is live.
Use with `openclaw-discord` when actually posting to Discord.
## Evidence First
Before drafting focus areas, read real release evidence:
1. Current GitHub release body for the tag.
2. `CHANGELOG.md` section for the released base version.
3. Commits since the previous shipped version or the operator-specified base.
4. Registry/package metadata for the exact version and current dist-tag.
5. Validation status that is relevant to user confidence.
Do not claim a full changelog audit unless you did it. If you only read the
generated release notes or top changelog section, say that and either audit
properly or draft with that limitation.
For beta focus areas, prioritize user-observable changes over internal test or
CI mechanics:
- install/update paths
- OS/platform-specific behavior
- Gateway startup/restart, config, and runtime behavior
- provider/model/runtime routing
- plugin loading and local plugin development
- channels and media paths
- security/data-loss/user-impact fixes
Do not let late release-branch fixes automatically dominate the announcement.
If the version includes a large delta from the previous shipped version, rank
focus areas by the whole release delta and expected user impact; mention late
fixes in their natural category.
## Required Copy
Every beta announcement must make beta status explicit and include:
- exact version, e.g. `OpenClaw 2026.5.25-beta.1`
- one-sentence risk framing: beta, useful for testing, not stable promotion
- focused test areas derived from evidence, not guesswork
- update command promoted near the top:
```sh
openclaw update --channel beta --yes
openclaw --version
```
- fresh install path:
`Install from https://openclaw.ai`
- GitHub release link
- concise validation note, without making CI the headline
Do not suggest npm install commands in beta announcements unless the operator
explicitly asks for npm-specific copy or troubleshooting text. It is fine to use
registry metadata as evidence; do not turn that into public install guidance.
For stable announcements, use the stable channel wording:
```sh
openclaw update --channel stable --yes
openclaw --version
```
Fresh installs still point to `https://openclaw.ai`.
## Style
- Discord Markdown, no tables.
- Keep it skimmable: short intro, bullets, commands, links.
- Lead with what users can feel or test, not proof plumbing.
- Mention validation only after install/update instructions.
- Be specific about where feedback is useful.
- Do not mention private local proof paths in public announcements.
- Do not overstate unverified platforms, channels, or provider behavior.
## Posting
When asked to post, use the configured Discord workflow from
`openclaw-discord` or the approved OpenClaw relay. Never print tokens.
For public channels, inspect the final body before sending.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "OpenClaw Release Announcement"
short_description: "Draft Discord beta/stable release announcements from evidence."
default_prompt: "Use this skill to draft an OpenClaw beta or stable Discord announcement from changelog, release notes, npm/GitHub release proof, and validation evidence."

View File

@@ -70,7 +70,8 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
release blocker unless the operator waives it or the data clearly proves
infrastructure noise.
- Generate the changelog before version/tag preparation so the top changelog
section is deduped and ordered by user impact.
section is deduped and ordered by user impact. Use
`$openclaw-changelog-update` for the rewrite.
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
stable base version section, for example `v2026.4.20-beta.1` uses
`## 2026.4.20` release notes.

View File

@@ -0,0 +1,87 @@
---
name: verify-release
description: "Verify an OpenClaw release is fully published across GitHub, npm, plugins, ClawHub, package smoke, and live Gateway agent turns."
---
# Verify Release
Use this when asked whether an OpenClaw release is fully released, published,
promoted, smoke-tested, or live-verified. This is a verification skill, not a
publish skill; use `$release-openclaw-maintainer` before changing release state.
## Rules
- Resolve short suffixes like `.27` to the concrete CalVer version from the
current date/context, then say the resolved version.
- Verify live state. Do not trust local checkout state, release notes, or old
memory as current truth.
- If the checkout is dirty or divergent, use it only for scripts/reference.
For version metadata, fetch from GitHub release/tag or unpack the tag tarball
under `/tmp`.
- Never print secrets. Use inherited live keys only for scoped smoke commands.
- Keep the final terse: `yes/no`, evidence bullets, caveats, cleanup.
## Core Checks
1. GitHub release:
- `gh release view v<VERSION> --repo openclaw/openclaw --json tagName,name,publishedAt,isDraft,isPrerelease,targetCommitish,url,body,assets`
- Confirm stable releases are not draft/prerelease.
- Confirm release body has npm, CI, plugin npm, ClawHub, mac/appcast evidence
links when expected.
- Confirm assets expected for stable mac releases are uploaded: zip, dmg,
dSYM, dependency evidence when present.
2. Root npm:
- `npm view openclaw@<VERSION> version dist-tags.latest dist.tarball dist.integrity time.<VERSION> --json`
- `latest` must equal `<VERSION>` for stable.
- Record tarball, integrity, publish time.
3. Plugin publish set:
- Get exact tag metadata from GitHub, not the local checkout when dirty:
download `https://api.github.com/repos/openclaw/openclaw/tarball/v<VERSION>`
into `/tmp/openclaw-v<VERSION>-src`.
- Count `extensions/*/package.json` with
`openclaw.release.publishToNpm === true` and
`openclaw.release.publishToClawHub === true`.
- Compare expected counts to workflow job counts:
`gh api repos/openclaw/openclaw/actions/runs/<RUN>/jobs --paginate`.
- Each expected npm plugin must have version `<VERSION>` and
`dist-tags.latest === <VERSION>`.
4. ClawHub:
- Check the Plugin ClawHub Release workflow conclusion and publish job count.
- Use OpenClaw itself for live registry proof:
`openclaw plugins search <known-plugin> --json`.
- Install one official plugin from ClawHub in an isolated HOME:
`openclaw plugins install clawhub:@openclaw/matrix --pin`.
Prefer `matrix` unless that plugin is not in the expected set.
5. Release workflows:
- Verify conclusions for release notes evidence links:
Full Release Validation, OpenClaw Release Checks, OpenClaw NPM Release,
Plugin NPM Release, Plugin ClawHub Release, mac preflight/validation/publish
when stable mac assets are expected.
- Summarize only relevant successful/failed jobs; ignore routine skipped
optional lanes unless the release body promised them.
6. Published package smoke:
- In `/tmp`, isolated HOME:
`npm exec --yes --package openclaw@<VERSION> -- openclaw --version`.
- Run at least one harmless command that touches the published CLI surface,
for example `plugins --help` or `gateway --help`.
7. Dev Gateway live model smoke:
- Use temp HOME/workspace, not the user's normal state:
`HOME=/tmp/openclaw-release-smoke/home OPENCLAW_WORKSPACE=/tmp/openclaw-release-smoke/work pnpm openclaw --dev gateway run --auth none --force --verbose`.
- Health check via CLI: `openclaw --dev gateway health --json`.
- Run one Gateway-backed agent turn with inherited `OPENAI_API_KEY`, short
prompt, explicit session key, JSON output, and a known-available model.
- If the configured default model fails as unavailable, record that caveat
and retry with the newest known-good OpenAI model instead of declaring the
release failed.
- Stop the gateway and verify the port is not listening.
## Caveats To Report
- Dist-tag caveat: stable `latest` is release truth; if optional `beta` mirrors
still point at a beta version, report it as a caveat, not a stable-release
blocker, unless the user asked to verify beta promotion.
- Divergent checkout caveat: say when local source SHA differs from release tag
or origin and which live sources were used instead.
- Smoke caveat: distinguish Gateway-backed agent success from local embedded
fallback. A valid Gateway smoke has health OK plus gateway log/run id for the
agent call.

View File

@@ -20,7 +20,8 @@ actions:
workflow: .github/workflows/crabbox-hydrate.yml
# Default AWS hydration uses local Actions replay. Use
# `crabbox actions hydrate --github-runner --job hydrate-github` when the
# hydrate job needs GitHub secrets.
# hydrate job needs GitHub secrets, or `--github-runner --job
# hydrate-windows-daemon` for focused native Windows daemon proof.
job: hydrate
ref: main
runnerLabels:

3
.gitattributes vendored
View File

@@ -1,3 +1,6 @@
* text=auto eol=lf
CLAUDE.md -text
src/gateway/server-methods/CLAUDE.md -text
ui/src/i18n/.i18n/* linguist-generated
ui/src/i18n/locales/*.ts linguist-generated
ui/src/i18n/locales/en.ts -linguist-generated

View File

@@ -11,6 +11,8 @@ body:
Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`.
If this is a plugin beta-release blocker, rename the issue title to `Beta blocker: <plugin-name> - <summary>` and apply the `beta-blocker` label after filing.
Please only report one issue per submission. Break multiple issues up into separate submissions.
- type: dropdown
id: bug_type
attributes:

View File

@@ -55,7 +55,7 @@ runs:
shell: bash
run: |
set -euo pipefail
npm install -g bun@1.3.13
npm install -g bun@1.3.14
- name: Runtime versions
shell: bash

View File

@@ -62,6 +62,12 @@ runs:
;;
esac
corepack enable
for attempt in 1 2 3; do
if corepack prepare "$package_manager" --activate; then
exit 0
fi
sleep $((attempt * 5))
done
corepack prepare "$package_manager" --activate
- name: Resolve pnpm store path

View File

@@ -8,7 +8,10 @@ openclaw_node_version_matches() {
fi
case "$requested" in
*x)
[[ "${actual%%.*}" == "${requested%%.*}" ]]
[[ "${actual%%.*}" == "${requested%%.*}" ]] || return 1
if [[ "${requested%%.*}" == "22" ]]; then
openclaw_node_version_at_least "$actual" "22.19.0"
fi
;;
*.*.*)
[[ "$actual" == "$requested" ]]
@@ -22,20 +25,44 @@ openclaw_node_version_matches() {
esac
}
openclaw_node_version_at_least() {
local actual="$1"
local minimum="$2"
local actual_major actual_minor actual_patch minimum_major minimum_minor minimum_patch
IFS=. read -r actual_major actual_minor actual_patch <<< "$actual"
IFS=. read -r minimum_major minimum_minor minimum_patch <<< "$minimum"
actual_minor="${actual_minor:-0}"
actual_patch="${actual_patch:-0}"
minimum_minor="${minimum_minor:-0}"
minimum_patch="${minimum_patch:-0}"
if (( actual_major != minimum_major )); then
(( actual_major > minimum_major ))
return
fi
if (( actual_minor != minimum_minor )); then
(( actual_minor > minimum_minor ))
return
fi
(( actual_patch >= minimum_patch ))
}
openclaw_active_node_version() {
node -p 'process.versions.node' 2>/dev/null || true
}
openclaw_prepend_node_bin() {
local node_bin_dir="$1"
local github_path_dir="${2:-$node_bin_dir}"
local shell_node_bin_dir="$node_bin_dir"
if command -v cygpath >/dev/null 2>&1; then
shell_node_bin_dir="$(cygpath -u "$node_bin_dir" 2>/dev/null || printf '%s' "$node_bin_dir")"
fi
export PATH="$shell_node_bin_dir:$PATH"
if [[ -n "${GITHUB_PATH:-}" ]]; then
local github_node_bin_dir="$shell_node_bin_dir"
if command -v cygpath >/dev/null 2>&1; then
local github_node_bin_dir="$github_path_dir"
if [[ $# -lt 2 ]] && command -v cygpath >/dev/null 2>&1; then
github_node_bin_dir="$shell_node_bin_dir"
github_node_bin_dir="$(cygpath -w "$shell_node_bin_dir" 2>/dev/null || printf '%s' "$shell_node_bin_dir")"
fi
echo "$github_node_bin_dir" >> "$GITHUB_PATH"
@@ -57,6 +84,9 @@ openclaw_find_toolcache_node() {
"/Users/runner/hostedtoolcache" \
"/c/hostedtoolcache/windows"
do
if [[ ! -d "$root" && "$root" == *\\* ]] && command -v cygpath >/dev/null 2>&1; then
root="$(cygpath -u "$root" 2>/dev/null || printf '%s' "$root")"
fi
if [[ -d "$root/node" ]]; then
roots+=("$root/node")
elif [[ "$(basename "$root")" == "node" && -d "$root" ]]; then
@@ -108,6 +138,10 @@ openclaw_node_download_platform() {
Linux:aarch64 | Linux:arm64) printf 'linux-arm64\n' ;;
Darwin:x86_64) printf 'darwin-x64\n' ;;
Darwin:arm64) printf 'darwin-arm64\n' ;;
MINGW*:x86_64 | MSYS*:x86_64 | CYGWIN*:x86_64 | MINGW*:AMD64 | MSYS*:AMD64 | CYGWIN*:AMD64)
printf 'win-x64\n'
;;
MINGW*:aarch64 | MINGW*:arm64 | MSYS*:aarch64 | MSYS*:arm64 | CYGWIN*:aarch64 | CYGWIN*:arm64) printf 'win-arm64\n' ;;
*)
return 1
;;
@@ -116,15 +150,47 @@ openclaw_node_download_platform() {
openclaw_download_node() {
local requested_node="$1"
local version platform archive_url install_root
local version platform archive_url install_root temp_root
version="$(openclaw_resolve_node_download_version "$requested_node")"
platform="$(openclaw_node_download_platform)" || return 1
install_root="${RUNNER_TEMP:-/tmp}/openclaw-node-${version}-${platform}"
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.tar.xz"
mkdir -p "$install_root"
echo "Downloading Node ${version} from ${archive_url}"
curl -fsSL "$archive_url" | tar -xJ -C "$install_root" --strip-components=1
openclaw_prepend_node_bin "$install_root/bin"
temp_root="${RUNNER_TEMP:-/tmp}"
if command -v cygpath >/dev/null 2>&1; then
temp_root="$(cygpath -u "$temp_root" 2>/dev/null || printf '%s\n' "$temp_root")"
fi
install_root="${temp_root}/openclaw-node-${version}-${platform}"
if [[ "$platform" == win-* ]]; then
local archive_path ps_archive_path ps_install_root ps_bin_dir node_bin_dir
archive_path="${temp_root}/node-${version}-${platform}.zip"
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.zip"
rm -rf "$install_root"
mkdir -p "$install_root"
echo "Downloading Node ${version} from ${archive_url}"
curl -fsSL -o "$archive_path" "$archive_url"
ps_archive_path="$archive_path"
ps_install_root="$install_root"
if command -v cygpath >/dev/null 2>&1; then
ps_archive_path="$(cygpath -w "$archive_path")"
ps_install_root="$(cygpath -w "$install_root")"
fi
ps_bin_dir="$ps_install_root\\node-${version}-${platform}"
node_bin_dir="$install_root/node-${version}-${platform}"
if command -v pwsh >/dev/null 2>&1; then
pwsh -NoLogo -NoProfile -Command "Expand-Archive -LiteralPath '${ps_archive_path}' -DestinationPath '${ps_install_root}' -Force"
openclaw_prepend_node_bin "$node_bin_dir" "$ps_bin_dir"
elif command -v powershell.exe >/dev/null 2>&1; then
powershell.exe -NoLogo -NoProfile -Command "Expand-Archive -LiteralPath '${ps_archive_path}' -DestinationPath '${ps_install_root}' -Force"
openclaw_prepend_node_bin "$node_bin_dir" "$ps_bin_dir"
else
unzip -q "$archive_path" -d "$install_root"
openclaw_prepend_node_bin "$node_bin_dir"
fi
else
archive_url="https://nodejs.org/dist/${version}/node-${version}-${platform}.tar.xz"
mkdir -p "$install_root"
echo "Downloading Node ${version} from ${archive_url}"
curl -fsSL "$archive_url" | tar -xJ -C "$install_root" --strip-components=1
openclaw_prepend_node_bin "$install_root/bin"
fi
}
openclaw_ensure_node() {

View File

@@ -17,7 +17,8 @@ paths:
- src/acp/control-plane
- src/agents/command
- src/agents/cli-runner
- src/agents/pi-embedded-runner
- src/agents/embedded-agent-runner
- src/agents/sessions
- src/agents/tools
- src/agents/*completion*.ts
- src/agents/*transport*.ts

View File

@@ -22,6 +22,8 @@ paths:
- src/agents/sandbox
- src/agents/sandbox.ts
- src/agents/sandbox-*.ts
- src/agents/sessions/*auth*.ts
- src/agents/sessions/**/*auth*.ts
- src/cron/service/jobs.ts
- src/cron/stagger.ts
- src/gateway/*auth*.ts

View File

@@ -24,14 +24,15 @@ paths:
- src/agents/openclaw-plugin-tools.ts
- src/agents/openclaw-tools.runtime.ts
- src/agents/openclaw-tools.registration.ts
- src/agents/pi-tool-definition-adapter.ts
- src/agents/pi-tools.abort.ts
- src/agents/pi-tools.before-tool-call*.ts
- src/agents/pi-tools.host-edit.ts
- src/agents/pi-tools-parameter-schema.ts
- src/agents/pi-embedded-runner/effective-tool-policy.ts
- src/agents/pi-embedded-runner/tool-name-allowlist.ts
- src/agents/pi-embedded-runner/tool-schema-runtime.ts
- src/agents/agent-tool-definition-adapter.ts
- src/agents/agent-tools.abort.ts
- src/agents/agent-tools.before-tool-call*.ts
- src/agents/agent-tools.read.ts
- src/agents/agent-tools-parameter-schema.ts
- src/agents/sessions/tools/**
- src/agents/embedded-agent-runner/effective-tool-policy.ts
- src/agents/embedded-agent-runner/tool-name-allowlist.ts
- src/agents/embedded-agent-runner/tool-schema-runtime.ts
- src/agents/tools/gateway-tool.ts
- src/agents/tools/message-tool.ts
- src/agents/tools/sessions-send-tool.ts

6
.github/labeler.yml vendored
View File

@@ -10,6 +10,11 @@
- "extensions/file-transfer/**"
- "docs/nodes/index.md"
- "docs/plugins/sdk-runtime.md"
"plugin: pixverse":
- changed-files:
- any-glob-to-any-file:
- "extensions/pixverse/**"
- "docs/providers/pixverse.md"
"channel: discord":
- changed-files:
- any-glob-to-any-file:
@@ -491,6 +496,7 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/diffs/**"
- "extensions/diffs-language-pack/**"
"extensions: elevenlabs":
- changed-files:
- any-glob-to-any-file:

View File

@@ -79,12 +79,20 @@ jobs:
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_REF: ${{ inputs.target_ref || github.sha }}
CHECKOUT_FALLBACK_REF: ${{ github.sha }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"
if ! git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"; then
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
exit 1
fi
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_FALLBACK_REF}:refs/remotes/origin/checkout"
fi
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Resolve checkout SHA
@@ -202,6 +210,7 @@ jobs:
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
{ check_name: "checks-fast-bun-launcher", runtime: "bun", task: "bun-launcher" },
);
} else {
if (runNodeFastCiRouting) {
@@ -305,12 +314,20 @@ jobs:
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_REF: ${{ inputs.target_ref || github.sha }}
CHECKOUT_FALLBACK_REF: ${{ github.sha }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"
if ! git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"; then
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
exit 1
fi
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_FALLBACK_REF}:refs/remotes/origin/checkout"
fi
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Ensure security base commit
@@ -473,9 +490,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build:ci-artifacts
- name: Build Control UI
run: pnpm ui:build
- name: Check Control UI i18n
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
run: pnpm ui:i18n:check
@@ -568,6 +582,20 @@ jobs:
pids+=("$!")
}
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
gateway_watch_log="${RUNNER_TEMP}/gateway-watch.log"
echo "starting gateway-watch: node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000"
if node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000 >"$gateway_watch_log" 2>&1; then
result="success"
else
result="failure"
fi
echo "::group::gateway-watch log"
cat "$gateway_watch_log"
echo "::endgroup::"
results["gateway-watch"]="$result"
fi
if [ "$RUN_CHANNELS" = "true" ]; then
start_check "channels" env \
NODE_OPTIONS=--max-old-space-size=8192 \
@@ -582,10 +610,6 @@ jobs:
node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts
fi
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
start_check "gateway-watch" node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000
fi
for index in "${!pids[@]}"; do
name="${names[$index]}"
log="${logs[$index]}"
@@ -683,7 +707,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || 'false' }}
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
env:
@@ -704,6 +728,9 @@ jobs:
ci-routing)
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
;;
bun-launcher)
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
;;
*)
echo "Unsupported checks-fast task: $TASK" >&2
exit 1
@@ -1507,7 +1534,7 @@ jobs:
- name: Setup Node.js
env:
REQUESTED_NODE_VERSION: "24.x"
REQUESTED_NODE_VERSION: "22.x"
run: |
set -euo pipefail
source .github/actions/setup-pnpm-store-cache/ensure-node.sh
@@ -1516,7 +1543,7 @@ jobs:
- name: Setup pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: 24.x
node-version: 22.x
- name: Runtime versions
run: |

View File

@@ -71,7 +71,9 @@ on:
- "src/acp/control-plane/**"
- "src/agents/cli-runner/**"
- "src/agents/command/**"
- "src/agents/pi-embedded-runner/**"
- "src/agents/embedded-agent-runner/**"
- "src/agents/sessions/**"
- "src/agents/sessions/tools/**"
- "src/agents/tools/**"
- "src/agents/*completion*.ts"
- "src/agents/*transport*.ts"
@@ -222,7 +224,15 @@ jobs:
network_runtime=true
session_diagnostics=true
;;
src/acp/control-plane/*|src/agents/cli-runner/*|src/agents/command/*|src/agents/pi-embedded-runner/*|src/agents/tools/*|src/agents/*completion*.ts|src/agents/*transport*.ts|src/agents/model-*.ts|src/agents/openclaw-tools*.ts|src/agents/provider-*.ts|src/agents/session*.ts|src/agents/tool-call*.ts|src/auto-reply/reply/agent-runner*.ts|src/auto-reply/reply/commands*.ts|src/auto-reply/reply/directive-handling*.ts|src/auto-reply/reply/dispatch-*.ts|src/auto-reply/reply/get-reply-run*.ts|src/auto-reply/reply/provider-dispatcher*.ts|src/auto-reply/reply/queue*.ts|src/auto-reply/reply/reply-run-registry*.ts|src/auto-reply/reply/session*.ts)
src/agents/sessions/tools/*)
agent=true
mcp_process=true
;;
src/agents/sessions/*auth*.ts|src/agents/sessions/**/*auth*.ts)
agent=true
core_auth_secrets=true
;;
src/acp/control-plane/*|src/agents/cli-runner/*|src/agents/command/*|src/agents/embedded-agent-runner/*|src/agents/sessions/*|src/agents/tools/*|src/agents/*completion*.ts|src/agents/*transport*.ts|src/agents/model-*.ts|src/agents/openclaw-tools*.ts|src/agents/provider-*.ts|src/agents/session*.ts|src/agents/tool-call*.ts|src/auto-reply/reply/agent-runner*.ts|src/auto-reply/reply/commands*.ts|src/auto-reply/reply/directive-handling*.ts|src/auto-reply/reply/dispatch-*.ts|src/auto-reply/reply/get-reply-run*.ts|src/auto-reply/reply/provider-dispatcher*.ts|src/auto-reply/reply/queue*.ts|src/auto-reply/reply/reply-run-registry*.ts|src/auto-reply/reply/session*.ts)
agent=true
;;
src/auto-reply/reply/post-compaction-context.ts|src/auto-reply/reply/queue/*|src/auto-reply/reply/startup-context.ts|src/commands/doctor-session-*.ts|src/commands/session-store-targets.ts|src/commands/sessions*.ts|src/infra/diagnostic-*.ts|src/infra/diagnostics-timeline.ts|src/infra/session-delivery-queue*.ts|src/logging/diagnostic*.ts)

View File

@@ -19,6 +19,15 @@ on:
- ".github/workflows/**"
- "packages/**"
- "src/**"
push:
branches:
- main
paths:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "packages/**"
- "src/**"
schedule:
- cron: "0 6 * * *"

View File

@@ -41,7 +41,7 @@ env:
jobs:
hydrate:
name: hydrate
if: ${{ inputs.crabbox_job != 'hydrate-github' }}
if: ${{ inputs.crabbox_job != 'hydrate-github' && inputs.crabbox_job != 'hydrate-windows-daemon' }}
runs-on: [self-hosted, "${{ inputs.crabbox_runner_label }}"]
timeout-minutes: 120
steps:
@@ -72,7 +72,24 @@ jobs:
echo "PNPM_HOME=$PNPM_HOME"
} >> "$GITHUB_ENV"
package_manager="$(node -e "const fs = require('node:fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); process.stdout.write(pkg.packageManager || '')")"
case "$package_manager" in
pnpm@*) ;;
*)
echo "::error::Expected packageManager to pin pnpm, got '${package_manager:-<empty>}'"
exit 1
;;
esac
corepack enable --install-directory "$PNPM_HOME"
for attempt in 1 2 3; do
if corepack prepare "$package_manager" --activate; then
break
fi
if [ "$attempt" = 3 ]; then
corepack prepare "$package_manager" --activate
fi
sleep $((attempt * 5))
done
node_bin="$(dirname "$(node -p 'process.execPath')")"
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
echo "$node_bin" >> "$GITHUB_PATH"
@@ -114,7 +131,7 @@ jobs:
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
fi
- name: Prepare Crabbox shell
- name: Fetch main ref
shell: bash
run: |
set -euo pipefail
@@ -123,6 +140,11 @@ jobs:
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
fi
- name: Prepare Crabbox shell
shell: bash
run: |
set -euo pipefail
node_bin="$(dirname "$(node -p 'process.execPath')")"
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
@@ -228,7 +250,7 @@ jobs:
fi
}
{
for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE XDG_CACHE_HOME COREPACK_HOME PNPM_HOME PNPM_CONFIG_CHILD_CONCURRENCY PNPM_CONFIG_MODULES_DIR PNPM_CONFIG_NETWORK_CONCURRENCY PNPM_CONFIG_STORE_DIR PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN PNPM_CONFIG_VIRTUAL_STORE_DIR; do
for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE XDG_CACHE_HOME COREPACK_HOME NODE_BIN PNPM_HOME PNPM_CONFIG_CHILD_CONCURRENCY PNPM_CONFIG_MODULES_DIR PNPM_CONFIG_NETWORK_CONCURRENCY PNPM_CONFIG_STORE_DIR PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN PNPM_CONFIG_VIRTUAL_STORE_DIR PATH; do
write_export "$key"
done
} > "${env_file}.tmp"
@@ -275,6 +297,200 @@ jobs:
sleep 15
done
hydrate-windows-daemon:
name: hydrate-windows-daemon
if: ${{ inputs.crabbox_job == 'hydrate-windows-daemon' }}
runs-on: [self-hosted, "${{ inputs.crabbox_runner_label }}"]
timeout-minutes: 120
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Fetch main ref
shell: powershell
run: |
$ErrorActionPreference = "Stop"
if (git rev-parse --is-inside-work-tree 2>$null) {
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
}
- name: Setup pnpm and dependencies
shell: powershell
env:
CI: "true"
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
run: |
$ErrorActionPreference = "Stop"
$workspace = (Get-Location).Path
$cacheRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() }
$env:XDG_CACHE_HOME = Join-Path $cacheRoot "cache"
$env:COREPACK_HOME = Join-Path $env:XDG_CACHE_HOME "corepack"
$env:PNPM_HOME = Join-Path $cacheRoot "pnpm-home"
$env:PNPM_CONFIG_STORE_DIR = Join-Path $cacheRoot "openclaw-pnpm-store"
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $workspace "node_modules"
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $workspace "node_modules\.pnpm"
$env:PNPM_CONFIG_CHILD_CONCURRENCY = "4"
$env:PNPM_CONFIG_NETWORK_CONCURRENCY = "8"
$env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN = "false"
$env:PNPM_CONFIG_SIDE_EFFECTS_CACHE = "false"
function Add-GitHubCommandLine([string]$Path, [string]$Value) {
$Value | Out-File -FilePath $Path -Encoding utf8 -Append
}
New-Item -ItemType Directory -Force `
$env:XDG_CACHE_HOME, `
$env:COREPACK_HOME, `
$env:PNPM_HOME, `
$env:PNPM_CONFIG_STORE_DIR | Out-Null
$env:PATH = "$env:PNPM_HOME;$env:PATH"
@(
"XDG_CACHE_HOME=$env:XDG_CACHE_HOME"
"COREPACK_HOME=$env:COREPACK_HOME"
"PNPM_HOME=$env:PNPM_HOME"
"PNPM_CONFIG_STORE_DIR=$env:PNPM_CONFIG_STORE_DIR"
"PNPM_CONFIG_MODULES_DIR=$env:PNPM_CONFIG_MODULES_DIR"
"PNPM_CONFIG_VIRTUAL_STORE_DIR=$env:PNPM_CONFIG_VIRTUAL_STORE_DIR"
"PNPM_CONFIG_CHILD_CONCURRENCY=$env:PNPM_CONFIG_CHILD_CONCURRENCY"
"PNPM_CONFIG_NETWORK_CONCURRENCY=$env:PNPM_CONFIG_NETWORK_CONCURRENCY"
"PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=$env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN"
"PNPM_CONFIG_SIDE_EFFECTS_CACHE=$env:PNPM_CONFIG_SIDE_EFFECTS_CACHE"
) | ForEach-Object { Add-GitHubCommandLine $env:GITHUB_ENV $_ }
Add-GitHubCommandLine $env:GITHUB_PATH $env:PNPM_HOME
$packageManager = (Get-Content package.json -Raw | ConvertFrom-Json).packageManager
if (-not $packageManager -or -not $packageManager.StartsWith("pnpm@")) {
Write-Error "Expected packageManager to pin pnpm, got '$packageManager'"
}
corepack enable --install-directory $env:PNPM_HOME
for ($attempt = 1; $attempt -le 3; $attempt++) {
corepack prepare $packageManager --activate
if ($LASTEXITCODE -eq 0) {
break
}
if ($attempt -eq 3) {
exit $LASTEXITCODE
}
Start-Sleep -Seconds ($attempt * 5)
}
$nodeBin = Split-Path -Parent (node -p "process.execPath")
Add-GitHubCommandLine $env:GITHUB_ENV "NODE_BIN=$nodeBin"
Add-GitHubCommandLine $env:GITHUB_PATH $nodeBin
$env:PATH = "$nodeBin;$env:PATH"
node -v
npm -v
pnpm -v
$installArgs = @(
"install",
"--filter",
"openclaw",
"--prefer-offline",
"--ignore-scripts=true",
"--config.engine-strict=false",
"--config.enable-pre-post-scripts=false",
"--config.side-effects-cache=false",
"--frozen-lockfile",
"--child-concurrency=$env:PNPM_CONFIG_CHILD_CONCURRENCY",
"--modules-dir=$env:PNPM_CONFIG_MODULES_DIR",
"--network-concurrency=$env:PNPM_CONFIG_NETWORK_CONCURRENCY",
"--store-dir=$env:PNPM_CONFIG_STORE_DIR",
"--virtual-store-dir=$env:PNPM_CONFIG_VIRTUAL_STORE_DIR"
)
pnpm @installArgs
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
$corepackShimDir = Join-Path $nodeBin "node_modules\corepack\shims"
if (Test-Path $corepackShimDir) {
$env:PNPM_HOME = $corepackShimDir
Add-GitHubCommandLine $env:GITHUB_ENV "PNPM_HOME=$env:PNPM_HOME"
Add-GitHubCommandLine $env:GITHUB_PATH $env:PNPM_HOME
}
- name: Mark Crabbox ready
shell: powershell
env:
CRABBOX_ID: ${{ inputs.crabbox_id }}
CRABBOX_JOB: ${{ inputs.crabbox_job }}
run: |
$ErrorActionPreference = "Stop"
$job = if ($env:CRABBOX_JOB) { $env:CRABBOX_JOB } else { "hydrate-windows-daemon" }
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
Write-Error "Invalid crabbox_id"
}
$actionsRoot = Join-Path $HOME ".crabbox\actions"
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
$servicesFile = Join-Path $actionsRoot "$env:CRABBOX_ID.services"
$keys = @(
"CI", "GITHUB_ACTIONS", "GITHUB_WORKSPACE", "GITHUB_REPOSITORY",
"GITHUB_RUN_ID", "GITHUB_RUN_NUMBER", "GITHUB_RUN_ATTEMPT",
"GITHUB_REF", "GITHUB_REF_NAME", "GITHUB_SHA", "GITHUB_EVENT_NAME",
"GITHUB_ACTOR", "RUNNER_OS", "RUNNER_ARCH", "RUNNER_TEMP",
"RUNNER_TOOL_CACHE", "XDG_CACHE_HOME", "COREPACK_HOME", "NODE_BIN",
"PNPM_HOME", "PNPM_CONFIG_CHILD_CONCURRENCY", "PNPM_CONFIG_MODULES_DIR",
"PNPM_CONFIG_NETWORK_CONCURRENCY", "PNPM_CONFIG_STORE_DIR",
"PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN", "PNPM_CONFIG_VIRTUAL_STORE_DIR",
"PNPM_CONFIG_SIDE_EFFECTS_CACHE", "PATH"
)
$envLines = foreach ($key in $keys) {
$value = [Environment]::GetEnvironmentVariable($key)
if ($value) {
"$key=$value"
}
}
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllLines("$envFile.tmp", $envLines, $utf8NoBom)
Move-Item -Force "$envFile.tmp" $envFile
[System.IO.File]::WriteAllLines(
"$servicesFile.tmp",
@("# Docker containers visible from the hydrated runner", "docker not available on native Windows hydration"),
$utf8NoBom
)
Move-Item -Force "$servicesFile.tmp" $servicesFile
$stateLines = @(
"WORKSPACE=$env:GITHUB_WORKSPACE",
"RUN_ID=$env:GITHUB_RUN_ID",
"JOB=$job",
"ENV_FILE=$envFile",
"SERVICES_FILE=$servicesFile",
"READY_AT=$((Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"))"
)
[System.IO.File]::WriteAllLines("$state.tmp", $stateLines, $utf8NoBom)
Move-Item -Force "$state.tmp" $state
- name: Keep Crabbox job alive
shell: powershell
env:
CRABBOX_ID: ${{ inputs.crabbox_id }}
CRABBOX_KEEP_ALIVE_MINUTES: ${{ inputs.crabbox_keep_alive_minutes }}
run: |
$ErrorActionPreference = "Stop"
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
Write-Error "Invalid crabbox_id"
}
$minutes = 90
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
}
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
$deadline = (Get-Date).AddMinutes($minutes)
while ((Get-Date) -lt $deadline) {
if (Test-Path $stop) {
exit 0
}
Start-Sleep -Seconds 15
}
hydrate-github:
name: hydrate-github
if: ${{ inputs.crabbox_job == 'hydrate-github' }}
@@ -428,7 +644,7 @@ jobs:
fi
}
{
for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE PNPM_CONFIG_CHILD_CONCURRENCY PNPM_CONFIG_MODULES_DIR PNPM_CONFIG_NETWORK_CONCURRENCY PNPM_CONFIG_STORE_DIR PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN PNPM_CONFIG_VIRTUAL_STORE_DIR; do
for key in CI GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_RUN_NUMBER GITHUB_RUN_ATTEMPT GITHUB_REF GITHUB_REF_NAME GITHUB_SHA GITHUB_EVENT_NAME GITHUB_ACTOR RUNNER_OS RUNNER_ARCH RUNNER_TEMP RUNNER_TOOL_CACHE NODE_BIN PNPM_HOME PNPM_CONFIG_CHILD_CONCURRENCY PNPM_CONFIG_MODULES_DIR PNPM_CONFIG_NETWORK_CONCURRENCY PNPM_CONFIG_STORE_DIR PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN PNPM_CONFIG_VIRTUAL_STORE_DIR PATH; do
write_export "$key"
done
} > "${env_file}.tmp"

View File

@@ -75,6 +75,7 @@ jobs:
contents: read
outputs:
digest: ${{ steps.build.outputs.digest }}
browser_digest: ${{ steps.build-browser.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -102,14 +103,18 @@ jobs:
set -euo pipefail
tags=()
slim_tags=()
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main-amd64")
slim_tags+=("${IMAGE}:main-slim-amd64")
browser_tags=()
browser_supported=0
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
browser_supported=1
fi
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
version="${SOURCE_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}-amd64")
slim_tags+=("${IMAGE}:${version}-slim-amd64")
if [[ "${browser_supported}" == "1" ]]; then
browser_tags+=("${IMAGE}:${version}-browser-amd64")
fi
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}"
@@ -119,6 +124,9 @@ jobs:
echo "value<<EOF"
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
echo "EOF"
echo "browser<<EOF"
printf "%s\n" "${browser_tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve OCI labels (amd64)
@@ -162,6 +170,91 @@ jobs:
provenance: mode=max
push: true
- name: Build and push amd64 browser image
id: build-browser
if: steps.tags.outputs.browser != ''
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64
cache-from: |
type=gha,scope=docker-release-amd64
type=gha,scope=docker-release-browser-amd64
cache-to: type=gha,mode=max,scope=docker-release-browser-amd64
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
OPENCLAW_INSTALL_BROWSER=1
tags: ${{ steps.tags.outputs.browser }}
labels: ${{ steps.labels.outputs.value }}
sbom: true
provenance: mode=max
push: true
- name: Smoke test amd64 runtime workspace templates
shell: bash
env:
IMAGE_REFS: ${{ steps.tags.outputs.value }}
run: |
set -euo pipefail
mapfile -t image_refs <<< "${IMAGE_REFS}"
image_ref="${image_refs[0]}"
if [[ -z "${image_ref}" ]]; then
echo "::error::No amd64 image ref resolved for runtime template smoke"
exit 1
fi
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
set -eu
test -f /app/src/agents/templates/HEARTBEAT.md
temp_root="$(mktemp -d)"
trap "rm -rf \"${temp_root}\"" EXIT
mkdir -p "${temp_root}/home" "${temp_root}/cwd"
cd "${temp_root}/cwd"
set +e
HOME="${temp_root}/home" \
USERPROFILE="${temp_root}/home" \
OPENCLAW_HOME="${temp_root}/home" \
OPENCLAW_NO_ONBOARD=1 \
OPENCLAW_SUPPRESS_NOTES=1 \
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \
AWS_EC2_METADATA_DISABLED=true \
AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \
AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \
node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \
>"${temp_root}/out.log" 2>&1
status="$?"
set -e
if grep -F "Missing workspace template:" "${temp_root}/out.log"; then
cat "${temp_root}/out.log"
exit 1
fi
test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md"
if [ "${status}" -ne 0 ]; then
cat "${temp_root}/out.log"
fi
'
- name: Smoke test amd64 browser image
if: steps.tags.outputs.browser != ''
shell: bash
env:
IMAGE_REFS: ${{ steps.tags.outputs.browser }}
run: |
set -euo pipefail
mapfile -t image_refs <<< "${IMAGE_REFS}"
image_ref="${image_refs[0]}"
if [[ -z "${image_ref}" ]]; then
echo "::error::No amd64 browser image ref resolved"
exit 1
fi
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
set -eu
browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)"
test -n "${browser}"
"${browser}" --version
'
# Build arm64 image. Default and slim tags point to the same slim runtime.
build-arm64:
needs: [approve_manual_backfill]
@@ -173,6 +266,7 @@ jobs:
contents: read
outputs:
digest: ${{ steps.build.outputs.digest }}
browser_digest: ${{ steps.build-browser.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -200,14 +294,18 @@ jobs:
set -euo pipefail
tags=()
slim_tags=()
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main-arm64")
slim_tags+=("${IMAGE}:main-slim-arm64")
browser_tags=()
browser_supported=0
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
browser_supported=1
fi
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
version="${SOURCE_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}-arm64")
slim_tags+=("${IMAGE}:${version}-slim-arm64")
if [[ "${browser_supported}" == "1" ]]; then
browser_tags+=("${IMAGE}:${version}-browser-arm64")
fi
fi
if [[ ${#tags[@]} -eq 0 ]]; then
echo "::error::No arm64 tags resolved for ref ${SOURCE_REF}"
@@ -217,6 +315,9 @@ jobs:
echo "value<<EOF"
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
echo "EOF"
echo "browser<<EOF"
printf "%s\n" "${browser_tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Resolve OCI labels (arm64)
@@ -260,6 +361,91 @@ jobs:
provenance: mode=max
push: true
- name: Build and push arm64 browser image
id: build-browser
if: steps.tags.outputs.browser != ''
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/arm64
cache-from: |
type=gha,scope=docker-release-arm64
type=gha,scope=docker-release-browser-arm64
cache-to: type=gha,mode=max,scope=docker-release-browser-arm64
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
OPENCLAW_INSTALL_BROWSER=1
tags: ${{ steps.tags.outputs.browser }}
labels: ${{ steps.labels.outputs.value }}
sbom: true
provenance: mode=max
push: true
- name: Smoke test arm64 runtime workspace templates
shell: bash
env:
IMAGE_REFS: ${{ steps.tags.outputs.value }}
run: |
set -euo pipefail
mapfile -t image_refs <<< "${IMAGE_REFS}"
image_ref="${image_refs[0]}"
if [[ -z "${image_ref}" ]]; then
echo "::error::No arm64 image ref resolved for runtime template smoke"
exit 1
fi
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
set -eu
test -f /app/src/agents/templates/HEARTBEAT.md
temp_root="$(mktemp -d)"
trap "rm -rf \"${temp_root}\"" EXIT
mkdir -p "${temp_root}/home" "${temp_root}/cwd"
cd "${temp_root}/cwd"
set +e
HOME="${temp_root}/home" \
USERPROFILE="${temp_root}/home" \
OPENCLAW_HOME="${temp_root}/home" \
OPENCLAW_NO_ONBOARD=1 \
OPENCLAW_SUPPRESS_NOTES=1 \
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \
AWS_EC2_METADATA_DISABLED=true \
AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \
AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \
node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \
>"${temp_root}/out.log" 2>&1
status="$?"
set -e
if grep -F "Missing workspace template:" "${temp_root}/out.log"; then
cat "${temp_root}/out.log"
exit 1
fi
test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md"
if [ "${status}" -ne 0 ]; then
cat "${temp_root}/out.log"
fi
'
- name: Smoke test arm64 browser image
if: steps.tags.outputs.browser != ''
shell: bash
env:
IMAGE_REFS: ${{ steps.tags.outputs.browser }}
run: |
set -euo pipefail
mapfile -t image_refs <<< "${IMAGE_REFS}"
image_ref="${image_refs[0]}"
if [[ -z "${image_ref}" ]]; then
echo "::error::No arm64 browser image ref resolved"
exit 1
fi
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
set -eu
browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)"
test -n "${browser}"
"${browser}" --version
'
# Create multi-platform manifests
create-manifest:
needs: [approve_manual_backfill, build-amd64, build-arm64]
@@ -294,18 +480,25 @@ jobs:
set -euo pipefail
tags=()
slim_tags=()
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
tags+=("${IMAGE}:main")
slim_tags+=("${IMAGE}:main-slim")
browser_tags=()
browser_supported=0
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
browser_supported=1
fi
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
version="${SOURCE_REF#refs/tags/v}"
tags+=("${IMAGE}:${version}")
slim_tags+=("${IMAGE}:${version}-slim")
if [[ "${browser_supported}" == "1" ]]; then
browser_tags+=("${IMAGE}:${version}-browser")
fi
# Manual backfills should only republish the requested version tags.
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
tags+=("${IMAGE}:latest")
slim_tags+=("${IMAGE}:slim")
tags+=("${IMAGE}:latest" "${IMAGE}:main")
slim_tags+=("${IMAGE}:slim" "${IMAGE}:main-slim")
if [[ "${browser_supported}" == "1" ]]; then
browser_tags+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser")
fi
fi
fi
if [[ ${#tags[@]} -eq 0 ]]; then
@@ -316,25 +509,39 @@ jobs:
echo "value<<EOF"
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
echo "EOF"
echo "browser<<EOF"
printf "%s\n" "${browser_tags[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create and push manifest
shell: bash
env:
TAGS: ${{ steps.tags.outputs.value }}
BROWSER_TAGS: ${{ steps.tags.outputs.browser }}
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
AMD64_BROWSER_DIGEST: ${{ needs.build-amd64.outputs.browser_digest }}
ARM64_BROWSER_DIGEST: ${{ needs.build-arm64.outputs.browser_digest }}
run: |
set -euo pipefail
mapfile -t tags <<< "${TAGS}"
args=()
for tag in "${tags[@]}"; do
[ -z "$tag" ] && continue
args+=("-t" "$tag")
done
docker buildx imagetools create "${args[@]}" \
"${AMD64_DIGEST}" \
"${ARM64_DIGEST}"
mapfile -t browser_tags <<< "${BROWSER_TAGS}"
create_manifest() {
local amd64_digest="$1"
local arm64_digest="$2"
shift 2
local args=()
for tag in "$@"; do
[ -z "$tag" ] && continue
args+=("-t" "$tag")
done
docker buildx imagetools create "${args[@]}" "$amd64_digest" "$arm64_digest"
}
create_manifest "${AMD64_DIGEST}" "${ARM64_DIGEST}" "${tags[@]}"
if [[ -n "${BROWSER_TAGS}" ]]; then
create_manifest "${AMD64_BROWSER_DIGEST}" "${ARM64_BROWSER_DIGEST}" "${browser_tags[@]}"
fi
verify-attestations:
needs: [create-manifest]
@@ -372,21 +579,39 @@ jobs:
slim_multi_refs=()
amd64_refs=()
arm64_refs=()
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
multi_refs+=("${IMAGE}:main")
slim_multi_refs+=("${IMAGE}:main-slim")
amd64_refs+=("${IMAGE}:main-amd64" "${IMAGE}:main-slim-amd64")
arm64_refs+=("${IMAGE}:main-arm64" "${IMAGE}:main-slim-arm64")
browser_supported=0
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
tag="${SOURCE_REF#refs/tags/}"
git fetch --depth=1 origin "refs/tags/${tag}:refs/tags/${tag}"
if git show "${SOURCE_REF}:Dockerfile" | grep -q '^ARG OPENCLAW_INSTALL_BROWSER'; then
browser_supported=1
fi
elif grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
browser_supported=1
fi
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
version="${SOURCE_REF#refs/tags/v}"
multi_refs+=("${IMAGE}:${version}")
slim_multi_refs+=("${IMAGE}:${version}-slim")
amd64_refs+=("${IMAGE}:${version}-amd64" "${IMAGE}:${version}-slim-amd64")
arm64_refs+=("${IMAGE}:${version}-arm64" "${IMAGE}:${version}-slim-arm64")
amd64_refs+=(
"${IMAGE}:${version}-amd64"
"${IMAGE}:${version}-slim-amd64"
)
arm64_refs+=(
"${IMAGE}:${version}-arm64"
"${IMAGE}:${version}-slim-arm64"
)
if [[ "${browser_supported}" == "1" ]]; then
multi_refs+=("${IMAGE}:${version}-browser")
amd64_refs+=("${IMAGE}:${version}-browser-amd64")
arm64_refs+=("${IMAGE}:${version}-browser-arm64")
fi
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
multi_refs+=("${IMAGE}:latest")
slim_multi_refs+=("${IMAGE}:slim")
multi_refs+=("${IMAGE}:latest" "${IMAGE}:main")
slim_multi_refs+=("${IMAGE}:slim" "${IMAGE}:main-slim")
if [[ "${browser_supported}" == "1" ]]; then
multi_refs+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser")
fi
fi
fi
if [[ ${#multi_refs[@]} -eq 0 || ${#amd64_refs[@]} -eq 0 || ${#arm64_refs[@]} -eq 0 ]]; then

View File

@@ -80,7 +80,7 @@ on:
default: ""
type: string
evidence_package_spec:
description: Optional published package spec to prove in the private release evidence report
description: Optional published package spec to prove in the release evidence report
required: false
default: ""
type: string
@@ -225,7 +225,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
docker_runtime_assets_preflight:
name: Verify Docker runtime-assets prune path
name: Verify Docker runtime image assets
needs: [resolve_target]
if: inputs.rerun_group == 'all'
runs-on: ubuntu-24.04
@@ -250,6 +250,49 @@ jobs:
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
.
- name: Build and smoke test final Docker runtime image
env:
DOCKER_BUILDKIT: "1"
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
run: |
set -euo pipefail
image_ref="openclaw-release-runtime-smoke:${TARGET_SHA}"
timeout --kill-after=30s 35m docker build \
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
-t "${image_ref}" \
.
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
set -eu
test -f /app/src/agents/templates/HEARTBEAT.md
temp_root="$(mktemp -d)"
trap "rm -rf \"${temp_root}\"" EXIT
mkdir -p "${temp_root}/home" "${temp_root}/cwd"
cd "${temp_root}/cwd"
set +e
HOME="${temp_root}/home" \
USERPROFILE="${temp_root}/home" \
OPENCLAW_HOME="${temp_root}/home" \
OPENCLAW_NO_ONBOARD=1 \
OPENCLAW_SUPPRESS_NOTES=1 \
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \
AWS_EC2_METADATA_DISABLED=true \
AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \
AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \
node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \
>"${temp_root}/out.log" 2>&1
status="$?"
set -e
if grep -F "Missing workspace template:" "${temp_root}/out.log"; then
cat "${temp_root}/out.log"
exit 1
fi
test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md"
if [ "${status}" -ne 0 ]; then
cat "${temp_root}/out.log"
fi
'
normal_ci:
name: Run normal full CI
needs: [resolve_target, docker_runtime_assets_preflight]
@@ -1407,9 +1450,9 @@ jobs:
exit "$failed"
- name: Request private evidence update
- name: Request release evidence update
env:
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
RELEASES_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
@@ -1417,11 +1460,11 @@ jobs:
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
echo "Release checks were skipped by rerun group; skipping automatic release evidence update."
exit 0
fi
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_DISPATCH_TOKEN is not configured; skipping automatic release evidence update."
exit 0
fi
@@ -1440,7 +1483,7 @@ jobs:
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
if [[ -z "$release_id" ]]; then
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic private evidence update."
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic release evidence update."
exit 0
fi
@@ -1466,18 +1509,18 @@ jobs:
if ! curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
-H "Authorization: Bearer ${RELEASES_DISPATCH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/releases-private/dispatches \
https://api.github.com/repos/openclaw/releases/dispatches \
-d "$payload"; then
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
echo "::warning::Automatic release evidence dispatch failed; child workflow validation remains authoritative."
{
echo "### Private release evidence dispatch failed"
echo "### Release evidence dispatch failed"
echo
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases\`:"
echo
echo "\`\`\`bash"
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases-private --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
echo "\`\`\`"
} >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -93,8 +93,8 @@ jobs:
echo "It does not sign, notarize, or upload macOS assets."
echo
echo "Next step:"
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the private mac validation lane to pass."
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full private mac preflight."
echo "- For the real publish path, run the same private mac publish workflow from \`main\` with the successful private preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them."
echo "- For stable releases, the private publish workflow also publishes the signed \`appcast.xml\` to public \`main\`, or opens an appcast PR if direct push is blocked."
echo "- Run \`openclaw/releases/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the macOS validation lane to pass."
echo "- Run \`openclaw/releases/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full macOS preflight."
echo "- For the real publish path, run the same macOS publish workflow from \`main\` with the successful preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them."
echo "- For stable releases, the publish workflow also publishes the signed \`appcast.xml\` to public \`main\`, or opens an appcast PR if direct push is blocked."
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -48,7 +48,8 @@ env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
CRABBOX_REF: main
CRABBOX_CAPACITY_REGIONS: eu-west-1,eu-west-2,eu-central-1,us-east-1,us-west-2
CRABBOX_AWS_REGION: us-east-1
CRABBOX_CAPACITY_REGIONS: us-east-1
MANTIS_OUTPUT_DIR: .artifacts/qa-e2e/mantis/telegram-desktop-proof
jobs:
@@ -224,6 +225,7 @@ jobs:
- name: Checkout harness ref
uses: actions/checkout@v6
with:
ref: main
persist-credentials: false
fetch-depth: 0
@@ -239,9 +241,6 @@ jobs:
set -euo pipefail
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if [[ -n "${PR_NUMBER:-}" ]]; then
git fetch --no-tags origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true
fi
resolve_commit() {
local input_ref="$2"
@@ -255,7 +254,6 @@ jobs:
}
baseline_revision="$(resolve_commit baseline "$BASELINE_REF")"
candidate_revision="$(resolve_commit candidate "$CANDIDATE_REF")"
if ! git merge-base --is-ancestor "$baseline_revision" refs/remotes/origin/main; then
echo "baseline ref '${BASELINE_REF}' resolved to ${baseline_revision}, which is not on main." >&2
exit 1
@@ -269,6 +267,11 @@ jobs:
pr_state="$(jq -r '.state' <<<"$pr_head")"
pr_head_sha="$(jq -r '.head_sha' <<<"$pr_head")"
pr_head_repo="$(jq -r '.head_repo' <<<"$pr_head")"
candidate_revision="$CANDIDATE_REF"
if [[ ! "$candidate_revision" =~ ^[0-9a-f]{40}$ ]]; then
echo "candidate ref '${CANDIDATE_REF}' is not an immutable commit SHA." >&2
exit 1
fi
if [[ "$pr_state" != "open" || "$candidate_revision" != "$pr_head_sha" ]]; then
echo "candidate ref '${CANDIDATE_REF}' resolved to ${candidate_revision}, which is not the open PR head." >&2
exit 1
@@ -423,7 +426,7 @@ jobs:
{
printf '%s\n' 'Defaults env_keep += "CODEX_HOME CODEX_INTERNAL_ORIGINATOR_OVERRIDE"'
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER CRABBOX_CAPACITY_REGIONS"'
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_AWS_REGION CRABBOX_CAPACITY_REGIONS CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CREDENTIAL_OWNER_ID OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
@@ -452,6 +455,7 @@ jobs:
CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
CRABBOX_AWS_REGION: ${{ env.CRABBOX_AWS_REGION }}
CRABBOX_CAPACITY_REGIONS: ${{ env.CRABBOX_CAPACITY_REGIONS }}
CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }}
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}

View File

@@ -44,6 +44,8 @@ env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
CRABBOX_REF: main
CRABBOX_AWS_REGION: us-east-1
CRABBOX_CAPACITY_REGIONS: us-east-1
jobs:
authorize_actor:
@@ -383,6 +385,8 @@ jobs:
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
CRABBOX_ACCESS_CLIENT_ID: ${{ secrets.CRABBOX_ACCESS_CLIENT_ID }}
CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}
CRABBOX_AWS_REGION: ${{ env.CRABBOX_AWS_REGION }}
CRABBOX_CAPACITY_REGIONS: ${{ env.CRABBOX_CAPACITY_REGIONS }}
CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }}
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
SCENARIO_INPUT: ${{ needs.resolve_request.outputs.scenario }}

View File

@@ -521,7 +521,7 @@ jobs:
set -euo pipefail
for attempt in 1 2; do
echo "live-cache attempt ${attempt}/2"
if timeout --kill-after=30s 8m pnpm test:live:cache; then
if timeout --foreground --kill-after=30s 8m pnpm test:live:cache; then
exit 0
fi
if [[ "$attempt" == "2" ]]; then
@@ -1434,7 +1434,7 @@ jobs:
fi
echo "Validating Docker E2E package tarball: $target"
started_at="$(date +%s)"
timeout --kill-after=30s 5m node scripts/check-openclaw-package-tarball.mjs "$target"
timeout --foreground 5m node scripts/check-openclaw-package-tarball.mjs "$target"
finished_at="$(date +%s)"
echo "Docker E2E package tarball validation finished in $((finished_at - started_at))s."
digest="$(sha256sum "$target" | awk '{print $1}')"
@@ -1778,7 +1778,7 @@ jobs:
- name: Run Docker live model sweep
if: contains(matrix.profiles, inputs.release_test_profile)
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
validate_live_models_docker_targeted:
name: Docker live models (selected providers)
@@ -1857,7 +1857,6 @@ jobs:
normalize_provider() {
local value="${1,,}"
case "$value" in
z.ai|z-ai) echo "zai" ;;
opencode|opencode-go) echo "opencode-go" ;;
open-router|openrouter) echo "openrouter" ;;
*) echo "$value" ;;
@@ -1953,7 +1952,7 @@ jobs:
done
- name: Run Docker live model sweep
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
run: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-models-docker.sh
validate_live_provider_suites:
needs: validate_selected_ref
@@ -1987,7 +1986,7 @@ jobs:
- suite_id: native-live-src-gateway-profiles-anthropic-opus
suite_group: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic Opus
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 30
profile_env_only: false
advisory: true
@@ -1995,7 +1994,7 @@ jobs:
- suite_id: native-live-src-gateway-profiles-anthropic-sonnet-haiku
suite_group: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic Sonnet/Haiku
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 30
profile_env_only: false
advisory: true
@@ -2289,32 +2288,32 @@ jobs:
include:
- suite_id: live-gateway-docker
label: Docker live gateway OpenAI
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: beta minimum stable full
- suite_id: live-gateway-anthropic-docker
label: Docker live gateway Anthropic
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-sonnet-4-6,anthropic/claude-haiku-4-5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-gateway-google-docker
label: Docker live gateway Google
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
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=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m 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=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-gateway-advisory-docker-deepseek-fireworks
suite_group: live-gateway-advisory-docker
label: Docker live gateway advisory DeepSeek/Fireworks
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek,fireworks OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek,fireworks OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
advisory: true
@@ -2322,7 +2321,7 @@ jobs:
- suite_id: live-gateway-advisory-docker-opencode-openrouter
suite_group: live-gateway-advisory-docker
label: Docker live gateway advisory OpenCode/OpenRouter
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go,openrouter OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=opencode-go,openrouter OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
advisory: true
@@ -2330,32 +2329,32 @@ jobs:
- suite_id: live-gateway-advisory-docker-xai-zai
suite_group: live-gateway-advisory-docker
label: Docker live gateway advisory xAI/Z.ai
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai,zai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=xai,zai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
timeout_minutes: 40
profile_env_only: false
advisory: true
profiles: full
- suite_id: live-cli-backend-docker
label: Docker live CLI backend
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 45m bash .release-harness/scripts/test-live-cli-backend-docker.sh
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 45m bash .release-harness/scripts/test-live-cli-backend-docker.sh
timeout_minutes: 50
profile_env_only: false
profiles: stable full
- suite_id: live-acp-bind-docker
label: Docker live ACP bind
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 45m bash .release-harness/scripts/test-live-acp-bind-docker.sh
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 45m bash .release-harness/scripts/test-live-acp-bind-docker.sh
timeout_minutes: 50
profile_env_only: false
profiles: stable full
- suite_id: live-codex-harness-docker
label: Docker live Codex harness
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-subagent-announce-docker
label: Docker live subagent announce
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
timeout_minutes: 25
profile_env_only: false
profiles: stable full

View File

@@ -47,8 +47,8 @@ jobs:
# KEEP THIS WORKFLOW SHORT AND DETERMINISTIC OR IT CAN GET STUCK AND JEOPARDIZE THE RELEASE.
# RELEASE-TIME LIVE OR END-TO-END VALIDATION BELONGS IN openclaw-release-checks.yml.
# SECURITY NOTE: TOKEN-BASED npm dist-tag mutation moved to
# openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml
# so this public workflow can stay focused on OIDC publish only.
# openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml
# so this source workflow can stay focused on OIDC publish only.
preflight_openclaw_npm:
if: ${{ inputs.preflight_only }}
runs-on: ubuntu-latest

View File

@@ -551,25 +551,31 @@ jobs:
retention-days: ${{ matrix.deep_profile == 'true' && 14 || 30 }}
- name: Prepare clawgrit reports checkout
id: clawgrit_reports
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }}
env:
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
shell: bash
run: |
set -euo pipefail
echo "ready=false" >> "$GITHUB_OUTPUT"
reports_root=".artifacts/clawgrit-reports"
mkdir -p "$reports_root"
git -C "$reports_root" init -b main
git -C "$reports_root" remote add origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
if git -C "$reports_root" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then
git -C "$reports_root" fetch --depth=1 origin main
if timeout 60s git -C "$reports_root" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then
if ! timeout 120s git -C "$reports_root" fetch --depth=1 origin main; then
echo "::warning::Skipping optional clawgrit report publish because the reports checkout fetch timed out or failed."
exit 0
fi
git -C "$reports_root" checkout -B main FETCH_HEAD
else
git -C "$reports_root" checkout -B main
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- name: Publish to clawgrit reports
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }}
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' && steps.clawgrit_reports.outputs.ready == 'true' }}
env:
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
shell: bash
@@ -642,6 +648,9 @@ jobs:
exit 0
fi
sleep $((attempt * 2))
git -C "$reports_root" fetch --depth=1 origin main
timeout 120s git -C "$reports_root" fetch --depth=1 origin main || {
echo "::warning::Skipping optional clawgrit report rebase because the reports fetch timed out or failed."
exit 0
}
git -C "$reports_root" rebase FETCH_HEAD
done

View File

@@ -946,7 +946,7 @@ jobs:
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--runtime-pair openclaw,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity"
- name: Run standard runtime parity tier
@@ -959,7 +959,7 @@ jobs:
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--runtime-pair openclaw,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
- name: Run soak runtime parity tier
@@ -973,7 +973,7 @@ jobs:
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--runtime-pair openclaw,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
- name: Generate runtime parity report

View File

@@ -265,7 +265,7 @@ jobs:
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } const allowedBranches = new Set(["main", process.env.EXPECTED_WORKFLOW_BRANCH].filter(Boolean)); if (!allowedBranches.has(run.headBranch)) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have headBranch in ${[...allowedBranches].join(", ")}, got ${run.headBranch ?? "<missing>"}.`); process.exit(1); } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
manifest="${RUNNER_TEMP}/full-release-validation-manifest/full-release-validation-manifest.json"
if [[ ! -f "$manifest" ]]; then
@@ -810,7 +810,7 @@ jobs:
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- full release CI report: https://github.com/openclaw/releases-private/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,

View File

@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 1
fetch-depth: 0
fetch-tags: false
persist-credentials: false
submodules: false

View File

@@ -52,6 +52,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL || 'openai/gpt-5.5' }}
OPENCLAW_CI_OPENAI_FALLBACK_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_FALLBACK_MODEL || 'openai/gpt-5.4' }}
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
@@ -288,8 +289,8 @@ jobs:
--runtime-parity-tier live-only \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--runtime-pair pi,codex \
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
--runtime-pair openclaw,codex \
--fast \
--allow-failures \
--output-dir "${output_dir}/runtime-suite"
@@ -373,7 +374,7 @@ jobs:
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
--profile "${INPUT_MATRIX_PROFILE}" \
--fast
)
@@ -457,7 +458,7 @@ jobs:
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
--profile "${{ matrix.profile }}" \
--fast
)
@@ -555,7 +556,7 @@ jobs:
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
--fast \
--credential-source convex \
--credential-role ci \
@@ -649,7 +650,7 @@ jobs:
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model openai/gpt-5.5 \
--alt-model openai/gpt-5.5 \
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
--fast \
--credential-source convex \
--credential-role ci \
@@ -746,7 +747,7 @@ jobs:
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
--fast \
--credential-source convex \
--credential-role ci \
@@ -840,7 +841,7 @@ jobs:
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
--fast \
--credential-source convex \
--credential-role ci \

2
.gitignore vendored
View File

@@ -249,6 +249,8 @@ extensions/qa-lab/web/dist/
# Generated bundled plugin runtime dependency manifests
extensions/**/.openclaw-runtime-deps.json
extensions/**/.openclaw-runtime-deps-stamp.json
extensions/diffs/assets/viewer-runtime.js
extensions/diffs-language-pack/assets/viewer-runtime.js
# Output dir for scripts/run-opengrep.sh (local opengrep scans)
/.opengrep-out/

View File

@@ -30,6 +30,7 @@
"docker-compose.yml",
"dist/",
"docs/_layouts/",
"extensions/diffs/assets/viewer-runtime.js",
"**/*.json",
"node_modules/",
"patches/",

View File

@@ -182,6 +182,7 @@
"dist-runtime/",
"docs/_layouts/",
"extensions/diffs/assets/viewer-runtime.js",
"extensions/diffs-language-pack/assets/viewer-runtime.js",
"node_modules/",
"patches/",
"pnpm-lock.yaml",

View File

@@ -146,6 +146,7 @@ Skills own workflows; root owns hard policy and routing.
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
- External boundaries: prefer `zod` or existing schema helpers.
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
- Cross-function state: when valid combos matter, return a closed mode/result shape. Avoid parallel nullable fields or derived booleans that callers must keep in sync; make impossible states unrepresentable.
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
@@ -210,6 +211,7 @@ Skills own workflows; root owns hard policy and routing.
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
- Backport means apply to newest open `release/` branch unless user names another target.
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.

View File

@@ -2,10 +2,91 @@
Docs: https://docs.openclaw.ai
## Unreleased
## 2026.5.28
### Highlights
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375)
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334)
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, OAuth and local service startup requests are bounded, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361)
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, and viewer assets. (#86699)
- Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.
### Changes
- Status: show active subagent details in status output.
- Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.
- ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.
- iOS: refresh the dev app with Pro Command, Chat, Agents, and Settings tabs wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367) Thanks @Solvely-Colin.
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, and backport targets. (#87313, #63050) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
### Fixes
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
## 2026.5.27
### Highlights
- Safer local/runtime boundaries: OpenClaw now rejects unsafe command wrappers, malformed CLI numeric options, unsafe Node runtime env overrides, no-auth Tailscale exposure, and non-admin device-role pairing approvals before they can affect live runs. (#87308, #87305, #87292, #87146)
- Matrix and auto-reply delivery are steadier: mention previews stay inert, final mention replies deliver normally, shared-DM notices are awaited, MXID parsing ignores filenames, and reasoning-prefixed `NO_REPLY` responses stay suppressed.
- Provider and agent reliability improved across OpenAI-compatible embeddings, cached token usage, Anthropic/Codex/Claude runtime state, unsupported tool-schema quarantine, heartbeat templates, and session fallback errors. (#85269, #82062, #85416, #86855)
- Plugin and package release paths got tighter: Pixverse ships as an external video plugin with region selection, package exclusions and shrinkwrap inventory match the published npm shape, and release/package smoke commands fail bounded instead of hanging.
- Gateway hot paths do less rediscovery by reusing current plugin metadata fingerprints, stable plugin index fingerprints, read-only session metadata, active working stores, status fast paths, and auth/env snapshots. (#86439)
### Changes
- Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.
- Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.
- Pixverse: add video generation provider support, API region selection, and external plugin publishing.
- Plugins: expose approval action metadata for plugin-driven approval surfaces.
### Fixes
- Security/CLI/runtime: harden hostname normalization for repeated trailing dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject loose numeric CLI and gateway options, require admin approval for node device-role pairing, and reject no-auth Tailscale exposure. (#87305, #87292, #87308, #87146) Thanks @pgondhi987.
- Doctor: validate runtime tool schemas for every configured embedded agent while skipping ACP-only profiles, so bad non-default plugin or MCP tools are reported before assistant turns.
- Telegram: route `sendMessage` action replies through durable outbound delivery so completed agent responses remain retryable when the gateway send path times out. (#87261) Thanks @mbelinky.
- Matrix/auto-reply: keep draft previews mention-inert, preserve final mention delivery, send mention finals normally, await shared DM notices, ignore filename-embedded MXIDs, and suppress reasoning-prefixed `NO_REPLY` responses.
- Agents/providers: add OpenAI-compatible cache retention, forward cached token usage in chat completions, preserve runtime context before active user turns, strip stale Anthropic thinking, load Claude CLI OAuth for Pi auth profiles, avoid false Codex runtime live switches, and quarantine unsupported tool schemas. (#82062, #87167, #86855)
- Gateway/performance: cache plugin metadata fingerprints and stable plugin index fingerprints, borrow read-only session metadata safely, keep the active session working store hot, keep status on a bounded fast path, and preserve model auth profile suffixes. (#86439)
- Package/install/release: align npm package exclusions and inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, cap tsdown heap in containers, bound install/release smoke waits, and harden post-publish verification.
- Codex/Auth: bound ChatGPT OAuth token exchange and refresh requests, and honor cancellation across Codex and Anthropic OAuth login flows.
- QA/E2E/CI: bound Telegram, kitchen-sink, Open WebUI, ClawHub, MCP, Discord, realtime, labeler, and GitHub API waits; fail empty explicit test, live-media, gateway CPU, startup benchmark, plugin gauntlet, and beta-smoke runs instead of false-greening.
- Agents/Codex: keep spawned agent bootstrap files rooted in the agent workspace while running task commands, transcripts, and compaction from the requested cwd. (#87218) Thanks @mbelinky.
## 2026.5.26
### Highlights
- Faster Gateway and replies: startup avoids repeated plugin, channel, session, usage-cost, warning, scheduled-service, and filesystem scans; visible replies separate user-facing sends from slower follow-up work; Gateway runtime/session caches churn less under load.
- Transcripts are core: transcript-backed meeting summaries, source-provider chunks, cleaned user turns, media provenance, Codex mirrors, WebChat replies, and CLI/TUI replay now use one more reliable transcript path.
- More channels are production-ready: Telegram keeps typing/progress context and forum topics, iMessage handles attachment roots, remote media staging, and duplicate local Messages sources, WhatsApp restores group/media behavior, Discord improves voice playback and model picking, and Signal/iMessage/WhatsApp get reaction approvals.
- Better voice and Talk: realtime Talk runs can be inspected, steered, cancelled, or followed up from Web UI and Discord voice; wake-name handling is more tolerant without letting ambient speech trigger agents.
- Safer content boundaries: Browser snapshot reads honor SSRF policy, system-event text cannot spoof nested prompt markers, fetched file text is wrapped as external content, ClickClack inbound sender allowlists run before agent dispatch, stale device tokens are rejected, and serialized tool-call text is scrubbed from replies.
- Providers, Codex, and local models are steadier: named auth profiles, OpenAI sampling params, Codex app-server resume/timeout/usage-limit recovery, dynamic tool-schema guards, xAI usage-limit surfacing, Ollama top-p normalization, and local approval resolution reduce provider-specific dead ends.
- More reliable install/update/release paths: Alpine installs, trusted runtime fallback roots, stable update channels, Docker/package timeouts, Windows Scheduled Tasks, Windows/macOS proof lanes, Testbox/Crabbox delegation, plugin publish checks, and macOS runner bootstraps all got hardened.
- Better observability: Activity tab, gateway secret-prep traces, tool/model stream progress, explicit fast-mode status, systemd Gateway hygiene, OpenTelemetry LLM spans, release performance evidence, and richer telemetry signals make failures easier to inspect.
### Changes
- Transcripts: add core transcript capture and source-provider support for transcript-backed meeting summaries, including the renamed Transcripts docs, CLI surface, source-provider chunks, and cleaned user-turn persistence.
- Auth: add named model login profiles and supported credential migration for Hermes, OpenCode, and Codex auth profiles, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.
- Diagnostics: trace gateway secret preparation, classify skill/tool usage, surface model stream progress, add OpenTelemetry LLM content spans, and expose alertable telemetry for blocked tools, failover, stale sessions, liveness, oversized payloads, and webhook ingress. (#83019, #80370, #86191)
- Channels: add Signal reaction approvals, iMessage thumb approval reactions, and WhatsApp thumb approval reaction support so mobile approval flows work without textual `/approve` commands. (#85894, #85952, #85477)
- Agents/API: forward OpenAI sampling params through the Gateway and expose estimated context-budget status for active agent runs. (#84094)
- TUI/status: queue prompts submitted while an agent is busy and show explicit fast-mode state plus richer systemd Gateway hygiene in status output. (#86722, #87115, #86976)
- Exec approvals: hide durable approval actions that are unavailable for the current prompt and keep approval runtime tokens local-only so stale prompts cannot offer misleading controls. (#86270, #86359)
- Plugin SDK: add reaction approval helpers and keep diagnostic event root exports discoverable across function-name and alias-bound module graphs. (#86735, #87084)
- Android/iOS: add the Android pair-new-gateway action and improve mobile Talk mode surfaces, including iOS realtime Talk mode and Android offline voice/gateway recovery. (#86798, #86355) Thanks @ngutman.
- Performance: cache plugin metadata snapshots, package realpaths, stable gateway metadata, model cost indexes, channel resolution, usage-cost indexes, and session/auth hot-path facts so common Gateway and reply paths do less rediscovery. (#84649, #85843, #86517, #86678)
- Voice: expose shared realtime turn-context tracking through the realtime voice SDK and reuse it for Discord speaker attribution and wake-name context recovery.
- Voice: reuse shared realtime output activity tracking in Google Meet command and node audio bridges, including recent-output checks for local barge-in detection.
- Voice: expose shared realtime output activity tracking through the realtime voice SDK and reuse it for Discord playback activity and barge-in decisions.
@@ -13,22 +94,62 @@ Docs: https://docs.openclaw.ai
- Voice: share activation-name matching and consult-transcript screening through the realtime voice SDK so Discord, browser voice, and meeting surfaces can reuse one implementation.
- Cron: default `cron.maxConcurrentRuns` to 8 so scheduled automations and their isolated agent turns can make progress in parallel without explicit configuration.
- QA-Lab: add `qa coverage --match <query>` so focused proof selection can discover matching scenarios from existing metadata before running live or remote lanes.
- Discord/model picker: surface an alpha-bucket select (e.g. `AG (12) · HN (18) · OZ (5)`) when the provider list or a provider's model list exceeds 25 items, so configs with `provider/*` wildcards stay one click from the right page instead of paginating through prev/next; falls back to numeric chunks when every item shares the same first letter. (#86181) Thanks @rendrag-git.
- Control UI: add an ephemeral Activity tab for sanitized live tool activity summaries without persisting raw telemetry. Fixes #12831. Thanks @BunsDev.
- Build: include `ui:build` in the `full` and `ciArtifacts` profiles of `scripts/build-all.mjs` so `pnpm build` always rebuilds `dist/control-ui` after `tsdown` cleans `dist`, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)
- Migrate: import supported Hermes, OpenCode, and Codex auth credentials into OpenClaw auth profiles when credential migration is selected, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.
- iOS: improve Talk mode with direct realtime voice sessions, compact toolbar status, and responsive voice waveform feedback. (#86355) Thanks @ngutman.
- Media: replace the Sharp image backend with Photon for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)
- Media: replace the Sharp image backend with Rastermill for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)
- Codex: update the bundled Codex CLI to 0.134.0 and keep native compaction disabled for budget-triggered app-server turns so OpenClaw owns the recovery boundary. (#86772)
### Fixes
- Memory/security: reject prompt-like text submitted through the explicit `memory_store` tool before embedding or storage, matching the existing auto-capture prompt-injection filter. (#87142)
- Gateway/security: enable the default auth rate limiter for remote non-browser and HTTP gateway auth failures when `gateway.auth.rateLimit` is unset, while preserving the loopback exemption. (#87148)
- Prompt hardening: route untrusted group prompt metadata through sanitized untrusted structured context while preserving trusted operator-configured group system prompts and aligning the plugin SDK docs/test helpers. (#87144)
- Security/content boundaries: validate Browser snapshot tab URLs against SSRF policy before ChromeMCP or direct CDP reads, sanitize queued system-event text so untrusted plugin/channel labels cannot spoof nested prompt markers, wrap fetched file text and metadata as external content, apply ClickClack `allowFrom` sender allowlists before agent dispatch, reject RPCs from invalidated device-token clients during rotation, require staged sandbox media refs, and scrub serialized tool-call text from replies. (#78526, #87094, #87062, #83741, #70707, #86924) Thanks @zsxsoft, @ttzero25, and @mmaps.
- Transcripts/user turns: persist CLI, WebChat, media, follow-up, hook, and Codex-mirror user turns to the admitted session target; keep cleaned transcript text, inline image routing, provenance metadata, replay hooks, and fallback paths idempotent when runtimes fail or restart.
- TUI/status/onboarding/UI: queue busy TUI prompts instead of dropping them, preserve the configured default model during onboarding, show failed tool results as errors, show config-open failures in Control UI, keep status JSON plugin scans healthy, preserve xAI usage-limit errors locally, and expose explicit fast-mode/systemd state. (#86722, #87000, #85786, #87108, #87001, #86614, #87115, #86976)
- Plugin commands/SDK: preserve plugin LLM command auth, bind native plugin command dispatch to the host agent's LLM auth, keep `onDiagnosticEvent` exports discoverable through `Function.name`, stabilize diagnostic event root aliases, correlate pathless read diagnostics, suppress transient runner failures in channel command paths, and repair local approval resolution. (#85936, #87084, #86977, #87069, #86771)
- Codex/providers: keep WebChat delivery hints out of user prompts, avoid false queued-terminal idle timeouts, share the native hook relay registry, quarantine unsupported dynamic tool schemas, preserve Claude resumed-session system prompts, normalize greedy Ollama `top_p`, preserve per-agent thinking defaults for ingress runs, and avoid native compaction takeover on budget-triggered Codex turns. (#87096, #73950, #87049, #86689, #86772)
- Gateway/perf/release: reuse startup-warning metadata and prepared auth stores, avoid cloning live-switch and lifecycle session caches on read paths, defer warning and scheduled-service fallback imports, trim Gateway session/startup/runtime CPU churn, skip duplicate turn session touches, stop chat timeout fallback cascades, drop stale subagent announce history, bound benchmark/watch/kitchen-sink teardown waits, bound macOS/package/onboarding/plugin smoke commands, bound install finalization probes, resolve Parallels npm-update commands from guest `PATH`, and bootstrap raw AWS macOS Node/pnpm commands through `/usr/bin/env`. (#86997)
- Reply/perf: reduce visible reply delivery latency by preserving Telegram typing/progress context, lazy-loading slash-command startup metadata, avoiding hot-path model hydration, flag-gating Codex profiler timing, deferring context compaction maintenance, and tracking delivery timing. (#86989, #86990, #86991, #86992, #86993, #86994) Thanks @keshavbotagent.
- Reply/source delivery: keep TUI, Control UI, media, TTS, transcript, and Codex source-reply finals live without duplicate terminal events or stale replay artifacts.
- Agents/replay: repair legacy tool results before replay, preserve `sessions_spawn` transcript payloads, restore current guard checks, stage sandboxed workspace media, and keep duplicate transcripts tool display metadata from reappearing. (#82203, #86934, #87025) Thanks @martingarramon, @vincentkoc, and @joshavant.
- Agents/sessions: handle active-fallback failures in `sessions_send` so fallback routing reports the real failure and does not leave callers with an ambiguous dropped send. (#86638)
- Agents/hooks/subagents: enforce default hook agent allowlists, recover failed subagent lifecycle completions, and keep node task lifecycle cleanup from closing the Gateway listener. (#86101)
- Codex: project newer OpenClaw chat history into resumed app-server threads and keep Codex turn timeouts inside the Codex runtime boundary so timeouts do not poison shared app-server clients or fall through to unrelated provider fallback. (#86677, #86476) Thanks @TurboTheTurtle and @pashpashpash.
- Config/doctor/update: narrow profiled tool-section doctor repair, keep runtime-injected legacy web-search provider config out of user-authored config validation, and keep prerelease tags excluded from stable updater resolution. (#87030, #86818, #86559) Thanks @joshavant, @luoyanglang, and @stevenepalmer.
- Doctor/runtime: validate active bundled MCP tool schemas through the same runtime projection path so unsupported MCP input schemas are reported and quarantined instead of poisoning assistant startup.
- CLI/Windows: add a Windows-only stack-size respawn for stack-heavy startup paths, default CLI logs to local timestamps, and validate timeout/banner TTY state more strictly. (#87031, #85387) Thanks @giodl73-repo and @vincentkoc.
- Locking/security: require owner identity proof before stale plugin lock removal, memoize session lock owner arguments, and avoid writing default exec approval stores unless policy state actually changed. (#86814, #86964) Thanks @Alix-007 and @vincentkoc.
- Install/release: bound Docker package build, inventory, pack, and tarball preparation with process-group timeouts; pin shrinkwrap patch drift to the pnpm lock; harden macOS restart and dSYM packaging; and run release Docker/live timeout wrappers in the foreground so child processes cannot wedge gates.
- QA/Telegram: bound Telegram user credential tar and broker calls so live proof setup fails with a timeout instead of waiting for the outer Crabbox job deadline.
- QA/Tool Search: bound gateway E2E HTTP probes, run only the fixture plugin, and clean up temporary fixture trees after the compact tool-catalog proof completes.
- Telegram/network: treat `ENETDOWN` as a transient pre-connect network failure so Telegram sends, gateway unhandled-rejection handling, and cron network retries follow the same recovery path as sibling network outages. (#86762) Thanks @TurboTheTurtle.
- Telegram: preserve inbound text entities, overlapping DM replies, account topic cache sidecars, outbound reply context, targeted bot-command mentions, durable group retry targets, forum topic names, and native progress callbacks. (#83873, #85361, #85555, #85656, #85709, #86299, #86553) Thanks @SebTardif, @luoyanglang, and @neeravmakwana.
- iMessage: read image attachments from local Messages attachment roots, dedupe duplicate local Messages-source accounts, seed direct DM history, fix image/group media attachment commands, advance catchup cursors after live handling, and keep slash-command acknowledgements in the source conversation. (#82642, #85475, #86569, #86705, #86706, #86770) Thanks @homer-byte, @TurboTheTurtle, @swang430, and @OmarShahine.
- WhatsApp/QQ/Twitch/IRC/Slack: restore WhatsApp ack identity and group-drop warnings, make QQ Bot media respect `OPENCLAW_HOME`, serialize Twitch auth disconnects, store IRC channel routes canonically, and keep Slack downloaded files out of reply media. (#83833, #85309, #85777, #85794, #85906, #86318, #86697) Thanks @sliverp, @neeravmakwana, and @Kailigithub.
- Discord/voice: improve voice playback and wake replies, bucket large model picker menus, merge media captions into one message, route metadata through configured proxies, restore numeric channel sends, suppress self-reply echoes, and tighten wake matching without breaking fuzzy wake phrases. (#80227, #86238, #86487, #86571, #86595, #86601)
- Codex: preserve native web-search metadata, keep oversized native thread reuse, bridge CLI API-key auth into the app server, preserve sandbox bootstrap path style, recover context-window prompt errors, honor yolo approval policy, disable native thread personality, and route compaction through Codex auth. (#85378, #85542, #85891, #85909, #86408)
- Agents/runtime: enforce session lock max-hold reclaim, release embedded-attempt locks on all exits, treat aborted subagent runs as terminal, avoid runtime model hydration on hot paths, disclose scoped session list counts, derive overflow budgets from provider errors, and keep fallback errors scoped to the active model candidate. (#70473, #85764, #86014, #86134, #86427, #86944) Thanks @openperf, @fuller-stack-dev, @zhangguiping-xydt, and @ferminquant.
- Config/update/doctor: retry config recovery after failed backup restore, skip shell env fallback on Windows, exclude prerelease tags from the stable git channel, support deep config edits, warn instead of aborting on unreadable cron stores, prune stale bundled plugin paths, and avoid duplicate restart prompts when the Gateway is already healthy. (#85739, #85787, #86060, #86260, #86384, #86533) Thanks @liaoyl830.
- Install/release: support Alpine CLI installs and runtime floors, prefer trusted startup argv runtime fallback roots, reject stale CLI node runtimes, avoid npm `min-release-age` installer failures, bound npm/package/Docker install phases, restore config parent ownership in Docker, seed Docker lockfile package tarballs before prune, make release/plugin prerelease checks fail closed instead of hanging or false-greening, and use host-visible Crabbox local work roots for Docker-backed proof. (#85491)
- Windows daemon: keep Scheduled Task gateway launches running on battery power and avoid workgroup-machine prompts for a domain user during task installation. (#59299)
- Security: avoid printing Gateway tokens in Docker, validate plugin model-pattern regexes safely, escape transcript metadata field names, harden session allowlist glob matching, audit Claude permission overrides under YOLO, and require explicit allow for ACP auto approvals. (#85849, #85934, #86046, #86557)
- Media/images: replace Sharp with Rastermill, keep EXIF normalization best-effort, normalize HEIC/HEIF before image descriptions, route Codex image API keys through OpenAI, preserve image compression metadata, and auto-scale live tool result caps. (#85776, #86037, #86437, #86857, #86923)
- Memory: prevent semantic vector indexes from silently degrading when embeddings are unavailable, stop doctor OOMs on large session stores, preserve sidecar hooks/artifacts, write fallback dream diaries, use CJK-aware dreaming dedupe, and avoid per-file watcher FD fan-out. (#80613, #82928, #85060, #85704, #85967, #86701) Thanks @brokemac79, @openperf, and @yaaboo-gif.
- Agents/sessions: include visibility metadata on restricted `sessions_list` results so scoped counts are clearly reported without widening access or exposing hidden-session counts. (#86944) Thanks @ferminquant.
- Gateway/DNS: validate wide-area discovery domains before deriving zone paths or writing zone files, so invalid `discovery.wideArea.domain` and `dns setup --domain` values fail with a DNS-name diagnostic instead of falling through to unrelated configuration errors. Thanks @mmaps.
- Agents/BTW: route fallback side-question streams through the embedded stream resolver so Anthropic-compatible MiniMax requests use the same capped transport as normal chat. (#86312) Thanks @neeravmakwana.
- Telegram: treat `/command@TargetBot` bot-command entities as explicit mentions for the addressed bot so `requireMention` groups no longer drop targeted commands or captions. Fixes #84462. (#86553) Thanks @luoyanglang.
- CI: bound Docker/Bash E2E tarball npm installs with `OPENCLAW_E2E_NPM_INSTALL_TIMEOUT` so package, onboarding, plugin, and upgrade lanes fail instead of hanging on a stuck npm install.
- CI: fail Parallels npm-update smoke jobs after the guest command timeout and cleanup backstop instead of only logging a timeout line.
- CI: bound kitchen-sink RPC HTTP probes so stalled gateway readiness or response bodies fail and retry instead of wedging the walker.
- CI: bound Telegram user Crabbox proof Bot API calls so stalled Telegram responses fail instead of wedging credential and desktop proof cleanup.
- CI: bound MCP channel stdio client initialization so Docker channel proof fails and closes the bridge transport instead of waiting for the outer job timeout.
- CI: keep `OPENCLAW_TESTBOX=1 pnpm check:changed` delegating to Blacksmith Testbox through Crabbox without forwarding local Testbox or worker env into the remote command.
- CI: send KILL after the TERM grace period for manual checkout fetch timeouts so stuck Testbox and workflow checkout retries cannot hang behind a wedged `git fetch`.
- CI: send KILL after the TERM grace period for Bun global install smoke command timeouts so trapped `openclaw` child processes cannot wedge the scheduled install smoke.
- iMessage: thread current channel/account inbound attachment roots into the image tool so iMessage-saved attachments under `~/Library/Messages/Attachments` (including the wildcard `/Users/*/Library/Messages/Attachments` root) are read through the existing inbound path policy instead of being rejected as `path-not-allowed`. Literal `localRoots` stays workspace-scoped. Fixes #30170. (#86569)
- QQ Bot: respect `OPENCLAW_HOME` for outbound media path resolution so `<qqmedia>` sends no longer silently fail when `HOME` and `OPENCLAW_HOME` differ (Docker / multi-user hosts). Persisted QQ Bot data (sessions, known users, refs) stays anchored on the OS home for upgrade compatibility. Fixes #83562. Thanks @sliverp.
- Update: report the primary malformed `openclaw.extensions` payload error without adding a duplicate missing-main diagnostic. (#86596) Thanks @ferminquant.
@@ -142,106 +263,44 @@ Docs: https://docs.openclaw.ai
- Providers/Ollama: strip inline Kimi cloud reasoning prefixes from streamed and final visible replies while keeping ordinary Kimi answers append-only. (#86286) Thanks @jason-allen-oneal.
- Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman.
- Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) thanks @zhangguiping-xydt.
- Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) Thanks @zhangguiping-xydt.
- iMessage: dedupe watcher startup when `channels.imessage.accounts` lists both `default` and a named account that point at the same local Messages source, so the gateway no longer spawns two `imsg rpc` processes or doubles inbound replies; the dedupe is scoped to watcher startup, leaving duplicate accounts addressable for outbound sends, status, and capability listings, and `openclaw doctor` flags the redundant account with a rebinding hint. Fixes #65141. (#86705) Thanks @swang430.
## 2026.5.25
### Fixes
- Installer: let the local-prefix CLI installer use Alpine's `apk` Node.js, npm, and Git packages on musl Linux instead of downloading glibc Node tarballs that fail `node:sqlite`.
- Checks: prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs.
- Plugins: allow linked local plugin paths to probe TypeScript source entries without requiring compiled package output, restoring source-checkout plugin development on native Windows.
- CLI: route source-checkout build output to stderr before launching OpenClaw commands so stale local builds do not corrupt `--json` stdout.
- Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path.
- Agents/perf: cache manifest-backed CLI provider descriptors and fallback provider resolution so model fallback retries avoid repeated bundled provider runtime scans while still invalidating across plugin reloads.
- Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install.
- Windows: run direct Node package scripts with env overrides through a cross-platform launcher so gateway, TUI, Docker-all, generated-module formatting, and optional Discord native opus installer entrypoints work on native Windows.
- Tests: run Vitest import timing entrypoints through a Node wrapper so native Windows package scripts can collect import diagnostics.
- Control UI: split large build-time runtime dependencies into stable chunks so Linux/Docker install and package builds stay below the app chunk warning threshold.
- Tests: run `test:max` and `test:changed:max` through a Node wrapper so high-worker Vitest entrypoints work on native Windows.
- Tests: retry transient loopback HTTP resets in the kitchen-sink RPC walk so native Windows readiness probes do not fail after the gateway is already ready.
- Tests: run `test:serial` through a Node wrapper so targeted serial Vitest commands work on native Windows.
- Tests: normalize Vitest config path assertions so the infra config suite runs on native Windows paths.
- Installer: avoid the incompatible generated `--before` install filter when raw npm `min-release-age` config is present. (#85491) Thanks @TurboTheTurtle.
- Agents/MCP: bound bundled MCP `tools/list` catalog discovery so hung MCP servers do not block session tool materialization. (#85063) Thanks @nxmxbbd.
- Channels/iMessage: recover malformed anchorless group watch payloads by GUID before debounce/routing, and drop unrecoverable payloads instead of replying to the sender DM. Fixes #84470. Refs #84503. Thanks @zhangguiping-xydt and @zqchris.
- Channels/iMessage: advance the startup catchup cursor from live-handled rows after a completed catchup pass, including rows received while catchup is still running, so restarts do not replay them. (#85475) Thanks @TurboTheTurtle.
- Tests: mount the shared Windows command helper into bare Docker E2E harness containers so published upgrade-survivor config walks can start on Linux.
- Tests: keep the plugin binding command escape Docker smoke focused on its intended Vitest cases and skip source-only install lifecycle scripts.
- Tests: let the generic plugin install E2E assertions use a configurable temp root and Windows home-relative install paths.
- Tests: keep kitchen-sink plugin assertion fixtures on a configurable temp root so native Windows runs no longer skip full-surface diagnostic coverage.
- Tests: fail Gateway startup benchmarks when a child startup never produces ready probes or process metrics instead of reporting all `n/a` samples as passing.
- Config/secrets: allow exec SecretRef ids to include `#` selectors so AWS-style `secret#json_key` ids validate consistently. (#80731) Thanks @TurboTheTurtle.
- Tests: keep the Telegram user credential helper on platform temp and path APIs so native Windows credential export and restore commands do not write through POSIX-only paths.
- Installer: include the optional verify phase in the progress counter so `--verify` shows `[4/4] Verifying installation` instead of `[4/3]`.
- Crabbox: let the wrapper find a sibling Crabbox checkout from linked Git worktrees so Codex worktrees can run remote gates without a PATH shim.
- CI: tolerate the standard `--` option separator in shared helper flag parsing so perf and test commands accept package-manager argument forwarding.
- Tests: preserve `--` passthrough arguments in live-media, live-shard, and extension batch harnesses so Vitest filters are not misread or silently ignored.
- Crabbox: default AWS macOS runner requests to on-demand capacity so EC2 Mac proof commands do not fail on the unsupported Spot market default.
- Tests: run upgrade-survivor config recipe commands through the Windows npm shim so native Windows package walks keep baseline config coverage.
- Image tool: use bundled Anthropic media limits when resolving image compression policy without provider-runtime hooks.
- Tests: fail the kitchen-sink RPC Docker walk when gateway RSS sampling is unavailable instead of silently disabling the per-process memory guard.
- Tests: suppress the current Rolldown plugin timing warning format in the Vitest wrapper so tiny focused runs do not drown useful stderr in repeated build-timing noise.
- Models/OpenRouter: use endpoint-specific OpenRouter context limits from `top_provider` metadata so provider-routed models no longer overstate available context. (#85949) Thanks @TurboTheTurtle.
- Crabbox: sync clean sparse-checkout remote changed gates from a temporary full checkout with local-only commits overlaid as worktree changes so git-backed script checks can seed the runner repository.
- Agents: avoid loading bundled channel plugins while resolving completion delivery policy and queue defaults on subagent handoff paths.
- Tests: allow split Vitest config shards through the explicit-target preflight so CI shard jobs run their intended projects.
- Tests: make startup memory and startup bench smoke scripts build CLI startup artifacts when run from a fresh source checkout.
- iMessage: mark authorized slash-command turns as text-sourced commands so `/status`, `/new`, and `/restart` acknowledgements return to the source conversation. (#82642) thanks @homer-byte.
- Crabbox: install Corepack shims into the writable hydration `PNPM_HOME` so local AWS runner hydration no longer tries to overwrite `/usr/local/bin/pnpm`.
- Live tests: fail Gateway live model sweeps when selected coverage is lost to timeouts or stale high-signal filters instead of reporting false missing-profile coverage, and pin Docker OpenAI gateway coverage to the current `gpt-5.5` lane.
- Tests: fail Docker resource-ceiling checks when stats samples or configured limits are invalid instead of silently reporting zero peaks.
- Auth/Codex: emit a one-shot actionable `log.warn` from the embedded legacy Codex OAuth sidecar loader when the only available seed lives in the macOS Keychain, naming `openclaw doctor --fix` and macOS Keychain instead of letting the credential silently fall through to a downstream `No API key found for provider "openai-codex"`. Thanks @romneyda.
- Agents: fail closed when provider-less session models match multiple provider-prefixed runtime policies so CLI runtime routing no longer depends on config order. (#85970) Thanks @potterdigital.
- Control UI/agents: keep collapsed tool rows readable without early ellipses, preserve raw expanded tool details, and make post-compaction AGENTS.md reinjection opt-in to avoid duplicated project context. Fixes #45649 and #45488. Thanks @BunsDev.
## 2026.5.24
## 2026.5.22
### Changes
- iMessage: support thumb-approval reactions — `👍` (Like tapback) resolves an approval as `allow-once` and `👎` resolves as `deny`, with the explicit-approver allowlist read from `channels.imessage.allowFrom`; `allow-always` stays on the manual `/approve <id> allow-always` text fallback. Mirrors the WhatsApp behavior from #85477.
- Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.
- Gateway/perf: cache stable install-record, channel-catalog, bundled-channel, and Telegram session-store metadata during process-local hot paths to reduce repeated JSON and manifest reads.
- Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.
- Talk/realtime: let WebUI and Discord voice callers ask for active OpenClaw run status, cancel, steer, or queue follow-up work while a consult is still running. (#84231) Thanks @Solvely-Colin.
- Discord/voice: add realtime wake-name gating with agent-name defaults and raise profile bootstrap context budget for longer `USER.md`/`SOUL.md` files.
- Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.
- Gateway/perf: cache plugin SDK public-surface alias maps and skip irrelevant macOS Linuxbrew PATH probes so Gateway startup avoids repeated filesystem walks and slow missing-directory stats.
- Image tool: add adaptive model-aware image compression with an `agents.defaults.imageQuality` preference for choosing token-efficient, balanced, or high-detail media handling.
- Meeting Notes: add a source-only external meeting-notes plugin and SDK source-provider contract outside the core npm package, with auto-start capture config, manual transcript imports, read-only `openclaw meeting-notes` CLI access, and Discord voice as the first live source.
- Meeting Notes/Discord: release channel account startup before meeting-notes auto-capture, wait for the Discord voice manager during gateway boot, and stop plugin services before channel shutdown so voice capture state remains available during startup and cleanup.
- Transcripts: add the initial transcript capture and source-provider foundation, including auto-start capture config, manual transcript imports, read-only transcript access, and Discord voice as the first live source.
- Docs/channels/config: add Signal `configPath`, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.
- Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.
- Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.
- Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.
- Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking.
- CLI/models: let `openclaw models auth login` store a single returned provider auth profile under a requested `--profile-id`, and document named Codex OAuth profile setup. (#49315) Thanks @DanielLSM.
- Crabbox/Testbox: run clean sparse-checkout Testbox syncs from a temporary full checkout and route remote changed gates through Corepack pnpm.
- Docs: clarify IPv4-only Gateway BYOH binding, trusted-proxy scope clearing, Android pairing approval, macOS Accessibility grants, Zalo profile env vars, password-store SecretRef setup, and Chinese memory navigation. Thanks @itskai-dev, @gwh7078, @longstoryscott, @MoeJaberr, and @yuaiccc.
- Docs: consolidate GLM under Z.AI, add the Upstash Box install guide and Gateway exposure runbook, clarify MEDIA directives, Copilot and Voyage setup, config path quoting, real behavior proof, and memory-file write guidance. Thanks @BobDu, @alitariksahin, @Jefsky, @musaabhasan, @OmerZeyveli, @leno23, @WuKongAI-CMU, @luoyanglang, and @majin1102.
- Docs: clarify media provider credentials, Codex/OpenClaw code-mode boundaries, Slack and Telegram ack reactions, Feishu dynamic agents, secrets plaintext boundaries, memory guidance, and Chinese glossary terms. Thanks @nielskaspers, @cosmopolitan033, @drclaw-iq, @alexgduarte, @zccyman, @chengoak, and @cassthebandit.
- Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.
- Media understanding: stop auto-probing Gemini CLI and use Antigravity CLI only as a lower-priority image/video fallback after configured provider APIs.
- Diagnostics: emit sanitized `secrets.prepare` timeline spans for Gateway secret preparation so operators can distinguish secret startup latency without exposing provider names, secret ids, or secret values. (#83019) Thanks @samzong.
- Diagnostics: export bounded skill usage metrics/spans and tool source/owner labels for core, plugin, MCP, and channel tool execution without exposing raw paths or session identifiers. (#80370) Thanks @gauravprasadgp.
- Agents/subagents: limit default sub-agent bootstrap context to `AGENTS.md` and `TOOLS.md`, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.
- Maintainer skills: require clean autoreview before surfacing bug-sweep PR URLs and treat changelog-only conflicts as routine busy-main churn.
- Maintainer skills: exclude plugin SDK/API boundary work from `openclaw-landable-bug-sweep` so bugbash sweeps stay focused on small paper-cut fixes.
- QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.
- Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.
- Plugin SDK/cron delivery: route cron delivery through the modern target resolver and outbound session-route APIs, deprecate parser-backed target helpers and `plugin-sdk/messaging-targets`, and move bundled callers to `plugin-sdk/channel-targets`.
- Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as `docker` and `blacksmith`. (#85302) Thanks @hxy91819.
- Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.
- Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.
- CLI/onboarding: start classic onboarding when bare `openclaw` runs before an authored config exists, while keeping configured installs on Crestodian. (#72343) Thanks @fuller-stack-dev.
- Agents/runtime: internalize the former Pi agent runtime into OpenClaw, remove legacy package dependencies, and keep Pi-named SDK aliases only as deprecated plugin compatibility.
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.
- xAI/Grok: reuse xAI OAuth auth profiles for Grok `web_search`, thread active-agent auth through web search, add Grok model aliases, and let media providers declare default operation timeouts. (#85182) Thanks @fuller-stack-dev.
- Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
- Plugins/SDK: add a general `embeddingProviders` capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
- ACPX: bump the bundled ACP backend to `acpx` 0.10.0 for session export/import support.
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
- QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.
- QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.
@@ -261,75 +320,21 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/update: allow package-manager-managed hardlinked package roots during global update swaps while keeping generic plugin, hook, and dependency-free install moves fail-closed. (#85569) Thanks @ai-hpc.
- Gateway/update: avoid fetching unrelated tags during dev-channel git updates so moved release tags do not block branch-based updates. (#84737) Thanks @rubencu.
- CLI/update: suppress the expected future-config warning while an old update parent hands off to the freshly installed post-core process.
- MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst.
- Agents/Anthropic: strip missing or blank thinking signatures for signed-thinking providers even when recovery supplies a narrow replay policy without signature preservation. Fixes #84430. (#84448) Thanks @NianJiuZst.
- Agents/channels: send a visible notice when an aborted main session cannot be resumed after restart, including Telegram group targets. (#85805) Thanks @pfrederiksen.
- Discord/voice: serialize overlapping voice joins, retry aborted startup readiness within the configured timeout, upgrade meeting-notes-only sessions to realtime when the normal follow join arrives, detach promoted meeting-notes ownership without leaving voice, and include `OpenClaw` in default realtime wake names.
- Gateway/restart: honor the configured restart drain budget for embedded runs and avoid spending the deferral timeout twice after forced restart timeouts. (#85708) Thanks @Kaspre.
- Gateway/boot: run `BOOT.md` startup checks in an isolated boot session so gateway restarts do not overwrite the agent's main session mapping. (#85479)
- Meeting Notes: include a speaker-labeled transcript section in generated summaries so Discord group voice captures show who said each captured utterance.
- Discord/voice: recover stale realtime playback state when Discord stream-close/player-idle events do not arrive, and keep generated runtime plugin aliases available after postbuild rewrites.
- Discord/voice: keep realtime playback running when meeting notes attaches to an existing voice session or a realtime consult starts, and route realtime user transcripts into meeting notes.
- Config/secrets: preflight active runtime SecretRefs before root and include config writes persist, and roll back unchanged file/env state when post-write refresh fails. Fixes #46531. (#84454) Thanks @samzong.
- CLI/models: preserve SecretRef-backed custom provider `apiKey` markers when `models status` regenerates `models.json`, avoiding resolved plaintext secrets on disk. Fixes #84632. (#84658) Thanks @NianJiuZst.
- WhatsApp/auto-reply: deliver deferred media replies through the foreground reply fence so overlapping no-reply turns no longer hide already visible responses. (#85517) Thanks @cavit99.
- Sessions/security: replace agent-to-agent wildcard allowlist regexes with a precompiled linear matcher so cross-agent access checks avoid backtracking-prone patterns. (#85849) Thanks @SebTardif.
- WebChat: keep the run-complete indicator in progress until deferred history replay renders the assistant reply, so Done no longer appears before response text. (#85374) Thanks @neeravmakwana.
- Agents/tools: give timed-out or cancelled process trees a bounded SIGTERM cleanup window before SIGKILL while preserving tree-aware cancellation. Fixes #66399. (#85865) Thanks @IWhatsskill.
- Agents/subagents: treat aborted subagent stop reasons as killed terminal failures so parent sessions get error announcements instead of silent success. Fixes #72293. (#85860) Thanks @IWhatsskill.
- Agents/providers: clamp proxy-like OpenAI Chat Completions output caps against the final request payload so strict local/API-compatible servers no longer reject prompts that already consume part of the context window. Fixes #83086. (#85889) Thanks @rendrag-git.
- Agents/compaction: skip agent-harness preflight for provider-owned CLI runtime sessions so over-threshold Claude CLI sessions continue through normal compaction instead of failing on a missing harness. Fixes #84857. (#84878) Thanks @zhangguiping-xydt.
- Codex/app-server: keep successful native hook relays available through a short post-turn grace window so late Codex hook subprocesses can finish policy enforcement without clearing a replacement relay. (#83987) Thanks @Kaspre.
- Control UI/config: save form-mode edits from the source config snapshot so runtime-only provider defaults like empty `models.providers.<id>.baseUrl` are not written back and rejected. Fixes #85831. Thanks @garyd9.
- Browser/existing-session: launch Chrome DevTools MCP with usage statistics disabled by default so its telemetry watchdog stays off unless an operator explicitly opts in. (#85886) Thanks @rohitjavvadi.
- Telegram: normalize legacy durable group retry targets before retry sends, polls, and pins so group retries keep using the real chat id. (#85656) Thanks @luoyanglang.
- Agents/PDF: route MiniMax PDF fallback policy through plugin metadata so MiniMax uses text extraction instead of VLM image fallback. (#85590, fixes #85575) Thanks @neeravmakwana.
- CLI/plugins: tighten timeout, numeric option, media payload, permission, profile/TLS, plugin metadata, JSON, and remote URL handling; prevent stuck progress/app-server/IRC/Synology/Twitch waits; and keep imported chat history ordering stable.
- Telegram/config: suppress the missing `accounts.default` warning when `channels.telegram.defaultAccount` names a configured account that also sorts first. Fixes #83948. Thanks @crypto86m.
- Telegram: serialize visible topic replies through core reply-lane admission so heartbeat and queued follow-up turns cannot continue ownerless or misroute responses. (#85709) Thanks @jalehman.
- CLI/node: print node status recovery hints on stdout consistently while keeping status errors on stderr. Fixes #83925. Thanks @davinci282828.
- WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.
- Gateway/WebChat: hide duplicate `gateway-injected` assistant rows when Cursor ACP already persisted the same `acp-runtime` reply. Fixes #85741. Thanks @lxf-lxf.
- WebChat: scope the visible attachment button to its own composer file input so clicking Upload reliably opens the file picker. (#83952, fixes #47983) Thanks @jason-allen-oneal.
- Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.
- Gateway/update: stop treating inherited macOS `XPC_SERVICE_NAME` values as launchd supervision during update respawn, so GUI-spawned gateways use detached respawn instead of exiting for a missing LaunchAgent. Fixes #85224. Thanks @richardmqq.
- Gateway: stop sending duplicate message-phase `sessions.changed` websocket events after displayable `session.message` transcript updates. (#84834)
- Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.
- Telegram/ACP: preserve explicit `:topic:` conversation suffixes when inbound ACP targets do not carry a separate thread id.
- Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so `openclaw browser start` works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.
- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.
- OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.
- Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.
- Sandbox: keep workspace skill mounts read-only for remote container-cwd file operations and reject symlinked skill roots before creating protected overlays. (#85591) Thanks @jason-allen-oneal.
- Scripts/Windows: route remaining QA, release, profile, and live-media `pnpm` launches through the managed runner so native Windows avoids brittle `.cmd` execution and shell-argv warnings.
- Release: align generated config/API baselines and the meeting-notes plugin version so release preflight stays green on native Windows.
- Install/Windows: run Git hook setup through a Node prepare helper so native Windows installs no longer print POSIX shell errors.
- Checks/Windows: chunk and serialize extension oxlint shards on native Windows so changed gates avoid Go-backed linter memory spikes.
- Release/Windows: run installed `openclaw.cmd` verification through explicit `cmd.exe` wrapping so npm prepublish/postpublish checks avoid Node shell-argv warnings.
- Release/Windows: run release-check npm pack/install/root probes through the shared npm runner so native Windows avoids bare `npm` lookup and `.cmd` shell-argv handling.
- Release/Windows: run cross-OS release check `.cmd` shims through explicit `cmd.exe` wrapping so native Windows install and gateway probes avoid Node shell-argv handling.
- Control UI/Windows: run i18n Pi, npm, and pnpm helper commands through explicit Windows runners so native Windows translation sync avoids brittle `.cmd` launches.
- Scripts/Windows: run the Z.AI fallback repro through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
- Codex/Windows: run app-server protocol formatting through the shared pnpm runner so native Windows avoids raw `.cmd` launches.
- Plugins/Windows: run plugin npm package staging through the shared npm runner so native Windows release checks avoid bare `npm` lookup and `.cmd` shell-argv handling.
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
- Agents/fs: allow workspace-only host write/edit tools to write through in-workspace symlink directory parents while preserving outside-workspace symlink rejection. Fixes #84696. Thanks @garbagenetwork.
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
- Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.
- Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.
- Sessions/status: preserve user-facing model, fallback, usage, and cost attribution when internal subagent handoff runs use fallback models. (#85726, fixes #85082) Thanks @brokemac79.
- Install/update: honor `OPENCLAW_HOME` when deriving default dev checkout and installer onboarding paths, while keeping explicit `OPENCLAW_GIT_DIR` and `OPENCLAW_CONFIG_PATH` overrides authoritative. Fixes #54014. Thanks @robertPiro.
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.
- Plugins/Gateway: treat non-empty return values from plugin gateway method handlers as successful responses so `openclaw gateway call` no longer times out after completed plugin work. Fixes #59470. Thanks @HTMG23.
- Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.
- Channels: honor `/verbose on` for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.
- Update: keep the detached gateway restart handoff best-effort when the restart script process cannot be spawned. (#83892) Thanks @davinci282828.
- Windows/config: skip POSIX login-shell env fallback on native Windows so startup no longer warns about missing `/bin/sh`. Fixes #84795. Thanks @JIRBOY.
- Telegram: persist the prompt-context message cache through plugin state and record bot-authored replies after sends and draft streaming so later turns can include prior assistant replies without relying on the JSON sidecar. (#85231) Thanks @keshavbotagent.
- Agents/subagents: keep Codex persona and user workspace files turn-scoped so native Codex subagents inherit only shared tool guidance by default. (#85811) Thanks @lastguru-net.
- CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.
- Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.
- Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.
@@ -338,7 +343,6 @@ Docs: https://docs.openclaw.ai
- Telegram: send local `path`/`filePath` and structured attachment media from `sendMessage` actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.
- Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.
- Gateway/config: pin relative `OPENCLAW_STATE_DIR` overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.
- Checks/Parallels: make changed-lane scripts, shrinkwrap generation, and Parallels package smoke host commands run through native Windows-safe paths and `npm`/`pnpm` shims.
- Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute `npm.cmd` instead of treating it as a binary.
- Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.
- Secrets: show the irreversible apply warning after interactive `secrets configure` confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.
@@ -350,13 +354,10 @@ Docs: https://docs.openclaw.ai
- Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired `context-1m-2025-08-07` beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:` and `:topicId` forms for announce delivery. Thanks @etticat.
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
- Control UI/chat: keep light-mode model, thinking, config, and agents select arrows visible without tiling background icons. Fixes #85713. Thanks @Linux2010.
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
- Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.
- Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using `message`, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.
- Gateway: defer channel account startup work until HTTP readiness and remove startup model prewarm, avoiding startup event-loop stalls and timer-delay warnings.
- Models/perf: reuse plugin metadata during models.json planning, keep bundled catalog augmentation manifest/static, and use static provider catalogs for metadata-only startup discovery so provider model normalization, auth discovery, and Gateway startup metadata do not reload broad plugin runtimes.
- Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.
- Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)
- StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.
@@ -364,22 +365,16 @@ Docs: https://docs.openclaw.ai
- Windows installer: fail Git checkout installs when `pnpm install` or `pnpm build` fails instead of writing a wrapper to a missing CLI build.
- Sessions: surface previous-transcript archive failures during `/new` rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.
- TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in `openclaw tui`. Fixes #85538. Thanks @danpolasek.
- Gateway/TUI: preserve source-reply metadata through reply normalization and emit message-tool-only agent replies over the live chat stream so `openclaw tui` renders Codex replies without waiting for a history refresh. Thanks @shakkernerd.
- Codex/TUI: keep long source-reply runs alive after Codex reasoning completes so delayed visible `message` calls can still reach `openclaw tui`. Thanks @shakkernerd.
- TUI: keep quiet active runs busy after the response watchdog notice instead of reopening the prompt and encouraging duplicate submissions while the backend turn is still running. Thanks @shakkernerd.
- Agents: preserve the latest assistant thinking blocks while stripping invalid replay signatures from older turns, and retry Anthropic thinking failures without thinking replay. Fixes #85557. Thanks @bryanbaer.
- Agents: keep parallel OpenAI-compatible tool-call deltas in separate argument buffers so interleaved tool calls no longer corrupt streamed arguments. (#82263) Thanks @luna-system.
- Telegram: avoid false pairing prompts after transient pairing-store read failures while preserving configured `allowFrom` and per-DM pairing authorization. (#85555)
- Memory/doctor: report missing or unusable QMD workspace directories as workspace failures instead of generic binary failures. (#63167) Thanks @sercada.
- Debug proxy: record CONNECT client-socket errors and destroy the paired upstream socket so abrupt client disconnects no longer leak tunnel resources. (#82444) Thanks @SebTardif.
- Diffs: continue hydrating later diff cards when one card fails so a single broken card no longer blanks the whole diff viewer. (#84775) Thanks @cosmopolitan033.
- Mac app: use the native settings sidebar window chrome so the sidebar toggle stays on the left and content no longer clips under oversized titlebar padding.
- QA-Lab/Codex: bundle auth/plugin fixture imports for flow scenarios and let terminal async media tools end Codex app-server turns without timing out. (#80397, refs #80323) Thanks @100yenadmin.
- WhatsApp: persist inbound message delivery state through plugin state before dispatch and delay read receipts until handler completion, so retryable failures can redeliver without adding a plugin-local disk cache. Thanks @samzong.
- Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.
- Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.
- Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.
- Codex app-server: leave automatic compaction to native Codex, drop OpenClaw preflight/CLI/context-engine forced compaction for Codex runtime sessions, and still forward explicit `/compact` or plugin compaction requests into Codex while failing native compaction honestly. (#85500)
- Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)
- Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.
- OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.
- OpenAI video: send uploaded video edit requests to the documented `/videos/edits` endpoint with a `video` file instead of posting MP4 references to `/videos`. Thanks @shakkernerd.
@@ -388,11 +383,9 @@ Docs: https://docs.openclaw.ai
- CLI/agents: default new omitted-account bindings to all accounts when the channel has multiple configured accounts, and clarify account-scope docs. (#49769) Thanks @Gcaufy.
- Codex app-server: let authorized `/codex` control commands such as `/codex detach` escape plugin-owned conversation bindings while keeping unknown or unauthorized slash text routed to the bound plugin. Fixes #85157. (#85188) Thanks @TurboTheTurtle.
- Auto-reply/models: keep `/models` browse replies fast by sharing the bounded read-only catalog path with Gateway model listing. (#84735) Thanks @safrano9999.
- Browser/Doctor: read macOS Chrome app bundle versions from `Info.plist` before spawning Chrome and extend the fallback version probe timeout, avoiding false cold-cache warnings from Gatekeeper latency. Fixes #85418. Thanks @davidcittadini.
- Codex app-server: disable native Code Mode when the effective exec host is `node` and keep OpenClaw `exec`/`process` available, so `/exec host=node` routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.
- Agents: bound embedded auto-compaction session write-lock watchdogs to the compaction timeout instead of the full run timeout, so stuck compaction cannot hold the live session lock for the whole run window. (#84949) Thanks @luoyanglang.
- Gateway/agents: return phase-aware `agent.wait` timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.
- Gateway/systemd: launch managed update handoff helpers in a transient user scope so systemd-supervised Update Now flows survive the gateway unit restart. Fixes #84068.
- Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.
- Gateway/models: coalesce provider auth-state rewarms after auth-profile failures and log event-loop delay for warm/rewarm work, so provider auth bursts no longer stack full auth sweeps behind channel replies.
- Gateway/models: stop cancelled provider auth-state prewarms from continuing full provider sweeps, so reload and auth-failure bursts no longer keep startup busy.
@@ -407,7 +400,6 @@ Docs: https://docs.openclaw.ai
- Control UI/logs: strip ANSI escape sequences from displayed Gateway log messages so color codes no longer appear as raw text. Fixes #64399. Thanks @guguangxin-eng.
- Docker: pre-create the workspace and auth-profile config mount points with `node` ownership so first-run named volumes do not start root-owned. Fixes #85076. Thanks @Noerr.
- Telegram: pass configured markdown table mode through outbound markdown chunking so chunked sends render tables consistently. Fixes #85085. Thanks @ShuaiHui.
- Diagnostics/OTel: drop snake_case diagnostic id attributes alongside camelCase ids so exported telemetry cannot leak run, session, message, chat, trace, or tool-call identifiers. (#72645) Thanks @Lion0710.
- CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)
- Agents/providers: honor per-model `api` and `baseUrl` overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.
- Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.
@@ -418,11 +410,9 @@ Docs: https://docs.openclaw.ai
- Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.
- Gateway chat display: preserve OpenAI-compatible `prompt_tokens`, `completion_tokens`, and `total_tokens` usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79.
- Dashboard/CLI: allow macOS browser launching through `open` even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44.
- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving available action query metadata in tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.
- Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.
- OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated `kimi-k2.6` turns do not fail schema validation. Fixes #83812. Thanks @Sleeck.
- Browser/CDP: add a WSL2 portproxy self-loop hint when Chrome DevTools endpoints accept connections but return an empty HTTP reply. Fixes #59209. Thanks @Owlock.
- Agents/tools: add bounded tool-policy audit log entries that identify which allow/deny rule removed tools or blocked a sandboxed tool call. Fixes #55801. Thanks @justinjkline.
- CLI/logs: read implicit local Gateway logs through the passive backend client path so `openclaw logs --follow` does not register as a paired device, and use the active Linux systemd journal instead of stale configured-file fallbacks when live local RPC is unavailable. Fixes #83656 and #66841.
- Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.
- Doctor/Codex: point native Codex asset warnings at the canonical `openclaw migrate plan codex` preview command. Fixes #84948. Thanks @markoa.
- CLI/models: make `capability model auth logout --agent` remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.
@@ -464,7 +454,7 @@ Docs: https://docs.openclaw.ai
- fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.
- Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
- Agents: estimate tool-heavy prompt pressure at the LLM boundary before provider submission for non-Codex embedded runtimes, so persistent PI-style sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
- Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
- Agents/hooks: wait for local one-shot CLI and Codex `agent_end` plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)
- Providers/Google: preserve Gemini 3 cron `thinkingDefault: "low"` when stale catalog metadata says `reasoning:false`, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
@@ -485,7 +475,6 @@ Docs: https://docs.openclaw.ai
- CLI/update: pre-pack GitHub/git package update targets before the staged npm install, restoring `openclaw update --tag main` for one-off package updates. (#81296) Thanks @fuller-stack-dev.
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
- Media generation: keep image, music, and video completion delivery from duplicating or losing task ownership when generated media finishes through active session replies. (#84006) Thanks @fuller-stack-dev.
- CLI/doctor: remove stale bundled plugin load paths from old versioned OpenClaw package roots after pnpm/npm upgrades. Fixes #58626. Thanks @solink7.
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
- Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
@@ -512,7 +501,6 @@ Docs: https://docs.openclaw.ai
- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.
- Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.
- Discord: keep session recovery and `/stop` abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.
- Discord/voice-call: keep forced realtime voice consult diagnostics in debug logs instead of agent prompts, so callers do not hear OpenClaw policy text when the provider misses `openclaw_agent_consult`. (#84411) Thanks @fuller-stack-dev.
- Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.
- Codex app-server: give visible `message` dynamic tool sends a longer timeout budget so slow channel delivery can return its own result or error instead of hitting the 30-second Codex wrapper. (#85216) Thanks @amknight.
- Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.
@@ -521,7 +509,6 @@ Docs: https://docs.openclaw.ai
- PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
- Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.
- Media-understanding: restore the 4096-token default for image descriptions so reasoning-capable vision models no longer truncate before returning text, while preserving smaller model caps. (#84932) Thanks @scotthuang.
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
- Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
@@ -564,6 +551,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: validate a forced plugin harness against the candidate provider/model before pinning it, so unsupported fallback-chain candidates fail with a clear harness error instead of producing a late `Model provider X not found` from the underlying harness. Codex harness `supports()` now also accepts the canonical `openai` and `openai-codex` routing ids so documented Codex configs keep working. Thanks @cathrynlavery.
- Control UI/WebChat: keep selected external-channel sessions live by mirroring Codex prompts at turn start, streaming hidden runs only to exact selected-session subscribers, and deduplicating accumulated stream snapshots around tool cards. Fixes #83528, #82611, refs #83949. Thanks @BunsDev.
- CLI/tasks: include stale-running task maintenance decisions in `openclaw tasks maintenance --json` so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.
- Codex app-server: keep system-prompt reports working when bootstrap hooks provide workspace files with only a path and content, so hook-supplied SOUL/IDENTITY/TOOLS/USER context still reports injected characters correctly. (#84736) Thanks @JARVIS-Glasses.
@@ -1929,6 +1917,7 @@ Docs: https://docs.openclaw.ai
- Agents/read tool: treat positive offsets beyond EOF as empty ranges instead of surfacing the upstream read error, so stale pagination cursors no longer crash tool calls while unrelated read failures still fail loud. Fixes #62466. (#75536) Thanks @vyctorbrzezowski.
- Google/Gemini: normalize retired Gemini 3 Pro Preview refs left in Google API-key onboarding model allowlists and fallbacks, so setup-emitted config keeps testing `google/gemini-3.1-pro-preview` instead of `google/gemini-3-pro-preview`.
- Telegram/context: bound selected topic context to the active session so messages from before `/new` or `/reset` are not replayed into later turns. (#80848) Thanks @VACInc.
- Docs/providers/openai: clarify that OpenAI Realtime voice goes through the OpenAI Platform Realtime API and requires Platform credits — Codex/ChatGPT subscription quota does not cover this route. Fixes #76498. Thanks @lonexreb.
- Google/Gemini: normalize retired nested Gemini 3 Pro Preview ids when resolving exact configured proxy-provider refs, so `kilocode/google/gemini-3-pro-preview` resolves to `kilocode/google/gemini-3.1-pro-preview` for Gemini 3.1 testing.
- CLI: strip generic OSC terminal escape payloads from sanitized output fields, preventing clipboard/title escape bodies from leaking into commitment tables and other terminal-safe text. Thanks @shakkernerd.
- Codex app-server: match connector-backed plugin approval elicitations by stable connector id so enabled destructive actions no longer fall through to display-name-only rejection.

View File

@@ -178,6 +178,7 @@ COPY --from=runtime-assets --chown=node:node /app/package.json .
COPY --from=runtime-assets --chown=node:node /app/pnpm-workspace.yaml .
COPY --from=runtime-assets --chown=node:node /app/patches ./patches
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
COPY --from=runtime-assets --chown=node:node /app/src/agents/templates ./src/agents/templates
COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR}
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
COPY --from=runtime-assets --chown=node:node /app/docs ./docs

View File

@@ -19,3 +19,6 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Third-party notices for incorporated or adapted code are recorded in
THIRD_PARTY_NOTICES.md.

View File

@@ -25,7 +25,7 @@ If you want a personal, single-user assistant that feels local, fast, and always
Supported channels include: WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, QQ, WebChat.
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Onboarding](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [Third-party notices](THIRD_PARTY_NOTICES.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Onboarding](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
@@ -306,7 +306,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to s
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
[pi-mono](https://github.com/badlogic/pi-mono).
[pi-mono](https://github.com/earendil-works/pi-mono).
Special thanks to Adam Doppelt for the lobster.bot domain.
Thanks to all clawtributors:

37
THIRD_PARTY_NOTICES.md Normal file
View File

@@ -0,0 +1,37 @@
# Third-party notices
This file records third-party notices for code or substantial implementation
portions incorporated into OpenClaw source, beyond normal package-manager
dependency metadata.
## Pi / pi-mono
Portions of OpenClaw were adapted from Pi / pi-mono, and OpenClaw also depends
on `@earendil-works/pi-tui` for terminal UI rendering.
- Upstream: https://github.com/earendil-works/pi-mono
- Package family: `@earendil-works/pi-*`
- License: MIT
- Copyright: Copyright (c) 2025 Mario Zechner
MIT License
Copyright (c) 2025 Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -2,6 +2,262 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.5.27</title>
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052790</sparkle:version>
<sparkle:shortVersionString>2026.5.27</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.27</h2>
<h3>Highlights</h3>
<ul>
<li>Stronger security and content boundaries: group prompt text is kept out of the system prompt, repeated-dot hostnames are normalized, side-effecting command wrappers and unsafe Node runtime env overrides are blocked, no-auth Tailscale exposure is rejected, and node/device-role approvals now require admin authority. (#87144, #87305, #87292, #87308, #87146) Thanks @eleqtrizit and @pgondhi987.</li>
<li>More reliable Codex app-server runs: Codex runtime models resolve first, workspace memory is routed through tools, shared app-server clients survive startup and spawned-helper failures, native hook relay generations survive restarts and rotate on fresh fallbacks, and false runtime live switches are avoided. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
<li>Faster Gateway and reply paths: session reads, plugin metadata fingerprints, auth env snapshots, auto-enabled plugin config, tool-search catalogs, and stable metadata caches do less hot-path rediscovery while visible replies no longer inherit hidden cleanup timeouts. (#86439, #87044) Thanks @keshavbotagent.</li>
<li>Better provider and model coverage: OpenAI-compatible embedding providers are core, DeepInfra catalog browsing loads the full credential-aware model set, Pixverse adds video generation and API region selection, VLLM thinking params are wired, Claude CLI OAuth overlays load for PI auth profiles, and bare direct Anthropic model ids work. (#85269, #84549, #87167) Thanks @dutifulbob, @ats3v, and @joshavant.</li>
<li>Channel delivery is steadier: Telegram <code>sendMessage</code> actions use durable outbound delivery, iMessage suppresses duplicate native exec approval prompts and sends, Slack keeps delivered final replies during late cleanup, Matrix mention previews/finals are stricter, QQBot fallback approval buttons honor slash-command auth, Discord guild requester checks are tighter, recovered Discord tool-warning artifacts stay out of successful replies, and Google Chat stops thread sends in DMs. (#87261, #87154) Thanks @mbelinky and @eleqtrizit.</li>
<li>Release, package, and CI proof paths are harder to wedge: npm/package inventory honors dist exclusions, shrinkwrap override pins merge correctly, Docker runtime workspace templates are packaged and smoked, release postpublish checks are stricter, beta smoke rejects empty runs, and E2E log/probe waits are bounded.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.</li>
<li>Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.</li>
<li>Providers: add the Pixverse video generation provider, API region selection, docs, and external plugin packaging support.</li>
<li>DeepInfra: load the full model catalog when users browse models during onboarding, preserve configured API-key catalogs, refresh media/video defaults, and keep pricing/default model metadata aligned. (#84549) Thanks @ats3v.</li>
<li>Plugin SDK: expose plugin approval action metadata and stop exporting Vitest test helpers from the public SDK surface. (#87120) Thanks @RomneyDa.</li>
<li>Channel SDK: move channel message compatibility into core, remove old channel turn runtime aliases, and preserve runtime catalog markdown metadata for plugins.</li>
<li>ClawHub: add plugin display metadata so catalog/package listings use cleaner names. (#87354) Thanks @thewilloftheshadow.</li>
<li>Agents: split the heartbeat runtime template out of docs assets and add compatibility repair for legacy heartbeat template content. (#85416) Thanks @hxy91819.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security/content boundaries: route untrusted group prompt metadata outside system prompts, normalize repeated trailing hostname dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject no-auth Tailscale exposure, block untrusted Microsoft Teams service URLs, enforce <code>/allowlist configWrites</code> origin policy, gate QQBot fallback approval buttons, and require admin for node/device-role approvals. (#87144, #87305, #87292, #87308, #87146, #87154, #87334) Thanks @eleqtrizit and @pgondhi987.</li>
<li>Codex: resolve Codex runtime models before generic routing, route workspace memory through tools, preserve shared app-server clients after startup and spawned-helper failures, preserve native hook relay generations across restarts and fresh fallbacks, keep raw reasoning/source-reply guards intact, report quarantined dynamic tools, keep the attempt watchdog armed for queued terminal turns, and route Codex OAuth compaction through OpenAI-Codex. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
<li>Agents/runtime: avoid session event queue self-waits, bound compaction wake and steering retries, preserve grace for pending error diagnostics, avoid false Codex runtime live switches, avoid stale restart continuation reuse, preserve session fallback errors, suppress duplicate Claude CLI skill prompts, keep runtime context before active user turns, strip stale Anthropic thinking, quarantine unsupported tool schemas, recover completed write timeouts safely, release retained session write locks on timeout abort, and validate forced plugin harness support before pinning. (#86123, #55424, #86855, #74341, #87278) Thanks @luoyanglang, @cathrynlavery, and @openperf.</li>
<li>Reply/session delivery: keep visible turn admission unbounded, keep visible fallback delivery on latest targets, preserve bridge hook context, classify direct fallback targets by channel grammar, report approval resolutions in bridge mode, and avoid stale source-reply artifacts. (#87044) Thanks @keshavbotagent.</li>
<li>Channels: make Telegram <code>sendMessage</code> action replies durable and preserve SecretRef prompt config, suppress duplicate iMessage native exec approval prompts and sends, keep iMessage approval polling alive after denied reactions, keep Slack delivered final replies during late cleanup, keep Matrix mention previews/finals mention-inert and normally delivered, ignore filename-embedded Matrix IDs, suppress recovered Discord tool-warning artifacts from successful replies, suppress Google Chat thread sends in DMs, and harden Discord guild requester checks. (#87261, #87452) Thanks @mbelinky.</li>
<li>Memory: salvage QMD search JSON after nonzero exits and keep workspace memory routing through the Codex tool path where possible. (#87225, #87383, #87403) Thanks @osolmaz.</li>
<li>Providers/models: forward cached token usage in OpenAI-compatible chat completions, load Claude CLI OAuth overlays for PI auth profiles, send bare direct Anthropic model ids, wire configured VLLM thinking params, honor OpenAI-compatible cache retention, normalize OpenAI Responses replay tool ids, resolve OpenAI <code>gpt-5.5</code> without a cached catalog, preserve <code>retry-after</code> fallback handling, bound GitHub Copilot auth requests, and load DeepInfra custom/live catalogs consistently. (#82062, #87167, #84549) Thanks @caz0075, @joshavant, and @ats3v.</li>
<li>Gateway/performance: borrow read-only session metadata and active session working stores, cache current/stable plugin metadata fingerprints, cache auto-enabled plugin config, slim metadata identity caches, trust current metadata lifecycle caches, stabilize isolated cron prompt-cache affinity, persist model auth profile suffixes, drain probe client closes, expire browser tokens after auth rotation, and keep default status fast paths bounded. Thanks @ferminquant.</li>
<li>CLI/help/config: reject loose or malformed numeric options for gateway timeouts, model limits, directory limits, message options, webhooks, and partial values; respect subcommand version options; route generated/root/plugin help targets correctly; keep skills JSON output flushing naturally; and keep plugin descriptor loading quiet in root help. (#87398) Thanks @Patrick-Erichsen.</li>
<li>Plugin state/tool search: evict the current namespace when plugin rows hit caps, reuse unchanged tool-search catalogs, align the release catalog reuse wrapper, and keep fallback tool warnings mention-inert.</li>
<li>Install/package/release: match npm globstar exclusions, honor dist package exclusions in inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, package Docker runtime workspace templates, smoke Docker runtime templates during full validation, merge nested shrinkwrap override pins, preserve forked shrinkwrap pins, pin aged <code>lru-cache</code>, harden postpublish verification, accept main full-validation proof, and reject empty beta smoke runs.</li>
<li>E2E/QA/Crabbox: bound Telegram, Open WebUI, ClawHub, Matrix, Tool Search, MCP, gateway network, bundled runtime, kitchen-sink, codex media, config reload, and agent-turn assertion waits; prefer Azure for Windows targets; reinitialize invalid changed-gate git dirs; full-sync sparse container runs; and fail empty explicit test requests. (#87186)</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
</item>
<item>
<title>2026.5.26</title>
<pubDate>Wed, 27 May 2026 12:24:26 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052690</sparkle:version>
<sparkle:shortVersionString>2026.5.26</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.26</h2>
<h3>Highlights</h3>
<ul>
<li>Faster Gateway and replies: startup avoids repeated plugin, channel, session, usage-cost, warning, scheduled-service, and filesystem scans; visible replies separate user-facing sends from slower follow-up work; Gateway runtime/session caches churn less under load.</li>
<li>Transcripts are core: transcript-backed meeting summaries, source-provider chunks, cleaned user turns, media provenance, Codex mirrors, WebChat replies, and CLI/TUI replay now use one more reliable transcript path.</li>
<li>More channels are production-ready: Telegram keeps typing/progress context and forum topics, iMessage handles attachment roots, remote media staging, and duplicate local Messages sources, WhatsApp restores group/media behavior, Discord improves voice playback and model picking, and Signal/iMessage/WhatsApp get reaction approvals.</li>
<li>Better voice and Talk: realtime Talk runs can be inspected, steered, cancelled, or followed up from Web UI and Discord voice; wake-name handling is more tolerant without letting ambient speech trigger agents.</li>
<li>Safer content boundaries: Browser snapshot reads honor SSRF policy, system-event text cannot spoof nested prompt markers, fetched file text is wrapped as external content, ClickClack inbound sender allowlists run before agent dispatch, stale device tokens are rejected, and serialized tool-call text is scrubbed from replies.</li>
<li>Providers, Codex, and local models are steadier: named auth profiles, OpenAI sampling params, Codex app-server resume/timeout/usage-limit recovery, dynamic tool-schema guards, xAI usage-limit surfacing, Ollama top-p normalization, and local approval resolution reduce provider-specific dead ends.</li>
<li>More reliable install/update/release paths: Alpine installs, trusted runtime fallback roots, stable update channels, Docker/package timeouts, Windows Scheduled Tasks, Windows/macOS proof lanes, Testbox/Crabbox delegation, plugin publish checks, and macOS runner bootstraps all got hardened.</li>
<li>Better observability: Activity tab, gateway secret-prep traces, tool/model stream progress, explicit fast-mode status, systemd Gateway hygiene, OpenTelemetry LLM spans, release performance evidence, and richer telemetry signals make failures easier to inspect.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Transcripts: add core transcript capture and source-provider support for transcript-backed meeting summaries, including the renamed Transcripts docs, CLI surface, source-provider chunks, and cleaned user-turn persistence.</li>
<li>Auth: add named model login profiles and supported credential migration for Hermes, OpenCode, and Codex auth profiles, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.</li>
<li>Diagnostics: trace gateway secret preparation, classify skill/tool usage, surface model stream progress, add OpenTelemetry LLM content spans, and expose alertable telemetry for blocked tools, failover, stale sessions, liveness, oversized payloads, and webhook ingress. (#83019, #80370, #86191)</li>
<li>Channels: add Signal reaction approvals, iMessage thumb approval reactions, and WhatsApp thumb approval reaction support so mobile approval flows work without textual <code>/approve</code> commands. (#85894, #85952, #85477)</li>
<li>Agents/API: forward OpenAI sampling params through the Gateway and expose estimated context-budget status for active agent runs. (#84094)</li>
<li>TUI/status: queue prompts submitted while an agent is busy and show explicit fast-mode state plus richer systemd Gateway hygiene in status output. (#86722, #87115, #86976)</li>
<li>Exec approvals: hide durable approval actions that are unavailable for the current prompt and keep approval runtime tokens local-only so stale prompts cannot offer misleading controls. (#86270, #86359)</li>
<li>Plugin SDK: add reaction approval helpers and keep diagnostic event root exports discoverable across function-name and alias-bound module graphs. (#86735, #87084)</li>
<li>Android/iOS: add the Android pair-new-gateway action and improve mobile Talk mode surfaces, including iOS realtime Talk mode and Android offline voice/gateway recovery. (#86798, #86355) Thanks @ngutman.</li>
<li>Performance: cache plugin metadata snapshots, package realpaths, stable gateway metadata, model cost indexes, channel resolution, usage-cost indexes, and session/auth hot-path facts so common Gateway and reply paths do less rediscovery. (#84649, #85843, #86517, #86678)</li>
<li>Voice: expose shared realtime turn-context tracking through the realtime voice SDK and reuse it for Discord speaker attribution and wake-name context recovery.</li>
<li>Voice: reuse shared realtime output activity tracking in Google Meet command and node audio bridges, including recent-output checks for local barge-in detection.</li>
<li>Voice: expose shared realtime output activity tracking through the realtime voice SDK and reuse it for Discord playback activity and barge-in decisions.</li>
<li>Voice: expose shared realtime consult question matching, speakable-result extraction, and alias-aware forced-consult coordination through the realtime voice SDK, then reuse it in Gateway Talk, Voice Call, and Discord voice paths.</li>
<li>Voice: share activation-name matching and consult-transcript screening through the realtime voice SDK so Discord, browser voice, and meeting surfaces can reuse one implementation.</li>
<li>Cron: default <code>cron.maxConcurrentRuns</code> to 8 so scheduled automations and their isolated agent turns can make progress in parallel without explicit configuration.</li>
<li>QA-Lab: add <code>qa coverage --match <query></code> so focused proof selection can discover matching scenarios from existing metadata before running live or remote lanes.</li>
<li>Discord/model picker: surface an alpha-bucket select (e.g. <code>AG (12) · HN (18) · OZ (5)</code>) when the provider list or a provider's model list exceeds 25 items, so configs with <code>provider/*</code> wildcards stay one click from the right page instead of paginating through prev/next; falls back to numeric chunks when every item shares the same first letter.</li>
<li>Control UI: add an ephemeral Activity tab for sanitized live tool activity summaries without persisting raw telemetry. Fixes #12831. Thanks @BunsDev.</li>
<li>Build: include <code>ui:build</code> in the <code>full</code> and <code>ciArtifacts</code> profiles of <code>scripts/build-all.mjs</code> so <code>pnpm build</code> always rebuilds <code>dist/control-ui</code> after <code>tsdown</code> cleans <code>dist</code>, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)</li>
<li>iOS: improve Talk mode with direct realtime voice sessions, compact toolbar status, and responsive voice waveform feedback. (#86355) Thanks @ngutman.</li>
<li>Media: replace the Sharp image backend with Rastermill for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)</li>
<li>Codex: update the bundled Codex CLI to 0.134.0 and keep native compaction disabled for budget-triggered app-server turns so OpenClaw owns the recovery boundary. (#86772)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Memory/security: reject prompt-like text submitted through the explicit <code>memory_store</code> tool before embedding or storage, matching the existing auto-capture prompt-injection filter. (#87142)</li>
<li>Gateway/security: enable the default auth rate limiter for remote non-browser and HTTP gateway auth failures when <code>gateway.auth.rateLimit</code> is unset, while preserving the loopback exemption. (#87148)</li>
<li>Security/content boundaries: validate Browser snapshot tab URLs against SSRF policy before ChromeMCP or direct CDP reads, sanitize queued system-event text so untrusted plugin/channel labels cannot spoof nested prompt markers, wrap fetched file text and metadata as external content, apply ClickClack <code>allowFrom</code> sender allowlists before agent dispatch, reject RPCs from invalidated device-token clients during rotation, require staged sandbox media refs, and scrub serialized tool-call text from replies. (#78526, #87094, #87062, #83741, #70707, #86924) Thanks @zsxsoft, @ttzero25, and @mmaps.</li>
<li>Transcripts/user turns: persist CLI, WebChat, media, follow-up, hook, and Codex-mirror user turns to the admitted session target; keep cleaned transcript text, inline image routing, provenance metadata, replay hooks, and fallback paths idempotent when runtimes fail or restart.</li>
<li>TUI/status/onboarding/UI: queue busy TUI prompts instead of dropping them, preserve the configured default model during onboarding, show failed tool results as errors, show config-open failures in Control UI, keep status JSON plugin scans healthy, preserve xAI usage-limit errors locally, and expose explicit fast-mode/systemd state. (#86722, #87000, #85786, #87108, #87001, #86614, #87115, #86976)</li>
<li>Plugin commands/SDK: preserve plugin LLM command auth, bind native plugin command dispatch to the host agent's LLM auth, keep <code>onDiagnosticEvent</code> exports discoverable through <code>Function.name</code>, stabilize diagnostic event root aliases, correlate pathless read diagnostics, suppress transient runner failures in channel command paths, and repair local approval resolution. (#85936, #87084, #86977, #87069, #86771)</li>
<li>Codex/providers: keep WebChat delivery hints out of user prompts, avoid false queued-terminal idle timeouts, share the native hook relay registry, quarantine unsupported dynamic tool schemas, preserve Claude resumed-session system prompts, normalize greedy Ollama <code>top_p</code>, preserve per-agent thinking defaults for ingress runs, and avoid native compaction takeover on budget-triggered Codex turns. (#87096, #73950, #87049, #86689, #86772)</li>
<li>Gateway/perf/release: reuse startup-warning metadata and prepared auth stores, avoid cloning live-switch and lifecycle session caches on read paths, defer warning and scheduled-service fallback imports, trim Gateway session/startup/runtime CPU churn, skip duplicate turn session touches, stop chat timeout fallback cascades, drop stale subagent announce history, bound benchmark/watch/kitchen-sink teardown waits, bound macOS/package/onboarding/plugin smoke commands, bound install finalization probes, resolve Parallels npm-update commands from guest <code>PATH</code>, and bootstrap raw AWS macOS Node/pnpm commands through <code>/usr/bin/env</code>. (#86997)</li>
<li>Reply/perf: reduce visible reply delivery latency by preserving Telegram typing/progress context, lazy-loading slash-command startup metadata, avoiding hot-path model hydration, flag-gating Codex profiler timing, deferring context compaction maintenance, and tracking delivery timing. (#86989, #86990, #86991, #86992, #86993, #86994) Thanks @keshavbotagent.</li>
<li>Reply/source delivery: keep TUI, Control UI, media, TTS, transcript, and Codex source-reply finals live without duplicate terminal events or stale replay artifacts.</li>
<li>Agents/replay: repair legacy tool results before replay, preserve <code>sessions_spawn</code> transcript payloads, restore current guard checks, stage sandboxed workspace media, and keep duplicate transcripts tool display metadata from reappearing. (#82203, #86934, #87025) Thanks @martingarramon, @vincentkoc, and @joshavant.</li>
<li>Agents/sessions: handle active-fallback failures in <code>sessions_send</code> so fallback routing reports the real failure and does not leave callers with an ambiguous dropped send. (#86638)</li>
<li>Agents/hooks/subagents: enforce default hook agent allowlists, recover failed subagent lifecycle completions, and keep node task lifecycle cleanup from closing the Gateway listener. (#86101)</li>
<li>Codex: project newer OpenClaw chat history into resumed app-server threads and keep Codex turn timeouts inside the Codex runtime boundary so timeouts do not poison shared app-server clients or fall through to unrelated provider fallback. (#86677, #86476) Thanks @TurboTheTurtle and @pashpashpash.</li>
<li>Config/doctor/update: narrow profiled tool-section doctor repair, keep runtime-injected legacy web-search provider config out of user-authored config validation, and keep prerelease tags excluded from stable updater resolution. (#87030, #86818, #86559) Thanks @joshavant, @luoyanglang, and @stevenepalmer.</li>
<li>CLI/Windows: add a Windows-only stack-size respawn for stack-heavy startup paths, default CLI logs to local timestamps, and validate timeout/banner TTY state more strictly. (#87031, #85387) Thanks @giodl73-repo and @vincentkoc.</li>
<li>Locking/security: require owner identity proof before stale plugin lock removal, memoize session lock owner arguments, and avoid writing default exec approval stores unless policy state actually changed. (#86814, #86964) Thanks @Alix-007 and @vincentkoc.</li>
<li>Install/release: bound Docker package build, inventory, pack, and tarball preparation with process-group timeouts; pin shrinkwrap patch drift to the pnpm lock; harden macOS restart and dSYM packaging; and run release Docker/live timeout wrappers in the foreground so child processes cannot wedge gates.</li>
<li>Telegram/network: treat <code>ENETDOWN</code> as a transient pre-connect network failure so Telegram sends, gateway unhandled-rejection handling, and cron network retries follow the same recovery path as sibling network outages. (#86762) Thanks @TurboTheTurtle.</li>
<li>Telegram: preserve inbound text entities, overlapping DM replies, account topic cache sidecars, outbound reply context, targeted bot-command mentions, durable group retry targets, forum topic names, and native progress callbacks. (#83873, #85361, #85555, #85656, #85709, #86299, #86553) Thanks @SebTardif, @luoyanglang, and @neeravmakwana.</li>
<li>iMessage: read image attachments from local Messages attachment roots, dedupe duplicate local Messages-source accounts, seed direct DM history, fix image/group media attachment commands, advance catchup cursors after live handling, and keep slash-command acknowledgements in the source conversation. (#82642, #85475, #86569, #86705, #86706, #86770) Thanks @homer-byte, @TurboTheTurtle, @swang430, and @OmarShahine.</li>
<li>WhatsApp/QQ/Twitch/IRC/Slack: restore WhatsApp ack identity and group-drop warnings, make QQ Bot media respect <code>OPENCLAW_HOME</code>, serialize Twitch auth disconnects, store IRC channel routes canonically, and keep Slack downloaded files out of reply media. (#83833, #85309, #85777, #85794, #85906, #86318, #86697) Thanks @sliverp, @neeravmakwana, and @Kailigithub.</li>
<li>Discord/voice: improve voice playback and wake replies, bucket large model picker menus, merge media captions into one message, route metadata through configured proxies, restore numeric channel sends, suppress self-reply echoes, and tighten wake matching without breaking fuzzy wake phrases. (#80227, #86238, #86487, #86571, #86595, #86601)</li>
<li>Codex: preserve native web-search metadata, keep oversized native thread reuse, bridge CLI API-key auth into the app server, preserve sandbox bootstrap path style, recover context-window prompt errors, honor yolo approval policy, disable native thread personality, and route compaction through Codex auth. (#85378, #85542, #85891, #85909, #86408)</li>
<li>Agents/runtime: enforce session lock max-hold reclaim, release embedded-attempt locks on all exits, treat aborted subagent runs as terminal, avoid runtime model hydration on hot paths, disclose scoped session list counts, derive overflow budgets from provider errors, and keep fallback errors scoped to the active model candidate. (#70473, #85764, #86014, #86134, #86427, #86944) Thanks @openperf, @fuller-stack-dev, @zhangguiping-xydt, and @ferminquant.</li>
<li>Config/update/doctor: retry config recovery after failed backup restore, skip shell env fallback on Windows, exclude prerelease tags from the stable git channel, support deep config edits, warn instead of aborting on unreadable cron stores, prune stale bundled plugin paths, and avoid duplicate restart prompts when the Gateway is already healthy. (#85739, #85787, #86060, #86260, #86384, #86533) Thanks @liaoyl830.</li>
<li>Install/release: support Alpine CLI installs and runtime floors, prefer trusted startup argv runtime fallback roots, reject stale CLI node runtimes, avoid npm <code>min-release-age</code> installer failures, bound npm/package/Docker install phases, restore config parent ownership in Docker, seed Docker lockfile package tarballs before prune, make release/plugin prerelease checks fail closed instead of hanging or false-greening, and use host-visible Crabbox local work roots for Docker-backed proof. (#85491)</li>
<li>Windows daemon: keep Scheduled Task gateway launches running on battery power and avoid workgroup-machine prompts for a domain user during task installation. (#59299)</li>
<li>Security: avoid printing Gateway tokens in Docker, validate plugin model-pattern regexes safely, escape transcript metadata field names, harden session allowlist glob matching, audit Claude permission overrides under YOLO, and require explicit allow for ACP auto approvals. (#85849, #85934, #86046, #86557)</li>
<li>Media/images: replace Sharp with Rastermill, keep EXIF normalization best-effort, normalize HEIC/HEIF before image descriptions, route Codex image API keys through OpenAI, preserve image compression metadata, and auto-scale live tool result caps. (#85776, #86037, #86437, #86857, #86923)</li>
<li>Memory: prevent semantic vector indexes from silently degrading when embeddings are unavailable, stop doctor OOMs on large session stores, preserve sidecar hooks/artifacts, write fallback dream diaries, use CJK-aware dreaming dedupe, and avoid per-file watcher FD fan-out. (#80613, #82928, #85060, #85704, #85967, #86701) Thanks @brokemac79, @openperf, and @yaaboo-gif.</li>
<li>Agents/sessions: include visibility metadata on restricted <code>sessions_list</code> results so scoped counts are clearly reported without widening access or exposing hidden-session counts. (#86944) Thanks @ferminquant.</li>
<li>Gateway/DNS: validate wide-area discovery domains before deriving zone paths or writing zone files, so invalid <code>discovery.wideArea.domain</code> and <code>dns setup --domain</code> values fail with a DNS-name diagnostic instead of falling through to unrelated configuration errors. Thanks @mmaps.</li>
<li>Agents/BTW: route fallback side-question streams through the embedded stream resolver so Anthropic-compatible MiniMax requests use the same capped transport as normal chat. (#86312) Thanks @neeravmakwana.</li>
<li>Telegram: treat <code>/command@TargetBot</code> bot-command entities as explicit mentions for the addressed bot so <code>requireMention</code> groups no longer drop targeted commands or captions. Fixes #84462. (#86553) Thanks @luoyanglang.</li>
<li>CI: bound Docker/Bash E2E tarball npm installs with <code>OPENCLAW_E2E_NPM_INSTALL_TIMEOUT</code> so package, onboarding, plugin, and upgrade lanes fail instead of hanging on a stuck npm install.</li>
<li>CI: fail Parallels npm-update smoke jobs after the guest command timeout and cleanup backstop instead of only logging a timeout line.</li>
<li>CI: bound kitchen-sink RPC HTTP probes so stalled gateway readiness or response bodies fail and retry instead of wedging the walker.</li>
<li>CI: keep <code>OPENCLAW_TESTBOX=1 pnpm check:changed</code> delegating to Blacksmith Testbox through Crabbox without forwarding local Testbox or worker env into the remote command.</li>
<li>CI: send KILL after the TERM grace period for manual checkout fetch timeouts so stuck Testbox and workflow checkout retries cannot hang behind a wedged <code>git fetch</code>.</li>
<li>CI: send KILL after the TERM grace period for Bun global install smoke command timeouts so trapped <code>openclaw</code> child processes cannot wedge the scheduled install smoke.</li>
<li>iMessage: thread current channel/account inbound attachment roots into the image tool so iMessage-saved attachments under <code>~/Library/Messages/Attachments</code> (including the wildcard <code>/Users/*/Library/Messages/Attachments</code> root) are read through the existing inbound path policy instead of being rejected as <code>path-not-allowed</code>. Literal <code>localRoots</code> stays workspace-scoped. Fixes #30170. (#86569)</li>
<li>QQ Bot: respect <code>OPENCLAW_HOME</code> for outbound media path resolution so <code><qqmedia></code> sends no longer silently fail when <code>HOME</code> and <code>OPENCLAW_HOME</code> differ (Docker / multi-user hosts). Persisted QQ Bot data (sessions, known users, refs) stays anchored on the OS home for upgrade compatibility. Fixes #83562. Thanks @sliverp.</li>
<li>Update: report the primary malformed <code>openclaw.extensions</code> payload error without adding a duplicate missing-main diagnostic. (#86596) Thanks @ferminquant.</li>
<li>Control UI: keep host-local Markdown file paths inert while preserving app-relative links. (#86620) Thanks @BryanTegomoh.</li>
<li>Gateway: dampen repeated unauthenticated device-required probes per URL while preserving explicit-auth and paired recovery paths. (#86575) Thanks @ferminquant.</li>
<li>IRC: store inbound channel routes with the canonical <code>channel:#name</code> target and join transient channel sends before writing. (#85906) Thanks @Kailigithub.</li>
<li>Usage: surface unknown all-zero model pricing as missing cost entries instead of a confident <code>$0</code> total. (#85882) Thanks @MichaelZelbel.</li>
<li>Agents/Codex: honor yolo app-server approval policy only for the full <code>never</code> plus <code>danger-full-access</code> case. (#85909) Thanks @earlvanze.</li>
<li>Gateway/Gmail: clear Gmail watcher renewal intervals on re-entry so hot reloads do not leak lifecycle timers. (#82947) Thanks @SebTardif.</li>
<li>Logging: exit cleanly on broken stdout/stderr pipes without masking existing failure exit codes. (#80059) Thanks @pavelzak.</li>
<li>Gateway/security: escape transcript metadata field names while extracting oversized session line prefixes. (#85934) Thanks @SebTardif.</li>
<li>Plugins/security: validate manifest model pattern regexes with the safe-regex compiler so unsafe patterns are ignored before matching. (#86046) Thanks @SebTardif.</li>
<li>Discord: route gateway metadata REST lookups through the configured Discord proxy so proxied accounts do not fall back to direct <code>discord.com</code> connections before opening the WebSocket. Fixes #80227. Thanks @Clivilwalker.</li>
<li>Agents/media: hydrate current-turn image attachments from filename-derived MIME types so active vision can see generated or forwarded images whose source omitted an image content type. (#84812) Thanks @marchpure.</li>
<li>Agents/fs: point workspace-only scratch-path guidance at in-workspace temp directories while keeping host-root writes rejected by the tool guard. (#86501) Thanks @tianxiaochannel-oss88.</li>
<li>Agents/media: keep async cron media completions scoped to their run session while preserving direct delivery for stale generated-media success and failure notifications. (#86529) Thanks @ai-hpc.</li>
<li>Gateway: emit plugin <code>session_end</code>/<code>session_start</code> hooks when <code>agent.send</code> rotates or replaces a session id, keeping hook lifecycle state aligned with <code>sessions.changed</code> notifications. Fixes #83507. (#85875) Thanks @brokemac79.</li>
<li>OpenShell/SSH: reject malformed generated exec commands before sandbox/session setup so unresolved workflow placeholders fail fast instead of reaching the remote shell. Fixes #72373. Thanks @brokemac79.</li>
<li>Google: stop normalizing <code>gemini-3.1-flash-lite</code> to the retired preview endpoint and update Flash Lite alias guidance to the GA model id. Fixes #86151. (#86240) Thanks @SebTardif.</li>
<li>Installer: make Alpine apk installs cover Git, verify the Node runtime floor, try <code>nodejs-current</code>, and report Alpine version guidance when repositories only provide older Node packages.</li>
<li>Agents/status: prefer the active Claude CLI OAuth auth label over an unused Anthropic env API-key label for equivalent runtime aliases. Fixes #80184. (#86570) Thanks @brokemac79.</li>
<li>Agents/media: send direct fallback for generated media still missing after an active requester wake fails. (#85489) Thanks @fuller-stack-dev.</li>
<li>Agents: derive overflow compaction budgets from provider-reported and synthetic over-budget token counts so confirmed context overflows compact before retrying. (#70473) Thanks @fuller-stack-dev.</li>
<li>Agents/Codex: recover Codex context-window prompt errors through overflow compaction and surface reset guidance when recovery is exhausted. (#85542) Thanks @fuller-stack-dev.</li>
<li>Agents/Codex: allow Codex app-server runs to bootstrap from <code>CODEX_API_KEY</code> or <code>OPENAI_API_KEY</code> when no Codex auth profile is configured.</li>
<li>Agents/Codex: keep selected Codex runtime routing on OpenAI-Codex while preserving direct OpenAI API-key compaction fallback. (#86408) Thanks @funmerlin and @VACInc.</li>
<li>Agent transcript: include OpenClaw agent session logs when finding local transcript candidates.</li>
<li>Crabbox: bootstrap raw AWS macOS shell commands wrapped in absolute <code>time</code> paths so RSS probes can run Node and pnpm on fresh macOS runners.</li>
<li>Crabbox: bootstrap raw AWS macOS shell commands even when setup statements precede Node or pnpm usage.</li>
<li>TUI/local: skip unnecessary secret resolution, gateway model catalog loading, bootstrap, and skill scans in explicit local-model runs so startup reaches the model request faster.</li>
<li>Sessions/doctor: load large session stores without clone amplification during read-only doctor checks and reclaim stale <code>sessions.json.*.tmp</code> sidecars. Fixes #56827. Thanks @openperf.</li>
<li>Tests: clean successful plugin gateway gauntlet isolated temp roots while keeping an explicit preservation switch for failed/debug runs.</li>
<li>Plugins/perf: reuse derived plugin metadata snapshots for the lifetime of the process so reply-time skill setup no longer rescans plugin metadata on every turn.</li>
<li>Discord/OpenAI voice: keep wake-name master consults using the current speaker context after ignored ambient transcripts and shorten the default capture silence grace.</li>
<li>Doctor: skip redundant Gateway restart prompts when a recent supervisor restart leaves the Gateway healthy. Fixes #86518. (#86533) Thanks @liaoyl830.</li>
<li>Cron: restore suspended cron lanes to the configured/default concurrency instead of falling back to one after quota or circuit-breaker auto-resume.</li>
<li>Gateway: keep session-only Control UI tool-start mirrors flowing during diagnostic queue pressure instead of silently dropping non-terminal tool updates.</li>
<li>Agents/memory: return optional not-found context for missing date-only daily memory reads instead of logging benign first-run <code>ENOENT</code> failures. Fixes #82928. Thanks @galiniliev.</li>
<li>Discord: merge streamed text captions into following media block replies so captions and attachments send as one message. (#86487) Thanks @neeravmakwana.</li>
<li>Gateway: avoid sending duplicate tool-event frames to Control UI connections that are subscribed by both run and session.</li>
<li>Discord/OpenAI voice: accept broader edge-position fuzzy wake-name transcripts while keeping ambient speech gated.</li>
<li>Discord/OpenAI voice: accept longer leading wake-name mistranscripts such as "Open Club" for OpenClaw.</li>
<li>Agents/OpenAI-compatible: stop ModelStudio-compatible chat requests before sending system/tool-only payloads that have no usable user or assistant turn. (#86177) Thanks @TurboTheTurtle.</li>
<li>Gateway/plugins: reuse plugin package realpath checks while building installed plugin indexes so startup avoids repeated filesystem resolution work.</li>
<li>Kilo Gateway: send string <code>stop</code> sequences as arrays so Kilo accepts OpenAI-compatible chat completions. (#86461) Thanks @SebTardif.</li>
<li>Discord/OpenAI voice: accept leading fuzzy wake-name transcripts such as "Monty" or "Moti" for a Molty agent while keeping ambient speech gated.</li>
<li>Media understanding: convert HEIC and HEIF images to JPEG before image description providers run so iPhone photos work in direct and configured image-description flows. (#86037)</li>
<li>Agents: release embedded-attempt session locks from outer teardown so post-prompt exceptions cannot wedge later requests behind <code>SessionWriteLockTimeoutError</code>. Fixes #86014. Thanks @openperf.</li>
<li>Discord/OpenAI voice: rotate Realtime sessions at provider max duration without logging the expected session-expiry event as an error.</li>
<li>Sessions: skip metadata-only entries during QMD-slugified session lookup so one incomplete row does not block transcript hit resolution. (#86327) Thanks @abnershang.</li>
<li>Agents/media: derive bundled plugin local-media trust from plugin tool metadata instead of importing the full plugin registry on subscription paths. (#84409) Thanks @samzong.</li>
<li>Image tool: keep config-backed custom-provider API keys usable for auto-discovered vision models, including deferred image-tool execution without env keys or auth profiles. (#85733)</li>
<li>Memory/local embeddings: run local GGUF embeddings in an isolated worker sidecar and degrade to configured fallback or keyword search on worker failure so native embedding crashes do not take down the Gateway. (#85348) Thanks @osolmaz.</li>
<li>Gateway: clear the runtime config snapshot before <code>SIGUSR1</code> in-process restarts so config changes survive the next gateway loop. (#86388) Thanks @XuZehan-iCenter.</li>
<li>Models: show OAuth delegation markers as configured <code>models.json</code> auth while keeping runtime route usability checks strict. (#86378) Thanks @rohitjavvadi.</li>
<li>Cron: seed active scheduled and manual cron task rows with a progress summary so status surfaces do not look blank while jobs run. (#86313) Thanks @ferminquant.</li>
<li>Cron: preserve unsupported persisted cron payload rows during routine store writes while keeping those rows non-runnable. Fixes #84922. (#86415) Thanks @IWhatsskill.</li>
<li>Updater: exclude prerelease git tags from stable channel resolution so source updates do not check out newer alpha/rc/preview/canary tags. (#86260) Thanks @stevenepalmer.</li>
<li>Security/Audit: flag webhook <code>hooks.token</code> reuse of active Gateway password auth in <code>openclaw security audit</code> while keeping password-mode startup compatibility. (#84338) Thanks @coygeek.</li>
<li>QQBot: derive the outbound reply watchdog from configured agent and provider timeouts so slow local model replies are not cut off at five minutes. Fixes #85267. (#85271) Thanks @SymbolStar.</li>
<li>Agents/heartbeat: stop heartbeat turns after the first valid <code>heartbeat_respond</code> so repeated response loops do not burn tokens. (#86357) Thanks @udaymanish6.</li>
<li>Tasks: keep retained lost tasks out of default status health counts, explain their cleanup window during maintenance, and prune lost task records after 24 hours instead of the general 7-day terminal retention.</li>
<li>Memory-core: keep REM dreaming focused on live light-staged memories and mark staged entries as considered so old recall history no longer dominates fresh candidates. (#86302) Thanks @SebTardif.</li>
<li>Memory: abort sync instead of downgrading an existing semantic vector index to FTS-only when the configured embedding provider is temporarily unavailable. (#85704) Thanks @yaaboo-gif.</li>
<li>Telegram: propagate forum topic names through the account-scoped topic cache for native command context and topic create/edit actions. (#86299) Thanks @SebTardif.</li>
<li>Slack: keep downloaded read-only files out of reply media so Slack file reads do not echo files back to the conversation. (#86318) Thanks @neeravmakwana.</li>
<li>Cron: accept leading-plus relative durations such as <code>+5m</code> for one-shot <code>--at</code> schedules. (#86341) Thanks @mushuiyu886.</li>
<li>Agents/media: preserve async-started media tool metadata so background generation starts no longer surface generic incomplete-turn warnings while replay stays unsafe. (#85933) Thanks @fuller-stack-dev.</li>
<li>Docker E2E: dedupe scheduler lane resources so npm/service package lanes are not over-counted and serialized unnecessarily.</li>
<li>QA/diagnostics: add a collector-backed OpenTelemetry smoke lane, make the OTLP payload leak check scenario-aware, and keep source QA builds from failing on optional dependency imports resolved through pnpm's temp module path.</li>
<li>Crabbox: bootstrap Git metadata for sparse remote changed gates so raw synced workspaces can run <code>pnpm check:changed</code> from the intended diff.</li>
<li>xAI/LM Studio: avoid buffering ordinary bracketed or <code>final</code> prose until stream completion while watching for plain-text tool-call fallbacks.</li>
<li>Doctor: warn and continue when the cron job store exists but cannot be read so later health checks still run. Fixes #86102. (#86384) Thanks @1052326311.</li>
<li>Discord: suppress a bot's previous reply body and referenced media from prompt context when a user replies to that bot message, while keeping reply metadata for routing. (#86238) Thanks @fuller-stack-dev.</li>
<li>Discord: restore bare numeric channel IDs for outbound message-tool sends while keeping explicit DM targets unambiguous. (#86571) Thanks @joshavant.</li>
<li>Docker E2E: avoid rebuilding the Control UI twice while preparing the shared OpenClaw package tarball for package-backed scenario runs.</li>
<li>Tests: avoid rebuilding the Control UI twice during the installer Docker smoke now that <code>pnpm build</code> includes <code>ui:build</code>.</li>
<li>Tests: give QA config mutation RPCs enough native Windows budget to finish gateway config writes and restart settle after hot scenario runs.</li>
<li>Tests: keep the gateway restart-inflight QA scenario focused on restart recovery on native Windows by allowing expected embedded prompt handoff errors and using the Windows-safe timeout budget.</li>
<li>QA-Lab: make the synthetic OpenAI provider honor generic <code>reply exactly:</code> directives after required kickoff reads so restart-recovery scenarios do not fall through to generic repo-summary prose.</li>
<li>Gateway: abort active <code>agent</code> RPC runs during forced restart shutdown so stale in-process turns cannot keep writing a session after the Gateway lifecycle restarts.</li>
<li>Crabbox: sync clean sparse worktrees through a temporary full checkout even when reusing an existing lease so tracked build-time files are not omitted.</li>
<li>Build: route <code>scripts/ui.js</code> through the shared pnpm runner and keep Control UI chunking helpers in sparse-included source so native Windows Corepack builds can produce <code>dist/control-ui</code>.</li>
<li>Tests: give the memory fallback QA scenario enough turn budget to exercise native Windows gateway runs instead of failing on the client timeout while the mock agent is still dispatching.</li>
<li>Tests: collect QA gateway CPU/RSS metrics on native Windows and give the channel baseline enough turn budget to report slow gateway runs instead of timing out before proof.</li>
<li>Install/update: bypass npm <code>min-release-age</code> policies with <code>--min-release-age=0</code> instead of <code>--before</code> so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo.</li>
<li>Diagnostics: reclaim wedged session lanes when stale active-run bookkeeping blocks queued work despite no forward progress. Fixes #85639. Thanks @openperf.</li>
<li>WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd.</li>
<li>Gateway/perf: fail startup benchmark samples when the Gateway process exits before benchmark teardown, including signal deaths after readiness probes.</li>
<li>Gateway/perf: fail restart benchmark samples when the Gateway exits before benchmark teardown, including clean exits and signal deaths after successful restart probes.</li>
<li>Agents/tests: keep model catalog visibility on static selection helpers so catalog visibility checks avoid the broad model-selection barrel import.</li>
<li>Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc.</li>
<li>xAI/LM Studio: promote plain-text tool-call fallbacks into structured tool calls and strip leaked internal tool syntax before user-facing delivery. (#86222) Thanks @fuller-stack-dev.</li>
<li>CLI: suppress benign self-update version-skew warnings during package post-update finalization.</li>
<li>Gateway/perf: tighten restart and startup benchmark failure handling so long profiling runs, failed probes, and fresh Linux runners no longer produce false passing or <code>n/a</code> results.</li>
<li>Checks: keep intentional Knip unused-file findings optional so full CI and sparse proof workspaces stay aligned.</li>
<li>Docker: restore writable <code>~/.config</code> in runtime images. Fixes #85968. Thanks @hkoessler and @Bartok9.</li>
<li>Plugin SDK: keep legacy root diagnostic subscriptions connected when built plugin SDK aliases resolve diagnostic helpers through a separate module graph.</li>
<li>Diagnostics: export alertable OTel and Prometheus signals for blocked tools, model failover, stale sessions, liveness warnings, oversized payloads, and webhook ingress while fixing shared OTLP endpoints with query strings.</li>
<li>Tests: normalize macOS canonical temp paths in exec allowlists, fs-safe trash assertions, installed plugin matching, Telegram topic-name stores, and built ACPX MCP server expectations so native macOS proof runners cover the intended behavior.</li>
<li>Codex/app-server: preserve message-tool-only source reply delivery mode on active runs so sub-agent completion wakeups can steer the active Codex turn instead of being rejected. (#86287) Thanks @ferminquant.</li>
<li>Tests: sample the Windows kitchen-sink RPC gateway directly and serialize RSS probes so native runs keep the memory guard active.</li>
<li>Tests: normalize bundled plugin lifecycle probe paths and state-root lookup so native Windows release sweeps accept valid packaged plugin installs.</li>
<li>Agents/Claude CLI: route live native Bash permission requests through OpenClaw exec policy so Claude turns no longer stall on <code>control_request</code>, and document that OpenClaw exec policy is authoritative. Fixes #80819. (#86330, from #81971) Thanks @guthirry and @sallyom.</li>
<li>Security audit: warn when YOLO OpenClaw exec policy overrides a restrictive raw Claude <code>--permission-mode</code> for managed live sessions. (#86557) Thanks @sallyom.</li>
<li>Config: keep benign legacy metadata write anomalies out of default doctor and config command output while preserving explicit anomaly logging for diagnostics.</li>
<li>Codex: log when implicit app-server <code>never</code> approvals are promoted for OpenClaw tool policy, including whether the trigger was a <code>before_tool_call</code> hook or trusted tool policy.</li>
<li>Codex harness: make subscription usage-limit errors without reset times explain that OpenClaw cannot determine the reset and point users to wait until Codex is available, use another Codex account, or switch to another configured model/provider. Thanks @amknight.</li>
<li>Google Vertex: support production ADC modes such as Workload Identity Federation, service-account credentials, and metadata-server ADC for the native Vertex transport. (#83971) Thanks @damianFelixPago.</li>
<li>Telegram: route normal <code>[telegram][diag]</code> polling diagnostics through <code>runtime.log</code> while keeping non-diag warnings and persistence failures on <code>runtime.error</code>, so healthy polling startup no longer looks like an error. Fixes #82957. (#82958) Thanks @galiniliev.</li>
<li>Providers/Ollama: strip inline Kimi cloud reasoning prefixes from streamed and final visible replies while keeping ordinary Kimi answers append-only. (#86286) Thanks @jason-allen-oneal.</li>
</ul>
<ul>
<li>Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman.</li>
<li>Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) Thanks @zhangguiping-xydt.</li>
<li>iMessage: dedupe watcher startup when <code>channels.imessage.accounts</code> lists both <code>default</code> and a named account that point at the same local Messages source, so the gateway no longer spawns two <code>imsg rpc</code> processes or doubles inbound replies; the dedupe is scoped to watcher startup, leaving duplicate accounts addressable for outbound sends, status, and capability listings, and <code>openclaw doctor</code> flags the redundant account with a rebinding hint. Fixes #65141. (#86705) Thanks @swang430.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.26/OpenClaw-2026.5.26.zip" length="54484748" type="application/octet-stream" sparkle:edSignature="y4WXG7JT8ktJ+K7YDgllY7u5Z9BSKR/SwGiwEh0gikOJ/SWqwcQd+z2tWa2zgwvCJKWsAUFwJs1ATor880SUBg=="/>
</item>
<item>
<title>2026.5.22</title>
<pubDate>Sun, 24 May 2026 01:41:27 +0000</pubDate>
@@ -281,397 +537,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.22/OpenClaw-2026.5.22.zip" length="54409357" type="application/octet-stream" sparkle:edSignature="am1mwLOmUHor9QuQWtxSsKoBOCySUBo4fB+0Qdcrz0E3wf6ESIMTfOC0k+dKJSh9gtLZw5jzpWVqTBzEdU36Aw=="/>
</item>
<item>
<title>2026.5.20</title>
<pubDate>Thu, 21 May 2026 21:19:52 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052090</sparkle:version>
<sparkle:shortVersionString>2026.5.20</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.20</h2>
<h3>Changes</h3>
<ul>
<li>Exec approvals: remove the old <code>cat SKILL.md && printf ... && <skill-wrapper></code> allowlist compatibility path so skill files must be loaded with the read tool and only the real skill executable is auto-allowed.</li>
<li>Discord: let voice sessions follow configured Discord users into voice channels, with allowed-channel checks, multi-user handoff, bounded reconciliation, and DAVE recovery preservation. (#84264) Thanks @fuller-stack-dev.</li>
<li>Discord/voice: include bounded <code>IDENTITY.md</code>, <code>USER.md</code>, and <code>SOUL.md</code> profile context in realtime voice session instructions by default, with <code>voice.realtime.bootstrapContextFiles: []</code> available to disable it. (#84499) Thanks @fuller-stack-dev.</li>
<li>Dependencies: bump the bundled Codex harness to <code>@openai/codex</code> <code>0.132.0</code> and refresh the app-server model-list docs for the new catalog.</li>
<li>CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.</li>
<li>Agents/config: allow <code>agents.list[].experimental.localModelLean</code> so lean local-model mode can be enabled for one configured agent instead of globally.</li>
<li>Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.</li>
<li>Providers/OpenRouter: honor provider-level <code>params.provider</code> routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>CLI/tasks: include stale-running task maintenance decisions in <code>openclaw tasks maintenance --json</code> so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.</li>
<li>Codex app-server: keep system-prompt reports working when bootstrap hooks provide workspace files with only a path and content, so hook-supplied SOUL/IDENTITY/TOOLS/USER context still reports injected characters correctly. (#84736) Thanks @JARVIS-Glasses.</li>
<li>Providers/MiniMax music: stop advertising <code>durationSeconds</code> control and remove prompt-injected duration hints, so <code>music_generate</code> reports MiniMax duration as an unsupported override instead of suggesting MiniMax can enforce track length. Fixes #84508. Thanks @neeravmakwana.</li>
<li>Doctor: warn when sandbox tool policy hides configured MCP server tools before provider requests. (#84699) Thanks @nxmxbbd.</li>
<li>WhatsApp: update Baileys to <code>7.0.0-rc12</code>.</li>
<li>Build: suppress per-locale <code>rolldown-plugin-dts:fake-js</code> CommonJS dts warnings emitted while bundling the intentionally-inlined <code>zod/v4/locales/*.d.cts</code> files, so <code>pnpm build</code> output stays readable after the 0.25.1 plugin bump. Thanks @romneyda.</li>
<li>CLI/nodes: route lazy plugin-registration logs to stderr for JSON-mode <code>openclaw nodes</code> commands so stdout stays parseable. (#84684) Thanks @TurboTheTurtle.</li>
<li>Approvals: route manual <code>/approve</code> decisions through the trusted approval runtime so active exec and plugin approvals no longer look unknown or expired.</li>
<li>Mac app: update the About settings copyright year to 2026. (#84385) Thanks @pejmanjohn.</li>
<li>Dependencies: update <code>@openclaw/fs-safe</code> to <code>0.2.7</code> so OpenClaw's default Python-helper-off policy keeps best-effort Node write fallbacks for private stores, secret writes, run logs, and media attachments on Linux/macOS.</li>
<li>Infra/secrets: restore the fail-closed contract for <code>tryReadSecretFileSync</code> so credential loaders that pass <code>rejectSymlink: true</code> (Telegram, LINE, Zalo, IRC, Nextcloud Talk tokens) refuse symlinked credential files instead of silently accepting them, and the infra-state CI shard's secret-file symlink test passes again. Thanks @romneyda.</li>
<li>Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)</li>
<li>Doctor: remove unrecognized <code>models.providers.*.models[*].compat.thinkingFormat</code> values during <code>doctor --fix</code> so stale provider model config can validate after upgrade. Fixes #77803.</li>
<li>Doctor: warn when <code>openclaw.json</code> stores plaintext secret-bearing config fields, including model provider API keys and sensitive provider headers. (#84718) Thanks @lukaIvanic.</li>
<li>Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from <code>agents.defaults.model.primary</code>.</li>
<li>WebChat: clear stale typing indicators when session change events mark the active chat run complete.</li>
<li>Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.</li>
<li>macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.</li>
<li>Cron: deliver preferred final assistant output for successful scheduled runs when trailing plain tool warnings remain in diagnostics instead of marking the run failed.</li>
<li>fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.</li>
<li>Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.</li>
<li>CLI: keep the private QA subcommand out of exported command descriptors unless <code>OPENCLAW_ENABLE_PRIVATE_QA_CLI=1</code>, so root help and subcommand markers match runtime registration. (#84519)</li>
<li>CLI/cron: bound <code>openclaw cron show</code> job lookup pagination so non-advancing or unbounded <code>cron.list</code> responses fail instead of hanging the command. Fixes #83856. (#83989)</li>
<li>Agents/messages: stop message-tool-only turns after a successful source-channel <code>message</code> send while keeping transcript mirrors under the session write lock. (#84289)</li>
<li>Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.</li>
<li>Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving <code>strict=false</code> fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.</li>
<li>Agents/code mode: spell out the <code>exec</code> tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre.</li>
<li>Codex: give <code>image_generate</code> dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.</li>
<li>Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.</li>
<li>CLI/message: include a stable top-level <code>messageId</code> in <code>openclaw message --json</code> output when channel sends return one. (#84191) Thanks @100menotu001.</li>
<li>Cron: preserve legacy top-level array <code>jobs.json</code> stores when loading or adding scheduled jobs so old cron jobs are no longer treated as an empty store during upgrade. Fixes #60799. (#84433) Thanks @IWhatsskill.</li>
<li>Gateway/agents: use an agent's <code>identity.name</code> in Gateway agent summaries when <code>agents.list[].name</code> is unset, so configured agent labels remain visible in clients. (#84355; refs #57835) Thanks @luoyanglang.</li>
<li>Channels/replies: keep normal <code>/verbose</code> failed-tool progress compact in message-tool replies and prevent late text-only tool output from appearing after the final answer. (#84303) Thanks @VACInc.</li>
<li>Plugins/hooks: apply a default 30-second timeout to <code>before_compaction</code> and <code>after_compaction</code> hooks so a hung plugin handler no longer blocks compaction completion. (#84153)</li>
<li>Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.</li>
<li>Twitch: add a test-only client-manager registry reset helper so non-isolated Twitch tests can clear cached managers between cases. Fixes #83887. (#84244) Thanks @hclsys.</li>
<li>Cron: run main-session scheduled work on a cron-owned wake lane while preserving reply delivery context, so background cron turns no longer block human main-session chat. Fixes #82766. (#82767) Thanks @galiniliev.</li>
<li>Cron: use structured embedded-run denial metadata for isolated scheduled tasks so blocked exec requests fail the job without treating ordinary assistant prose as a denial. (#84067) Thanks @abnershang.</li>
<li>Cron: keep recovered tool warnings diagnostic for successful scheduled runs so final cron output is delivered instead of being replaced by a post-processing warning. (#84045) Thanks @abnershang.</li>
<li>Plugins/perf: thread explicit plugin discovery results through <code>loadBundledCapabilityRuntimeRegistry</code>, <code>resolveBundledPluginSources</code>, and <code>listChannelCatalogEntries</code> so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif.</li>
<li>harden update restart script creation [AI]. (#84088) Thanks @pgondhi987.</li>
<li>Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong.</li>
<li>CLI/channels: preserve the first line of <code>openclaw channels logs</code> output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed <code>readLogSlice</code> behavior in <code>src/logging/log-tail.ts</code>.</li>
<li>Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)</li>
<li>CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.</li>
<li>Matrix/config: accept <code>messages.queue.byChannel.matrix</code> queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.</li>
<li>CLI: format <code>openclaw acp client</code> failures through the shared error formatter so object-shaped errors stay readable instead of printing <code>[object Object]</code>. Fixes #83904. (#84080)</li>
<li>Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when <code>/api/show</code> omits capabilities. (#84055) Thanks @dutifulbob.</li>
<li>Installer/Windows: launch <code>install.ps1</code> onboarding as an attached child process so fresh native Windows installs do not freeze visibly at <code>Starting setup...</code> or corrupt the wizard's terminal rendering.</li>
<li>CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so <code>openclaw update</code> no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.</li>
<li>CLI/gateway: include the running Gateway version in <code>gateway status</code> JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.</li>
<li>Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79.</li>
<li>CLI/nodes: request pending node surface approval scopes before <code>openclaw nodes approve</code> so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with <code>missing scope: operator.admin</code>. (#84392) Thanks @joshavant.</li>
<li>Gateway: reject slow node event sends before outbound buffers grow unbounded and log the rejected payload diagnostic. (#84387) Thanks @samzong.</li>
<li>Agents: include bounded trajectory queued-writer diagnostics in <code>pi-trajectory-flush</code> timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.</li>
<li>Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev.</li>
<li>Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.</li>
<li>Providers/Anthropic: route Anthropic model refs selected with Claude CLI auth through the Claude CLI runtime so shorthand refs such as <code>anthropic/opus-4.7</code> no longer fall back to embedded Anthropic billing. Fixes #84222. (#84374) Thanks @joshavant.</li>
<li>Agents: honor explicit <code>models.providers.<id>.timeoutSeconds</code> values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei.</li>
<li>Agents/Codex: keep encrypted Responses reasoning replay provenance-bound so stale mirrored Codex transcripts drop invalid encrypted content before request assembly while preserving matching same-session replay. Fixes #83836. (#84367) Thanks @joshavant.</li>
<li>Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing <code>reason=no_active_run</code> queue noise. (#82964) Thanks @galiniliev.</li>
<li>CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030.</li>
<li>Media: decode URL path basenames before using them as remote media fallback filenames, so files like <code>My%20Report.pdf</code> are surfaced as <code>My Report.pdf</code>. Fixes #84050. (#84052) Thanks @jbetala7.</li>
<li>WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to <code>channels.whatsapp.groups</code> without changing routing or sender authorization. (#83846) Thanks @neeravmakwana.</li>
<li>WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga.</li>
<li>CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent.</li>
<li>Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman.</li>
<li>iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.</li>
<li>Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin.</li>
<li>Control UI/usage: truncate long context skill, tool, and file names in the usage panel while keeping the full name available on hover. (#42197) Thanks @Rain120.</li>
<li>Codex: respect explicit <code>models auth order set</code> and <code>config.auth.order</code> precedence over stale <code>lastGood</code> in <code>/codex account</code>, and show <code>no working credential</code> when every explicit-order profile is ineligible instead of marking a lower-ranked profile as active. Fixes #84386. (#84412) Thanks @openperf.</li>
<li>Agents: honor <code>messages.suppressToolErrors</code> for mutating tool failures so configured chat surfaces do not receive separate warning payloads. (#81561) Thanks @moeedahmed.</li>
<li>Agents/fallback: surface billing guidance for mixed rate-limit plus billing fallback exhaustion instead of generic failure copy. Fixes #79396. (#79489) Thanks @aayushprsingh.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.20/OpenClaw-2026.5.20.zip" length="54396392" type="application/octet-stream" sparkle:edSignature="Ufz+twYjgj5NDg29tG3Ttx/JNyT3/a3EKLciBGvsa38C6Dwqp4yFYC5jSBiSlubwBXhrq8OQDMgavMKtSsclBQ=="/>
</item>
<item>
<title>2026.5.19</title>
<pubDate>Wed, 20 May 2026 21:27:21 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026051990</sparkle:version>
<sparkle:shortVersionString>2026.5.19</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.19</h2>
<h3>Changes</h3>
<ul>
<li>Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.</li>
<li>Dependencies: update <code>@openclaw/proxyline</code> to 0.3.3.</li>
<li>Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.</li>
<li>Docker/Podman: add <code>OPENCLAW_IMAGE_APT_PACKAGES</code> as the runtime-neutral image build arg for extra apt packages while keeping <code>OPENCLAW_DOCKER_APT_PACKAGES</code> as a legacy fallback. (#62431) Thanks @urtabajev.</li>
<li>Gateway/ACPX: attribute startup probe, config, runtime, and resource-count costs in restart traces without changing readiness behavior. (#83300) Thanks @samzong.</li>
<li>Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving <code>/readyz</code> sidecar gating. (#83301) Thanks @samzong.</li>
<li>Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.</li>
<li>Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.</li>
<li>Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.</li>
<li>Skills: rename the repo-local Codex closeout review skill and helper to <code>autoreview</code> while preserving the Codex-first fallback behavior.</li>
<li>Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.</li>
<li>Skills CLI: allow <code>openclaw skills install</code> and <code>openclaw skills update</code> to target shared managed skills with <code>--global</code>. (#74466) Thanks @Marvae.</li>
<li>Browser: surface pending and recently handled modal dialogs in snapshots, return <code>blockedByDialog</code> when an action opens a modal, and allow <code>browser dialog --dialog-id</code> to answer pending dialogs.</li>
<li>Browser CLI: add <code>openclaw browser evaluate --timeout-ms</code> so long-running page functions can extend both the evaluate action and request timeout budgets. (#83447) Thanks @eefreenyc.</li>
<li>Codex app-server: scope OpenClaw prompt guidance by runtime surface so native Codex keeps Codex-owned base/personality instructions while OpenClaw contributes only runtime context, delivery guidance, and explicitly scoped command hints. (#83454) Thanks @100yenadmin.</li>
<li>Docker/Podman: add <code>OPENCLAW_IMAGE_PIP_PACKAGES</code> for opt-in Python package installation in local image builds. (#83771) Thanks @stephenredmond-straiteis.</li>
<li>Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.</li>
<li>Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.</li>
<li>CLI/plugins: add <code>defineToolPlugin</code> plus <code>openclaw plugins build</code>, <code>validate</code>, and <code>init</code> for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.</li>
<li>Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.</li>
<li>Skills: update the Obsidian skill to target the official <code>obsidian</code> CLI and require its registered binary instead of the third-party <code>obsidian-cli</code>.</li>
<li>Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.</li>
<li>Codex: add <code>/codex plugins list</code>, <code>enable</code>, and <code>disable</code> for managing configured native Codex plugins from chat without editing config by hand.</li>
<li>Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy <code>interactive</code>/Slack directive producer APIs as deprecated.</li>
<li>Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection.</li>
<li>Proxy: support HTTPS managed forward-proxy endpoints and scoped <code>proxy.tls.caFile</code> CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.</li>
<li>QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.</li>
<li>QA-Lab: add <code>openclaw qa suite --runtime-parity-tier</code> and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.</li>
<li>QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.</li>
<li>QA-Lab: add live-only harness self-health scenarios for plugin hook crashes, manifest contract errors, and WebChat direct-reply self-message routing. (#80323) Thanks @100yenadmin.</li>
<li>QA-Lab: add runtime tool fixture scenarios and coverage reporting for Codex-native workspace tools, OpenClaw dynamic tools, and optional plugin-backed tools. Fixes #80173. Thanks @100yenadmin.</li>
<li>QA-Lab: expose runtime tool fixture coverage through <code>openclaw qa coverage --tools</code>, with optional suite-summary evaluation for parity gate artifacts. Thanks @100yenadmin.</li>
<li>QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.</li>
<li>QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.</li>
<li>QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.</li>
<li>QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1.</li>
<li>QA-Lab: add a report-only dreaming shadow-trial scenario so candidate memory promotion can be evaluated without mutating <code>MEMORY.md</code>. Thanks @iFiras-Max1.</li>
<li>Gateway/performance: add <code>pnpm test:restart:gateway</code> benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong.</li>
<li>Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens.</li>
<li>Gateway/config: expose config lookup reload metadata so tools can distinguish restart-required, hot-reloadable, and no-op fields before applying config edits. Fixes #81409. (#81612) Thanks @LLagoon3.</li>
<li>Telegram: add allowlisted native DM draft previews for transient tool progress while keeping final answers on the normal persistent delivery path. (#83622) Thanks @akrimm702.</li>
<li>QA-Lab: add a personal-agent share-safe diagnostics artifact scenario so support handoffs keep useful status while omitting raw personal content. Thanks @iFiras-Max1.</li>
<li>QA-Lab: add a personal-agent no-fake-progress scenario so completion claims stay tied to local evidence instead of unsupported external progress. (#83824) Thanks @iFiras-Max1.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>CLI: reject explicit port numbers above 65535 before they reach Gateway or Node bind paths. Fixes #83900. (#84008) Thanks @hclsys.</li>
<li>Codex app-server: preserve plugin tool auth profiles when Codex owns model transport so OpenClaw dynamic tools can resolve their provider credentials. (#83603) Thanks @rubencu.</li>
<li>Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.</li>
<li>Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.</li>
<li>Telegram: keep forum topics from blocking sibling topic traffic by routing inbound serialization, media/text buffers, and account API queues on topic-aware lanes. (#83829)</li>
<li>Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc.</li>
<li>CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.</li>
<li>CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.</li>
<li>Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle.</li>
<li>Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.</li>
<li>Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.</li>
<li>Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.</li>
<li>Providers/Anthropic: preserve Claude 4 image capability when configured model refs resolve through a stale local catalog row. (#83756) Thanks @TurboTheTurtle.</li>
<li>Providers/DeepSeek: normalize MCP tool schemas with <code>anyOf</code>/<code>oneOf</code> unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle.</li>
<li>Control UI: render live tool progress from session-scoped <code>session.tool</code> Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.</li>
<li>Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.</li>
<li>Discord: preserve streamed reply previews when recovered tool-warning finals are delivered before or after the assistant's final reply. (#84169) Thanks @neeravmakwana.</li>
<li>Control UI: keep the chat delete confirmation popover clamped inside the visible viewport on small screens. (#83804) Thanks @ThiagoCAltoe.</li>
<li>Browser: enforce current-tab URL allowlist checks for <code>/act</code> evaluate/batch actions and <code>/highlight</code> routes while leaving tab-management actions unblocked. (#78523)</li>
<li>CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)</li>
<li>Models: show the effective OpenAI/Codex auth profile in <code>/models</code> provider headers instead of falling back to the OpenAI env-key label. (#83697) Thanks @yu-xin-c.</li>
<li>CLI: include active bundled loopback MCP tools in CLI system prompts and reset provider-side CLI sessions when that prompt-visible tool surface changes. (#83785) Thanks @TurboTheTurtle.</li>
<li>Browser: keep a profile <code>cdpPort</code> when its <code>cdpUrl</code> omits a port, while still letting explicitly written URL ports win. (#82166) Thanks @Marvae.</li>
<li>Agents/image generation: allow distinct <code>image_generate</code> prompts to start separate session-backed background tasks while same-prompt retries still return the active task status. (#83614) Thanks @Elarwei001.</li>
<li>Gateway/WebChat: honor configured <code>channels.webchat.textChunkLimit</code> and <code>chunkMode</code> overrides when chunking WebChat replies. (#83713)</li>
<li>Control UI: stop the chat reading indicator from sticking after an assistant response finishes. (#83515) Thanks @njuboy11.</li>
<li>Skills: reject empty or whitespace-only skill names and descriptions during quick validation. (#27061)</li>
<li>Sessions: skip trailing custom transcript entries when checking tail assistant replies so embedded CLI gap-fill does not duplicate canonical assistant output. (#83635) Thanks @yaoyi1222.</li>
<li>Memory Wiki: keep <code>wiki_lint</code> tool output path-safe by reporting vault-internal lint reports as relative paths in tool text and details while preserving absolute report paths for CLI/file callers. (#83439) Thanks @LLagoon3.</li>
<li>Telegram: keep verbose tool progress visible without mirroring non-final progress into active session transcripts, preventing embedded provider replies from aborting mid-run. (#83631) Thanks @kurplunkin.</li>
<li>Telegram: log successful outbound text and media deliveries with account, chat, message, operation, thread, reply, silent, and chunk metadata while keeping message bodies out of logs. Fixes #83196. (#83247) Thanks @jrwrest.</li>
<li>Cron: link isolated scheduled task runs to their stable cron session so task status and cleanup can follow the backing agent run. (#83606) Thanks @jai.</li>
<li>Codex app-server: mark Codex-native subagent task mirrors terminal when blocked or failed spawn-agent calls arrive with stale initializing child state, preventing task registry entries from staying running. Fixes #83852. (#83945) Thanks @joshavant.</li>
<li>CLI: enforce the documented Node.js 22.19 runtime floor in the source launcher.</li>
<li>Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage.</li>
<li>Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval.</li>
<li>Telegram: preserve reply-target context for bare mention replies on runtime-only turns so the model sees the replied-to message body. Fixes #83767. (#83953) Thanks @joshavant.</li>
<li>ClawHub: preserve configured base URL path prefixes when building API request URLs, so self-hosted ClawHub instances mounted under a subpath keep routing correctly. (#83982) Thanks @ThiagoCAltoe.</li>
<li>Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.</li>
<li>Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.</li>
<li>Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.</li>
<li>Gateway/sessions: clear stored CLI provider resume bindings on non-subagent <code>/reset</code> so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.</li>
<li>Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.</li>
<li>Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.</li>
<li>LM Studio: resolve env-template API keys like <code>${LMSTUDIO_API_KEY}</code> through the standard SecretInput path instead of sending the raw template as the bearer token, and preserve header-auth and discovery-key precedence when the template is unset. Fixes #80495. (#80568) Thanks @MonkeyLeeT.</li>
<li>Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.</li>
<li>Gateway/sessions: rotate failed agent sessions when their transcript file is missing instead of wedging per-channel lanes. Fixes #83488. (#83553) Thanks @LLagoon3.</li>
<li>Agents: refresh final-delivery routing from fresh session state before declaring a no-send failure, keeping recovered runs on the normal durable delivery path. (#83835) Thanks @joshavant.</li>
<li>Agents: guard final-delivery fresh session routing against mismatched logical sessions before reusing recovered delivery context. (#83928) Thanks @joshavant.</li>
<li>Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.</li>
<li>Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.</li>
<li>Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.</li>
<li>Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.</li>
<li>Telegram: keep <code>/btw</code> and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.</li>
<li>Agents: log strict-agentic execution contract diagnostics only when the planning-only retry path actually triggers.</li>
<li>Agents: stop embedded session takeover and session write-lock errors from consuming model fallbacks while preserving provider fallback metadata. Fixes #83510. Thanks @luyao618.</li>
<li>Agents/video: hide <code>video_generate</code> reference-audio parameters unless a registered video provider supports audio inputs.</li>
<li>Plugins: fall back to npm for official ClawHub updates when artifact downloads are unavailable, including beta-to-default fallback and dry-run version reporting.</li>
<li>Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.</li>
<li>Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.</li>
<li>Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.</li>
<li>Codex app-server: expose OpenClaw's sandbox-routed shell as <code>sandbox_exec</code>/<code>sandbox_process</code> for non-Docker sandbox backends so SSH sandbox agents keep a correctly routed shell path without shadowing Codex native shell. Fixes #80322. Thanks @keramblock.</li>
<li>Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.</li>
<li>Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.</li>
<li>Messages/Codex: keep Codex direct/source chats on message-tool visible delivery by default while documenting and testing <code>messages.visibleReplies: "automatic"</code> as the old-mode opt-out; channel wildcard model overrides now apply to direct chats before harness delivery defaults.</li>
<li>Memory/QMD: keep archived session transcript hits visible after QMD export while preserving normal <code>.md</code> session ids that only resemble archive names. (#83518; fixes #83506) Thanks @tanshanshan.</li>
<li>Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.</li>
<li>Codex app-server: honor writable Docker bind mounts for sandboxed workspace-write turns while disabling native Code Mode when container-path aliases or read-only bind shadows cannot be represented safely host-side. Fixes #83737. (#83849) Thanks @joshavant.</li>
<li>QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.</li>
<li>Messages: default group/channel visible replies to automatic final delivery again, keeping <code>message_tool</code> opt-in for ambient/shared rooms and tool-reliable models.</li>
<li>CLI/TUI: force standalone <code>/exit</code> runs to terminate after <code>runTui</code> returns so onboarding-launched TUI children do not stay alive invisibly. (#83501) Thanks @fuller-stack-dev.</li>
<li>Agents/code mode: honor per-agent code-mode config in schema, runtime catalog activation, and model payload filtering. Fixes #83388. Thanks @Kaspre.</li>
<li>Agents/code mode: preserve agent, session, run, and channel context in <code>before_tool_call</code> hooks for top-level <code>exec</code>/<code>wait</code> dispatches. Fixes #83387.</li>
<li>QQBot: shorten C2C typing indicators to a 10-second window renewed every 5 seconds, capped to keep a final passive-reply slot available. (#83469)</li>
<li>Replies: keep final payload delivery after live preview updates so channels can finalize or send the completed answer instead of losing preview-only drafts. (#83468)</li>
<li>Discord: deliver final replies in progress-mode preview streams instead of deduplicating the final visible message. (#83443) Thanks @compoodment.</li>
<li>Providers/Xiaomi: replay MiMo Anthropic-compatible <code>reasoning_content</code> as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for <code>mimo-v2-flash</code>. Fixes #83407. Thanks @Xgenious7.</li>
<li>Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.</li>
<li>Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow <code>system.which</code> timeout warnings.</li>
<li>CLI/config: keep broken discovered plugins that are not referenced by active config from failing <code>openclaw config validate</code>, while preserving fatal errors for explicitly configured plugin entries.</li>
<li>GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with <code>invalid_request_body</code>. Fixes #83220. Thanks @galiniliev.</li>
<li>Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.</li>
<li>QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.</li>
<li>Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.</li>
<li>UI: show reasoning choices as plain labels instead of leaking internal override wording in session and chat pickers.</li>
<li>Mac app: avoid repeating the Configuration heading inside channel quick settings.</li>
<li>Mac app: keep the Settings sidebar always visible and remove the redundant titlebar hide/show control.</li>
<li>Mac app: normalize Settings pane content margins so pages share the same left and right rail.</li>
<li>Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.</li>
<li>Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.</li>
<li>Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected.</li>
<li>Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.</li>
<li>Mac app: tighten remote Gateway fields in Settings so the Connection pane keeps readable labels and full action button text.</li>
<li>Mac app: keep custom Settings card rows left-aligned and full-width so Discovery and status sections no longer appear centered or detached.</li>
<li>Mac app: align Location permission controls to the same trailing column as the rest of Settings.</li>
<li>Mac app: add Dashboard, Chat, Canvas, and Settings shortcuts to the Dock icon menu.</li>
<li>Mac app: replace the Settings window's native split-view sidebar with an explicit layout so page content keeps its leading gutter when the sidebar is shown or hidden.</li>
<li>Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane.</li>
<li>Gateway/webchat: hide internal runtime-context and other <code>display: false</code> transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.</li>
<li>CLI/help: keep <code>gateway</code>, <code>doctor</code>, <code>status</code>, and <code>health</code> help registration out of action/runtime imports so subcommand <code>--help</code> stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.</li>
<li>CLI/help: show plugin-owned command help based on the active memory slot so LanceDB memory users see <code>ltm</code> instead of unavailable <code>memory</code> commands. Fixes #83745. (#83841) Thanks @joshavant.</li>
<li>Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.</li>
<li>Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.</li>
<li>Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.</li>
<li>Telegram: fail topic sends closed when Telegram reports <code>message thread not found</code> instead of retrying without <code>message_thread_id</code> into the base chat. Refs #83302.</li>
<li>Config/subagents: remove ignored agent-model <code>timeoutMs</code> keys, keep subagent model config to primary/fallback selection, and clean shipped stale config through doctor. Fixes #83291. Thanks @giodl73-repo.</li>
<li>Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.</li>
<li>OpenAI/Codex: stop rejecting available <code>openai-codex</code> GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.</li>
<li>Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.</li>
<li>Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.</li>
<li>Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.</li>
<li>Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.</li>
<li>Codex app-server: keep OpenClaw session spawning searchable while steering Codex-native delegation through native subagents, avoiding duplicate direct subagent surfaces. (#83329) Thanks @fuller-stack-dev.</li>
<li>Codex app-server: recover stale childless Codex-native subagent task mirrors during maintenance and allow their registry rows to be cancelled without an OpenClaw child session. (#82836) Thanks @yshimadahrs-ship-it and @joshavant.</li>
<li>Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.</li>
<li>CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.</li>
<li>Plugins: apply a default 15-second timeout to legacy <code>before_agent_start</code> hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.</li>
<li>Feishu: refresh inbound session delivery context for DM, group, and broadcast turns so later replies do not inherit stale WebChat routing. Fixes #78274.</li>
<li>Agents/subagents: require the initial subagent registry save before reporting spawn accepted, returning a spawn error instead of losing an untracked run when the registry write fails. (#83146) Thanks @yetval.</li>
<li>QA-Lab/qa-channel: attach redacted agent tool-start traces to outbound <code>QaBusMessage</code> records so scenarios can assert actual tool use instead of relying only on reply text. Fixes #67637. Thanks @100yenadmin.</li>
<li>QA-Lab: fail live runtime parity reports when assistant-message usage is missing, preventing <code>0 vs 0</code> live token rows from being reported as passing proof. Fixes #80411. Thanks @100yenadmin.</li>
<li>QA-Lab: add a runtime token-efficiency sidecar report that classifies Codex savings separately from regressions and fails only positive Codex-over-Pi live token deltas above threshold. Fixes #81093. Thanks @100yenadmin.</li>
<li>QA-Lab: fail Codex-backed OpenAI live runtime-pair runs before launching isolated workers when no portable Codex auth is available, while staging API-key fallbacks and configured Codex keys for isolated QA agents. Fixes #80412. Thanks @100yenadmin.</li>
<li>QA-Lab: refresh parity gates, mock frontier fixtures, model scenarios, and workflow artifact lanes to compare GPT-5.5 against Claude Opus 4.7. Fixes #74262. Thanks @100yenadmin.</li>
<li>QA-Lab: make mock parity dispatch provider-aware for source discovery and subagent scenarios so OpenAI and Anthropic lanes no longer share identical canned plans. Fixes #64879. Thanks @100yenadmin.</li>
<li>QA-Lab: stop returning Control UI bearer tokens from unauthenticated bootstrap payloads and bind Docker harness ports to loopback-only host addresses. (#66355) Thanks @pgondhi987.</li>
<li>Mac app: avoid a SwiftUI metadata crash when rendering the Cron Jobs settings pane.</li>
<li>Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.</li>
<li>Agents/OpenAI streams: yield via <code>setTimeout(0)</code> instead of <code>setImmediate</code> between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.</li>
<li>Agents/Codex: keep legacy <code>oauthRef</code>-backed OAuth profiles usable while <code>openclaw doctor --fix</code> migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.</li>
<li>Agents/Codex: load the selected provider owner alongside the Codex harness runtime so <code>openai-codex</code> models resolve when plugin allowlists scope runtime loading. Fixes #83380. (#83519) Thanks @joshavant.</li>
<li>Telegram: fail stalled isolated-ingress handlers into tombstones and abort same-lane reply work before restarting, so later same-chat updates drain after a hung turn. Fixes #83272. (#83505) Thanks @joshavant.</li>
<li>CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.</li>
<li>CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like <code>0.0.0.0</code>. Fixes #83286. Thanks @giodl73-repo.</li>
<li>CLI/plugins: ship the bundled memory CLI as a package entry so package-installed <code>openclaw memory</code> commands register correctly.</li>
<li>CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.</li>
<li>CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back only to official <code>@openclaw/*</code> npm plugin packages when ClawHub plugin artifacts are unavailable, and keep managed service package roots authoritative during updates.</li>
<li>Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.</li>
<li>Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.</li>
<li>CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.</li>
<li>Telegram: keep isolated long polling below the hard <code>getUpdates</code> request guard so idle bot accounts with high <code>timeoutSeconds</code> do not false-disconnect and restart-loop. Fixes #83264. Thanks @riccodecarvalho.</li>
<li>Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing <code>thought_signature</code> 400s. Fixes #72879. (#80358) Thanks @abnershang.</li>
<li>Telegram: skip transcript-only delivery mirrors and gateway-injected rows when resolving latest assistant text, preventing retained previews from replacing final replies with stale fragments. Fixes #83159. (#83362) Thanks @joshavant.</li>
<li>Memory/QMD: keep lexical search on raw hyphenated queries while normalizing semantic QMD sub-searches, avoiding fallback to the builtin index for dashed identifiers and dates. Fixes #81328.</li>
<li>Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded <code>memory index</code> warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.</li>
<li>Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.</li>
<li>Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.</li>
<li>Gateway/plugins: bind admin HTTP RPC dispatch to the accepting gateway instance so multi-gateway processes cannot execute plugin HTTP control-plane calls against another live gateway. Fixes #83486. (#83487) Thanks @coygeek.</li>
<li>Telegram: keep hot-reload restarts from marking polling accounts manually stopped and restart isolated ingress cleanly after worker shutdown, preserving Telegram replies across config reloads. Fixes #83008. (#83410) Thanks @joshavant.</li>
<li>Telegram/Ollama: pass current Telegram image attachments into native PI/Ollama vision turns so live photo prompts reach Ollama as native images. Fixes #83023. (#83516) Thanks @joshavant.</li>
<li>Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.</li>
<li>Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.</li>
<li>Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.</li>
<li>Agents/Codex: use the Codex runtime context window for OpenAI-model preflight compaction and memory flush checks, so GPT-5.5 Codex sessions compact before hitting the smaller native context limit. Fixes #82982. Thanks @vliuyt.</li>
<li>QA-Lab: clean orphaned gateway temp roots when a suite parent exits and wait on gateway plus transport readiness after config restarts, reducing stale <code>qa-channel</code> noise from interrupted runs. Fixes #65506. Thanks @100yenadmin.</li>
<li>QA-Lab: wake qa-bus long polls that arrive with stale future cursors after a bus restart, preserving reconnect readiness for harness clients. (#67142) Thanks @hxy91819.</li>
<li>QA-Lab: stage Multipass transfer scripts under OpenClaw's preferred temp root instead of raw OS temp paths, keeping the VM runner inside temp-path guardrails. (#64098) Thanks @ImLukeF.</li>
<li>Agents/replies: keep surviving reply media and append a warning when other media references fail, so partial media normalization no longer drops failures silently. Thanks @Jerry-Xin.</li>
<li>Config/models: accept <code>thinkingFormat: "together"</code> in model compat config so Together routes can opt into the Together-specific thinking response shape.</li>
<li>Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.7.1, bringing Codex hook approval compatibility, pre-tool command wrapping fixes, and Rolldown/Vitest output compaction improvements into the OpenClaw plugin.</li>
<li>Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.</li>
<li>Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.</li>
<li>Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.</li>
<li>Browser/CDP: keep loopback proxy bypass active across both <code>NO_PROXY</code> casings and redact home-relative Chrome MCP profile paths in attach-failure diagnostics.</li>
<li>Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward <code>music_generate</code> audio creation instead of lyric-only replies, and reserve <code>lyrics</code> for exact sung words.</li>
<li>Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.</li>
<li>Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)</li>
<li>CLI/infer: run gateway model probes in fresh explicit sessions so one-shot provider checks do not inherit default agent transcript state. (#82861) Thanks @Kaspre.</li>
<li>Providers/Together: send video-generation requests to Together's v2 video API even when shared text-model config still points at the v1 base URL. (#82992)</li>
<li>Browser CLI: preserve browser-level options on nested commands, skip option values during lazy command registration, and keep long-running wait/download/dialog hooks open for their advertised wait window.</li>
<li>CLI/sessions: accept <code>openclaw sessions list</code> as an alias for <code>openclaw sessions</code>, matching other list-style commands. Fixes #81139. (#81163) Thanks @YB0y.</li>
<li>Channels/stream previews: widen compact progress draft lines and cut prose at word boundaries while preserving command/path suffixes, with <code>streaming.progress.maxLineChars</code> for channel-specific tuning.</li>
<li>CLI/plugins: have <code>openclaw plugins doctor</code> warn when a configured runtime needs a missing owner plugin, sharing the same install mapping as <code>openclaw doctor --fix</code>. Fixes #81326. (#81674) Thanks @Zavianx.</li>
<li>Agents/Codex: route OpenAI runs that resolve to <code>openai-codex</code> through the Codex provider and bootstrap OpenClaw's stored OAuth profile into the Codex harness when the harness owns transport, so <code>openai/*</code> model refs no longer fail with <code>No API key found for openai-codex</code> despite an existing Codex OAuth profile. (#82864) Thanks @ragesaq.</li>
<li>Agents/ACP: distinguish prompt-submitted and runtime-active child stalls from true interactive waits, including redacted proxy-env diagnostics for Codex ACP no-output runs. Fixes #44810.</li>
<li>Agents/memory: explain that memory-triggered compaction exposes only <code>read</code> and append-only <code>write</code> when configured core tools are unavailable in <code>tools.allow</code> warnings. Fixes #82941. Thanks @galiniliev.</li>
<li>Agents/OpenAI: preserve deterministic tool payload ordering for prompt-cache reuse across OpenAI Responses and chat completions calls. (#82940) Thanks @galiniliev.</li>
<li>ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.</li>
<li>Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.</li>
<li>Agents/diagnostics: treat repeated same-handle embedded-run cleanup as idempotent while preserving true replacement-handle mismatch diagnostics. Fixes #82959. (#82960) Thanks @galiniliev.</li>
<li>Agents/subagents: preserve high-priority <code>AGENTS.md</code> policy in bootstrap context when oversized files are trimmed, and warn agents to read the full policy file before relying on scoped rules. Fixes #82920. (#82921) Thanks @galiniliev.</li>
<li>Agents/skills: apply the full effective tool policy pipeline to inline <code>command-dispatch: tool</code> skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)</li>
<li>Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.</li>
<li>Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving <code>default</code> instead of silently dropping it.</li>
<li>Agents/CLI: reject empty successful CLI subprocess replies as <code>empty_response</code> and keep them out of shared auth-profile health, so blank Claude CLI results no longer become green no-payload turns. Fixes #83231. (#83421) Thanks @joshavant.</li>
<li>Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram <code>/verbose</code> stays visible when command events arrive only at completion.</li>
<li>Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.</li>
<li>Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.</li>
<li>Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.</li>
<li>Gateway/protocol: restore Gateway WS protocol v4 and keep <code>message.action</code> room-event metadata on the existing <code>inboundTurnKind</code> wire field while preserving internal inbound-event classification.</li>
<li>Agents/tools: prefer non-webchat session-key routes when the message tool has stale webchat context, so message-tool-only replies keep delivering to the originating channel. Fixes #82911. (#83004) Thanks @joshavant.</li>
<li>Channels: keep direct-message last-route writes on isolated <code>per-channel-peer</code> sessions instead of contaminating the agent main session with channel delivery context. Fixes #36614. Thanks @aspenas.</li>
<li>Mac app: move the Settings sidebar toggle into the native titlebar and tighten the General pane width.</li>
<li>Mac app: keep visited Settings panes mounted so switching tabs no longer blanks and reloads their content.</li>
<li>Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.</li>
<li>Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.</li>
<li>Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.</li>
<li>Telegram: redact nested raw-update identifiers and user metadata before verbose raw update logging, preserving useful update/message ids without exposing chat, user, command, or profile details. (#82945) Thanks @galiniliev and @joshavant.</li>
<li>Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)</li>
<li>Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style <code>reasoning.enabled</code>/<code>max_tokens</code> controls for reasoning-capable OpenAI-completions models.</li>
<li>Agents/diagnostics: split slow embedded-run <code>attempt-dispatch</code> startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.</li>
<li>Agents/Codex: flatten nested tool-result middleware blocks into bounded text so successful message sends are no longer replaced with <code>Tool output unavailable due to post-processing error</code>. Fixes #82912. Thanks @joeykrug.</li>
<li>CLI/media: accept HTTP(S) URLs in <code>openclaw infer image describe --file</code>, fetching remote images through the guarded media path instead of treating URLs as local files. Fixes #82837. (#82854) Thanks @neeravmakwana.</li>
<li>Agents/subagents: keep session-backed parent runs active when the child wait call times out before the child session has actually settled, so late subagent completions are reconciled instead of being lost. Fixes #82787. Thanks @ramitrkar-hash.</li>
<li>Control UI: advertise shared Gateway protocol constants in browser connect frames, fixing protocol mismatch handshakes after protocol constant drift. Fixes #82882. Thanks @galiniliev.</li>
<li>Gateway: add rollback protocol-mismatch diagnostics, including client protocol ranges in Gateway logs and deep status/doctor hints for stale client processes. Fixes #82841. (#82908)</li>
<li>Agents/subagents: keep successful keep-mode completion payloads pending after final-delivery retry exhaustion, so requester recovery no longer loses final subagent results. Fixes #82583. (#82999) Thanks @joshavant.</li>
<li>Gateway/auth: allow same-host trusted-proxy callers to use the documented local direct <code>gateway.auth.password</code> fallback after revisiting the #78684 fail-closed policy, while keeping token fallback rejected and forwarded-header requests on the trusted-proxy path. Fixes #82607. (#82953) Thanks @joshavant.</li>
<li>Agents/subagents: wait for queued completion handoffs to reach the parent transcript before marking them announced, preventing busy parent runs from cleaning up before observing child results. Fixes #82913. (#83039) Thanks @joshavant.</li>
<li>Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.</li>
<li>Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo.</li>
<li>Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo.</li>
<li>CLI/models: reuse command-scoped plugin metadata across model listing, provider catalog, auth, and synthetic-auth checks, restoring fast <code>openclaw models</code> runs for plugin-heavy installs. Fixes #82881. (#83033) Thanks @joshavant.</li>
<li>CLI/channels: show configured official external channels such as Discord in <code>openclaw channels list</code> when their plugin package is missing, including the install and doctor repair command instead of reporting no configured channels. Fixes #82813.</li>
<li>Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.</li>
<li>Agents/tools: keep the <code>message</code> tool available in embedded runs when it is explicitly allowed through <code>tools.alsoAllow</code> or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.</li>
<li>WhatsApp: honor forced document delivery for outbound image, GIF, and video media so <code>forceDocument</code>/<code>asDocument</code> sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.</li>
<li>WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as <code>file.pdf</code> and <code>file.csv</code> instead of an extensionless <code>file</code>. Thanks @mcaxtr.</li>
<li>Process/diagnostics: report active lane blockers in lane wait warnings so <code>queueAhead=0</code> no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.</li>
<li>Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.</li>
<li>Agents/replies: classify provider conversation-state rejections and return a clear message-channel error instead of auto-resetting or falling back to a generic runner failure. (#82616) Thanks @dutifulbob.</li>
<li>Browser plugin: trust managed Chrome CDP diagnostics when launch HTTP probes race cold-start readiness, avoiding false startup failures. Fixes #82904. (#82986) Thanks @kmanan and @hclsys.</li>
<li>Android: prompt before replacing a changed Gateway TLS thumbprint, showing the old and new SHA-256 fingerprints so users can accept expected certificate rotations instead of hard failing on pin mismatch. (#83077) Thanks @sliekens.</li>
<li>CLI/status: render extra gateway-like service diagnostics as warning/info output instead of error output. Fixes #46930. (#82922) thanks @giodl73-repo.</li>
<li>Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.</li>
<li>Plugin SDK: bundle <code>openclaw/plugin-sdk/zod</code> into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local <code>zod</code> symlink. Fixes #78398. (#78515) Thanks @ggzeng.</li>
<li>Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.</li>
<li>Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman.</li>
<li>WhatsApp: treat <code>upload-file</code> as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman.</li>
<li>iOS: end Live Activities when OpenClaw is connected, idle, or disconnected, and show compact attention states for approval-required reconnects. (#83597) Thanks @ngutman.</li>
<li>Control UI: hide child nav items when collapsing the active sidebar group. Fixes #42167. (#42223) Thanks @Aroool.</li>
<li>CI/proof: skip the real-behavior-proof gate for private org maintainers by minting a least-privilege (<code>members: read</code>) GitHub App token and checking active membership in the <code>maintainer</code> team, instead of treating <code>author_association=CONTRIBUTOR</code> as definitively external. (#83418) Thanks @romneyda.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.19/OpenClaw-2026.5.19.zip" length="54062201" type="application/octet-stream" sparkle:edSignature="7bVi6rv+TjhrUfi32V62BW2VgyV17jm7x+H6p10PRClCdXKZjhM7AX6MyvAz2+e7kzXIknj1Y9X7q43/E9fBBw=="/>
</item>
</channel>
</rss>

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026052601
versionName = "2026.5.26"
versionCode = 2026052801
versionName = "2026.5.28"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -44,7 +44,7 @@ internal fun isLoopbackGatewayHost(
return isMappedIpv4 && address[12] == 127.toByte()
}
internal fun isPrivateLanGatewayHost(
internal fun isLocalCleartextGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
): Boolean {

View File

@@ -632,7 +632,7 @@ class GatewaySession(
private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean {
if (authSource != GatewayConnectAuthSource.BOOTSTRAP_TOKEN) return false
if (isLoopbackGatewayHost(endpoint.host)) return true
if (isLocalCleartextGatewayHost(endpoint.host)) return true
return tls != null
}
@@ -1212,9 +1212,7 @@ class GatewaySession(
endpoint: GatewayEndpoint,
tls: GatewayTlsParams?,
): Boolean {
if (isLoopbackGatewayHost(endpoint.host)) {
return true
}
if (isLocalCleartextGatewayHost(endpoint.host)) return true
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
}
}

View File

@@ -8,6 +8,7 @@ import ai.openclaw.app.gateway.GatewayClientInfo
import ai.openclaw.app.gateway.GatewayConnectOptions
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayTlsParams
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import android.os.Build
@@ -35,7 +36,12 @@ class ConnectionManager(
val stableId = endpoint.stableId
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
val isManual = stableId.startsWith("manual|")
val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host)
val cleartextAllowedHost =
if (isManual) {
isLocalCleartextGatewayHost(endpoint.host)
} else {
isLoopbackGatewayHost(endpoint.host)
}
if (isManual) {
if (!manualTlsEnabled && cleartextAllowedHost) return null

View File

@@ -1,6 +1,6 @@
package ai.openclaw.app.ui
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -56,9 +56,9 @@ internal data class GatewayScannedSetupCodeResult(
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
private const val remoteGatewaySecurityRule =
"Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed only for localhost and the Android emulator."
"Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, the Android emulator, and private LAN IPs."
private const val remoteGatewaySecurityFix =
"Use localhost/the Android emulator, or enable Tailscale Serve / expose a wss:// gateway URL."
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
@@ -147,7 +147,7 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
}
val tls = scheme == "wss" || scheme == "https"
if (!tls && !isLoopbackGatewayHost(host)) {
if (!tls && !isLocalCleartextGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort = if (tls) 443 else 18789

View File

@@ -51,7 +51,7 @@ internal fun buildGatewayDiagnosticsReport(
Please:
- pick one route only: same machine, same LAN, Tailscale, or public URL
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
- remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; ws:// is loopback-only
- remember: public routes require wss:// or Tailscale Serve; ws:// is allowed for localhost, the Android emulator, and private LAN IPs
- quote the exact app status/error below
- tell me whether `openclaw devices list` should show a pending pairing request
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`

View File

@@ -4,8 +4,8 @@ import ai.openclaw.app.LocationMode
import ai.openclaw.app.SecurePrefs
import ai.openclaw.app.VoiceWakeMode
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability
@@ -109,7 +109,7 @@ class ConnectionManagerTest {
}
@Test
fun resolveTlsParamsForEndpoint_manualPrivateLanForcesTlsWhenToggleIsOff() {
fun resolveTlsParamsForEndpoint_manualPrivateLanRespectsManualTlsToggle() {
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
val params =
@@ -119,9 +119,21 @@ class ConnectionManagerTest {
manualTlsEnabled = false,
)
assertEquals(true, params?.required)
assertNull(params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
assertNull(params)
}
@Test
fun resolveTlsParamsForEndpoint_manualPrivateLanCleartextCanOverrideStoredPin() {
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = "pinned",
manualTlsEnabled = false,
)
assertNull(params)
}
@Test
@@ -245,11 +257,11 @@ class ConnectionManagerTest {
}
@Test
fun isPrivateLanGatewayHost_acceptsLanIpsButRejectsMdnsAndTailnetHosts() {
assertTrue(isPrivateLanGatewayHost("192.168.1.20"))
assertFalse(isPrivateLanGatewayHost("gateway.local"))
assertFalse(isPrivateLanGatewayHost("100.64.0.9"))
assertFalse(isPrivateLanGatewayHost("gateway.tailnet.ts.net"))
fun isLocalCleartextGatewayHost_acceptsLanIpsButRejectsMdnsAndTailnetHosts() {
assertTrue(isLocalCleartextGatewayHost("192.168.1.20"))
assertFalse(isLocalCleartextGatewayHost("gateway.local"))
assertFalse(isLocalCleartextGatewayHost("100.64.0.9"))
assertFalse(isLocalCleartextGatewayHost("gateway.tailnet.ts.net"))
}
@Test

View File

@@ -99,9 +99,18 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointRejectsPrivateLanCleartextWsUrls() {
fun parseGatewayEndpointAllowsPrivateLanCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://192.168.1.20:18789")
assertNull(parsed)
assertEquals(
GatewayEndpointConfig(
host = "192.168.1.20",
port = 18789,
tls = false,
displayUrl = "http://192.168.1.20:18789",
),
parsed,
)
}
@Test
@@ -146,9 +155,13 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointRejectsLinkLocalIpv6ZoneCleartextWsUrls() {
fun parseGatewayEndpointAllowsLinkLocalIpv6ZoneCleartextWsUrls() {
val parsed = parseGatewayEndpoint("ws://[fe80::1%25eth0]")
assertNull(parsed)
assertEquals("fe80::1%25eth0", parsed?.host)
assertEquals(18789, parsed?.port)
assertEquals(false, parsed?.tls)
assertEquals("http://[fe80::1%25eth0]:18789", parsed?.displayUrl)
}
@Test
@@ -249,6 +262,16 @@ class GatewayConfigResolverTest {
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultFlagsInsecureRemoteGateway() {
val setupCode =
@@ -277,10 +300,19 @@ class GatewayConfigResolverTest {
}
@Test
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
fun parseGatewayEndpointResultAllowsPrivateLanCleartextGateway() {
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
assertNull(parsed.config)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
assertEquals(
GatewayEndpointConfig(
host = "192.168.1.20",
port = 18789,
tls = false,
displayUrl = "http://192.168.1.20:18789",
),
parsed.config,
)
assertNull(parsed.error)
}
@Test
@@ -421,7 +453,7 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveGatewayConnectConfigRejectsPrivateLanManualCleartextEndpoint() {
fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() {
val resolved =
resolveGatewayConnectConfig(
useSetupCode = false,
@@ -437,7 +469,9 @@ class GatewayConfigResolverTest {
fallbackPassword = "",
)
assertNull(resolved)
assertEquals("192.168.31.100", resolved?.host)
assertEquals(18789, resolved?.port)
assertEquals(false, resolved?.tls)
}
@Test

View File

@@ -1,5 +1,13 @@
# OpenClaw iOS Changelog
## 2026.5.28 - 2026-05-28
Maintenance update for the current OpenClaw release.
## 2026.5.27 - 2026-05-27
Maintenance update for the current OpenClaw release.
## 2026.5.26 - 2026-05-26
Maintenance update for the current OpenClaw release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.5.26
OPENCLAW_MARKETING_VERSION = 2026.5.26
OPENCLAW_IOS_VERSION = 2026.5.28
OPENCLAW_MARKETING_VERSION = 2026.5.28
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "openclaw-icon.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -1,47 +0,0 @@
import OpenClawChatUI
import OpenClawKit
import SwiftUI
struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: OpenClawChatViewModel
private let userAccent: Color?
private let agentName: String?
init(gateway: GatewayNodeSession, sessionKey: String, agentName: String? = nil, userAccent: Color? = nil) {
let transport = IOSGatewayChatTransport(gateway: gateway)
self._viewModel = State(
initialValue: OpenClawChatViewModel(
sessionKey: sessionKey,
transport: transport))
self.userAccent = userAccent
self.agentName = agentName
}
var body: some View {
NavigationStack {
OpenClawChatView(
viewModel: self.viewModel,
showsSessionSwitcher: true,
userAccent: self.userAccent)
.navigationTitle(self.chatTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
}
}
private var chatTitle: String {
let trimmed = (self.agentName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "Chat" }
return "Chat (\(trimmed))"
}
}

View File

@@ -6,30 +6,162 @@ import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport {
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
static let defaultChatSendTimeoutMs = 30000
private let gateway: GatewayNodeSession
private struct CreateSessionParams: Codable {
var key: String
var label: String?
var parentSessionKey: String?
}
private struct RunParams: Codable {
var sessionKey: String
var runId: String
}
private struct ListSessionsParams: Codable {
var includeGlobal: Bool
var includeUnknown: Bool
var limit: Int?
}
private struct SessionKeyParams: Codable {
var key: String
}
private struct ChatSendParams: Codable {
var sessionKey: String
var message: String
var thinking: String
var attachments: [OpenClawChatAttachmentPayload]?
var timeoutMs: Int
var idempotencyKey: String
}
private struct AgentWaitParams: Codable {
var runId: String
var timeoutMs: Int
}
private struct AgentWaitResponse: Codable {
var runId: String?
var status: String?
var error: String?
}
struct AgentWaitCompletion: Equatable {
var runId: String
var status: String
var completed: Bool
}
static func isAgentWaitCompletionStatus(_ status: String) -> Bool {
switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "ok", "completed", "success", "succeeded":
true
default:
false
}
}
init(gateway: GatewayNodeSession) {
self.gateway = gateway
}
func abortRun(sessionKey: String, runId: String) async throws {
struct Params: Codable {
var sessionKey: String
var runId: String
static func agentWaitRequestTimeoutSeconds(timeoutMs: Int) -> Int {
max(1, Int(ceil(Double(timeoutMs) / 1000.0)) + 5)
}
static func makeListSessionsParamsJSON(limit: Int?) throws -> String {
try self.encodeParams(ListSessionsParams(includeGlobal: true, includeUnknown: false, limit: limit))
}
static func makeChatSendParamsJSON(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [OpenClawChatAttachmentPayload]) throws -> String
{
let params = ChatSendParams(
sessionKey: sessionKey,
message: message,
thinking: thinking,
attachments: attachments.isEmpty ? nil : attachments,
timeoutMs: self.defaultChatSendTimeoutMs,
idempotencyKey: idempotencyKey)
return try self.encodeParams(params)
}
static func decodeAgentWaitCompletion(_ data: Data, fallbackRunId: String) throws -> AgentWaitCompletion {
let decoded = try JSONDecoder().decode(AgentWaitResponse.self, from: data)
let status = (decoded.status ?? "unknown").lowercased()
return AgentWaitCompletion(
runId: decoded.runId ?? fallbackRunId,
status: status,
completed: self.isAgentWaitCompletionStatus(status))
}
private static func makeCreateSessionParamsJSON(
key: String,
label: String?,
parentSessionKey: String?) throws -> String
{
let params = CreateSessionParams(
key: key,
label: label,
parentSessionKey: parentSessionKey)
return try self.encodeParams(params)
}
private static func makeRunParamsJSON(sessionKey: String, runId: String) throws -> String {
try self.encodeParams(RunParams(sessionKey: sessionKey, runId: runId))
}
private static func makeSessionKeyParamsJSON(_ sessionKey: String) throws -> String {
try self.encodeParams(SessionKeyParams(key: sessionKey))
}
private static func makeHistoryParamsJSON(sessionKey: String) throws -> String {
struct Params: Codable { var sessionKey: String }
return try self.encodeParams(Params(sessionKey: sessionKey))
}
private static func makeAgentWaitParamsJSON(runId: String, timeoutMs: Int) throws -> String {
try self.encodeParams(AgentWaitParams(runId: runId, timeoutMs: timeoutMs))
}
private static func encodeParams(_ params: some Encodable) throws -> String {
let data = try JSONEncoder().encode(params)
guard let json = String(bytes: data, encoding: .utf8) else {
throw EncodingError.invalidValue(
params,
EncodingError.Context(codingPath: [], debugDescription: "Encoded gateway params were not UTF-8"))
}
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
let json = String(data: data, encoding: .utf8)
return json
}
func createSession(
key: String,
label: String?,
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
{
let json = try Self.makeCreateSessionParamsJSON(
key: key,
label: label,
parentSessionKey: parentSessionKey)
let res = try await self.gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res)
}
func abortRun(sessionKey: String, runId: String) async throws {
let json = try Self.makeRunParamsJSON(sessionKey: sessionKey, runId: runId)
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
}
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
struct Params: Codable {
var includeGlobal: Bool
var includeUnknown: Bool
var limit: Int?
}
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
let json = String(data: data, encoding: .utf8)
let json = try Self.makeListSessionsParamsJSON(limit: limit)
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
}
@@ -40,23 +172,17 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
}
func resetSession(sessionKey: String) async throws {
struct Params: Codable { var key: String }
let data = try JSONEncoder().encode(Params(key: sessionKey))
let json = String(data: data, encoding: .utf8)
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
}
func compactSession(sessionKey: String) async throws {
struct Params: Codable { var key: String }
let data = try JSONEncoder().encode(Params(key: sessionKey))
let json = String(data: data, encoding: .utf8)
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
let json = try Self.makeHistoryParamsJSON(sessionKey: sessionKey)
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
}
@@ -73,35 +199,52 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
+ "len=\(message.count) attachments=\(attachments.count)"
Self.logger.info(
"\(startLogMessage, privacy: .public)")
struct Params: Codable {
var sessionKey: String
var message: String
var thinking: String
var attachments: [OpenClawChatAttachmentPayload]?
var timeoutMs: Int
var idempotencyKey: String
}
let params = Params(
GatewayDiagnostics.log(startLogMessage)
let json = try Self.makeChatSendParamsJSON(
sessionKey: sessionKey,
message: message,
thinking: thinking,
attachments: attachments.isEmpty ? nil : attachments,
timeoutMs: 30000,
idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
idempotencyKey: idempotencyKey,
attachments: attachments)
do {
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
return decoded
} catch {
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
GatewayDiagnostics.log("chat.send failed error=\(error.localizedDescription)")
throw error
}
}
func waitForRunCompletion(runId rawRunId: String, timeoutMs: Int) async -> Bool {
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !runId.isEmpty else { return false }
do {
let json = try Self.makeAgentWaitParamsJSON(runId: runId, timeoutMs: timeoutMs)
let requestTimeoutSeconds = Self.agentWaitRequestTimeoutSeconds(timeoutMs: timeoutMs)
GatewayDiagnostics.log("agent.wait start runId=\(runId)")
let res = try await self.gateway.request(
method: "agent.wait",
paramsJSON: json,
timeoutSeconds: requestTimeoutSeconds)
let completion = try Self.decodeAgentWaitCompletion(res, fallbackRunId: runId)
GatewayDiagnostics.log("agent.wait completed runId=\(completion.runId) status=\(completion.status)")
if !completion.completed {
Self.logger.warning(
"agent.wait status \(completion.status, privacy: .public) runId=\(runId, privacy: .public)")
}
return completion.completed
} catch {
Self.logger.warning("agent.wait failed \(error.localizedDescription, privacy: .public)")
GatewayDiagnostics.log("agent.wait failed runId=\(runId) error=\(error.localizedDescription)")
return false
}
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)

View File

@@ -0,0 +1,690 @@
import Foundation
import OpenClawKit
import SwiftUI
struct AgentProDreamingDestination: View {
@Environment(NodeAppModel.self) private var appModel
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let overviewLoading: Bool
let dreamingValue: String
let dreamingDetail: String
let dreamingColor: Color
let refresh: () async -> Void
@State private var selectedDreamDiaryDayID: String?
@State private var dreamActionBusy: DreamAction?
@State private var dreamActionStatusText: String?
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.detailSummaryCard(
icon: "moon",
title: "Dreaming",
value: self.dreamingValue,
detail: self.dreamingDetail,
color: self.dreamingColor)
self.dreamingTotalsCard
self.dreamingActionsCard
self.dreamDiaryCard
self.dreamingEntriesList(
title: "Promoted Entries",
entries: self.overview?.dreaming?.promotedEntries ?? [],
emptyTitle: "No promoted entries",
emptyDetail: "Dreaming has not promoted durable memory entries yet.")
self.dreamingEntriesList(
title: "Signal Entries",
entries: self.overview?.dreaming?.signalEntries ?? [],
emptyTitle: "No signal entries",
emptyDetail: "No recent recall, daily, grounded, or phase signals were reported.")
self.dreamingEntriesList(
title: "Short-Term Recall",
entries: self.overview?.dreaming?.shortTermEntries ?? [],
emptyTitle: "No short-term entries",
emptyDetail: "The short-term dreaming store is empty.")
self.dreamingPhasesCard
}
.padding(.vertical, 18)
}
.refreshable {
await self.refresh()
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Dreaming")
.navigationBarTitleDisplayMode(.inline)
}
private enum DreamAction: String, CaseIterable, Identifiable {
case backfill
case repair
case dedupe
var id: Self {
self
}
var title: String {
switch self {
case .backfill: "Backfill"
case .repair: "Repair"
case .dedupe: "Dedupe"
}
}
var icon: String {
switch self {
case .backfill: "book.pages"
case .repair: "wrench.and.screwdriver"
case .dedupe: "square.stack.3d.down.right"
}
}
var method: String {
switch self {
case .backfill: "doctor.memory.backfillDreamDiary"
case .repair: "doctor.memory.repairDreamingArtifacts"
case .dedupe: "doctor.memory.dedupeDreamDiary"
}
}
}
private func detailSummaryCard(
icon: String,
title: String,
value: String,
detail: String,
color: Color) -> some View
{
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(value: value, color: color)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var dreamingTotalsCard: some View {
ProCard {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Memory State")
.font(.headline)
Spacer()
ProValuePill(value: self.dreamingValue, color: self.dreamingColor)
}
HStack(spacing: 10) {
self.detailMetric(
label: "Short-term",
value: Self.compactNumber(self.overview?.dreaming?.shortTermCount ?? 0))
self.detailMetric(
label: "Signals",
value: Self.compactNumber(self.overview?.dreaming?.totalSignalCount ?? 0))
self.detailMetric(
label: "Promoted",
value: Self.compactNumber(self.overview?.dreaming?.promotedToday ?? 0))
}
if let storeError = self.normalized(self.overview?.dreaming?.storeError) {
Text(storeError)
.font(.caption2)
.foregroundStyle(OpenClawBrand.warn)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var dreamingActionsCard: some View {
ProCard {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("Maintenance")
.font(.headline)
Text("Refresh reads live state. Maintenance actions update the gateway diary/artifacts.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
Button {
Task { await self.refresh() }
} label: {
Image(systemName: self.overviewLoading ? "hourglass" : "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(self.overviewLoading)
.accessibilityLabel("Refresh dreaming")
}
HStack(spacing: 8) {
ForEach(DreamAction.allCases) { action in
Button {
Task { await self.runDreamAction(action) }
} label: {
Label(action.title, systemImage: self.dreamActionBusy == action ? "hourglass" : action.icon)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!self.gatewayConnected || self.dreamActionBusy != nil)
}
}
if let dreamActionStatusText {
Text(dreamActionStatusText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var dreamDiaryCard: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Dream Diary")
ProCard(padding: 0) {
if let diary = self.overview?.dreamDiary {
if diary.found, let content = self.normalizedMultiline(diary.content) {
let days = Self.dreamDiaryDays(from: content)
let selectedDay = self.selectedDreamDiaryDay(from: days)
VStack(alignment: .leading, spacing: 12) {
HStack {
ProIconBadge(systemName: "book.pages", color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 2) {
Text(diary.path)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.dreamDiaryUpdatedLabel(diary))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if !days.isEmpty {
self.dreamDiaryDayMenu(days: days, selectedDay: selectedDay)
}
}
if let selectedDay {
self.dreamDiaryDayView(selectedDay)
} else {
self.emptyDetailRow(
icon: "calendar.badge.exclamationmark",
title: "No day entries",
detail: "The diary is present, but it does not contain dated Dream Diary blocks.")
}
}
.padding(14)
} else {
self.emptyDetailRow(
icon: "book.closed",
title: diary.found ? "Dream diary is empty" : "No dream diary yet",
detail: diary.found
? "\(diary.path) exists but has no readable content."
: "The gateway did not find DREAMS.md or dreams.md in the active agent workspace.")
.padding(14)
}
} else {
self.emptyDetailRow(
icon: "book.closed",
title: self.gatewayConnected ? "Diary unavailable" : "Dreaming unavailable",
detail: self.gatewayConnected
? "The gateway did not return dream diary content."
: "Connect a gateway to read dream diary entries.")
.padding(14)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private func dreamDiaryDayMenu(days: [DreamDiaryDay], selectedDay: DreamDiaryDay?) -> some View {
Menu {
ForEach(Array(days.reversed())) { day in
Button {
self.selectedDreamDiaryDayID = day.id
} label: {
Label(
day.title,
systemImage: day.id == selectedDay?.id ? "checkmark.circle.fill" : "calendar")
}
}
} label: {
HStack(spacing: 6) {
Image(systemName: "calendar")
Text(selectedDay?.title ?? "Day")
.lineLimit(1)
.minimumScaleFactor(0.75)
}
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.padding(.horizontal, 10)
.frame(height: 34)
.background(Color.primary.opacity(0.055), in: Capsule())
}
.accessibilityLabel("Dream diary day")
}
private func dreamDiaryDayView(_ day: DreamDiaryDay) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
Text(day.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 8)
Text("\(day.entryCount) \(day.entryCount == 1 ? "entry" : "entries")")
.font(.caption2.weight(.semibold))
.foregroundStyle(OpenClawBrand.accent)
}
Text(day.body)
.font(.caption.monospaced())
.foregroundStyle(.primary)
.lineLimit(120)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(10)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
private func selectedDreamDiaryDay(from days: [DreamDiaryDay]) -> DreamDiaryDay? {
if let selectedDreamDiaryDayID,
let match = days.first(where: { $0.id == selectedDreamDiaryDayID })
{
return match
}
return days.last
}
private func dreamingEntriesList(
title: String,
entries: [DreamingEntryLite],
emptyTitle: String,
emptyDetail: String) -> some View
{
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: title)
ProCard(padding: 0) {
if entries.isEmpty {
self.emptyDetailRow(
icon: "doc.text.magnifyingglass",
title: emptyTitle,
detail: self.gatewayConnected ? emptyDetail : "Connect a gateway to load dreaming entries.")
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
self.dreamingEntryRow(entry)
if index < entries.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private func dreamingEntryRow(_ entry: DreamingEntryLite) -> some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: "text.page", color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 4) {
Text(self.dreamingEntryTitle(entry))
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(entry.snippet)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(4)
.textSelection(.enabled)
Text(self.dreamingEntryDetail(entry))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text("\(entry.totalSignalCount)")
.font(.caption2.weight(.semibold))
.foregroundStyle(OpenClawBrand.accent)
.lineLimit(1)
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
private var dreamingPhasesCard: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Phases")
ProCard(padding: 0) {
let phases = self.dreamingPhases
if phases.isEmpty {
self.emptyDetailRow(
icon: "moon.zzz",
title: self.gatewayConnected ? "No phase status" : "Dreaming unavailable",
detail: self.gatewayConnected
? "The gateway did not return dreaming phase details."
: "Connect a gateway to load dreaming phases.")
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(phases.enumerated()), id: \.element.id) { index, phase in
self.dreamingPhaseRow(phase)
if index < phases.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var dreamingPhases: [DreamingPhaseRow] {
let phaseOrder = ["light", "deep", "rem"]
let phases = self.overview?.dreaming?.phases ?? [:]
return phaseOrder.compactMap { id in
guard let phase = phases[id] else { return nil }
return DreamingPhaseRow(id: id, title: id.capitalized, status: phase)
}
}
private func dreamingPhaseRow(_ phase: DreamingPhaseRow) -> some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(
systemName: phase.status.enabled == false ? "pause.circle" : "moon.stars",
color: phase.status.enabled == false ? .secondary : OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 4) {
Text(phase.title)
.font(.subheadline.weight(.semibold))
Text(self.dreamingPhaseDetail(phase.status))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let cron = self.normalized(phase.status.cron) {
Text(cron)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Text(self.dreamingPhaseState(phase.status))
.font(.caption2.weight(.semibold))
.foregroundStyle(phase.status.managedCronPresent == true ? OpenClawBrand.accent : .secondary)
.lineLimit(1)
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
private func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
}
}
private func detailMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
private func dreamingEntryTitle(_ entry: DreamingEntryLite) -> String {
let path = entry.path.split(separator: "/").last.map(String.init) ?? entry.path
return "\(path):\(entry.startLine)"
}
private func dreamingEntryDetail(_ entry: DreamingEntryLite) -> String {
let parts = [
entry.promotedAt.map { "promoted \($0)" },
entry.lastRecalledAt.map { "recalled \($0)" },
"\(entry.recallCount) recalls",
"\(entry.groundedCount) grounded",
].compactMap(\.self)
return parts.joined(separator: "")
}
private func dreamingPhaseDetail(_ phase: DreamingPhaseStatusLite) -> String {
if let nextRunAtMs = phase.nextRunAtMs {
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
}
if phase.managedCronPresent == true {
return "Managed cron is installed."
}
return "Managed cron is not installed."
}
private func dreamingPhaseState(_ phase: DreamingPhaseStatusLite) -> String {
if phase.enabled == false { return "off" }
return phase.managedCronPresent == true ? "scheduled" : "setup"
}
private func dreamDiaryUpdatedLabel(_ diary: DreamDiaryLite) -> String {
guard let updatedAtMs = diary.updatedAtMs else { return "No update timestamp" }
return "Updated \(Self.relativeTime(fromMilliseconds: updatedAtMs))"
}
@MainActor
private func runDreamAction(_ action: DreamAction) async {
guard self.gatewayConnected, self.dreamActionBusy == nil else { return }
self.dreamActionBusy = action
self.dreamActionStatusText = nil
defer { self.dreamActionBusy = nil }
do {
let data = try await self.appModel.operatorSession.request(
method: action.method,
paramsJSON: "{}",
timeoutSeconds: 30)
self.dreamActionStatusText = Self.dreamActionSummary(action: action, data: data)
await self.refresh()
} catch {
self.dreamActionStatusText = error.localizedDescription
}
}
private static func dreamActionSummary(action: DreamAction, data: Data) -> String {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return "\(action.title) complete."
}
let written = json["written"] as? Int
let replaced = json["replaced"] as? Int
let removed = json["removedEntries"] as? Int
let changed = json["changed"] as? Bool
let parts = [
written.map { "\($0) written" },
replaced.map { "\($0) replaced" },
removed.map { "\($0) removed" },
changed.map { $0 ? "artifacts repaired" : "no repair needed" },
].compactMap(\.self)
if parts.isEmpty {
return "\(action.title) complete."
}
return "\(action.title): \(parts.joined(separator: ", "))."
}
private func normalized(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func normalizedMultiline(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func compactNumber(_ value: Int) -> String {
value.formatted(.number.notation(.compactName))
}
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
}
private static func dreamDiaryDays(from content: String) -> [DreamDiaryDay] {
let inner = Self.dreamDiaryInnerContent(content)
let separatorBlocks = inner
.components(separatedBy: "\n---")
.flatMap { $0.components(separatedBy: "\r\n---") }
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let blocks = separatorBlocks.count > 1 ? separatorBlocks : Self.splitDiaryBlocksByDateLine(inner)
let parsedBlocks = blocks.enumerated().map { index, block in
Self.dreamDiaryBlock(from: block, index: index)
}.filter(\.hasDatedEntry)
return Self.mergeDiaryBlocksByDay(parsedBlocks)
}
private static func dreamDiaryInnerContent(_ content: String) -> String {
let start = "<!-- openclaw:dreaming:diary:start -->"
let end = "<!-- openclaw:dreaming:diary:end -->"
guard let startRange = content.range(of: start),
let endRange = content.range(of: end, range: startRange.upperBound..<content.endIndex)
else {
return content
}
return String(content[startRange.upperBound..<endRange.lowerBound])
}
private static func dreamDiaryBlock(from block: String, index: Int) -> DreamDiaryDay {
let rawLines = block.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
let dateLineIndex = rawLines.firstIndex { line in
Self.isDiaryDateLine(line)
}
let markerDay = rawLines.compactMap(Self.backfillDay).first
let rawTitle = dateLineIndex.flatMap { Self.unwrappedEmphasis(rawLines[$0]) } ?? markerDay
let title = rawTitle.map(Self.dayTitle) ?? markerDay ?? "Diary"
let id = markerDay ?? Self.dayID(title)
let bodyLines = rawLines.enumerated().compactMap { offset, line -> String? in
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if offset == dateLineIndex { return nil }
if trimmed.hasPrefix("<!--") && trimmed.hasSuffix("-->") { return nil }
if trimmed == "#" || trimmed == "# Dream Diary" { return nil }
return line
}
let body = bodyLines
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return DreamDiaryDay(
id: id.isEmpty ? "\(index)" : id,
title: title,
body: body.isEmpty ? "No diary prose for this day." : body,
entryCount: 1,
hasDatedEntry: rawTitle != nil)
}
private static func mergeDiaryBlocksByDay(_ blocks: [DreamDiaryDay]) -> [DreamDiaryDay] {
var ordered: [DreamDiaryDay] = []
for block in blocks {
if let existingIndex = ordered.firstIndex(where: { $0.title == block.title }) {
let existing = ordered[existingIndex]
ordered[existingIndex] = DreamDiaryDay(
id: existing.id,
title: existing.title,
body: [existing.body, block.body].joined(separator: "\n\n---\n\n"),
entryCount: existing.entryCount + block.entryCount,
hasDatedEntry: true)
} else {
ordered.append(block)
}
}
return ordered
}
private static func splitDiaryBlocksByDateLine(_ content: String) -> [String] {
var blocks: [String] = []
var current: [String] = []
for line in content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if Self.isDiaryDateLine(line), !current.isEmpty {
blocks.append(current.joined(separator: "\n"))
current = []
}
current.append(line)
}
if !current.isEmpty {
blocks.append(current.joined(separator: "\n"))
}
return blocks
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
private static func isDiaryDateLine(_ line: String) -> Bool {
guard let value = unwrappedEmphasis(line) else { return false }
let monthNames = "January|February|March|April|May|June|July|August|September|October|November|December"
let monthDatePattern = #"\b("# + monthNames + #")\s+\d{1,2},\s+\d{4}\b"#
let isoDatePattern = #"\b\d{4}-\d{2}-\d{2}\b"#
return value.range(
of: "\(monthDatePattern)|\(isoDatePattern)",
options: .regularExpression) != nil
}
private static func dayTitle(_ rawTitle: String) -> String {
let noTime = rawTitle.replacingOccurrences(
of: #"\s+at\s+\d{1,2}:\d{2}.*$"#,
with: "",
options: .regularExpression)
return noTime.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func dayID(_ title: String) -> String {
title.lowercased()
.replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
}
private static func unwrappedEmphasis(_ line: String) -> String? {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("*"), trimmed.hasSuffix("*"), trimmed.count > 2 else { return nil }
return String(trimmed.dropFirst().dropLast())
}
private static func backfillDay(_ line: String) -> String? {
guard let range = line.range(of: #"day=\d{4}-\d{2}-\d{2}"#, options: .regularExpression) else {
return nil
}
return String(line[range].dropFirst(4))
}
}
private struct DreamDiaryDay: Identifiable {
let id: String
let title: String
let body: String
let entryCount: Int
let hasDatedEntry: Bool
}

View File

@@ -0,0 +1,368 @@
import Foundation
import OpenClawKit
import OpenClawProtocol
enum AgentProValueReader {
static func intValue(_ value: AnyCodable?) -> Int? {
switch value?.value {
case let int as Int: int
case let double as Double where double.isFinite: Int(double)
case let string as String: Int(string)
default: nil
}
}
static func doubleValue(_ value: AnyCodable?) -> Double? {
switch value?.value {
case let double as Double where double.isFinite: double
case let int as Int: Double(int)
case let string as String: Double(string)
default: nil
}
}
}
struct AgentOverviewSnapshot {
let skills: SkillStatusReportLite?
let presence: [PresenceEntry]
let cronStatus: CronStatusLite?
let cronJobs: [CronJob]
let dreaming: DreamingStatusLite?
let dreamDiary: DreamDiaryLite?
let usage: CostUsageSummaryLite?
let activeAgentId: String
let agentSkillFilter: [String]?
let loadedAt: Date
var hasAnyLiveData: Bool {
self.skills != nil
|| !self.presence.isEmpty
|| self.cronStatus != nil
|| !self.cronJobs.isEmpty
|| self.dreaming != nil
|| self.dreamDiary != nil
|| self.usage != nil
}
}
struct SkillStatusReportLite: Decodable {
let workspaceDir: String?
let managedSkillsDir: String?
let agentId: String?
let agentSkillFilter: [String]?
let skills: [SkillStatusEntryLite]
var totalCount: Int {
self.skills.count
}
var enabledCount: Int {
self.skills.count {
$0.isEnabled
}
}
var blockedCount: Int {
self.skills.count {
$0.blockedByAllowlist == true || $0.blockedByAgentFilter == true
}
}
var missingRequirementCount: Int {
self.skills.count {
$0.hasMissingRequirements
}
}
}
struct SkillStatusEntryLite: Decodable {
let name: String
let description: String?
let source: String?
let filePath: String?
let skillKey: String?
let primaryEnv: String?
let emoji: String?
let homepage: String?
let disabled: Bool?
let blockedByAllowlist: Bool?
let blockedByAgentFilter: Bool?
let missing: SkillStatusMissingLite?
let install: [SkillInstallOptionLite]?
var displayName: String {
if let emoji, !emoji.isEmpty {
return "\(emoji) \(self.name)"
}
return self.name
}
var effectiveSkillKey: String {
let trimmed = (self.skillKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? self.name : trimmed
}
var isGloballyEnabled: Bool {
self.disabled != true
}
var isEnabled: Bool {
self.disabled != true
&& self.blockedByAllowlist != true
&& self.blockedByAgentFilter != true
}
var hasMissingRequirements: Bool {
guard let missing else { return false }
return !missing.bins.isEmpty
|| !missing.env.isEmpty
|| !missing.config.isEmpty
|| !missing.os.isEmpty
}
var missingSummary: String? {
guard let missing else { return nil }
let values = [
missing.bins,
missing.env,
missing.config,
missing.os,
].flatMap(\.self)
return values.isEmpty ? nil : values.prefix(3).joined(separator: ", ")
}
var installSummary: String? {
guard let option = self.install?.first else { return nil }
return option.label
}
var missingBins: [String] {
self.missing?.bins ?? []
}
var homepageURL: URL? {
guard let homepage else { return nil }
return URL(string: homepage)
}
}
struct SkillInstallOptionLite: Decodable {
let id: String?
let kind: String?
let label: String
let bins: [String]?
}
struct SkillUpdateParams: Encodable {
let skillKey: String
var enabled: Bool?
var apiKey: String?
}
struct SkillInstallParams: Encodable {
let name: String
let installId: String
let timeoutMs: Int
}
struct SkillInstallResultLite: Decodable {
let message: String?
}
struct ClawHubSearchParams: Encodable {
let query: String?
let limit: Int
}
struct ClawHubSearchResponseLite: Decodable {
let results: [ClawHubSearchResultLite]
}
struct ClawHubSearchResultLite: Decodable {
let slug: String
let displayName: String
let summary: String?
let version: String?
}
struct ClawHubInstallParams: Encodable {
let source = "clawhub"
let slug: String
}
struct CronRunParams: Encodable {
let id: String
let mode: String
}
struct CronUpdatePatch: Encodable {
let enabled: Bool
}
struct CronUpdateParams: Encodable {
let id: String
let patch: CronUpdatePatch
}
struct SkillStatusMissingLite: Decodable {
let bins: [String]
let env: [String]
let config: [String]
let os: [String]
}
struct CronStatusLite: Decodable {
let enabled: Bool
let jobs: Int
let nextwakeatms: Int?
enum CodingKeys: String, CodingKey {
case enabled
case jobs
case nextwakeatms = "nextWakeAtMs"
}
}
struct CronJobsListLite: Decodable {
let jobs: [CronJob]
let total: Int?
}
struct DreamingStatusEnvelope: Decodable {
let dreaming: DreamingStatusLite?
}
struct DreamingStatusLite: Decodable {
let enabled: Bool
let shortTermCount: Int?
let totalSignalCount: Int?
let promotedToday: Int?
let storeError: String?
let shortTermEntries: [DreamingEntryLite]?
let signalEntries: [DreamingEntryLite]?
let promotedEntries: [DreamingEntryLite]?
let phases: [String: DreamingPhaseStatusLite]?
var nextRunAtMs: Int? {
self.phases?.values
.compactMap(\.nextRunAtMs)
.min()
}
}
struct DreamingEntryLite: Decodable, Identifiable {
let key: String
let path: String
let startLine: Int
let endLine: Int
let snippet: String
let recallCount: Int
let dailyCount: Int
let groundedCount: Int
let totalSignalCount: Int
let lightHits: Int
let remHits: Int
let phaseHitCount: Int
let promotedAt: String?
let lastRecalledAt: String?
var id: String {
"\(self.key):\(self.path):\(self.startLine):\(self.endLine)"
}
}
struct DreamDiaryLite: Decodable {
let agentId: String
let found: Bool
let path: String
let content: String?
let updatedAtMs: Int?
}
struct DreamingPhaseStatusLite: Decodable {
let enabled: Bool?
let cron: String?
let managedCronPresent: Bool?
let nextRunAtMs: Int?
}
struct DreamingPhaseRow: Identifiable {
let id: String
let title: String
let status: DreamingPhaseStatusLite
}
struct ConfigSnapshotLite: Decodable {
let hash: String?
let config: ConfigRootLite?
func agentConfig(id: String) -> AgentConfigLite? {
self.config?.agents?.list?.first { $0.id == id }
}
func effectiveSkillFilter(agentId: String) -> [String]? {
if let agentSkills = self.agentConfig(id: agentId)?.skills {
return agentSkills
}
return self.config?.agents?.defaults?.skills
}
}
struct ConfigRootLite: Decodable {
let agents: AgentsConfigLite?
}
struct AgentsConfigLite: Decodable {
let defaults: AgentDefaultsConfigLite?
let list: [AgentConfigLite]?
}
struct AgentDefaultsConfigLite: Decodable {
let skills: [String]?
}
struct AgentConfigLite: Decodable {
let id: String
let skills: [String]?
}
struct ConfigPatchParams: Encodable {
let raw: String
let baseHash: String
}
enum SkillMutationError: LocalizedError {
case missingConfigHash
case invalidPatchPayload
var errorDescription: String? {
switch self {
case .missingConfigHash:
"Config hash missing; refresh and retry."
case .invalidPatchPayload:
"Could not encode the skill config update."
}
}
}
struct CostUsageSummaryLite: Decodable {
let updatedAt: Int?
let days: Int?
let daily: [CostUsageDailyEntryLite]?
let totals: [String: AnyCodable]?
let cacheStatus: [String: AnyCodable]?
var totalCost: Double? {
AgentProValueReader.doubleValue(self.totals?["totalCost"])
}
var totalTokens: Int? {
AgentProValueReader.intValue(self.totals?["totalTokens"])
}
}
struct CostUsageDailyEntryLite: Decodable {
let date: String
let totalTokens: Int?
let totalCost: Double?
}

View File

@@ -0,0 +1,348 @@
import OpenClawProtocol
import SwiftUI
import UIKit
struct AgentProNodesDestination: View {
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let agentCount: Int
let instancesValue: String
let instancesDetail: String
let instancesColor: Color
let refresh: () async -> Void
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.summaryCard
self.totalsCard
self.nodesList
}
.padding(.vertical, 18)
}
.refreshable {
await self.refresh()
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Nodes")
.navigationBarTitleDisplayMode(.inline)
}
private var summaryCard: some View {
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: "display", color: self.instancesColor)
VStack(alignment: .leading, spacing: 3) {
Text("Nodes")
.font(.headline)
Text(self.instancesDetail)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(value: self.instancesValue, color: self.instancesColor)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var totalsCard: some View {
ProCard {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Presence")
.font(.headline)
Spacer()
ProValuePill(value: self.instancesValue, color: self.instancesColor)
}
HStack(spacing: 10) {
self.detailMetric(label: "Connected", value: "\(self.overview?.presence.count ?? 0)")
self.detailMetric(label: "Agents", value: "\(self.agentCount)")
self.detailMetric(label: "Gateway", value: self.gatewayConnected ? "online" : "offline")
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var nodesList: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Connected Nodes")
ProCard(padding: 0) {
let nodes = self.sortedPresenceEntries
if nodes.isEmpty {
self.emptyRow(
icon: "display",
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
detail: self.gatewayConnected
? "The gateway did not report any system presence entries."
: "Connect a gateway to inspect connected nodes.")
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(nodes.enumerated()), id: \.element.presenceKey) { index, entry in
NavigationLink {
self.nodeDetail(entry)
} label: {
self.nodePresenceRow(entry, showsChevron: true)
}
.buttonStyle(.plain)
if index < nodes.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var sortedPresenceEntries: [PresenceEntry] {
(self.overview?.presence ?? [])
.sorted { lhs, rhs in
if lhs.ts != rhs.ts { return lhs.ts > rhs.ts }
return (Self.presenceLabel(lhs) ?? lhs.presenceKey)
.localizedCaseInsensitiveCompare(Self.presenceLabel(rhs) ?? rhs.presenceKey) == .orderedAscending
}
}
private func nodePresenceRow(_ entry: PresenceEntry, showsChevron: Bool = false) -> some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
VStack(alignment: .leading, spacing: 4) {
Text(Self.presenceLabel(entry) ?? "Node")
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(Self.presenceDetail(entry))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let meta = Self.presenceMeta(entry) {
Text(meta)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Text(Self.presenceState(entry))
.font(.caption2.weight(.semibold))
.foregroundStyle(Self.presenceColor(entry))
.lineLimit(1)
if showsChevron {
Image(systemName: "chevron.right")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
private func nodeDetail(_ entry: PresenceEntry) -> some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
VStack(alignment: .leading, spacing: 3) {
Text(Self.presenceLabel(entry) ?? "Node")
.font(.headline)
Text(Self.presenceDetail(entry))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(value: Self.presenceState(entry), color: Self.presenceColor(entry))
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
ProCard {
VStack(spacing: 0) {
self.nodeDetailRow("Instance", value: entry.instanceid)
Divider()
self.nodeDetailRow("Device", value: entry.deviceid)
Divider()
self.nodeDetailRow("Host", value: entry.host)
Divider()
self.nodeDetailRow("IP", value: entry.ip)
Divider()
self.nodeDetailRow("Platform", value: entry.platform)
Divider()
self.nodeDetailRow("Version", value: entry.version)
Divider()
self.nodeDetailRow("Mode", value: entry.mode)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.nodeListCard(title: "Scopes", values: entry.scopes ?? [])
self.nodeListCard(title: "Roles", values: entry.roles ?? [])
self.nodeListCard(title: "Tags", values: entry.tags ?? [])
}
.padding(.vertical, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
.navigationBarTitleDisplayMode(.inline)
}
private func nodeDetailRow(_ title: String, value: String?) -> some View {
let normalized = Self.normalized(value) ?? "n/a"
return HStack(spacing: 10) {
Text(title)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(normalized)
.lineLimit(1)
.truncationMode(.middle)
Button {
UIPasteboard.general.string = normalized
} label: {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.plain)
.disabled(normalized == "n/a")
.accessibilityLabel("Copy \(title)")
}
.font(.subheadline)
.padding(.vertical, 10)
}
private func nodeListCard(title: String, values: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: title)
ProCard {
if values.isEmpty {
Text("None reported.")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(values, id: \.self) { value in
Text(value)
.font(.caption.monospaced())
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private func detailMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
private func emptyRow(icon: String, title: String, detail: String) -> some View {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
}
}
private static func presenceLabel(_ entry: PresenceEntry) -> String? {
self.normalized(entry.host)
?? self.normalized(entry.devicefamily)
?? self.normalized(entry.platform)
?? self.normalized(entry.mode)
}
private static func presenceDetail(_ entry: PresenceEntry) -> String {
let parts = [
Self.normalized(entry.ip),
Self.normalized(entry.platform),
Self.normalized(entry.version),
].compactMap(\.self)
if !parts.isEmpty {
return parts.joined(separator: "")
}
return Self.normalized(entry.text) ?? "Presence beacon received."
}
private static func presenceMeta(_ entry: PresenceEntry) -> String? {
let tags = (entry.tags ?? []).prefix(2).joined(separator: ", ")
let scopesCount = entry.scopes?.count ?? 0
let rolesCount = entry.roles?.count ?? 0
let labels = [
Self.normalized(entry.instanceid).map { "instance \($0)" },
tags.isEmpty ? nil : tags,
scopesCount > 0 ? "\(scopesCount) scopes" : nil,
rolesCount > 0 ? "\(rolesCount) roles" : nil,
].compactMap(\.self)
return labels.isEmpty ? nil : labels.joined(separator: "")
}
private static func presenceState(_ entry: PresenceEntry) -> String {
if let reason = normalized(entry.reason) {
return reason
}
if let mode = Self.normalized(entry.mode) {
return mode
}
return Self.relativeTime(fromMilliseconds: entry.ts)
}
private static func presenceIcon(_ entry: PresenceEntry) -> String {
let family = Self.normalized(entry.devicefamily)?.lowercased()
if family?.contains("phone") == true { return "iphone" }
if family?.contains("tablet") == true || family?.contains("pad") == true { return "ipad" }
if family?.contains("desktop") == true || family?.contains("mac") == true { return "desktopcomputer" }
return "display"
}
private static func presenceColor(_ entry: PresenceEntry) -> Color {
self.normalized(entry.reason) == nil ? OpenClawBrand.accent : OpenClawBrand.warn
}
private static func normalized(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
}
}
extension PresenceEntry {
fileprivate var presenceKey: String {
self.instanceid
?? self.deviceid
?? self.host
?? self.ip
?? "\(self.ts)"
}
}

View File

@@ -0,0 +1,178 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
var cronStatusCard: some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Scheduler")
.font(.headline)
Spacer()
ProValuePill(
value: self.overview?.cronStatus?.enabled == true ? "on" : "off",
color: self.cronColor)
}
HStack(spacing: 10) {
let jobCount = self.overview?.cronStatus?.jobs
?? self.overview?.cronJobs.count
?? 0
self.detailMetric(label: "Jobs", value: "\(jobCount)")
self.detailMetric(label: "Next", value: self.cronNextRunLabel)
}
if let cronActionStatusText {
Text(cronActionStatusText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var cronNextRunLabel: String {
guard let nextWakeAtMs = self.overview?.cronStatus?.nextwakeatms else { return "none" }
return Self.relativeTime(fromMilliseconds: nextWakeAtMs)
}
func cronJobsList(limit: Int?) -> some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Jobs")
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
let jobs = self.sortedCronJobs
let visible = limit.map { Array(jobs.prefix($0)) } ?? jobs
if visible.isEmpty {
self.emptyCronRow
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(visible.enumerated()), id: \.element.id) { index, job in
self.cronJobDetailRow(job)
if index < visible.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var sortedCronJobs: [CronJob] {
(self.overview?.cronJobs ?? [])
.sorted { lhs, rhs in
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
switch (lhsNext, rhsNext) {
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
case (_?, nil): return true
case (nil, _?): return false
case (nil, nil): return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
}
func cronJobDetailRow(_ job: CronJob) -> some View {
let busy = self.cronActionBusyIDs.contains(job.id)
return HStack(alignment: .top, spacing: 12) {
ProIconBadge(
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
color: job.enabled ? OpenClawBrand.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(job.name)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.cronJobDetail(job))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
Text(self.cronScheduleSummary(job))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 8) {
Button {
Task { await self.runCronJob(job) }
} label: {
Label("Run", systemImage: "play.fill")
}
.disabled(busy || !self.gatewayConnected)
Button {
Task { await self.setCronJob(job, enabled: !job.enabled) }
} label: {
Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark")
}
.disabled(busy || !self.gatewayConnected)
}
.buttonStyle(.bordered)
.controlSize(.mini)
}
Spacer(minLength: 8)
if busy {
ProgressView()
.progressViewStyle(.circular)
.controlSize(.small)
} else {
Text(self.cronJobState(job))
.font(.caption2.weight(.semibold))
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
.lineLimit(1)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
@MainActor
func runCronJob(_ job: CronJob) async {
await self.runCronAction(job, success: "Queued \(job.name).") {
let params = CronRunParams(id: job.id, mode: "force")
_ = try await self.requestGateway(method: "cron.run", params: params, timeoutSeconds: 20)
}
}
@MainActor
func setCronJob(_ job: CronJob, enabled: Bool) async {
await self.runCronAction(job, success: enabled ? "Enabled \(job.name)." : "Paused \(job.name).") {
let params = CronUpdateParams(id: job.id, patch: CronUpdatePatch(enabled: enabled))
_ = try await self.requestGateway(method: "cron.update", params: params, timeoutSeconds: 20)
}
}
@MainActor
func runCronAction(
_ job: CronJob,
success: String,
action: () async throws -> Void) async
{
guard self.gatewayConnected else { return }
self.cronActionBusyIDs.insert(job.id)
self.cronActionStatusText = nil
defer { self.cronActionBusyIDs.remove(job.id) }
do {
try await action()
self.cronActionStatusText = success
await self.refreshOverview(force: true)
} catch {
self.cronActionStatusText = Self.skillMutationMessage(error)
}
}
func cronScheduleSummary(_ job: CronJob) -> String {
guard let schedule = job.schedule.value as? [String: AnyCodable] else { return "Schedule configured" }
if let expr = Self.stringValue(schedule["expr"]) {
return "Cron \(expr)"
}
if let everyMs = AgentProValueReader.intValue(schedule["everyMs"]) {
return "Every \(Self.duration(milliseconds: everyMs))"
}
if let kind = Self.stringValue(schedule["kind"]) {
return kind
}
return "Schedule configured"
}
}

View File

@@ -0,0 +1,148 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
@ViewBuilder
func destination(for route: AgentRoute) -> some View {
switch route {
case .skills:
self.skillsDestination
case .nodes:
self.nodesDestination
case .cron:
self.cronDestination
case .usage:
self.usageDestination
case .dreaming:
self.dreamingDestination
}
}
var skillsDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.detailSummaryCard(
icon: "sparkles",
title: "Skills",
value: self.skillsValue,
detail: self.skillsDetail,
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
self.skillsPolicyControls
self.skillsFilterField
self.clawHubSearchCard
self.skillsList
}
.padding(.vertical, 18)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Skills")
.navigationBarTitleDisplayMode(.inline)
}
var nodesDestination: some View {
AgentProNodesDestination(
overview: self.overview,
gatewayConnected: self.gatewayConnected,
agentCount: self.appModel.gatewayAgents.count,
instancesValue: self.instancesValue,
instancesDetail: self.instancesDetail,
instancesColor: self.instancesColor,
refresh: {
await self.refreshOverview(force: true)
})
}
var cronDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.detailSummaryCard(
icon: "clock.arrow.circlepath",
title: "Cron Jobs",
value: self.cronValue,
detail: self.cronDetail,
color: self.cronColor)
self.cronStatusCard
self.cronJobsList(limit: nil)
}
.padding(.vertical, 18)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Cron Jobs")
.navigationBarTitleDisplayMode(.inline)
}
var usageDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.detailSummaryCard(
icon: "chart.line.uptrend.xyaxis",
title: "Usage",
value: self.usageValue,
detail: self.usageDetail,
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
self.usageTotalsCard
self.usageDailyList
}
.padding(.vertical, 18)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Usage")
.navigationBarTitleDisplayMode(.inline)
}
var dreamingDestination: some View {
AgentProDreamingDestination(
overview: self.overview,
gatewayConnected: self.gatewayConnected,
overviewLoading: self.overviewLoading,
dreamingValue: self.dreamingValue,
dreamingDetail: self.dreamingDetail,
dreamingColor: self.dreamingColor,
refresh: {
await self.refreshOverview(force: true)
})
}
func detailSummaryCard(
icon: String,
title: String,
value: String,
detail: String,
color: Color) -> some View
{
ProCard(radius: AgentLayout.cardRadius) {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(value: value, color: color)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}

View File

@@ -0,0 +1,35 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
func detailMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
}
}
}

View File

@@ -0,0 +1,251 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
func agentName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
}
func agentBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
let normalizedEmoji = self.normalized(emoji)
{
return normalizedEmoji
}
let words = self.agentName(for: agent)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2)
let initials = words.compactMap(\.first).map(String.init).joined()
return initials.isEmpty ? "OC" : initials.uppercased()
}
func agentTint(for agent: AgentSummary, state: AgentRosterState) -> Color {
if agent.id == self.activeAgentID { return OpenClawBrand.accent }
return state.color.opacity(0.62)
}
func agentDetail(for agent: AgentSummary) -> String {
let parts = [
self.normalized(agent.workspace),
self.modelLabel(for: agent),
agent.id == self.appModel.gatewayDefaultAgentId ? "default" : nil,
].compactMap(\.self)
return parts.isEmpty ? agent.id : parts.joined(separator: "")
}
func agentSessionSummary(_ agent: AgentSummary) -> String {
guard self.gatewayConnected else { return "0" }
if agent.id == self.activeAgentID {
return self.appModel.isOperatorGatewayConnected ? "1 running" : "0"
}
return "0"
}
func agentRuntimeSummary(_ agent: AgentSummary) -> String {
if let runtime = agent.agentruntime,
let id = runtime["id"]?.value as? String,
let normalized = self.normalized(id)
{
return normalized
}
if let model = self.modelLabel(for: agent) {
return Self.shortModelLabel(model)
}
return "default"
}
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
guard self.gatewayConnected else { return .idle }
if agent.id == self.activeAgentID { return .online }
if self.cronJobsContain(agentID: agent.id) { return .busy }
return .idle
}
func cronJobsContain(agentID: String) -> Bool {
self.recentCronJobs.contains { job in
self.normalized(job.agentid) == agentID && job.enabled
}
}
func modelLabel(for agent: AgentSummary) -> String? {
guard let model = agent.model else { return nil }
for key in ["primary", "name", "id", "model"] {
if let value = model[key]?.value as? String,
let normalized = self.normalized(value)
{
return normalized
}
}
return nil
}
static func shortModelLabel(_ model: String) -> String {
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "default" }
let leaf = trimmed.split(separator: "/").last.map(String.init) ?? trimmed
return leaf
.replacingOccurrences(of: "claude-", with: "")
.replacingOccurrences(of: "gpt-", with: "")
}
func presenceLabel(_ entry: PresenceEntry) -> String? {
self.normalized(entry.host)
?? self.normalized(entry.devicefamily)
?? self.normalized(entry.platform)
?? self.normalized(entry.mode)
}
func cronJobDetail(_ job: CronJob) -> String {
if let nextRunAtMs = AgentProValueReader.intValue(job.state["nextRunAtMs"]) {
return "Next \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
}
if let description = self.normalized(job.description) {
return description
}
if let agentId = self.normalized(job.agentid) {
return agentId
}
return job.id
}
func cronJobState(_ job: CronJob) -> String {
if !job.enabled {
return "paused"
}
if let status = Self.stringValue(job.state["lastStatus"]) ?? Self.stringValue(job.state["lastRunStatus"]) {
return status
}
return "enabled"
}
@MainActor
func refreshOverview(force: Bool) async {
guard self.scenePhase == .active else { return }
guard self.appModel.isOperatorGatewayConnected else {
self.overview = nil
self.overviewErrorText = nil
self.overviewLoading = false
return
}
if self.overviewLoading, force == false {
return
}
self.overviewLoading = true
self.overviewErrorText = nil
defer { self.overviewLoading = false }
let activeAgentID = self.activeAgentID
let skillsParams = Self.agentScopedParams(agentId: activeAgentID)
async let skills = self.requestOptional(
SkillStatusReportLite.self,
method: "skills.status",
paramsJSON: skillsParams)
async let config = self.requestOptional(ConfigSnapshotLite.self, method: "config.get")
async let presence = self.requestOptional([PresenceEntry].self, method: "system-presence")
async let cronStatus = self.requestOptional(CronStatusLite.self, method: "cron.status")
async let cronJobs = self.requestOptional(
CronJobsListLite.self,
method: "cron.list",
paramsJSON: "{\"includeDisabled\":true,\"limit\":8,\"sortBy\":\"nextRunAtMs\",\"sortDir\":\"asc\"}",
timeoutSeconds: 12)
async let dreaming = self.requestOptional(DreamingStatusEnvelope.self, method: "doctor.memory.status")
async let dreamDiary = self.requestOptional(DreamDiaryLite.self, method: "doctor.memory.dreamDiary")
async let usage = self.requestOptional(
CostUsageSummaryLite.self,
method: "usage.cost",
paramsJSON: "{\"days\":31}",
timeoutSeconds: 12)
let loadedSkills = await skills
let loadedConfig = await config
let loadedPresence = await presence
let loadedCronStatus = await cronStatus
let loadedCronJobs = await cronJobs
let loadedDreaming = await dreaming
let loadedDreamDiary = await dreamDiary
let loadedUsage = await usage
let snapshot = AgentOverviewSnapshot(
skills: loadedSkills,
presence: loadedPresence ?? [],
cronStatus: loadedCronStatus,
cronJobs: loadedCronJobs?.jobs ?? [],
dreaming: loadedDreaming?.dreaming,
dreamDiary: loadedDreamDiary,
usage: loadedUsage,
activeAgentId: activeAgentID,
agentSkillFilter: loadedSkills?.agentSkillFilter
?? loadedConfig?.effectiveSkillFilter(agentId: activeAgentID),
loadedAt: Date())
if snapshot.hasAnyLiveData {
self.overview = snapshot
} else {
self.overview = snapshot
self.overviewErrorText = "Live overview could not load yet."
}
}
func requestOptional<T: Decodable>(
_ type: T.Type,
method: String,
paramsJSON: String = "{}",
timeoutSeconds: Int = 8) async -> T?
{
do {
let data = try await self.appModel.operatorSession.request(
method: method,
paramsJSON: paramsJSON,
timeoutSeconds: timeoutSeconds)
return try JSONDecoder().decode(T.self, from: data)
} catch {
return nil
}
}
func normalized(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
static func stringValue(_ value: AnyCodable?) -> String? {
guard let string = value?.value as? String else { return nil }
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
}
static func compactNumber(_ value: Int) -> String {
value.formatted(.number.notation(.compactName))
}
static func currency(_ value: Double) -> String {
value.formatted(.currency(code: "USD").precision(.fractionLength(0...2)))
}
static func duration(milliseconds: Int) -> String {
let seconds = max(0, milliseconds / 1000)
if seconds < 60 { return "\(seconds)s" }
let minutes = seconds / 60
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h" }
return "\(hours / 24)d"
}
static func agentScopedParams(agentId: String) -> String {
guard let data = try? JSONEncoder().encode(["agentId": agentId]),
let json = String(data: data, encoding: .utf8)
else {
return "{}"
}
return json
}
}

View File

@@ -0,0 +1,724 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
var rosterHeader: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text("Agents")
.font(.system(size: 28, weight: .bold))
Text("\(self.sortedAgents.count) total")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
HStack(spacing: 10) {
self.headerIconButton(
systemName: "magnifyingglass",
label: "Search agents",
action: {
withAnimation(.snappy(duration: 0.18)) {
self.agentSearchPresented.toggle()
}
})
self.headerIconButton(
systemName: "arrow.clockwise",
label: self.overviewLoading ? "Refreshing agents" : "Refresh agents",
action: {
self.overviewRefreshNonce += 1
})
}
.padding(.top, 2)
}
if self.agentSearchPresented {
TextField("Search agents", text: self.$agentSearchText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.subheadline)
.padding(.horizontal, 12)
.frame(height: 38)
.background {
Capsule()
.fill(self.searchFieldFill)
.overlay {
Capsule().strokeBorder(self.searchFieldStroke, lineWidth: 1)
}
}
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var agentFilters: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(AgentRosterFilter.allCases) { filter in
Button {
withAnimation(.snappy(duration: 0.18)) {
self.agentRosterFilter = filter
}
} label: {
Text(filter.title)
.font(.caption.weight(.semibold))
.foregroundStyle(self.agentRosterFilter == filter ? .primary : .secondary)
.padding(.horizontal, 15)
.frame(height: AgentLayout.filterHeight)
.background {
Capsule()
.fill(self.agentRosterFilter == filter
? Color.primary.opacity(0.13)
: Color.primary.opacity(0.055))
}
.overlay {
Capsule()
.strokeBorder(Color.primary.opacity(self.agentRosterFilter == filter ? 0.22 : 0.06))
}
}
.buttonStyle(.plain)
}
if self.agentFiltersActive {
self.headerIconButton(
systemName: "xmark",
label: "Clear filters",
action: {
self.agentRosterFilter = .all
self.agentSearchText = ""
})
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var agentFiltersActive: Bool {
self.agentRosterFilter != .all
|| !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var agentsSection: some View {
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
if self.filteredAgents.isEmpty {
self.emptyAgentsRow
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(self.filteredAgents.enumerated()), id: \.element.id) { index, agent in
self.agentRow(agent)
if index < self.filteredAgents.count - 1 {
Divider().padding(.leading, 76)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var operationsSection: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Live Operations")
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
self.metricTile(
icon: "sparkles",
title: "Skills",
value: self.skillsValue,
detail: self.skillsDetail,
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
route: .skills)
self.metricTile(
icon: "externaldrive.connected.to.line.below",
title: "Instances",
value: self.instancesValue,
detail: self.instancesDetail,
color: self.instancesColor,
route: .nodes)
self.metricTile(
icon: "clock.arrow.circlepath",
title: "Cron",
value: self.cronValue,
detail: self.cronDetail,
color: self.cronColor,
route: .cron)
self.metricTile(
icon: "chart.line.uptrend.xyaxis",
title: "Usage",
value: self.usageValue,
detail: self.usageDetail,
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
route: .usage)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
if let overviewErrorText {
Text(overviewErrorText)
.font(.caption)
.foregroundStyle(OpenClawBrand.warn)
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
}
var dreamingSection: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Dreaming")
ProCard(radius: AgentLayout.cardRadius) {
NavigationLink(value: AgentRoute.dreaming) {
self.agentMenuRow(
icon: "moon",
title: "Dreaming",
detail: self.dreamingDetail,
value: self.dreamingValue,
color: self.dreamingColor,
showsChevron: true)
}
.buttonStyle(.plain)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var cronSection: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Scheduled Work")
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
let jobs = self.recentCronJobs
if jobs.isEmpty {
NavigationLink(value: AgentRoute.cron) {
self.emptyCronRow
.padding(14)
}
.buttonStyle(.plain)
} else {
VStack(spacing: 0) {
ForEach(Array(jobs.enumerated()), id: \.element.id) { index, job in
NavigationLink(value: AgentRoute.cron) {
self.cronJobRow(job)
}
.buttonStyle(.plain)
if index < jobs.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var emptyAgentsRow: some View {
HStack(spacing: 12) {
ProIconBadge(systemName: "person.2.slash", color: .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(self.emptyAgentsTitle)
.font(.subheadline.weight(.semibold))
Text(self.emptyAgentsDetail)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
func agentRow(_ agent: AgentSummary) -> some View {
let isActive = agent.id == self.activeAgentID
let state = self.agentRosterState(for: agent)
return HStack(alignment: .top, spacing: 12) {
self.agentAvatar(agent, state: state)
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(self.agentName(for: agent))
.font(.subheadline.weight(.semibold))
.lineLimit(1)
HStack(spacing: 4) {
Circle()
.fill(state.color)
.frame(width: 6, height: 6)
Text(state.title)
.font(.caption2.weight(.semibold))
}
.foregroundStyle(state.color)
.lineLimit(1)
}
Text(self.agentDetail(for: agent))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
HStack(spacing: 0) {
self.agentMetric(label: "Sessions", value: self.agentSessionSummary(agent))
Divider()
.frame(height: 24)
.padding(.horizontal, 12)
self.agentMetric(label: "Runtime", value: self.agentRuntimeSummary(agent))
}
}
.layoutPriority(1)
Button {
self.appModel.setSelectedAgentId(agent.id)
} label: {
Image(systemName: isActive ? "checkmark" : "arrow.right")
.font(.caption.weight(.bold))
}
.buttonStyle(.plain)
.foregroundStyle(isActive ? OpenClawBrand.accent : .primary)
.frame(width: AgentLayout.actionButtonSize, height: AgentLayout.actionButtonSize)
.background {
Circle()
.fill(self.iconButtonFill)
.overlay {
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
}
}
.accessibilityLabel(isActive ? "Active agent" : "Make active agent")
}
.padding(.vertical, 14)
.padding(.horizontal, 13)
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
.contentShape(Rectangle())
.onTapGesture {
self.appModel.setSelectedAgentId(agent.id)
}
}
func headerIconButton(
systemName: String,
label: String,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Image(systemName: systemName)
.font(.subheadline.weight(.semibold))
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
.background {
Circle()
.fill(self.iconButtonFill)
.overlay {
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(label)
}
func agentAvatar(_ agent: AgentSummary, state: AgentRosterState) -> some View {
ZStack(alignment: .bottomTrailing) {
Text(self.agentBadge(for: agent))
.font(.system(size: self.agentBadge(for: agent).count > 2 ? 14 : 18, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.minimumScaleFactor(0.62)
.lineLimit(1)
.frame(width: 48, height: 48)
.background(
Circle()
.fill(
LinearGradient(
colors: [
self.agentTint(for: agent, state: state),
Color.primary.opacity(0.38),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)))
.overlay(Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
Circle()
.fill(state.color)
.frame(width: 10, height: 10)
.overlay(Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 1))
}
}
func agentMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
Text(value)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.74)
}
.frame(minWidth: 60, alignment: .leading)
}
func agentMenuRow(
icon: String,
title: String,
detail: String,
value: String,
color: Color,
showsChevron: Bool = false) -> some View
{
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(value)
.font(.caption2.weight(.semibold))
.foregroundStyle(color)
.lineLimit(1)
if showsChevron {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 10)
}
func metricTile(
icon: String,
title: String,
value: String,
detail: String,
color: Color,
route: AgentRoute? = nil) -> some View
{
Group {
if let route {
NavigationLink(value: route) {
self.metricTileContent(
icon: icon,
title: title,
value: value,
detail: detail,
color: color,
showsChevron: true)
}
.buttonStyle(.plain)
} else {
self.metricTileContent(
icon: icon,
title: title,
value: value,
detail: detail,
color: color,
showsChevron: false)
}
}
}
func metricTileContent(
icon: String,
title: String,
value: String,
detail: String,
color: Color,
showsChevron: Bool) -> some View
{
ProCard(padding: 12, radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
HStack {
ProIconBadge(systemName: icon, color: color)
Spacer()
ProValuePill(value: value, color: color)
if showsChevron {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption.weight(.semibold))
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: AgentLayout.metricTileHeight, alignment: .topLeading)
}
}
var emptyCronRow: some View {
HStack(spacing: 12) {
ProIconBadge(systemName: "clock.badge.questionmark", color: .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(self.gatewayConnected ? "No scheduled jobs" : "Cron unavailable")
.font(.subheadline.weight(.semibold))
Text(self.gatewayConnected
? "The gateway has no visible cron jobs."
: "Connect a gateway to load scheduled work.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
func cronJobRow(_ job: CronJob) -> some View {
HStack(spacing: 12) {
ProIconBadge(
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
color: job.enabled ? OpenClawBrand.accent : .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(job.name)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.cronJobDetail(job))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(self.cronJobState(job))
.font(.caption2.weight(.semibold))
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
.lineLimit(1)
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
var sortedAgents: [AgentSummary] {
self.appModel.gatewayAgents.sorted { lhs, rhs in
if lhs.id == self.activeAgentID { return true }
if rhs.id == self.activeAgentID { return false }
return self.agentName(for: lhs)
.localizedCaseInsensitiveCompare(self.agentName(for: rhs)) == .orderedAscending
}
}
var filteredAgents: [AgentSummary] {
let query = self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
return self.sortedAgents.filter { agent in
let matchesFilter: Bool = switch self.agentRosterFilter {
case .all:
true
case .online:
self.agentRosterState(for: agent) == .online
case .busy:
self.agentRosterState(for: agent) == .busy
case .idle:
self.agentRosterState(for: agent) == .idle
}
guard matchesFilter else { return false }
guard !query.isEmpty else { return true }
let haystack = [
self.agentName(for: agent),
agent.id,
self.normalized(agent.workspace),
self.modelLabel(for: agent),
]
.compactMap(\.self)
.joined(separator: " ")
return haystack.localizedCaseInsensitiveContains(query)
}
}
var activeAgentID: String {
self.normalized(self.appModel.selectedAgentId)
?? self.normalized(self.appModel.gatewayDefaultAgentId)
?? "main"
}
var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var searchFieldFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.78)
}
private var searchFieldStroke: Color {
self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.07)
}
private var iconButtonFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.white.opacity(0.78)
}
private var iconButtonStroke: Color {
self.colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.07)
}
var emptyAgentsTitle: String {
if !self.gatewayConnected { return "Agents unavailable" }
if !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matches" }
if self.agentRosterFilter != .all { return "No \(self.agentRosterFilter.title.lowercased()) agents" }
return "No agents reported"
}
var emptyAgentsDetail: String {
if !self.gatewayConnected { return "Connect a gateway to load the live agent roster." }
if !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "Try another search or clear the agent filters."
}
if self.agentRosterFilter != .all { return "Clear the filter to view the full roster." }
return "The connected gateway did not return an agent list."
}
var overviewTaskID: String {
[
self.gatewayConnected ? "connected" : "offline",
self.appModel.isOperatorGatewayConnected ? "operator" : "no-operator",
self.activeAgentID,
self.scenePhase == .active ? "active" : "inactive",
"\(self.overviewRefreshNonce)",
].joined(separator: ":")
}
var skillsValue: String {
guard self.gatewayConnected else { return "offline" }
guard let skills = self.overview?.skills else {
return self.overviewLoading ? "..." : "live"
}
return "\(skills.enabledCount)/\(skills.totalCount)"
}
var skillsDetail: String {
guard self.gatewayConnected else { return "Connect a gateway to load skills." }
guard let skills = self.overview?.skills else {
return self.overviewLoading ? "Loading skill status." : "Skill status is available from the gateway."
}
if skills.blockedCount > 0 {
return "\(skills.enabledCount) enabled, \(skills.blockedCount) blocked"
}
if skills.missingRequirementCount > 0 {
return "\(skills.enabledCount) enabled, \(skills.missingRequirementCount) need setup"
}
return "\(skills.enabledCount) enabled, \(skills.totalCount) installed"
}
var instancesValue: String {
guard self.gatewayConnected else { return "offline" }
guard let count = self.overview?.presence.count else {
return self.overviewLoading ? "..." : "live"
}
return "\(count)"
}
var instancesDetail: String {
guard self.gatewayConnected else { return "Connect a gateway to load instances." }
guard let presence = self.overview?.presence else {
return self.overviewLoading ? "Loading instance presence." : "Instance presence is available."
}
let labels = presence.prefix(2).compactMap(self.presenceLabel)
if labels.isEmpty {
return "No live instances reported."
}
return labels.joined(separator: ", ")
}
var instancesColor: Color {
guard self.gatewayConnected else { return .secondary }
return (self.overview?.presence.isEmpty == false) ? OpenClawBrand.accent : .secondary
}
var cronValue: String {
guard self.gatewayConnected else { return "offline" }
guard let cronStatus = self.overview?.cronStatus else {
return self.overviewLoading ? "..." : "live"
}
return cronStatus.enabled ? "\(cronStatus.jobs)" : "off"
}
var cronDetail: String {
guard self.gatewayConnected else { return "Connect a gateway to load cron." }
guard let cronStatus = self.overview?.cronStatus else {
return self.overviewLoading ? "Loading cron status." : "Cron status is available."
}
if let nextWakeAtMs = cronStatus.nextwakeatms {
return "Next wake \(Self.relativeTime(fromMilliseconds: nextWakeAtMs))"
}
return cronStatus.enabled ? "Scheduler enabled" : "Scheduler disabled"
}
var cronColor: Color {
guard self.gatewayConnected else { return .secondary }
return self.overview?.cronStatus?.enabled == true ? OpenClawBrand.accent : .secondary
}
var usageValue: String {
guard self.gatewayConnected else { return "offline" }
guard let usage = self.overview?.usage else {
return self.overviewLoading ? "..." : "7d"
}
if let cost = usage.totalCost {
return Self.currency(cost)
}
if let tokens = usage.totalTokens, tokens > 0 {
return Self.compactNumber(tokens)
}
return "7d"
}
var usageDetail: String {
guard self.gatewayConnected else { return "Connect a gateway to load usage." }
guard let usage = self.overview?.usage else {
return self.overviewLoading ? "Loading recent usage." : "Recent usage is available."
}
if let tokens = usage.totalTokens, tokens > 0 {
return "\(Self.compactNumber(tokens)) tokens in \(usage.days ?? 7)d"
}
return "No token usage reported for \(usage.days ?? 7)d."
}
var dreamingValue: String {
guard self.gatewayConnected else { return "offline" }
guard let dreaming = self.overview?.dreaming else {
return self.overviewLoading ? "..." : "live"
}
return dreaming.enabled ? "on" : "off"
}
var dreamingDetail: String {
guard self.gatewayConnected else { return "Connect a gateway to load dreaming." }
guard let dreaming = self.overview?.dreaming else {
return self.overviewLoading ? "Loading dreaming status." : "Background memory status is available."
}
if let nextRunAtMs = dreaming.nextRunAtMs {
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
}
return "\(dreaming.totalSignalCount ?? 0) signals, \(dreaming.promotedToday ?? 0) promoted today"
}
var dreamingColor: Color {
guard self.gatewayConnected else { return .secondary }
return self.overview?.dreaming?.enabled == true ? OpenClawBrand.accent : .secondary
}
var recentCronJobs: [CronJob] {
(self.overview?.cronJobs ?? [])
.sorted { lhs, rhs in
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
switch (lhsNext, rhsNext) {
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
case (_?, nil): return true
case (nil, _?): return false
case (nil, nil): return lhs.updatedatms > rhs.updatedatms
}
}
.prefix(4)
.map(\.self)
}
}

View File

@@ -0,0 +1,766 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
var skillsPolicyControls: some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text(self.activeAgentName)
.font(.headline)
Text(self.skillPolicySummary)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(
value: self.agentSkillFilter == nil ? "all" : "\(self.agentSkillFilter?.count ?? 0)",
color: OpenClawBrand.accent)
}
HStack(spacing: 8) {
Button("Enable All") {
Task { await self.enableAllSkills() }
}
.disabled(self.skillMutationBusy)
Button("Disable All", role: .destructive) {
Task { await self.disableAllSkills() }
}
.disabled(self.skillMutationBusy)
Button("Reset") {
Task { await self.resetSkillPolicy() }
}
.disabled(self.skillMutationBusy || self.agentSkillFilter == nil)
}
.buttonStyle(.bordered)
.controlSize(.small)
if let skillMutationStatusText {
Text(skillMutationStatusText)
.font(.caption2)
.foregroundStyle(OpenClawBrand.accent)
}
if let skillMutationErrorText {
Text(skillMutationErrorText)
.font(.caption2)
.foregroundStyle(OpenClawBrand.warn)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var skillsFilterField: some View {
ProCard(padding: 10, radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
TextField("Search skills", text: self.$skillFilter)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.subheadline)
if !self.skillFilter.isEmpty {
Button {
self.skillFilter = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
Picker("Status", selection: self.$skillStatusFilter) {
ForEach(SkillStatusFilter.allCases) { filter in
Text(filter.title).tag(filter)
}
}
.pickerStyle(.segmented)
.controlSize(.small)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var clawHubSearchCard: some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
ProIconBadge(systemName: "square.and.arrow.down", color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Install Skills")
.font(.headline)
Text("Search ClawHub and install into this workspace.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Button {
Task { await self.searchClawHubSkills() }
} label: {
Image(systemName: "magnifyingglass")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(self.clawHubLoading || !self.gatewayConnected)
.accessibilityLabel("Search ClawHub")
}
TextField("Search ClawHub", text: self.$clawHubQuery)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.subheadline)
.submitLabel(.search)
.onSubmit {
Task { await self.searchClawHubSkills() }
}
if self.clawHubLoading {
ProgressView()
.controlSize(.small)
}
if let clawHubErrorText {
Text(clawHubErrorText)
.font(.caption2)
.foregroundStyle(OpenClawBrand.warn)
}
if !self.clawHubResults.isEmpty {
VStack(spacing: 0) {
let results = Array(self.clawHubResults.prefix(8))
ForEach(Array(results.enumerated()), id: \.element.slug) { index, result in
self.clawHubResultRow(result)
if index < results.count - 1 {
Divider().padding(.leading, 42)
}
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func clawHubResultRow(_ result: ClawHubSearchResultLite) -> some View {
let installing = self.clawHubInstallSlug == result.slug
return HStack(alignment: .top, spacing: 10) {
ProIconBadge(systemName: "sparkles", color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 3) {
Text(result.displayName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(result.summary ?? result.slug)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
Button {
Task { await self.installClawHubSkill(result) }
} label: {
Image(systemName: installing ? "hourglass" : "square.and.arrow.down")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(installing || !self.skillConfigBusyKeys.isEmpty)
.accessibilityLabel("Install \(result.displayName)")
}
.padding(.vertical, 10)
}
var skillsList: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Installed Skills")
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
let skills = self.filteredSkills
if skills.isEmpty {
self.emptyDetailRow(
icon: "sparkles",
title: self.gatewayConnected ? "No skills found" : "Skills unavailable",
detail: self.gatewayConnected
? "Try a different search or refresh from the gateway."
: "Connect a gateway to load workspace skills.")
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(skills.enumerated()), id: \.element.name) { index, skill in
self.skillRow(skill)
if index < skills.count - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var activeAgentName: String {
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == self.activeAgentID }) {
return self.agentName(for: agent)
}
return self.activeAgentID
}
var agentSkillFilter: Set<String>? {
self.overview?.agentSkillFilter.map { Set($0) }
}
var skillPolicySummary: String {
guard self.gatewayConnected else { return "Connect a gateway to edit skills." }
guard let filter = self.agentSkillFilter else {
return "All available skills are allowed for this agent."
}
if filter.isEmpty {
return "No skills are allowed for this agent."
}
return "\(filter.count) skills are allowed for this agent."
}
var skillMutationBusy: Bool {
!self.skillMutationBusyKeys.isEmpty
}
var filteredSkills: [SkillStatusEntryLite] {
let skills = self.overview?.skills?.skills ?? []
let filter = self.skillFilter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return skills
.filter { skill in
self.matchesSkillStatusFilter(skill)
}
.filter { skill in
guard !filter.isEmpty else { return true }
return [
skill.name,
skill.description,
skill.source,
].compactMap(\.self)
.joined(separator: " ")
.lowercased()
.contains(filter)
}
.sorted(by: self.sortSkills)
}
func matchesSkillStatusFilter(_ skill: SkillStatusEntryLite) -> Bool {
switch self.skillStatusFilter {
case .all:
true
case .enabled:
self.skillStatus(skill).text == "enabled"
case .off:
!self.isSkillAllowed(skill) || skill.blockedByAgentFilter == true
case .setup:
skill.hasMissingRequirements
case .blocked:
skill.blockedByAllowlist == true
}
}
func sortSkills(_ lhs: SkillStatusEntryLite, _ rhs: SkillStatusEntryLite) -> Bool {
let lhsEnabled = self.isSkillAllowed(lhs)
let rhsEnabled = self.isSkillAllowed(rhs)
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
func skillRow(_ skill: SkillStatusEntryLite) -> some View {
let status = self.skillStatus(skill)
let busy = self.skillMutationBusyKeys.contains(skill.name)
return HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.isSkillAllowed(skill) ? "checkmark.circle" : "nosign", color: status.color)
VStack(alignment: .leading, spacing: 4) {
Text(skill.displayName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let missing = skill.missingSummary {
Text("Missing: \(missing)")
.font(.caption2)
.foregroundStyle(OpenClawBrand.warn)
.lineLimit(1)
}
if let install = skill.installSummary {
Text("Setup: \(install)")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 6) {
self.skillToggle(skill, title: status.text)
HStack(spacing: 6) {
if self.canInstallSkillRequirements(skill) {
Button {
Task { await self.installSkillRequirements(skill) }
} label: {
Image(systemName: "wrench.and.screwdriver")
}
.buttonStyle(.bordered)
.controlSize(.mini)
.disabled(self.isSkillConfigBusy(skill))
.accessibilityLabel("Set up \(skill.displayName)")
}
Button {
self.openSkillEditor(skill)
} label: {
Image(systemName: "slider.horizontal.3")
}
.buttonStyle(.bordered)
.controlSize(.mini)
.accessibilityLabel("Edit \(skill.displayName)")
}
Text(busy ? "saving" : status.text)
.font(.caption2.weight(.semibold))
.foregroundStyle(status.color)
.lineLimit(1)
}
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
func skillToggle(_ skill: SkillStatusEntryLite, title: String) -> some View {
Toggle(
title,
isOn: Binding(
get: { self.isSkillAllowed(skill) },
set: { enabled in
Task { await self.setSkillAllowed(skill, enabled: enabled) }
}))
.labelsHidden()
.disabled(self.skillMutationBusy)
.toggleStyle(.switch)
.controlSize(.mini)
}
func isSkillAllowed(_ skill: SkillStatusEntryLite) -> Bool {
guard let filter = self.agentSkillFilter else { return true }
return filter.contains(skill.name)
}
func isSkillConfigBusy(_ skill: SkillStatusEntryLite) -> Bool {
self.skillConfigBusyKeys.contains(skill.effectiveSkillKey)
|| self.clawHubInstallSlug != nil
}
func canInstallSkillRequirements(_ skill: SkillStatusEntryLite) -> Bool {
skill.install?.first?.id?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
&& !skill.missingBins.isEmpty
}
func skillByKey(_ key: String) -> SkillStatusEntryLite? {
(self.overview?.skills?.skills ?? []).first { skill in
skill.effectiveSkillKey == key || skill.name == key
}
}
func openSkillEditor(_ skill: SkillStatusEntryLite) {
self.skillEditorSelection = SkillEditorSelection(id: skill.effectiveSkillKey)
}
func skillAPIKeyBinding(for skill: SkillStatusEntryLite) -> Binding<String> {
Binding(
get: { self.skillAPIKeyDrafts[skill.effectiveSkillKey] ?? "" },
set: { self.skillAPIKeyDrafts[skill.effectiveSkillKey] = $0 })
}
var missingSkillEditorSheet: some View {
NavigationStack {
ContentUnavailableView("Skill unavailable", systemImage: "sparkles")
.navigationTitle("Skill")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
self.skillEditorSelection = nil
}
}
}
}
}
func skillEditorSheet(_ skill: SkillStatusEntryLite) -> some View {
NavigationStack {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.skillEditorHeader(skill)
self.skillEditorControls(skill)
self.skillEditorSetup(skill)
self.skillEditorMetadata(skill)
}
.padding(.vertical, 18)
}
}
.navigationTitle(skill.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
self.skillEditorSelection = nil
}
}
}
}
}
func skillEditorHeader(_ skill: SkillStatusEntryLite) -> some View {
let status = self.skillStatus(skill)
return ProCard(radius: AgentLayout.cardRadius) {
HStack(spacing: 12) {
ProIconBadge(
systemName: skill.isGloballyEnabled ? "checkmark.circle" : "pause.circle",
color: status.color)
VStack(alignment: .leading, spacing: 3) {
Text(skill.displayName)
.font(.headline)
Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
}
Spacer(minLength: 8)
ProValuePill(value: status.text, color: status.color)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func skillEditorControls(_ skill: SkillStatusEntryLite) -> some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Toggle(
"Enabled globally",
isOn: Binding(
get: { skill.isGloballyEnabled },
set: { enabled in
Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) }
}))
.disabled(self.isSkillConfigBusy(skill))
if let primaryEnv = skill.primaryEnv, !primaryEnv.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("API key")
.font(.subheadline.weight(.semibold))
SecureField(primaryEnv, text: self.skillAPIKeyBinding(for: skill))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
Task { await self.saveSkillAPIKey(skill) }
} label: {
Label("Save key", systemImage: "key")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(self.isSkillConfigBusy(skill))
if let homepage = skill.homepageURL {
Link("Get key", destination: homepage)
.font(.caption)
}
}
}
if let message = self.skillConfigMessages[skill.effectiveSkillKey] {
Text(message.text)
.font(.caption2)
.foregroundStyle(message.kind == .success ? OpenClawBrand.accent : OpenClawBrand.warn)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func skillEditorSetup(_ skill: SkillStatusEntryLite) -> some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
Text("Setup")
.font(.headline)
if let missing = skill.missingSummary {
Text("Missing: \(missing)")
.font(.caption)
.foregroundStyle(OpenClawBrand.warn)
} else {
Text("No missing requirements reported.")
.font(.caption)
.foregroundStyle(.secondary)
}
if let install = skill.install?.first {
Button {
Task { await self.installSkillRequirements(skill) }
} label: {
Label(install.label, systemImage: "wrench.and.screwdriver")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(self.isSkillConfigBusy(skill) || install.id == nil)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func skillEditorMetadata(_ skill: SkillStatusEntryLite) -> some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 8) {
self.detailMetric(label: "Key", value: skill.effectiveSkillKey)
self.detailMetric(label: "Source", value: self.normalized(skill.source) ?? "unknown")
if let filePath = self.normalized(skill.filePath) {
Text(filePath)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@MainActor
func setSkillAllowed(_ skill: SkillStatusEntryLite, enabled: Bool) async {
let allNames = self.allSkillNames
guard !allNames.isEmpty else { return }
let base = self.agentSkillFilter ?? Set(allNames)
var next = base
if enabled {
next.insert(skill.name)
} else {
next.remove(skill.name)
}
await self.patchAgentSkills(Array(next).sorted(), busyKey: skill.name)
}
@MainActor
func enableAllSkills() async {
let allNames = self.allSkillNames
guard !allNames.isEmpty else { return }
await self.patchAgentSkills(allNames, busyKey: "__all__")
}
@MainActor
func disableAllSkills() async {
await self.patchAgentSkills([], busyKey: "__all__")
}
@MainActor
func resetSkillPolicy() async {
await self.patchAgentSkills(nil, busyKey: "__all__")
}
var allSkillNames: [String] {
(self.overview?.skills?.skills ?? [])
.map(\.name)
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.sorted()
}
@MainActor
func patchAgentSkills(_ skills: [String]?, busyKey: String) async {
guard self.gatewayConnected else { return }
self.skillMutationBusyKeys.insert(busyKey)
self.skillMutationErrorText = nil
self.skillMutationStatusText = nil
defer { self.skillMutationBusyKeys.remove(busyKey) }
do {
let config = try await self.requestConfigSnapshot()
guard let baseHash = self.normalized(config.hash) else {
throw SkillMutationError.missingConfigHash
}
if skills == nil,
config.agentConfig(id: self.activeAgentID) == nil
{
self.skillMutationStatusText = "This agent already inherits the default skill policy."
return
}
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw SkillMutationError.invalidPatchPayload
}
_ = try await self.appModel.operatorSession.request(
method: "config.patch",
paramsJSON: json,
timeoutSeconds: 20)
self.skillMutationStatusText = skills == nil ? "Skill policy reset." : "Skill policy saved."
await self.appModel.refreshGatewayOverviewIfConnected()
await self.refreshOverview(force: true)
} catch {
self.skillMutationErrorText = Self.skillMutationMessage(error)
}
}
@MainActor
func updateSkillGlobalEnabled(_ skill: SkillStatusEntryLite, enabled: Bool) async {
await self.runSkillConfigMutation(skill) {
let params = SkillUpdateParams(skillKey: skill.effectiveSkillKey, enabled: enabled)
_ = try await self.requestGateway(method: "skills.update", params: params, timeoutSeconds: 20)
return enabled ? "Skill enabled." : "Skill disabled."
}
}
@MainActor
func saveSkillAPIKey(_ skill: SkillStatusEntryLite) async {
await self.runSkillConfigMutation(skill) {
let apiKey = self.skillAPIKeyDrafts[skill.effectiveSkillKey] ?? ""
let params = SkillUpdateParams(skillKey: skill.effectiveSkillKey, apiKey: apiKey)
_ = try await self.requestGateway(method: "skills.update", params: params, timeoutSeconds: 20)
self.skillAPIKeyDrafts[skill.effectiveSkillKey] = ""
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "API key cleared."
: "API key saved."
}
}
@MainActor
func installSkillRequirements(_ skill: SkillStatusEntryLite) async {
guard let installId = skill.install?.first?.id?.trimmingCharacters(in: .whitespacesAndNewlines),
!installId.isEmpty
else { return }
await self.runSkillConfigMutation(skill) {
let params = SkillInstallParams(name: skill.name, installId: installId, timeoutMs: 120_000)
let data = try await self.requestGateway(
method: "skills.install",
params: params,
timeoutSeconds: 125)
return (try? JSONDecoder().decode(SkillInstallResultLite.self, from: data).message) ?? "Installed."
}
}
@MainActor
func installClawHubSkill(_ result: ClawHubSearchResultLite) async {
guard self.gatewayConnected else { return }
self.clawHubInstallSlug = result.slug
self.clawHubErrorText = nil
defer { self.clawHubInstallSlug = nil }
do {
let params = ClawHubInstallParams(slug: result.slug)
_ = try await self.requestGateway(method: "skills.install", params: params, timeoutSeconds: 125)
await self.appModel.refreshGatewayOverviewIfConnected()
await self.refreshOverview(force: true)
} catch {
self.clawHubErrorText = Self.skillMutationMessage(error)
}
}
@MainActor
func searchClawHubSkills() async {
guard self.gatewayConnected else { return }
self.clawHubLoading = true
self.clawHubErrorText = nil
defer { self.clawHubLoading = false }
do {
let query = self.clawHubQuery.trimmingCharacters(in: .whitespacesAndNewlines)
let params = ClawHubSearchParams(query: query.isEmpty ? nil : query, limit: 20)
let data = try await self.requestGateway(method: "skills.search", params: params, timeoutSeconds: 20)
self.clawHubResults = try JSONDecoder().decode(ClawHubSearchResponseLite.self, from: data).results
} catch {
self.clawHubErrorText = Self.skillMutationMessage(error)
}
}
@MainActor
func runSkillConfigMutation(
_ skill: SkillStatusEntryLite,
action: () async throws -> String) async
{
let key = skill.effectiveSkillKey
self.skillConfigBusyKeys.insert(key)
self.skillConfigMessages[key] = nil
defer { self.skillConfigBusyKeys.remove(key) }
do {
let message = try await action()
self.skillConfigMessages[key] = SkillEditorMessage(kind: .success, text: message)
await self.appModel.refreshGatewayOverviewIfConnected()
await self.refreshOverview(force: true)
} catch {
self.skillConfigMessages[key] = SkillEditorMessage(
kind: .error,
text: Self.skillMutationMessage(error))
}
}
func requestGateway(
method: String,
params: some Encodable,
timeoutSeconds: Int) async throws -> Data
{
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw SkillMutationError.invalidPatchPayload
}
return try await self.appModel.operatorSession.request(
method: method,
paramsJSON: json,
timeoutSeconds: timeoutSeconds)
}
func requestConfigSnapshot() async throws -> ConfigSnapshotLite {
let data = try await self.appModel.operatorSession.request(
method: "config.get",
paramsJSON: "{}",
timeoutSeconds: 12)
return try JSONDecoder().decode(ConfigSnapshotLite.self, from: data)
}
static func agentSkillsPatchRaw(agentId: String, skills: [String]?) throws -> String {
let skillValue: Any = skills ?? NSNull()
let patch: [String: Any] = [
"agents": [
"list": [
[
"id": agentId,
"skills": skillValue,
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: patch, options: [.sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
throw SkillMutationError.invalidPatchPayload
}
return raw
}
static func skillMutationMessage(_ error: Error) -> String {
if let gatewayError = error as? GatewayResponseError {
let lower = gatewayError.message.lowercased()
if lower.contains("operator.admin") || lower.contains("unauthorized") {
return "This gateway connection cannot edit config yet. Reconnect with admin scope."
}
return gatewayError.message
}
return error.localizedDescription
}
func skillStatus(_ skill: SkillStatusEntryLite) -> (text: String, color: Color) {
if !self.isSkillAllowed(skill) {
return ("off", .secondary)
}
if skill.blockedByAllowlist == true {
return ("blocked", .secondary)
}
if skill.blockedByAgentFilter == true {
return ("off", .secondary)
}
if skill.disabled == true {
return ("disabled", .secondary)
}
if skill.hasMissingRequirements {
return ("setup", OpenClawBrand.warn)
}
return ("enabled", OpenClawBrand.accent)
}
}

View File

@@ -0,0 +1,81 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {
var usageTotalsCard: some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Totals")
.font(.headline)
Spacer()
ProValuePill(value: "\(self.overview?.usage?.days ?? 31)d", color: OpenClawBrand.accent)
}
HStack(spacing: 10) {
self.detailMetric(label: "Cost", value: self.usageValue)
self.detailMetric(label: "Tokens", value: self.usageTokenValue)
self.detailMetric(label: "Cache", value: self.usageCacheValue)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var usageTokenValue: String {
guard let tokens = self.overview?.usage?.totalTokens else { return "0" }
return Self.compactNumber(tokens)
}
var usageCacheValue: String {
guard let cacheStatus = self.normalized(self.overview?.usage?.cacheStatus?["status"]?.value as? String) else {
return "n/a"
}
return cacheStatus
}
var usageDailyList: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Daily")
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
let days = self.overview?.usage?.daily ?? []
if days.isEmpty {
self.emptyDetailRow(
icon: "chart.bar",
title: "No daily usage yet",
detail: "The gateway returned totals without daily session cost rows.")
.padding(14)
} else {
VStack(spacing: 0) {
ForEach(Array(days.prefix(14).enumerated()), id: \.element.date) { index, day in
self.usageDayRow(day)
if index < min(days.count, 14) - 1 {
Divider().padding(.leading, 60)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
func usageDayRow(_ day: CostUsageDailyEntryLite) -> some View {
HStack(spacing: 12) {
ProIconBadge(systemName: "calendar", color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 3) {
Text(day.date)
.font(.subheadline.weight(.semibold))
Text("\(Self.compactNumber(day.totalTokens ?? 0)) tokens")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text(Self.currency(day.totalCost ?? 0))
.font(.caption2.weight(.semibold))
.foregroundStyle(OpenClawBrand.accent)
}
.padding(.vertical, 10)
.padding(.horizontal, 14)
}
}

View File

@@ -0,0 +1,163 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
struct AgentProTab: View {
@Environment(NodeAppModel.self) var appModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.scenePhase) var scenePhase
@State var overview: AgentOverviewSnapshot?
@State var overviewErrorText: String?
@State var overviewLoading: Bool = false
@State var overviewRefreshNonce: Int = 0
@State var agentRosterFilter: AgentRosterFilter = .all
@State var agentSearchPresented = false
@State var agentSearchText = ""
@State var skillFilter: String = ""
@State var skillStatusFilter: SkillStatusFilter = .all
@State var skillMutationBusyKeys: Set<String> = []
@State var skillMutationErrorText: String?
@State var skillMutationStatusText: String?
@State var skillConfigBusyKeys: Set<String> = []
@State var skillConfigMessages: [String: SkillEditorMessage] = [:]
@State var skillAPIKeyDrafts: [String: String] = [:]
@State var skillEditorSelection: SkillEditorSelection?
@State var clawHubQuery: String = ""
@State var clawHubResults: [ClawHubSearchResultLite] = []
@State var clawHubLoading: Bool = false
@State var clawHubErrorText: String?
@State var clawHubInstallSlug: String?
@State var cronActionBusyIDs: Set<String> = []
@State var cronActionStatusText: String?
enum AgentRoute: Hashable {
case skills
case nodes
case cron
case usage
case dreaming
}
enum SkillStatusFilter: String, CaseIterable, Identifiable {
case all
case enabled
case off
case setup
case blocked
var id: Self {
self
}
var title: String {
switch self {
case .all: "All"
case .enabled: "Enabled"
case .off: "Off"
case .setup: "Setup"
case .blocked: "Blocked"
}
}
}
enum AgentRosterFilter: String, CaseIterable, Identifiable {
case all
case online
case busy
case idle
var id: Self {
self
}
var title: String {
switch self {
case .all: "All"
case .online: "Online"
case .busy: "Busy"
case .idle: "Idle"
}
}
}
enum AgentLayout {
static let cardRadius: CGFloat = 12
static let filterHeight: CGFloat = 34
static let rowMinHeight: CGFloat = 104
static let metricTileHeight: CGFloat = 94
static let actionButtonSize: CGFloat = 34
}
enum AgentRosterState: Equatable {
case online
case busy
case idle
var title: String {
switch self {
case .online: "Online"
case .busy: "Busy"
case .idle: "Idle"
}
}
var color: Color {
switch self {
case .online: OpenClawBrand.ok
case .busy: OpenClawBrand.warn
case .idle: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
}
}
}
struct SkillEditorSelection: Identifiable {
let id: String
}
struct SkillEditorMessage {
let kind: Kind
let text: String
enum Kind {
case success
case error
}
}
var body: some View {
NavigationStack {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.rosterHeader
self.agentFilters
self.agentsSection
self.operationsSection
self.dreamingSection
self.cronSection
}
.padding(.vertical, 18)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
.navigationDestination(for: AgentRoute.self) { route in
self.destination(for: route)
}
}
.task(id: self.overviewTaskID) {
await self.refreshOverview(force: false)
}
.sheet(item: self.$skillEditorSelection) { selection in
if let skill = self.skillByKey(selection.id) {
self.skillEditorSheet(skill)
} else {
self.missingSkillEditorSheet
}
}
}
}

View File

@@ -0,0 +1,191 @@
import OpenClawChatUI
import OpenClawProtocol
import SwiftUI
struct ChatProTab: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel: OpenClawChatViewModel?
var body: some View {
NavigationStack {
ZStack {
OpenClawProBackground()
VStack(spacing: 0) {
self.header
if let viewModel {
OpenClawChatView(
viewModel: viewModel,
drawsBackground: false,
showsSessionSwitcher: false,
userAccent: self.chatUserAccent,
assistantName: self.agentDisplayName,
assistantAvatarText: self.agentBadge,
assistantAvatarTint: OpenClawBrand.accent,
showsAssistantAvatars: false,
composerChrome: .clean,
messagePlaceholder: "Message \(self.agentDisplayName)...",
talkControl: self.talkControl)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} else {
ProCard {
VStack(alignment: .leading, spacing: 8) {
Text("Chat is preparing")
.font(.headline)
Text("The operator session will attach when the gateway is ready.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding()
Spacer()
}
}
}
.navigationBarHidden(true)
}
.task {
self.syncChatViewModel()
}
.onChange(of: self.appModel.chatSessionKey) { _, _ in
self.syncChatViewModel()
}
}
private var header: some View {
HStack(spacing: 11) {
Text(self.agentBadge)
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.minimumScaleFactor(0.6)
.lineLimit(1)
.frame(width: 38, height: 38)
.background(
Circle()
.fill(
LinearGradient(
colors: [
OpenClawBrand.accent,
OpenClawBrand.accentHot,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)))
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
VStack(alignment: .leading, spacing: 1) {
Text(self.agentDisplayName)
.font(.headline.weight(.semibold))
.lineLimit(1)
Text("AI Assistant")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
self.connectionPill
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
private func syncChatViewModel() {
let sessionKey = self.appModel.chatSessionKey
guard let viewModel else {
self.viewModel = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
onSessionChanged: { sessionKey in
self.appModel.focusChatSession(sessionKey)
},
diagnosticsLog: { message in
GatewayDiagnostics.log(message)
})
return
}
guard viewModel.sessionKey != sessionKey else { return }
viewModel.switchSession(to: sessionKey)
}
private var talkControl: OpenClawChatTalkControl {
OpenClawChatTalkControl(
isEnabled: self.appModel.talkMode.isEnabled,
isListening: self.appModel.talkMode.isListening,
isSpeaking: self.appModel.talkMode.isSpeaking,
isGatewayConnected: self.appModel.talkMode.isGatewayConnected,
statusText: self.appModel.talkMode.statusText,
providerLabel: self.appModel.talkMode.gatewayTalkProviderLabel,
toggle: { sessionKey in
self.appModel.focusChatSession(sessionKey)
self.appModel.setTalkEnabled(!self.appModel.talkMode.isEnabled)
})
}
private var activeAgentID: String {
self.normalized(self.appModel.selectedAgentId)
?? self.normalized(self.appModel.gatewayDefaultAgentId)
?? "main"
}
private var connectionPill: some View {
HStack(spacing: 6) {
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
Text(self.gatewayConnected ? "Connected" : "Connecting")
.font(.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
.padding(.horizontal, 10)
.frame(height: 30)
.background {
Capsule()
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
}
.overlay {
Capsule()
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
}
}
private var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var chatUserAccent: Color {
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
}
private var activeAgent: AgentSummary? {
self.appModel.gatewayAgents.first { $0.id == self.activeAgentID }
}
private var agentDisplayName: String {
self.normalized(self.activeAgent?.name) ?? self.appModel.activeAgentName
}
private var agentBadge: String {
if let identity = self.activeAgent?.identity,
let emoji = identity["emoji"]?.value as? String,
let normalizedEmoji = self.normalized(emoji)
{
return normalizedEmoji
}
let words = self.agentDisplayName
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2)
let initials = words.compactMap(\.first).map(String.init).joined()
if !initials.isEmpty {
return initials.uppercased()
}
return "OC"
}
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -0,0 +1,281 @@
import SwiftUI
struct CommandPanel<Content: View>: View {
var tint: Color?
var isProminent = false
var padding: CGFloat = 13
@ViewBuilder var content: Content
init(
tint: Color? = nil,
isProminent: Bool = false,
padding: CGFloat = 13,
@ViewBuilder content: () -> Content)
{
self.tint = tint
self.isProminent = isProminent
self.padding = padding
self.content = content()
}
var body: some View {
ProCard(
tint: self.tint,
isProminent: self.isProminent,
padding: self.padding,
radius: 12)
{
self.content
}
}
}
struct CommandControlBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
LinearGradient(
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
startPoint: .top,
endPoint: .bottom)
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
Color.white.opacity(0.34),
Color.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
.frame(height: 260)
}
}
.ignoresSafeArea()
}
private var darkColors: [Color] {
[
Color(red: 12 / 255, green: 13 / 255, blue: 15 / 255),
Color(red: 7 / 255, green: 8 / 255, blue: 10 / 255),
Color(red: 4 / 255, green: 5 / 255, blue: 6 / 255),
]
}
private var lightColors: [Color] {
[
Color(red: 247 / 255, green: 248 / 255, blue: 249 / 255),
Color(red: 251 / 255, green: 252 / 255, blue: 253 / 255),
.white,
]
}
}
struct CommandSessionRow: View {
@Environment(\.colorScheme) private var colorScheme
let item: CommandCenterTab.WorkItem
var body: some View {
HStack(alignment: .center, spacing: 12) {
Image(systemName: self.item.icon)
.font(.caption.weight(.semibold))
.foregroundStyle(self.item.color)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(self.item.color.opacity(0.12))
}
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.item.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.82)
Spacer(minLength: 6)
Text(self.item.trailing)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
Text(self.item.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 6)
if let progress = self.item.progress {
ProProgressBar(progress: progress, color: self.item.color)
.frame(width: 68)
}
Text(self.progressLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(self.item.color)
.lineLimit(1)
.frame(width: 48, alignment: .trailing)
}
}
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
}
private var progressLabel: String {
guard let progress = self.item.progress else {
return self.item.state
}
if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" {
return self.item.state
}
return "\(Int((progress * 100).rounded()))%"
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
}
}
struct CommandApprovalRow: View {
let item: CommandCenterTab.ApprovalItem
var body: some View {
HStack(spacing: 10) {
Image(systemName: self.item.icon)
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(self.item.color)
}
VStack(alignment: .leading, spacing: 2) {
Text(self.item.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.item.detail)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(self.item.priority)
.font(.caption.weight(.bold))
.foregroundStyle(self.item.color)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background {
Capsule()
.fill(self.item.color.opacity(0.10))
}
}
.padding(.horizontal, 8)
.padding(.vertical, 7)
}
}
struct CommandEmptyStateRow: View {
let icon: String
let title: String
let detail: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: self.icon)
.font(.caption.weight(.bold))
.foregroundStyle(OpenClawBrand.ok)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(OpenClawBrand.ok.opacity(0.10))
}
VStack(alignment: .leading, spacing: 2) {
Text(self.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.detail)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 9)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.06))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
}
}
}
}
struct CommandTaskRow: View {
let item: CommandCenterTab.WorkItem
var body: some View {
HStack(alignment: .center, spacing: 6) {
Text(self.item.title)
.font(.footnote.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.80)
.frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
Text(self.item.detail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(width: 64, alignment: .leading)
if let progress = self.item.progress {
ProProgressBar(progress: progress, color: self.item.color)
.frame(width: 56)
}
Text(self.item.state)
.font(.footnote.weight(.medium))
.foregroundStyle(self.item.progress == nil ? self.item.color : .secondary)
.lineLimit(1)
.frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing)
}
.padding(.vertical, 8)
}
}
struct CommandLiveActivityRow: View {
let title: String
let value: String
let color: Color
var body: some View {
HStack(spacing: 8) {
ProStatusDot(color: self.color)
Text(self.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 8)
Text(self.value)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.08))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)
}
}
}
}

View File

@@ -0,0 +1,692 @@
import OpenClawChatUI
import SwiftUI
struct CommandCenterTab: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var scenePhase
@State private var activeChatSessions: [OpenClawChatSessionEntry] = []
var openChat: () -> Void
var openSettings: () -> Void
enum WorkRoute {
case chat(String?)
case settings
}
struct WorkItem: Identifiable {
let id: String
let icon: String
let title: String
let detail: String
let state: String
let trailing: String
let color: Color
let progress: Double?
let route: WorkRoute
}
struct ApprovalItem: Identifiable {
let id: String
let icon: String
let title: String
let detail: String
let priority: String
let color: Color
}
var body: some View {
NavigationStack {
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 10) {
self.header
self.gatewayCard
self.pendingApprovals
self.activeTasks
self.liveActivity
self.startWorkAction
}
.padding(.top, 16)
.padding(.bottom, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
}
.task(id: self.activeSessionsRefreshID) {
await self.refreshActiveSessionsIfNeeded()
}
}
private var header: some View {
HStack(alignment: .center, spacing: 11) {
OpenClawProMark(size: 31, shadowRadius: 9)
Text("OpenClaw")
.font(.system(size: 27, weight: .bold, design: .rounded))
Spacer()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var commandAmbientOverlay: some View {
Group {
if self.colorScheme == .light {
LinearGradient(
colors: [
Color.white.opacity(0.05),
Color.clear,
],
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
.allowsHitTesting(false)
}
}
}
private var gatewayCard: some View {
CommandPanel(isProminent: true, padding: 12) {
VStack(alignment: .leading, spacing: 10) {
self.cardHeader(
title: "Gateway",
value: self.gatewayStateText,
color: self.gatewayStatusColor,
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
HStack(spacing: 0) {
self.gatewayFact(
icon: "network",
title: "Connection",
value: self.gatewayConnected ? "Online" : "Offline",
color: self.gatewayStatusColor)
Divider().frame(height: 38)
self.gatewayFact(
icon: "server.rack",
title: "Address",
value: self.gatewayAddressText,
color: OpenClawBrand.accent)
Divider().frame(height: 38)
self.gatewayFact(
icon: "person.2.fill",
title: "Agents",
value: self.gatewayAgentCountText,
color: OpenClawBrand.accentHot)
}
.padding(.vertical, 9)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.colorScheme == .dark ? Color.black.opacity(0.16) : Color.black.opacity(0.026))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
lineWidth: 1)
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func gatewayFact(icon: String, title: String, value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Image(systemName: icon)
.font(.caption2.weight(.bold))
.foregroundStyle(color)
Text(title)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(value)
.font(.caption.weight(.semibold))
.foregroundStyle(title == "Connection" ? color : .primary)
.lineLimit(1)
.minimumScaleFactor(0.72)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
}
private var pendingApprovals: some View {
self.pendingApprovalsContent
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var pendingApprovalsContent: some View {
CommandPanel(
tint: self.pendingApproval == nil ? nil : OpenClawBrand.warn,
isProminent: self.pendingApproval != nil,
padding: self.pendingApproval == nil ? 11 : 13)
{
VStack(alignment: .leading, spacing: 10) {
self.cardHeader(
title: "Pending approvals",
value: self.pendingApproval == nil ? nil : "Review requests ",
color: OpenClawBrand.accentHot,
badgeValue: self.approvalItems.isEmpty ? nil : "\(self.approvalItems.count)")
if self.approvalItems.isEmpty {
CommandEmptyStateRow(
icon: "checkmark.shield.fill",
title: "No approvals waiting",
detail: self
.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway.")
} else {
VStack(spacing: 0) {
ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in
CommandApprovalRow(item: item)
if index < self.approvalItems.count - 1 {
Divider().padding(.leading, 48)
}
}
}
.padding(.vertical, 4)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.approvalRowsFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.04),
lineWidth: 1)
}
}
}
if let pendingApproval {
HStack(spacing: 8) {
Button {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
} label: {
Label("Allow", systemImage: "checkmark")
}
.buttonStyle(.borderedProminent)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
if pendingApproval.allowsAllowAlways {
Button {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
} label: {
Label("Always", systemImage: "checkmark.shield")
}
.buttonStyle(.bordered)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
}
Button(role: .destructive) {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
} label: {
Label("Deny", systemImage: "xmark")
}
.buttonStyle(.bordered)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
Spacer(minLength: 0)
}
.controlSize(.small)
}
}
}
}
private var activeTasks: some View {
CommandPanel(padding: 0) {
VStack(spacing: 0) {
self.cardHeader(
title: "Active sessions",
value: self.activeSessionsSummaryText,
color: .secondary)
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 3)
VStack(spacing: 8) {
ForEach(self.visibleActiveSessionRows) { item in
Button {
self.open(item.route)
} label: {
CommandSessionRow(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var liveActivity: some View {
CommandPanel(padding: 0) {
VStack(spacing: 0) {
self.cardHeader(
title: "Live activity",
value: nil,
color: OpenClawBrand.accent)
.padding(.horizontal, 12)
.padding(.top, 11)
.padding(.bottom, 3)
CommandLiveActivityRow(
title: self.liveActivityTitle,
value: self.liveActivityValue,
color: self.liveActivityColor)
.padding(.horizontal, 14)
.padding(.bottom, 10)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var startWorkAction: some View {
CommandPanel(tint: OpenClawBrand.accent, isProminent: true, padding: 9) {
Button(action: self.openChat) {
Label("Start work", systemImage: "play.fill")
.font(.subheadline.weight(.bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background {
RoundedRectangle(cornerRadius: 13, style: .continuous)
.fill(LinearGradient(
colors: [OpenClawBrand.accentHot, OpenClawBrand.accent],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.shadow(color: OpenClawBrand.accentHot.opacity(0.34), radius: 18, y: 8)
}
}
.buttonStyle(.plain)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func cardHeader(
title: String,
value: String?,
color: Color,
icon: String? = nil,
badgeValue: String? = nil,
action: (() -> Void)? = nil) -> some View
{
HStack(spacing: 8) {
Text(title)
.font(.subheadline.weight(.bold))
if let badgeValue {
Text(badgeValue)
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(OpenClawBrand.accentHot, in: Capsule())
}
Spacer(minLength: 8)
if let value {
if let action {
Button(value, action: action)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
} else {
HStack(spacing: 4) {
if let icon {
Image(systemName: icon)
.font(.caption2.weight(.bold))
}
Text(value)
}
.font(.caption.weight(.semibold))
.foregroundStyle(color)
}
}
}
}
private var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var gatewayStateText: String {
guard !self.gatewayConnected else { return "Healthy" }
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let lowercased = status.lowercased()
if lowercased.contains("approval") { return "Approval" }
if lowercased.contains("reconnect") { return "Reconnecting" }
if lowercased.contains("connect") { return "Connecting" }
if lowercased.contains("idle") { return "Idle" }
return "Offline"
}
private var gatewayStatusColor: Color {
self.gatewayConnected ? OpenClawBrand.ok : .secondary
}
private var gatewayAddressText: String {
self.normalized(self.appModel.gatewayRemoteAddress)
?? self.normalized(self.appModel.gatewayServerName)
?? "Unknown"
}
private var gatewayAgentCountText: String {
guard self.gatewayConnected else { return "" }
return "\(self.appModel.gatewayAgents.count)"
}
private var activeSessionsSummaryText: String {
let count = self.activeSessionRows.count
if count == 0 {
return self.gatewayConnected ? "No sessions" : "Offline"
}
if self.sessionWorkItems.isEmpty {
return self.gatewayConnected ? "\(count) ready" : "Offline"
}
return "\(count) \(count == 1 ? "session" : "sessions")"
}
private var approvalItems: [ApprovalItem] {
if let pendingApproval {
return [
ApprovalItem(
id: "pending-real",
icon: "terminal.fill",
title: pendingApproval.commandPreview ?? "Review gateway action",
detail: "Agent: \(self.appModel.activeAgentName)",
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
color: OpenClawBrand.danger),
ApprovalItem(
id: "pending-context",
icon: "doc.text.fill",
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
detail: "Gateway request",
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
color: OpenClawBrand.warn),
]
}
return []
}
private var approvalRowsFill: Color {
self.colorScheme == .dark ? Color.black.opacity(0.12) : Color.black.opacity(0.022)
}
private var activeSessionRows: [WorkItem] {
self.sessionItems
}
private var visibleActiveSessionRows: [WorkItem] {
Array(self.activeSessionRows.prefix(3))
}
private var liveActivityTitle: String {
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }) {
return "\(Self.sessionTitle(session)) updated"
}
if self.pendingApproval != nil {
return "Approval waiting"
}
return self.gatewayConnected ? "Gateway connected" : self.gatewayStateText
}
private var liveActivityValue: String {
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }),
let updatedAt = session.updatedAt,
updatedAt > 0
{
return Self.relativeTimeText(forMilliseconds: updatedAt)
}
if self.pendingApproval != nil {
return "review"
}
return self.gatewayConnected ? self.gatewayAddressText : self.gatewayDisplayStatusValue
}
private var liveActivityColor: Color {
if self.pendingApproval != nil { return OpenClawBrand.warn }
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
}
private var gatewayDisplayStatusValue: String {
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
return status.isEmpty ? self.gatewayStateText : status
}
private var activeSessionsRefreshID: String {
[
self.appModel.isOperatorGatewayConnected ? "connected" : "offline",
self.appModel.chatSessionKey,
self.scenePhase == .active ? "active" : "inactive",
].joined(separator: ":")
}
private var sessionItems: [WorkItem] {
let liveItems = self.sessionWorkItems
if !liveItems.isEmpty { return liveItems }
return self.defaultSessionItems
}
private var sessionWorkItems: [WorkItem] {
let currentSessionKey = self.appModel.chatSessionKey
return self.activeChatSessions
.filter { !Self.isHiddenInternalSession($0.key) }
.prefix(4)
.map { session in
let isCurrent = session.key == currentSessionKey
return WorkItem(
id: "chat-session-\(session.key)",
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
title: Self.sessionTitle(session),
detail: Self.sessionDetail(session),
state: isCurrent ? "current" : "recent",
trailing: "chat",
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
progress: nil,
route: .chat(session.key))
}
}
private var defaultSessionItems: [WorkItem] {
[
WorkItem(
id: "main-chat",
icon: "bubble.left.and.text.bubble.right.fill",
title: "Main chat",
detail: self.appModel.activeAgentName,
state: self.gatewayConnected ? "ready" : "offline",
trailing: "session",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
progress: nil,
route: .chat(self.appModel.chatSessionKey)),
WorkItem(
id: "talk-mode",
icon: "waveform",
title: "Talk",
detail: self.appModel.talkMode.statusText,
state: self.appModel.talkMode.isEnabled ? "active" : "off",
trailing: "voice",
color: self.appModel.talkMode.isEnabled ? OpenClawBrand.ok : .secondary,
progress: nil,
route: .settings),
WorkItem(
id: "device-capture",
icon: self.appModel.screenRecordActive ? "record.circle.fill" : "display",
title: "Device capture",
detail: self.appModel.screenRecordActive ? "Screen capture is active" : "Screen and device tools",
state: self.appModel.screenRecordActive ? "running" : "idle",
trailing: "device",
color: self.appModel.screenRecordActive ? OpenClawBrand.warn : .secondary,
progress: nil,
route: .settings),
WorkItem(
id: "agent-roster",
icon: "person.2.fill",
title: "Agents",
detail: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count) available" : "Roster unavailable",
state: self.gatewayConnected ? "online" : "offline",
trailing: "gateway",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
progress: nil,
route: .settings),
]
}
private func open(_ route: WorkRoute) {
switch route {
case let .chat(sessionKey):
self.appModel.openChat(sessionKey: sessionKey)
self.openChat()
case .settings:
self.openSettings()
}
}
private func refreshActiveSessionsIfNeeded() async {
guard self.scenePhase == .active else { return }
guard self.appModel.isOperatorGatewayConnected else {
if !self.activeChatSessions.isEmpty {
self.activeChatSessions = []
}
return
}
do {
let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession)
let response = try await transport.listSessions(limit: 12)
self.activeChatSessions = Self.sessionChoices(
response.sessions,
currentSessionKey: self.appModel.chatSessionKey)
} catch {
self.activeChatSessions = []
}
}
private static func sessionChoices(
_ sessions: [OpenClawChatSessionEntry],
currentSessionKey: String) -> [OpenClawChatSessionEntry]
{
let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
var result: [OpenClawChatSessionEntry] = []
var included = Set<String>()
if let current = sorted.first(where: { $0.key == currentSessionKey }) {
result.append(current)
included.insert(current.key)
}
for session in sorted {
guard !included.contains(session.key) else { continue }
guard !Self.isHiddenInternalSession(session.key) else { continue }
result.append(session)
included.insert(session.key)
if result.count >= 4 { break }
}
return result
}
private static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
if let title = redactedSessionTitle(for: session.key) {
return title
}
let displayName = session.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
if let displayName, !displayName.isEmpty {
return Self.redactedSessionTitle(for: displayName) ?? displayName
}
let subject = session.subject?.trimmingCharacters(in: .whitespacesAndNewlines)
if let subject, !subject.isEmpty {
return Self.redactedSessionTitle(for: subject) ?? subject
}
return session.key
}
private static func redactedSessionTitle(for key: String) -> String? {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
let lowercased = trimmed.lowercased()
guard !trimmed.isEmpty else { return nil }
if lowercased.contains(":ios-") {
return "iOS chat"
}
if lowercased.hasPrefix("telegram:") {
return "Telegram chat"
}
if lowercased.hasPrefix("user:+") {
return "Direct chat"
}
if lowercased.hasPrefix("cron:") {
return Self.humanizedSessionKey(String(trimmed.dropFirst("cron:".count)))
}
return nil
}
private static func humanizedSessionKey(_ key: String) -> String? {
let words = key
.replacingOccurrences(of: "_", with: "-")
.split(separator: "-")
.map(String.init)
.filter { !$0.isEmpty }
guard !words.isEmpty else { return nil }
return words
.map { word in
switch word.lowercased() {
case "ai", "api", "ios", "qmd", "url":
word.uppercased()
default:
word.prefix(1).uppercased() + String(word.dropFirst())
}
}
.joined(separator: " ")
}
private static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
if let updatedAt = session.updatedAt, updatedAt > 0 {
return self.relativeTimeText(forMilliseconds: updatedAt)
}
return session.key
}
private static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
let date = Date(timeIntervalSince1970: milliseconds / 1000)
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .numeric
formatter.unitsStyle = .short
return formatter.localizedString(for: date, relativeTo: .now)
}
private static func isHiddenInternalSession(_ key: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
}
private var gatewaySubtitle: String {
if let server = normalized(appModel.gatewayServerName) {
return "\(self.appModel.activeAgentName) on \(server)"
}
if let address = normalized(appModel.gatewayRemoteAddress) {
return "\(self.appModel.activeAgentName) via \(address)"
}
return self.appModel.gatewayDisplayStatusText
}
private var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
self.appModel.pendingExecApprovalPrompt
}
private func normalized(_ value: String?) -> String? {
Self.normalized(value)
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -0,0 +1,145 @@
import SwiftUI
enum AppAppearancePreference: String, CaseIterable, Identifiable {
case system
case light
case dark
static let storageKey = "appearance.preference"
static var launchArgumentPreference: AppAppearancePreference? {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-appearance") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
return AppAppearancePreference(rawValue: arguments[valueIndex].lowercased())
}
var id: String {
self.rawValue
}
var label: String {
switch self {
case .system: "System"
case .light: "Light"
case .dark: "Dark"
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: nil
case .light: .light
case .dark: .dark
}
}
var userInterfaceStyle: UIUserInterfaceStyle {
switch self {
case .system: .unspecified
case .light: .light
case .dark: .dark
}
}
}
enum OpenClawBrand {
static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0)
static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0)
static let lightCanvasBottom = Color.white
static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0)
static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0)
static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0)
static let accent = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
})
static let accentHot = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
: UIColor(red: 204 / 255.0, green: 75 / 255.0, blue: 69 / 255.0, alpha: 1)
})
static let danger = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 252 / 255.0, green: 165 / 255.0, blue: 165 / 255.0, alpha: 1)
: UIColor(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0, alpha: 1)
})
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
static let graphite = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)
: UIColor(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0, alpha: 1)
})
static let graphiteElevated = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
: UIColor.white
})
static let graphiteSoft = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1)
: UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1)
})
static var sheetBackground: LinearGradient {
LinearGradient(
colors: [
graphite,
graphiteElevated.opacity(0.96),
Color(uiColor: .systemBackground),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
static var toolbarChrome: LinearGradient {
LinearGradient(
colors: [
graphiteElevated.opacity(0.92),
graphite.opacity(0.78),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
static func glassFill(brighten: Bool) -> Color {
Color.black.opacity(brighten ? 0.10 : 0.22)
}
static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color {
if active {
return self.accent.opacity(increasedContrast ? 0.70 : 0.46)
}
return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16))
}
static func formSectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(self.accent)
.textCase(.uppercase)
}
static func canvasColors(for colorScheme: ColorScheme) -> [Color] {
colorScheme == .dark
? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom]
: [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom]
}
}
extension View {
func openClawSheetChrome() -> some View {
self
.tint(OpenClawBrand.accent)
.background {
OpenClawBrand.sheetBackground
.ignoresSafeArea()
}
}
}

View File

@@ -0,0 +1,577 @@
import SwiftUI
enum OpenClawProMetric {
static let pagePadding: CGFloat = 20
static let cardRadius: CGFloat = 14
static let controlRadius: CGFloat = 12
static let bottomScrollInset: CGFloat = 96
static let heroRadius: CGFloat = 22
}
struct OpenClawProBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
LinearGradient(
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
OpenClawBrand.accent.opacity(0.05),
.clear,
],
startPoint: .topTrailing,
endPoint: .bottomLeading)
.frame(height: 260)
.ignoresSafeArea()
}
}
}
}
struct ProSectionHeader: View {
let title: String
var actionTitle: String?
var action: (() -> Void)?
var uppercase = true
var body: some View {
HStack {
Text(self.title)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.textCase(self.uppercase ? .uppercase : nil)
Spacer()
if let actionTitle {
if let action {
Button(actionTitle, action: action)
.font(.caption.weight(.medium))
.foregroundStyle(OpenClawBrand.accent)
} else {
Text(actionTitle)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
struct ProCard<Content: View>: View {
var tint: Color?
var isProminent: Bool = false
var padding: CGFloat = 14
var radius: CGFloat = OpenClawProMetric.cardRadius
@ViewBuilder var content: Content
var body: some View {
self.content
.padding(self.padding)
.frame(maxWidth: .infinity, alignment: .leading)
.proPanelSurface(
tint: self.tint,
radius: self.radius,
isProminent: self.isProminent)
}
}
private struct ProPanelBackground: View {
@Environment(\.colorScheme) private var colorScheme
let radius: CGFloat
let tint: Color?
let isProminent: Bool
var body: some View {
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
shape
.fill(self.fill)
.overlay {
ProPanelTexture()
.opacity(self.colorScheme == .dark ? 0.22 : 0.08)
.clipShape(shape)
}
.overlay {
shape.strokeBorder(self.borderStyle, lineWidth: 1)
}
.overlay {
shape
.strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7)
.padding(1)
}
.overlay(alignment: .top) {
shape
.strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7)
.mask(alignment: .top) {
Rectangle().frame(height: 28)
}
}
}
private var fill: AnyShapeStyle {
if self.colorScheme == .dark {
let base = self.isProminent
? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255)
: Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255)
return AnyShapeStyle(base)
}
let gradient = LinearGradient(
colors: [
Color.white.opacity(0.98),
(self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12),
Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
private var borderStyle: AnyShapeStyle {
if self.colorScheme == .dark {
return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11))
}
let gradient = LinearGradient(
colors: [
Color.white.opacity(0.72),
(self.tint ?? OpenClawBrand.accent).opacity(0.10),
Color.black.opacity(0.08),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
}
private struct ProPanelTexture: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Canvas { context, size in
let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08)
for y in stride(from: 2.0, through: size.height, by: 6.5) {
let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25
for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) {
let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7)
context.fill(Path(ellipseIn: dot), with: .color(color))
}
}
}
}
}
private struct ProLightGlassModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
let radius: CGFloat
func body(content: Content) -> some View {
if #available(iOS 26.0, *), self.colorScheme == .light {
content.glassEffect(.regular, in: .rect(cornerRadius: self.radius))
} else {
content
}
}
}
private struct ProGlassSurfaceModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
let fill: Color
let stroke: Color
let radius: CGFloat
let isProminent: Bool
var interactive = false
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
let surfaced = content.background {
shape
.fill(self.fill)
.overlay {
shape.strokeBorder(self.stroke, lineWidth: self.isProminent ? 1.2 : 1)
}
}
if #available(iOS 26.0, *), self.colorScheme == .light {
surfaced.glassEffect(
self.interactive ? .regular.interactive() : .regular,
in: .rect(cornerRadius: self.radius))
} else {
surfaced
}
}
}
extension View {
func proPanelSurface(
tint: Color? = nil,
radius: CGFloat = OpenClawProMetric.cardRadius,
isProminent: Bool = false) -> some View
{
self.modifier(ProPanelSurfaceModifier(
tint: tint,
radius: radius,
isProminent: isProminent))
}
func proGlassSurface(
fill: Color,
stroke: Color,
radius: CGFloat,
isProminent: Bool = false,
interactive: Bool = false) -> some View
{
self.modifier(ProGlassSurfaceModifier(
fill: fill,
stroke: stroke,
radius: radius,
isProminent: isProminent,
interactive: interactive))
}
}
private struct ProPanelSurfaceModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
let tint: Color?
let radius: CGFloat
let isProminent: Bool
func body(content: Content) -> some View {
content
.background {
ProPanelBackground(
radius: self.radius,
tint: self.tint,
isProminent: self.isProminent)
}
.modifier(ProLightGlassModifier(radius: self.radius))
.shadow(
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
radius: self.isProminent ? 20 : 12,
y: self.isProminent ? 10 : 6)
}
}
struct ProIconBadge: View {
let systemName: String
let color: Color
var body: some View {
Image(systemName: self.systemName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(self.color)
.frame(width: 34, height: 34)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.color.opacity(0.12))
}
}
}
struct ProStatusDot: View {
var color: Color
var body: some View {
Circle()
.fill(self.color)
.frame(width: 8, height: 8)
.shadow(color: self.color.opacity(0.35), radius: 4)
}
}
struct ProValuePill: View {
@Environment(\.colorScheme) private var colorScheme
let value: String
let color: Color
var body: some View {
Text(self.value)
.font(.caption.weight(.semibold))
.foregroundStyle(self.color)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background {
Capsule()
.fill(self.color.opacity(self.colorScheme == .dark ? 0.12 : 0.08))
}
}
}
struct OpenClawProMark: View {
var size: CGFloat = 42
var shadowRadius: CGFloat = 10
var body: some View {
Image("OpenClawIcon")
.resizable()
.scaledToFit()
.frame(width: self.size, height: self.size)
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
.accessibilityLabel("OpenClaw")
}
}
struct ProProgressBar: View {
let progress: Double
var color: Color = OpenClawBrand.accentHot
var body: some View {
GeometryReader { proxy in
let clamped = max(0, min(self.progress, 1))
ZStack(alignment: .leading) {
Capsule()
.fill(Color.primary.opacity(0.10))
Capsule()
.fill(self.color)
.frame(width: proxy.size.width * clamped)
}
}
.frame(height: 3)
}
}
struct ProWorkRow: View {
let icon: String
let title: String
let detail: String
let state: String
let trailing: String
let color: Color
var progress: Double?
var body: some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline) {
Text(self.title)
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
Text(self.trailing)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 8) {
if let progress {
ProProgressBar(progress: progress, color: self.color)
.frame(maxWidth: 120)
}
Text(self.state)
.font(.caption2.weight(.semibold))
.foregroundStyle(self.color)
}
}
}
.padding(.vertical, 9)
}
}
struct ProCapsule: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let color: Color
var icon: String?
var body: some View {
HStack(spacing: 6) {
if let icon {
Image(systemName: icon)
.font(.caption.weight(.semibold))
}
Text(self.title)
.font(.caption.weight(.semibold))
}
.foregroundStyle(self.color)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background {
Capsule()
.fill(self.color.opacity(self.colorScheme == .dark ? 0.16 : 0.10))
.overlay {
Capsule()
.strokeBorder(self.color.opacity(self.colorScheme == .dark ? 0.30 : 0.18), lineWidth: 1)
}
}
}
}
struct ProSegmentedControl: View {
@Environment(\.colorScheme) private var colorScheme
let labels: [String]
@Binding var selection: Int
var body: some View {
HStack(spacing: 4) {
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
Button {
self.selection = index
} label: {
Text(label)
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(4)
.background {
Capsule()
.fill(self.trackFill)
.overlay {
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
}
}
}
private func segmentFill(isSelected: Bool) -> Color {
guard isSelected else { return .clear }
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
}
private var trackFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
}
private var trackStroke: Color {
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
}
}
struct ProHeroActionButton: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let detail: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack(spacing: 12) {
Image(systemName: self.systemImage)
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 42, height: 42)
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "arrow.right")
.font(.subheadline.weight(.bold))
.foregroundStyle(OpenClawBrand.accentHot)
}
.padding(12)
.proGlassSurface(
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
radius: 18,
isProminent: true,
interactive: true)
}
.buttonStyle(.plain)
}
}
struct ProMetricTile: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Image(systemName: self.icon)
.font(.caption.weight(.semibold))
.foregroundStyle(self.color)
.frame(width: 24, height: 24)
.background(self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10), in: Circle())
Spacer(minLength: 4)
}
VStack(alignment: .leading, spacing: 2) {
Text(self.value)
.font(.headline.weight(.bold))
.lineLimit(1)
.minimumScaleFactor(0.72)
Text(self.title)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.proGlassSurface(
fill: self.colorScheme == .dark ? Color.white.opacity(0.04) : Color.white.opacity(0.52),
stroke: self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10),
radius: 16)
}
}
struct ProStatusRow: View {
let icon: String
let title: String
let detail: String
let value: String
let color: Color
var body: some View {
HStack(alignment: .center, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.subheadline.weight(.semibold))
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
ProValuePill(value: self.value, color: self.color)
}
.padding(.vertical, 11)
}
}
struct ProTimelineRow: View {
let done: Bool
let title: String
let detail: String
var body: some View {
HStack(alignment: .top, spacing: 10) {
ProIconBadge(
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.subheadline.weight(.medium))
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}

View File

@@ -0,0 +1,3 @@
import SwiftUI
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.

View File

@@ -0,0 +1,167 @@
import OpenClawKit
import SwiftUI
import UIKit
import UserNotifications
struct SettingsProTab: View {
@Environment(NodeAppModel.self) var appModel
@Environment(VoiceWakeManager.self) var voiceWake
@Environment(GatewayConnectionController.self) var gatewayController
@Environment(\.scenePhase) var scenePhase
@AppStorage(AppAppearancePreference.storageKey) var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
@AppStorage("node.displayName") var displayName: String = "iOS Node"
@AppStorage("node.instanceId") var instanceId: String = UUID().uuidString
@AppStorage("camera.enabled") var cameraEnabled: Bool = true
@AppStorage("location.enabledMode") var locationModeRaw: String = OpenClawLocationMode.off.rawValue
@AppStorage("screen.preventSleep") var preventSleep: Bool = true
@AppStorage("talk.enabled") var talkEnabled: Bool = false
@AppStorage(TalkModeProviderSelection.storageKey) var talkProviderSelectionRaw: String =
TalkModeProviderSelection.gatewayDefault.rawValue
@AppStorage(TalkModeRealtimeVoiceSelection.storageKey) var talkRealtimeVoiceSelectionRaw: String = ""
@AppStorage(TalkSpeechLocale.storageKey) var talkSpeechLocale: String = TalkSpeechLocale.automaticID
@AppStorage("talk.button.enabled") var talkButtonEnabled: Bool = true
@AppStorage("talk.background.enabled") var talkBackgroundEnabled: Bool = false
@AppStorage(TalkDefaults.speakerphoneEnabledKey) var talkSpeakerphoneEnabled: Bool =
TalkDefaults.speakerphoneEnabledByDefault
@AppStorage(VoiceWakePreferences.enabledKey) var voiceWakeEnabled: Bool = false
@AppStorage("gateway.autoconnect") var gatewayAutoConnect: Bool = false
@AppStorage("gateway.manual.enabled") var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") var manualGatewayHost: String = ""
@AppStorage("gateway.manual.port") var manualGatewayPort: Int = 18789
@AppStorage("gateway.manual.tls") var manualGatewayTLS: Bool = true
@AppStorage("gateway.discovery.debugLogs") var discoveryDebugLogsEnabled: Bool = false
@AppStorage("canvas.debugStatusEnabled") var canvasDebugStatusEnabled: Bool = false
@AppStorage("gateway.setupCode") var setupCode: String = ""
@AppStorage("gateway.onboardingComplete") var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") var hasConnectedOnce: Bool = false
@AppStorage("onboarding.requestID") var onboardingRequestID: Int = 0
@State var isReconnectingGateway = false
@State var isRefreshingGateway = false
@State var isChangingLocationMode = false
@State var connectingGatewayID: String?
@State var selectedAgentPickerId = ""
@State var gatewayToken = ""
@State var gatewayPassword = ""
@State var manualGatewayPortText = ""
@State var setupStatusText: String?
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State var defaultShareInstruction = ""
@State var showGatewayProblemDetails = false
@State var showQRScanner = false
@State var scannerError: String?
@State var showResetOnboardingAlert = false
@State var suppressCredentialPersist = false
@State var locationStatusText: String?
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@State var notificationStatusText = "Checking"
@State var notificationActionText = "Request Access"
@State var diagnosticsLastRunText = "Not run"
@State var diagnosticsIssueCount: Int?
var body: some View {
NavigationStack {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.settingsHeader
self.appearanceSection
self.gatewaySection
self.settingsListSection
}
.padding(.vertical, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
.navigationDestination(for: SettingsRoute.self) { route in
self.destination(for: route)
}
.task {
self.previousLocationModeRaw = self.locationModeRaw
self.syncSettingsState()
self.refreshNotificationSettings()
}
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
self.syncSettingsState()
self.refreshNotificationSettings()
}
}
.onChange(of: self.locationModeRaw) { _, newValue in
self.handleLocationModeChange(newValue)
}
.onChange(of: self.selectedAgentPickerId) { _, newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
}
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
if newValue != self.selectedAgentPickerId {
self.selectedAgentPickerId = newValue
}
}
.onChange(of: self.gatewayToken) { _, newValue in
self.persistGatewayToken(newValue)
}
.onChange(of: self.gatewayPassword) { _, newValue in
self.persistGatewayPassword(newValue)
}
.onChange(of: self.defaultShareInstruction) { _, newValue in
ShareToAgentSettings.saveDefaultInstruction(newValue)
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
})
}
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedGatewayLink(link)
},
onError: { error in
self.showQRScanner = false
self.setupStatusText = "Scanner error: \(error)"
self.scannerError = error
},
onDismiss: {
self.showQRScanner = false
})
.ignoresSafeArea()
.navigationTitle("Scan QR Code")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { self.showQRScanner = false }
}
}
}
}
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
Button("Reset", role: .destructive) {
self.resetOnboarding()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
}
.alert(
"QR Scanner Unavailable",
isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }))
{
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
}
}

View File

@@ -0,0 +1,646 @@
import OpenClawKit
import SwiftUI
import UIKit
import UserNotifications
extension SettingsProTab {
func detailStatusCard(
icon: String,
title: String,
detail: String,
value: String,
color: Color) -> some View
{
ProCard(radius: SettingsLayout.cardRadius) {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
ProValuePill(value: value, color: color)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var diagnosticChecksCard: some View {
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
self.diagnosticCheckRow(
icon: "stethoscope",
title: "Last Run",
detail: self.diagnosticsLastRunText,
value: self.diagnosticsRunValue,
color: self.diagnosticsRunColor)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "antenna.radiowaves.left.and.right",
title: "Gateway Link",
detail: self.appModel.gatewayDisplayStatusText,
value: self.gatewayConnected ? "online" : "offline",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "dot.radiowaves.left.and.right",
title: "Discovery",
detail: self.gatewayController.discoveryStatusText,
value: "\(self.gatewayController.gateways.count)",
color: self.gatewayController.gateways.isEmpty ? .secondary : OpenClawBrand.accent)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "waveform",
title: "Talk Config",
detail: self.appModel.talkMode.gatewayTalkTransportLabel,
value: self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing",
color: self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "bell",
title: "Notifications",
detail: "Approval and event alert channel",
value: self.notificationStatusText,
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "rectangle.on.rectangle",
title: "Screen Capture",
detail: "Live foreground capture state",
value: self.appModel.screenRecordActive ? "live" : "idle",
color: self.appModel.screenRecordActive ? OpenClawBrand.ok : .secondary)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "mic",
title: "Voice Wake",
detail: self.appModel.voiceWake.statusText,
value: self.voiceWakeEnabled ? "on" : "off",
color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func diagnosticCheckRow(
icon: String,
title: String,
detail: String,
value: String,
color: Color) -> some View
{
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
ProValuePill(value: value, color: color)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
func detailListCard(@ViewBuilder content: () -> some View) -> some View {
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
VStack(spacing: 0, content: content)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func detailRow(_ label: String, value: String) -> some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
.font(.caption)
.lineLimit(1)
.truncationMode(.middle)
}
.padding(.horizontal, 14)
.frame(height: 42)
}
func reconnectGateway() async {
guard !self.isReconnectingGateway else { return }
self.isReconnectingGateway = true
defer { self.isReconnectingGateway = false }
await self.gatewayController.connectLastKnown()
}
func refreshGateway() async {
guard !self.isRefreshingGateway else { return }
self.isRefreshingGateway = true
defer { self.isRefreshingGateway = false }
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
self.gatewayController.restartDiscovery()
await self.appModel.refreshGatewayOverviewIfConnected()
}
@MainActor
func runDiagnostics() async {
guard !self.isRefreshingGateway else { return }
self.isRefreshingGateway = true
defer { self.isRefreshingGateway = false }
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
self.gatewayController.restartDiscovery()
await self.appModel.refreshGatewayOverviewIfConnected()
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
self.applyNotificationStatus(notificationSettings.authorizationStatus)
let issueCount = SettingsDiagnostics.issueCount(
gatewayConnected: self.gatewayConnected,
discoveredGatewayCount: self.gatewayController.gateways.count,
talkConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
notificationStatusText: self.notificationStatusText)
self.diagnosticsIssueCount = issueCount
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
}
func syncSettingsState() {
self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : ""
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedInstanceId.isEmpty else { return }
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
}
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
self.connectingGatewayID = gateway.id
defer { self.connectingGatewayID = nil }
self.manualGatewayEnabled = false
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
if let err = await self.gatewayController.connectWithDiagnostics(gateway) {
self.setupStatusText = err
}
}
func applySetupCodeAndConnect() async {
self.setupStatusText = nil
guard self.applySetupCode() else { return }
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard let port = self.resolvedManualPort(host: host) else {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
self.setupStatusText = "Setup code applied. Connecting..."
await self.connectManual()
}
@discardableResult
func applySetupCode() -> Bool {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
self.setupStatusText = "Paste a setup code to continue."
return false
}
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return false
}
self.applyGatewayLink(link)
return true
}
func applyGatewayLink(_ link: GatewayConnectDeepLink) {
self.manualGatewayHost = link.host
self.manualGatewayPort = link.port
self.manualGatewayPortText = String(link.port)
self.manualGatewayTLS = link.tls
let instanceId = GatewaySettingsStore.currentInstanceID()
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
if setupAuth.hasBootstrapToken {
GatewayOnboardingReset.prepareForBootstrapPairing(appModel: self.appModel, instanceId: instanceId)
}
if !instanceId.isEmpty {
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: instanceId)
}
if setupAuth.shouldApplyTokenField {
self.gatewayToken = setupAuth.token
if !instanceId.isEmpty {
GatewaySettingsStore.saveGatewayToken(setupAuth.token, instanceId: instanceId)
}
}
if setupAuth.shouldApplyPasswordField {
self.gatewayPassword = setupAuth.password
if !instanceId.isEmpty {
GatewaySettingsStore.saveGatewayPassword(setupAuth.password, instanceId: instanceId)
}
}
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
}
func openGatewayQRScanner() {
self.appModel.disconnectGateway()
self.connectingGatewayID = nil
self.setupStatusText = "Opening QR scanner..."
self.showQRScanner = true
}
func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
self.showQRScanner = false
self.setupCode = ""
self.applyGatewayLink(link)
self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..."
Task { await self.connectAfterScannedGatewayLink() }
}
func connectAfterScannedGatewayLink() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard let port = self.resolvedManualPort(host: host) else {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
await self.connectManual()
}
func connectManual() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.setupStatusText = "Failed: host required"
return
}
guard self.manualPortIsValid else {
self.setupStatusText = "Failed: invalid port"
return
}
self.connectingGatewayID = "manual"
self.manualGatewayEnabled = true
defer { self.connectingGatewayID = nil }
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: self.gatewayToken,
pendingOverride: self.pendingManualAuthOverride,
password: self.gatewayPassword)
self.pendingManualAuthOverride = nil
await self.gatewayController.connectManual(
host: host,
port: self.manualGatewayPort,
useTLS: self.manualGatewayTLS,
authOverride: authOverride)
}
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
self.setupStatusText = "Tailscale is off on this iPhone. Turn it on, then try again."
return false
}
self.setupStatusText = "Checking gateway reachability..."
let ok = await TCPProbe.probe(host: trimmed, port: port, timeoutSeconds: 3, queueLabel: "gateway.preflight")
if !ok {
self.setupStatusText = "Can't reach gateway at \(trimmed):\(port). Check Tailscale or LAN."
}
return ok
}
func resetOnboarding() {
self.connectingGatewayID = nil
self.setupStatusText = nil
self.setupCode = ""
self.gatewayAutoConnect = false
self.suppressCredentialPersist = true
defer { self.suppressCredentialPersist = false }
self.gatewayToken = ""
self.gatewayPassword = ""
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
self.onboardingComplete = false
self.hasConnectedOnce = false
self.manualGatewayEnabled = false
self.manualGatewayHost = ""
self.onboardingRequestID += 1
}
func retryGatewayConnectionFromProblem() async {
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
await self.connectManual()
} else {
await self.gatewayController.connectLastKnown()
}
}
func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
if problem.suggestsOnboardingReset { return "Reset onboarding" }
return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection"
}
func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async {
if problem.suggestsOnboardingReset {
self.resetOnboarding()
return
}
if problem.canTrustRotatedCertificate {
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
return
}
await self.retryGatewayConnectionFromProblem()
}
func handleLocationModeChange(_ newValue: String) {
guard !self.isChangingLocationMode else { return }
guard newValue != self.previousLocationModeRaw else { return }
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
let previous = self.previousLocationModeRaw
Task {
await self.applyLocationMode(mode, rawValue: newValue, previous: previous)
}
}
@MainActor
func applyLocationMode(
_ mode: OpenClawLocationMode,
rawValue: String,
previous: String) async
{
self.isChangingLocationMode = true
self.locationStatusText = nil
defer { self.isChangingLocationMode = false }
if mode == .off {
self.previousLocationModeRaw = rawValue
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
return
}
let granted = await self.appModel.requestLocationPermissions(mode: mode)
if granted {
self.previousLocationModeRaw = rawValue
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
} else {
self.locationModeRaw = previous
self.previousLocationModeRaw = previous
self.locationStatusText = "Location permission was not granted."
}
}
func refreshNotificationSettings() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
let status = settings.authorizationStatus
Task { @MainActor in
self.applyNotificationStatus(status)
}
}
}
func handleNotificationAction() {
if self.notificationStatusText == "Allowed" || self.notificationStatusText == "Not Allowed" {
self.openSystemSettings()
return
}
Task {
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
.alert,
.badge,
.sound,
])) ?? false
await MainActor.run {
self.notificationStatusText = granted ? "Allowed" : "Not Allowed"
self.notificationActionText = granted ? "Open System Settings" : "Open System Settings"
}
}
}
@MainActor
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
switch status {
case .authorized, .provisional, .ephemeral:
self.notificationStatusText = "Allowed"
self.notificationActionText = "Open System Settings"
case .denied:
self.notificationStatusText = "Not Allowed"
self.notificationActionText = "Open System Settings"
case .notDetermined:
self.notificationStatusText = "Not Set"
self.notificationActionText = "Request Access"
@unknown default:
self.notificationStatusText = "Unknown"
self.notificationActionText = "Open System Settings"
}
}
func persistGatewayToken(_ value: String) {
guard !self.suppressCredentialPersist else { return }
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayToken(
value.trimmingCharacters(in: .whitespacesAndNewlines),
instanceId: instanceId)
}
func persistGatewayPassword(_ value: String) {
guard !self.suppressCredentialPersist else { return }
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instanceId.isEmpty else { return }
GatewaySettingsStore.saveGatewayPassword(
value.trimmingCharacters(in: .whitespacesAndNewlines),
instanceId: instanceId)
}
func openSystemSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
func title(for route: SettingsRoute) -> String {
switch route {
case .gateway: "Gateway"
case .permissions: "Permissions"
case .voice: "Voice & Talk"
case .diagnostics: "Diagnostics"
case .privacy: "Privacy"
case .notifications: "Notifications"
case .about: "About"
}
}
var manualPortBinding: Binding<String> {
Binding(
get: { self.manualGatewayPortText },
set: { newValue in
let filtered = newValue.filter(\.isNumber)
self.manualGatewayPortText = filtered
self.manualGatewayPort = Int(filtered) ?? 0
})
}
var manualPortIsValid: Bool {
if self.manualGatewayPortText.isEmpty { return true }
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
}
func resolvedManualPort(host: String) -> Int? {
if self.manualGatewayPort > 0 {
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
}
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
return 443
}
return 18789
}
var setupStatusLine: String? {
if let problem = self.appModel.lastGatewayProblem {
return problem.message
}
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
if !trimmedSetup.isEmpty { return trimmedSetup }
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
return gatewayStatus
}
var tailnetWarningText: String? {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil }
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
}
func friendlyGatewayMessage(from raw: String) -> String? {
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if lower.contains("pairing required") {
return "Pairing required. Run /pair approve in your OpenClaw chat, then connect again."
}
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
return "Secure handshake failed. Check Tailscale, then connect again."
}
if lower.contains("timed out") {
return "Connection timed out. Make sure Tailscale is connected, then try again."
}
if lower.contains("unauthorized role") {
return "Connected, but some controls are restricted for nodes. This is expected."
}
return nil
}
var shouldShowRealtimeVoicePicker: Bool {
let providerSelection = TalkModeProviderSelection.resolved(self.talkProviderSelectionRaw)
return providerSelection == .openAIRealtime || self.appModel.talkMode.gatewayTalkUsesRealtime
}
var talkProviderSelectionBinding: Binding<String> {
Binding(
get: { self.talkProviderSelectionRaw },
set: { newValue in
let selection = TalkModeProviderSelection.resolved(newValue)
self.talkProviderSelectionRaw = selection.rawValue
self.appModel.setTalkProviderSelection(selection.rawValue)
})
}
var talkRealtimeVoiceSelectionBinding: Binding<String> {
Binding(
get: { self.talkRealtimeVoiceSelectionRaw },
set: { newValue in
let voice = TalkModeRealtimeVoiceSelection.resolvedOverride(newValue) ?? ""
self.talkRealtimeVoiceSelectionRaw = voice
self.appModel.setTalkRealtimeVoiceSelection(voice)
})
}
var talkSpeakerphoneBinding: Binding<Bool> {
Binding(
get: { self.talkSpeakerphoneEnabled },
set: { newValue in
self.talkSpeakerphoneEnabled = newValue
self.appModel.setTalkSpeakerphoneEnabled(newValue)
})
}
var talkApiKeyStatus: String {
guard self.appModel.talkMode.gatewayTalkConfigLoaded else { return "Not loaded" }
return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured"
}
func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
var lines: [String] = []
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
let gw = gateway.gatewayPort.map(String.init)
let canvas = gateway.canvasPort.map(String.init)
if gw != nil || canvas != nil {
lines.append("Ports: gateway \(gw ?? "-") / canvas \(canvas ?? "-")")
}
return lines.isEmpty ? [gateway.debugID] : lines
}
var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
var gatewayAddress: String {
self.appModel.gatewayRemoteAddress ?? "Waiting for gateway"
}
var gatewayServer: String {
self.appModel.gatewayServerName ?? "OpenClaw Gateway"
}
var permissionsDetail: String {
var enabled = 0
if self.cameraEnabled { enabled += 1 }
if self.locationModeRaw != OpenClawLocationMode.off.rawValue { enabled += 1 }
if self.preventSleep { enabled += 1 }
return "\(enabled) enabled"
}
var voiceDetail: String {
if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" }
if self.talkEnabled { return "Talk on" }
if self.voiceWakeEnabled { return "Wake on" }
return "Off"
}
var diagnosticsDetail: String {
"System checks"
}
var diagnosticsHealthValue: String {
if self.gatewayConnected { return "ready" }
if self.gatewayController.gateways.isEmpty { return "check" }
return "partial"
}
var diagnosticsRunValue: String {
guard let diagnosticsIssueCount else { return "pending" }
return diagnosticsIssueCount == 0 ? "pass" : "\(diagnosticsIssueCount)"
}
var diagnosticsRunColor: Color {
guard let diagnosticsIssueCount else { return .secondary }
return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn
}
var privacyDetail: String {
let location = OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
return location == .off ? "Location off" : "Location \(self.locationLabel)"
}
var locationLabel: String {
switch OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off {
case .off: "Off"
case .whileUsing: "While Using"
case .always: "Always"
}
}
}

View File

@@ -0,0 +1,807 @@
import OpenClawKit
import SwiftUI
extension SettingsProTab {
var settingsHeader: some View {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var appearanceSection: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Appearance", uppercase: false)
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Picker("Appearance", selection: self.$appearancePreferenceRaw) {
ForEach(AppAppearancePreference.allCases) { preference in
Text(preference.label).tag(preference.rawValue)
}
}
.pickerStyle(.segmented)
Text("Follows iOS appearance.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var gatewaySection: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Gateway", uppercase: false)
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
NavigationLink(value: SettingsRoute.gateway) {
self.gatewayConnectionRow
.padding(14)
}
.buttonStyle(.plain)
Divider()
self.gatewayDetailRow(label: "Address", value: self.gatewayAddress)
Divider()
self.gatewayDetailRow(label: "Server", value: self.gatewayServer)
Divider()
self.gatewayDetailRow(label: "Agents", value: "\(self.appModel.gatewayAgents.count)")
Divider()
self.gatewayActions
.padding(14)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var gatewayConnectionRow: some View {
HStack(spacing: 12) {
ProIconBadge(
systemName: "antenna.radiowaves.left.and.right",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
VStack(alignment: .leading, spacing: 3) {
Text("Connection")
.font(.subheadline.weight(.semibold))
Text(self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText)
.font(.caption)
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .secondary)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
func gatewayDetailRow(label: String, value: String) -> some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.padding(.horizontal, 14)
.frame(height: 40)
}
var gatewayActions: some View {
HStack(spacing: 10) {
self.gatewayActionButton(
title: "Reconnect",
icon: "arrow.triangle.2.circlepath",
color: OpenClawBrand.warn,
isBusy: self.isReconnectingGateway)
{
Task { await self.reconnectGateway() }
}
self.gatewayActionButton(
title: "Diagnose",
icon: "cross.case",
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
}
}
}
var settingsListSection: some View {
VStack(spacing: 10) {
self.settingsListRow(
icon: "person.2",
title: "Permissions",
detail: self.permissionsDetail,
route: .permissions)
self.settingsListRow(
icon: "waveform",
title: "Voice & Talk",
detail: self.voiceDetail,
route: .voice)
self.settingsListRow(
icon: "globe",
title: "Diagnostics",
detail: self.diagnosticsDetail,
route: .diagnostics)
self.settingsListRow(
icon: "hand.raised",
title: "Privacy",
detail: self.privacyDetail,
route: .privacy)
self.settingsListRow(
icon: "bell",
title: "Notifications",
detail: self.notificationStatusText,
route: .notifications)
self.settingsListRow(
icon: "info.circle",
title: "About",
detail: DeviceInfoHelper.openClawVersionString(),
route: .about)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func settingsListRow(
icon: String,
title: String,
detail: String,
route: SettingsRoute) -> some View
{
NavigationLink(value: route) {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, minHeight: SettingsLayout.rowHeight, alignment: .leading)
.proPanelSurface(radius: SettingsLayout.cardRadius)
}
.buttonStyle(.plain)
}
func destination(for route: SettingsRoute) -> some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
switch route {
case .gateway:
self.gatewayDestination
case .permissions:
self.permissionsDestination
case .voice:
self.voiceDestination
case .diagnostics:
self.diagnosticsDestination
case .privacy:
self.privacyDestination
case .notifications:
self.notificationsDestination
case .about:
self.aboutDestination
}
}
.padding(.vertical, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(self.title(for: route))
.navigationBarTitleDisplayMode(.inline)
}
var gatewayDestination: some View {
VStack(alignment: .leading, spacing: 14) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
self.gatewayProblemCard(gatewayProblem)
}
self.detailStatusCard(
icon: "antenna.radiowaves.left.and.right",
title: "Gateway",
detail: self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText,
value: self.gatewayConnected ? "online" : "offline",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
self.detailListCard {
self.detailRow("Address", value: self.gatewayAddress)
Divider()
self.detailRow("Server", value: self.gatewayServer)
Divider()
self.detailRow("Discovered", value: "\(self.gatewayController.gateways.count)")
Divider()
self.detailRow("Active Agent", value: self.appModel.activeAgentName)
Divider()
self.detailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
}
ProCard(radius: SettingsLayout.cardRadius) {
self.gatewayActions
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.deviceIdentityCard
self.agentSelectionCard
self.gatewaySetupCard
self.discoveredGatewaysCard
self.manualGatewayCard
self.gatewayAdvancedCard
}
}
var permissionsDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.toggleCard(
icon: "camera",
title: "Camera",
detail: "Allow the gateway to request photos or video while OpenClaw is foregrounded.",
isOn: self.$cameraEnabled)
self.locationModeCard
self.toggleCard(
icon: "lock.display",
title: "Keep Awake",
detail: "Keep the screen awake while OpenClaw is open.",
isOn: self.$preventSleep)
self.privacyAccessCard
}
}
var voiceDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.detailStatusCard(
icon: "waveform",
title: "Voice & Talk",
detail: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
value: self.voiceDetail,
color: self.talkEnabled || self.voiceWakeEnabled ? OpenClawBrand.accent : .secondary)
self.voiceFeatureCard
self.talkVoiceSettingsCard
self.shareSettingsCard
}
}
var diagnosticsDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.detailStatusCard(
icon: "checklist.checked",
title: "Health Check",
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
value: self.diagnosticsHealthValue,
color: self.gatewayConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
self.diagnosticChecksCard
self.detailListCard {
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
Divider()
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
Divider()
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
Divider()
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
}
ProCard(radius: SettingsLayout.cardRadius) {
self.gatewayActionButton(
title: "Run Diagnostics",
icon: "cross.case",
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.diagnosticsAdvancedCard
}
}
var privacyDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.detailStatusCard(
icon: "hand.raised",
title: "Privacy",
detail: "Control what device context OpenClaw can expose to the gateway.",
value: self.privacyDetail,
color: .secondary)
self.toggleCard(
icon: "camera",
title: "Camera Access",
detail: "Disable to block camera capture requests from the gateway.",
isOn: self.$cameraEnabled)
self.locationModeCard
self.toggleCard(
icon: "lock.open.display",
title: "Background Listening",
detail: "Allow active Talk sessions to continue while the app is backgrounded.",
isOn: self.$talkBackgroundEnabled)
self.privacyAccessCard
}
}
var notificationsDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.detailStatusCard(
icon: "bell",
title: "Notifications",
detail: "Approvals and event alerts from OpenClaw.",
value: self.notificationStatusText,
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Button {
self.handleNotificationAction()
} label: {
Label(
self.notificationActionText,
systemImage: self.notificationStatusText == "Allowed" ? "gear" : "bell.badge")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Text("OpenClaw uses notifications for approval prompts and mirrored event alerts.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
var aboutDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.detailStatusCard(
icon: "info.circle",
title: "OpenClaw",
detail: "iOS companion app",
value: DeviceInfoHelper.openClawVersionString(),
color: OpenClawBrand.accent)
self.detailListCard {
self.detailRow("Version", value: DeviceInfoHelper.openClawVersionString())
Divider()
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
Divider()
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
Divider()
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
}
}
}
func gatewayActionButton(
title: String,
icon: String,
color: Color,
isBusy: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
HStack(spacing: 7) {
Image(systemName: isBusy ? "hourglass" : icon)
.font(.caption.weight(.semibold))
Text(title)
.font(.caption.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.76)
}
.frame(maxWidth: .infinity)
.frame(height: 34)
.foregroundStyle(color)
.background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(color.opacity(0.14))
}
}
.buttonStyle(.plain)
.disabled(isBusy)
}
func toggleCard(
icon: String,
title: String,
detail: String,
isOn: Binding<Bool>) -> some View
{
ProCard(radius: SettingsLayout.cardRadius) {
Toggle(isOn: isOn) {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: isOn.wrappedValue ? OpenClawBrand.accent : .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
.toggleStyle(.switch)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var locationModeCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
ProIconBadge(
systemName: "location",
color: self.locationModeRaw == OpenClawLocationMode.off.rawValue ? .secondary : OpenClawBrand
.accent)
VStack(alignment: .leading, spacing: 3) {
Text("Location")
.font(.subheadline.weight(.semibold))
Text("Controls whether location can be shared with gateway tools.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
if self.isChangingLocationMode {
ProgressView()
.controlSize(.small)
}
}
Picker("Location", selection: self.$locationModeRaw) {
Text("Off").tag(OpenClawLocationMode.off.rawValue)
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
Text("Always").tag(OpenClawLocationMode.always.rawValue)
}
.pickerStyle(.segmented)
.disabled(self.isChangingLocationMode)
if let locationStatusText {
Text(locationStatusText)
.font(.caption2)
.foregroundStyle(OpenClawBrand.warn)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var agentSelectionCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
Text("Active Agent")
.font(.subheadline.weight(.semibold))
Picker("Agent", selection: self.$selectedAgentPickerId) {
Text("Default").tag("")
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
Text(name.isEmpty ? agent.id : name).tag(agent.id)
}
}
Text("Controls which agent Chat and Talk use.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var gatewaySetupCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Text("Setup Code")
.font(.subheadline.weight(.semibold))
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
HStack(spacing: 10) {
self.gatewayActionButton(
title: "Scan QR",
icon: "qrcode.viewfinder",
color: OpenClawBrand.accent,
isBusy: self.connectingGatewayID != nil)
{
self.openGatewayQRScanner()
}
self.gatewayActionButton(
title: "Connect",
icon: "bolt.horizontal.circle",
color: OpenClawBrand.ok,
isBusy: self.connectingGatewayID == "manual")
{
Task { await self.applySetupCodeAndConnect() }
}
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if let status = self.setupStatusLine {
Text(status)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let warning = self.tailnetWarningText {
Text(warning)
.font(.caption.weight(.semibold))
.foregroundStyle(OpenClawBrand.warn)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var discoveredGatewaysCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Text("Discovered Gateways")
.font(.subheadline.weight(.semibold))
if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet. Use manual setup if Bonjour is blocked.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(self.gatewayController.gateways) { gateway in
self.discoveredGatewayRow(gateway)
if gateway.id != self.gatewayController.gateways.last?.id {
Divider()
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func discoveredGatewayRow(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(verbatim: gateway.name)
.font(.subheadline.weight(.semibold))
Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: ""))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
Button {
Task { await self.connect(gateway) }
} label: {
if self.connectingGatewayID == gateway.id {
ProgressView().controlSize(.small)
} else {
Text("Connect")
}
}
.buttonStyle(.bordered)
.disabled(self.connectingGatewayID != nil)
}
}
var manualGatewayCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
TextField("Port", text: self.manualPortBinding)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
self.gatewayActionButton(
title: "Connect Manual",
icon: "network",
color: OpenClawBrand.accent,
isBusy: self.connectingGatewayID == "manual")
{
Task { await self.connectManual() }
}
.disabled(self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !self.manualPortIsValid)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var gatewayAdvancedCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
SecureField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
SecureField("Gateway Password", text: self.$gatewayPassword)
.textFieldStyle(.roundedBorder)
Button("Reset Onboarding", role: .destructive) {
self.showResetOnboardingAlert = true
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var voiceFeatureCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
self.settingsToggle("Voice Wake", isOn: self.$voiceWakeEnabled) { enabled in
self.appModel.setVoiceWakeEnabled(enabled)
}
self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in
self.appModel.setTalkEnabled(enabled)
}
Picker("Speech Language", selection: self.$talkSpeechLocale) {
ForEach(TalkSpeechLocale.supportedOptions()) { option in
Text(option.label).tag(option.id)
}
}
self.settingsToggle("Background Listening", isOn: self.$talkBackgroundEnabled)
self.settingsToggle("Speakerphone", isOn: self.talkSpeakerphoneBinding)
NavigationLink {
VoiceWakeWordsSettingsView()
} label: {
self.simpleSettingsRow(
title: "Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var talkVoiceSettingsCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Picker("Provider", selection: self.talkProviderSelectionBinding) {
ForEach(TalkModeProviderSelection.allCases) { option in
Text(option.label).tag(option.rawValue)
}
}
if self.shouldShowRealtimeVoicePicker {
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
Text("Gateway Default").tag("")
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
}
}
}
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
Divider()
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
Divider()
self.detailRow("API Key", value: self.talkApiKeyStatus)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var shareSettingsCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Toggle("Show Talk Control", isOn: self.$talkButtonEnabled)
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
.lineLimit(2...5)
.textInputAutocapitalization(.sentences)
.textFieldStyle(.roundedBorder)
Button {
Task { await self.appModel.runSharePipelineSelfTest() }
} label: {
Label("Run Share Self-Test", systemImage: "checkmark.seal")
}
.buttonStyle(.bordered)
Text(self.appModel.lastShareEventText)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var privacyAccessCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
PrivacyAccessSectionView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var diagnosticsAdvancedCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, enabled in
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
}
Toggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
NavigationLink {
GatewayDiscoveryDebugLogView()
} label: {
self.simpleSettingsRow(title: "Discovery Logs", value: self.gatewayController.discoveryStatusText)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var deviceIdentityCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
TextField("Device Name", text: self.$displayName)
.textFieldStyle(.roundedBorder)
self.detailRow("Instance ID", value: self.instanceId)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func gatewayProblemCard(_ problem: GatewayConnectionProblem) -> some View {
ProCard(radius: SettingsLayout.cardRadius) {
GatewayProblemBanner(
problem: problem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(problem) }
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func settingsToggle(
_ title: String,
isOn: Binding<Bool>,
onChange: ((Bool) -> Void)? = nil) -> some View
{
Toggle(title, isOn: isOn)
.onChange(of: isOn.wrappedValue) { _, enabled in
onChange?(enabled)
}
}
func simpleSettingsRow(title: String, value: String) -> some View {
HStack {
Text(title)
Spacer(minLength: 8)
Text(value)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
.font(.subheadline)
}
}

View File

@@ -0,0 +1,105 @@
import Darwin
import SwiftUI
enum SettingsRoute: Hashable {
case gateway
case permissions
case voice
case diagnostics
case privacy
case notifications
case about
}
enum SettingsLayout {
static let cardRadius: CGFloat = 12
static let rowHeight: CGFloat = 58
}
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
case gatewayOffline
case discoveryUnavailable
case talkConfigMissing
case notificationsUnavailable
}
enum SettingsDiagnostics {
static func issues(
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationStatusText: String) -> [SettingsDiagnosticIssue]
{
var issues: [SettingsDiagnosticIssue] = []
if !gatewayConnected { issues.append(.gatewayOffline) }
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
if notificationStatusText != "Allowed" { issues.append(.notificationsUnavailable) }
return issues
}
static func issueCount(
gatewayConnected: Bool,
discoveredGatewayCount: Int,
talkConfigLoaded: Bool,
notificationStatusText: String) -> Int
{
self.issues(
gatewayConnected: gatewayConnected,
discoveredGatewayCount: discoveredGatewayCount,
talkConfigLoaded: talkConfigLoaded,
notificationStatusText: notificationStatusText).count
}
static func timestamp(_ date: Date) -> String {
date.formatted(date: .omitted, time: .shortened)
}
}
extension SettingsProTab {
static func hasTailnetIPv4() -> Bool {
var addrList: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
defer { freeifaddrs(addrList) }
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
let flags = Int32(ptr.pointee.ifa_flags)
let isUp = (flags & IFF_UP) != 0
let isLoopback = (flags & IFF_LOOPBACK) != 0
guard let addrPtr = ptr.pointee.ifa_addr else { continue }
let family = addrPtr.pointee.sa_family
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
var addr = addrPtr.pointee
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let result = getnameinfo(
&addr,
socklen_t(addrPtr.pointee.sa_len),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard result == 0 else { continue }
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
if self.isTailnetIPv4(ip) { return true }
}
return false
}
static func isTailnetHostOrIP(_ host: String) -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") { return true }
return self.isTailnetIPv4(trimmed)
}
static func isTailnetIPv4(_ ip: String) -> Bool {
let parts = ip.split(separator: ".")
guard parts.count == 4 else { return false }
let octets = parts.compactMap { Int($0) }
guard octets.count == 4 else { return false }
let a = octets[0]
let b = octets[1]
guard (0...255).contains(a), (0...255).contains(b) else { return false }
return a == 100 && b >= 64 && b <= 127
}
}

View File

@@ -2,7 +2,6 @@ import SwiftUI
private struct ExecApprovalPromptDialogModifier: ViewModifier {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(\.colorScheme) private var colorScheme
func body(content: Content) -> some View {
content
@@ -16,7 +15,6 @@ private struct ExecApprovalPromptDialogModifier: ViewModifier {
prompt: prompt,
isResolving: self.appModel.pendingExecApprovalPromptResolving,
errorText: self.appModel.pendingExecApprovalPromptErrorText,
brighten: self.colorScheme == .light,
onAllowOnce: {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
@@ -50,7 +48,6 @@ private struct ExecApprovalPromptCard: View {
let prompt: NodeAppModel.ExecApprovalPrompt
let isResolving: Bool
let errorText: String?
let brighten: Bool
let onAllowOnce: () -> Void
let onAllowAlways: () -> Void
let onDeny: () -> Void
@@ -147,7 +144,8 @@ private struct ExecApprovalPromptCard: View {
.controlSize(.large)
.frame(maxWidth: .infinity)
}
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
.padding(18)
.proPanelSurface(tint: OpenClawBrand.accentHot, radius: 20, isProminent: true)
}
private func normalized(_ value: String?) -> String? {

View File

@@ -25,4 +25,53 @@ struct GatewayConnectConfig {
if trimmed.isEmpty { return self.url.absoluteString }
return trimmed
}
func hasSameConnectionInputs(as other: GatewayConnectConfig) -> Bool {
self.url == other.url &&
self.stableID == other.stableID &&
Self.sameTLS(self.tls, other.tls) &&
self.token == other.token &&
self.bootstrapToken == other.bootstrapToken &&
self.password == other.password &&
Self.sameOptions(self.nodeOptions, other.nodeOptions)
}
private static func sameTLS(_ lhs: GatewayTLSParams?, _ rhs: GatewayTLSParams?) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
true
case let (lhs?, rhs?):
lhs.required == rhs.required &&
lhs.expectedFingerprint == rhs.expectedFingerprint &&
lhs.allowTOFU == rhs.allowTOFU &&
lhs.storeKey == rhs.storeKey
default:
false
}
}
private static func sameOptions(_ lhs: GatewayConnectOptions, _ rhs: GatewayConnectOptions) -> Bool {
let lhsScopes = Self.normalizedValues(lhs.scopes)
let rhsScopes = Self.normalizedValues(rhs.scopes)
let lhsCaps = Self.normalizedValues(lhs.caps)
let rhsCaps = Self.normalizedValues(rhs.caps)
let lhsCommands = Self.normalizedValues(lhs.commands)
let rhsCommands = Self.normalizedValues(rhs.commands)
return lhs.role == rhs.role &&
lhs.scopesAreExplicit == rhs.scopesAreExplicit &&
lhs.clientId == rhs.clientId &&
lhs.clientMode == rhs.clientMode &&
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&
lhsCommands == rhsCommands &&
lhs.permissions == rhs.permissions
}
private static func normalizedValues(_ values: [String]) -> [String] {
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.sorted()
}
}

View File

@@ -131,6 +131,12 @@ final class GatewayConnectionController {
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
private var pendingTrustConnect: PendingTrustConnect?
private struct SavedManualEndpoint: Equatable {
let host: String
let port: Int
let useTLS: Bool
}
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
self.appModel = appModel
@@ -181,7 +187,8 @@ final class GatewayConnectionController {
}
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
forceReconnect: Bool = false) async -> String?
{
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -245,7 +252,8 @@ final class GatewayConnectionController {
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
password: password,
forceReconnect: forceReconnect)
return nil
}
@@ -257,7 +265,8 @@ final class GatewayConnectionController {
host: String,
port: Int,
useTLS: Bool,
authOverride: ManualAuthOverride? = nil) async
authOverride: ManualAuthOverride? = nil,
forceReconnect: Bool = false) async
{
let instanceId = GatewaySettingsStore.currentInstanceID()
let token =
@@ -319,27 +328,38 @@ final class GatewayConnectionController {
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
password: password,
forceReconnect: forceReconnect)
}
func connectLastKnown() async {
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
switch last {
case let .manual(host, port, useTLS, _):
await self.connectManual(host: host, port: port, useTLS: useTLS)
await self.connectManual(host: host, port: port, useTLS: useTLS, forceReconnect: true)
case let .discovered(stableID, _):
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
_ = await self.connectDiscoveredGateway(gateway)
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else {
_ = await self.connectSavedManualEndpointFallback()
return
}
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
}
}
/// Rebuild connect options from current local settings (caps/commands/permissions)
/// and re-apply the active gateway config so capability changes take effect immediately.
func refreshActiveGatewayRegistrationFromSettings() {
Task { [weak self] in
await self?.refreshActiveGatewayRegistrationFromSettingsAsync()
}
}
private func refreshActiveGatewayRegistrationFromSettingsAsync() async {
guard let appModel else { return }
guard let cfg = appModel.activeGatewayConnectConfig else { return }
guard appModel.gatewayAutoReconnectEnabled else { return }
let nodeOptions = await self.makeConnectOptions(stableID: cfg.stableID)
let refreshedConfig = GatewayConnectConfig(
url: cfg.url,
stableID: cfg.stableID,
@@ -347,7 +367,7 @@ final class GatewayConnectionController {
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
nodeOptions: nodeOptions)
appModel.applyGatewayConnectConfig(refreshedConfig)
}
@@ -522,32 +542,14 @@ final class GatewayConnectionController {
return
}
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
if case let .manual(host, port, useTLS, stableID) = lastKnown {
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
let tlsParams = stored.map { fp in
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
}
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: resolvedUseTLS && tlsParams != nil)
else { return }
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
guard tlsParams != nil else { return }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
return
}
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
self.startLastKnownAutoConnect(
lastKnown,
token: token,
bootstrapToken: bootstrapToken,
password: password)
{
return
}
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
@@ -582,6 +584,44 @@ final class GatewayConnectionController {
}
return
}
_ = self.startSavedManualEndpointFallback()
}
private func startLastKnownAutoConnect(
_ lastKnown: GatewaySettingsStore.LastGatewayConnection,
token: String?,
bootstrapToken: String?,
password: String?) -> Bool
{
switch lastKnown {
case let .manual(host, port, useTLS, stableID):
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
let tlsParams = stored.map { fp in
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
}
guard let url = self.buildGatewayURL(
host: host,
port: port,
useTLS: resolvedUseTLS && tlsParams != nil)
else { return false }
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
guard tlsParams != nil else { return false }
self.didAutoConnect = true
self.startAutoConnect(
url: url,
gatewayStableID: stableID,
tls: tlsParams,
token: token,
bootstrapToken: bootstrapToken,
password: password)
return true
case .discovered:
return false
}
}
private func attemptAutoReconnectIfNeeded() {
@@ -594,6 +634,46 @@ final class GatewayConnectionController {
self.maybeAutoConnect()
}
private func savedManualEndpointFallback(defaults: UserDefaults = .standard) -> SavedManualEndpoint? {
guard defaults.bool(forKey: "gateway.autoconnect") else { return nil }
guard defaults.bool(forKey: "gateway.manual.enabled") else { return nil }
let host = defaults.string(forKey: "gateway.manual.host")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty else { return nil }
let configuredPort = defaults.integer(forKey: "gateway.manual.port")
let configuredUseTLS = defaults.bool(forKey: "gateway.manual.tls")
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: configuredUseTLS)
guard let resolvedPort = self.resolveManualPort(
host: host,
port: configuredPort,
useTLS: resolvedUseTLS)
else { return nil }
return SavedManualEndpoint(host: host, port: resolvedPort, useTLS: resolvedUseTLS)
}
private func startSavedManualEndpointFallback() -> Bool {
guard let endpoint = self.savedManualEndpointFallback() else { return false }
self.didAutoConnect = true
Task { [weak self] in
await self?.connectManual(
host: endpoint.host,
port: endpoint.port,
useTLS: endpoint.useTLS)
}
return true
}
private func connectSavedManualEndpointFallback() async -> Bool {
guard let endpoint = self.savedManualEndpointFallback() else { return false }
await self.connectManual(
host: endpoint.host,
port: endpoint.port,
useTLS: endpoint.useTLS)
return true
}
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
let defaults = UserDefaults.standard
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
@@ -615,16 +695,14 @@ final class GatewayConnectionController {
tls: GatewayTLSParams?,
token: String?,
bootstrapToken: String?,
password: String?)
password: String?,
forceReconnect: Bool = false)
{
guard let appModel else { return }
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
Task { [weak appModel] in
guard let appModel else { return }
await MainActor.run {
appModel.gatewayStatusText = "Connecting…"
}
appModel.gatewayStatusText = "Connecting…"
Task { [weak self, weak appModel] in
guard let self, let appModel else { return }
let nodeOptions = await self.makeConnectOptions(stableID: gatewayStableID)
let cfg = GatewayConnectConfig(
url: url,
stableID: gatewayStableID,
@@ -632,8 +710,8 @@ final class GatewayConnectionController {
token: token,
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions)
appModel.applyGatewayConnectConfig(cfg)
nodeOptions: nodeOptions)
appModel.applyGatewayConnectConfig(cfg, forceReconnect: forceReconnect)
}
}
@@ -824,7 +902,9 @@ final class GatewayConnectionController {
}
}
}
}
extension GatewayConnectionController {
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
let scheme = useTLS ? "wss" : "ws"
var components = URLComponents()
@@ -852,17 +932,18 @@ final class GatewayConnectionController {
"manual|\(host.lowercased())|\(port)"
}
private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
private func makeConnectOptions(stableID: String?) async -> GatewayConnectOptions {
let defaults = UserDefaults.standard
let displayName = self.resolvedDisplayName(defaults: defaults)
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
let permissions = await self.currentPermissions()
return GatewayConnectOptions(
role: "node",
scopes: [],
caps: self.currentCaps(),
commands: self.currentCommands(),
permissions: self.currentPermissions(),
permissions: permissions,
clientId: resolvedClientId,
clientMode: "node",
clientDisplayName: displayName)
@@ -1000,14 +1081,16 @@ final class GatewayConnectionController {
return commands
}
private func currentPermissions() -> [String: Bool] {
private func currentPermissions() async -> [String: Bool] {
var permissions: [String: Bool] = [:]
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized
permissions["location"] = Self.isLocationAuthorized(
status: CLLocationManager().authorizationStatus)
&& CLLocationManager.locationServicesEnabled()
let locationStatus = CLLocationManager().authorizationStatus
let locationServicesEnabled = await Self.locationServicesEnabled()
permissions["location"] = Self.isLocationAvailable(
servicesEnabled: locationServicesEnabled,
status: locationStatus)
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
@@ -1034,12 +1117,19 @@ final class GatewayConnectionController {
return permissions
}
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
private static func locationServicesEnabled() async -> Bool {
await Task.detached(priority: .utility) {
CLLocationManager.locationServicesEnabled()
}.value
}
private static func isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
guard servicesEnabled else { return false }
switch status {
case .authorizedAlways, .authorizedWhenInUse:
true
return true
default:
false
return false
}
}
@@ -1066,8 +1156,12 @@ extension GatewayConnectionController {
self.currentCommands()
}
func _test_currentPermissions() -> [String: Bool] {
self.currentPermissions()
func _test_currentPermissions() async -> [String: Bool] {
await self.currentPermissions()
}
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
}
func _test_platformString() -> String {
@@ -1112,6 +1206,14 @@ extension GatewayConnectionController {
func _test_resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
self.resolveManualPort(host: host, port: port, useTLS: useTLS)
}
func _test_savedManualEndpointFallback(
defaults: UserDefaults = .standard) -> (host: String, port: Int, useTLS: Bool)?
{
self.savedManualEndpointFallback(defaults: defaults).map { endpoint in
(host: endpoint.host, port: endpoint.port, useTLS: endpoint.useTLS)
}
}
}
#endif

View File

@@ -3,6 +3,8 @@ import SwiftUI
import UIKit
struct GatewayProblemBanner: View {
@Environment(\.colorScheme) private var colorScheme
let problem: GatewayConnectionProblem
var primaryActionTitle: String?
var onPrimaryAction: (() -> Void)?
@@ -57,9 +59,15 @@ struct GatewayProblemBanner: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(
.thinMaterial,
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.background {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThickMaterial)
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.primary.opacity(self.colorScheme == .dark ? 0.12 : 0.07), lineWidth: 1)
}
.shadow(color: .black.opacity(self.colorScheme == .dark ? 0.18 : 0.08), radius: 18, y: 8)
}
}
private var iconName: String {

View File

@@ -1,605 +0,0 @@
import SwiftUI
struct HomeToolbar: View {
var gateway: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var activity: StatusPill.Activity?
var brighten: Bool
var talkButtonEnabled: Bool
var talkActive: Bool
var talkTint: Color
var onStatusTap: () -> Void
var onChatTap: () -> Void
var onTalkTap: () -> Void
var onSettingsTap: () -> Void
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
.allowsHitTesting(false)
HStack(spacing: 12) {
HomeToolbarStatusButton(
gateway: self.gateway,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.activity,
brighten: self.brighten,
onTap: self.onStatusTap)
Spacer(minLength: 0)
HStack(spacing: 8) {
HomeToolbarActionButton(
systemImage: "text.bubble.fill",
accessibilityLabel: "Chat",
brighten: self.brighten,
action: self.onChatTap)
if self.talkButtonEnabled {
HomeToolbarActionButton(
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off",
brighten: self.brighten,
tint: self.talkTint,
isActive: self.talkActive,
action: self.onTalkTap)
}
HomeToolbarActionButton(
systemImage: "gearshape.fill",
accessibilityLabel: "Settings",
brighten: self.brighten,
action: self.onSettingsTap)
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.overlay(alignment: .top) {
LinearGradient(
colors: [
.white.opacity(self.brighten ? 0.10 : 0.06),
.clear,
],
startPoint: .top,
endPoint: .bottom)
.allowsHitTesting(false)
}
}
}
struct TalkToolbarTray: View {
var brighten: Bool
var tint: Color
var statusText: String
var agentName: String
var micLevel: Double
var isListening: Bool
var isSpeaking: Bool
var isUserSpeechDetected: Bool
var permissionState: TalkGatewayPermissionState
var voiceModeTitle: String
var voiceModeSubtitle: String?
var onEnableTalk: () -> Void
var onStopTalk: () -> Void
@Environment(\.colorSchemeContrast) private var contrast
private var state: TalkToolbarTrayState {
TalkToolbarTrayState(
statusText: self.statusText,
isListening: self.isListening,
isSpeaking: self.isSpeaking,
isUserSpeechDetected: self.isUserSpeechDetected,
permissionState: self.permissionState)
}
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(self.tint.opacity(self.state.iconFillOpacity))
.frame(width: 36, height: 36)
Image(systemName: self.state.systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(self.state.iconColor(tint: self.tint))
}
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(self.state.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
if self.state.showsProgress {
ProgressView()
.controlSize(.mini)
}
}
HStack(spacing: 8) {
TalkWaveformView(
mode: self.state.waveformMode(micLevel: self.micLevel),
tint: self.state.waveformTint(tint: self.tint))
.frame(width: 84, height: 18)
.accessibilityHidden(true)
Text(self.subtitle)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let voiceModeText = self.voiceModeText {
Text(voiceModeText)
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
switch self.state.action {
case .enable:
Button(action: self.onEnableTalk) {
Label("Enable Talk", systemImage: "key.fill")
.labelStyle(.titleAndIcon)
}
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.controlSize(.small)
case .stop:
Button(action: self.onStopTalk) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .bold))
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
.background {
Circle()
.fill(Color.black.opacity(self.brighten ? 0.10 : 0.18))
.overlay {
Circle()
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.42 : 0.16),
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
}
}
.accessibilityLabel("Stop Talk")
case .none:
EmptyView()
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.overlay(alignment: .top) {
Rectangle()
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
.allowsHitTesting(false)
}
.overlay(alignment: .bottom) {
LinearGradient(
colors: [
self.tint.opacity(self.brighten ? 0.12 : 0.16),
.clear,
],
startPoint: .leading,
endPoint: .trailing)
.frame(height: 1)
.allowsHitTesting(false)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("Talk Mode")
.accessibilityValue(self.accessibilityValue)
}
private var accessibilityValue: String {
if let voiceModeText {
return "\(self.state.title), \(self.subtitle), \(voiceModeText)"
}
return "\(self.state.title), \(self.subtitle)"
}
private var voiceModeText: String? {
guard !self.state.prefersPermissionCopy else { return nil }
let title = self.voiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty, title != "Not loaded" else { return nil }
let subtitle = (self.voiceModeSubtitle ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return subtitle.isEmpty ? title : "\(title)\(subtitle)"
}
private var subtitle: String {
let trimmedAgent = self.agentName.trimmingCharacters(in: .whitespacesAndNewlines)
if self.state.prefersPermissionCopy {
return "Gateway approval needed"
}
if !trimmedAgent.isEmpty {
return trimmedAgent
}
return "OpenClaw"
}
}
private enum TalkToolbarTrayAction {
case none
case enable
case stop
}
private enum TalkWaveformMode: Equatable {
case level(Double)
case inputSpeech
case speaking
case indeterminate
case still
}
private struct TalkToolbarTrayState: Equatable {
let statusText: String
let isListening: Bool
let isSpeaking: Bool
let isUserSpeechDetected: Bool
let permissionState: TalkGatewayPermissionState
private var normalizedStatus: String {
self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
var title: String {
switch self.permissionState {
case .missingScope, .requestFailed:
return "Gateway permission required"
case .requestingUpgrade:
return "Requesting approval"
case .upgradeRequested:
return "Approval requested"
default:
break
}
if self.isSpeaking { return "Speaking" }
if self.isListening { return "Listening" }
if self.normalizedStatus.contains("connecting") { return "Connecting" }
if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
if self.normalizedStatus == "ready" { return "Ready to talk" }
if self.normalizedStatus.isEmpty || self.normalizedStatus == "off" { return "Talk" }
return self.statusText
}
var systemImage: String {
switch self.permissionState {
case .missingScope, .requestFailed:
return "key.fill"
case .requestingUpgrade:
return "paperplane.fill"
case .upgradeRequested:
return "hourglass"
default:
break
}
if self.isSpeaking { return "speaker.wave.2.fill" }
if self.isListening { return "mic.fill" }
if self.normalizedStatus.contains("thinking") { return "sparkles" }
if self.normalizedStatus.contains("connecting") { return "dot.radiowaves.left.and.right" }
return "waveform"
}
var action: TalkToolbarTrayAction {
switch self.permissionState {
case .missingScope, .requestFailed:
.enable
case .requestingUpgrade, .upgradeRequested:
.none
default:
.stop
}
}
var showsProgress: Bool {
switch self.permissionState {
case .requestingUpgrade, .upgradeRequested:
true
default:
self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking")
}
}
var prefersPermissionCopy: Bool {
switch self.permissionState {
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
true
default:
false
}
}
var iconFillOpacity: Double {
self.prefersPermissionCopy ? 0.18 : 0.24
}
func iconColor(tint: Color) -> Color {
switch self.permissionState {
case .requestFailed:
.red
case .missingScope, .requestingUpgrade, .upgradeRequested:
.orange
default:
tint
}
}
func waveformTint(tint: Color) -> Color {
switch self.permissionState {
case .requestFailed:
.red
case .missingScope, .requestingUpgrade, .upgradeRequested:
.orange
default:
tint
}
}
func waveformMode(micLevel: Double) -> TalkWaveformMode {
switch self.permissionState {
case .requestingUpgrade, .upgradeRequested:
return .indeterminate
case .missingScope, .requestFailed:
return .still
default:
break
}
if self.isSpeaking {
return .speaking
}
if self.isListening, self.isUserSpeechDetected {
return .inputSpeech
}
if self.isListening {
return .level(micLevel)
}
if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
return .indeterminate
}
return .still
}
}
private struct TalkWaveformView: View {
var mode: TalkWaveformMode
var tint: Color
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let barCount = 14
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
HStack(alignment: .center, spacing: 3) {
ForEach(0..<self.barCount, id: \.self) { index in
Capsule(style: .continuous)
.fill(self.tint.opacity(self.opacity(for: index)))
.frame(width: 3, height: self.height(for: index, date: timeline.date))
}
}
.frame(maxHeight: .infinity)
}
}
private func height(for index: Int, date: Date) -> CGFloat {
let minimum: Double = 4
let maximum: Double = 18
let amplitude = self.amplitude(for: index, date: date)
return CGFloat(minimum + ((maximum - minimum) * amplitude))
}
private func opacity(for index: Int) -> Double {
switch self.mode {
case .still:
index == self.barCount / 2 ? 0.64 : 0.32
default:
0.78
}
}
private func amplitude(for index: Int, date: Date) -> Double {
if self.reduceMotion {
switch self.mode {
case let .level(level):
return min(max(level, 0.10), 1.0)
case .inputSpeech:
return 0.72
case .speaking:
return 0.62
case .indeterminate:
return 0.34
case .still:
return 0.18
}
}
let t = date.timeIntervalSinceReferenceDate
let phase = Double(index) * 0.52
switch self.mode {
case let .level(level):
let clamped = min(max(level, 0), 1)
let shaped = 0.12 + (0.88 * clamped)
let variation = 0.72 + (0.28 * sin((t * 12.0) + phase))
return min(max(shaped * variation, 0.10), 1.0)
case .inputSpeech:
let primary = 0.5 + (0.5 * sin((t * 14.0) + phase))
let secondary = 0.5 + (0.5 * sin((t * 5.0) + (phase * 1.35)))
return min(max(0.16 + (0.60 * primary) + (0.24 * secondary), 0.14), 1.0)
case .speaking:
let wave = 0.5 + (0.5 * sin((t * 7.5) + phase))
let secondary = 0.5 + (0.5 * sin((t * 3.0) + (phase * 0.7)))
return min(max(0.18 + (0.58 * wave) + (0.24 * secondary), 0.12), 1.0)
case .indeterminate:
let center = (sin((t * 3.2) + phase) + 1) / 2
return 0.16 + (0.42 * center)
case .still:
return index == self.barCount / 2 ? 0.32 : 0.16
}
}
}
private struct HomeToolbarStatusButton: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
var gateway: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var activity: StatusPill.Activity?
var brighten: Bool
var onTap: () -> Void
@State private var pulse: Bool = false
var body: some View {
Button(action: self.onTap) {
HStack(spacing: 8) {
HStack(spacing: 6) {
Circle()
.fill(self.gateway.color)
.frame(width: 8, height: 8)
.scaleEffect(
self.gateway == .connecting && !self.reduceMotion
? (self.pulse ? 1.15 : 0.85)
: 1.0)
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
Text(self.gateway.title)
.font(.footnote.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
if let activity {
Image(systemName: activity.systemImage)
.font(.footnote.weight(.semibold))
.foregroundStyle(activity.tint ?? .primary)
.transition(.opacity.combined(with: .move(edge: .top)))
} else {
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
.font(.footnote.weight(.semibold))
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)),
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel("Connection Status")
.accessibilityValue(self.accessibilityValue)
.accessibilityHint(
self.gateway == .connected
? "Double tap for gateway actions"
: "Double tap to open settings")
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
.onDisappear { self.pulse = false }
.onChange(of: self.gateway) { _, newValue in
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
}
.onChange(of: self.scenePhase) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
}
.onChange(of: self.reduceMotion) { _, newValue in
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
}
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
}
private var accessibilityValue: String {
if let activity {
return "\(self.gateway.title), \(activity.title)"
}
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
}
private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
return
}
guard !self.pulse else { return }
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
self.pulse = true
}
}
}
private struct HomeToolbarActionButton: View {
@Environment(\.colorSchemeContrast) private var contrast
let systemImage: String
let accessibilityLabel: String
let brighten: Bool
var tint: Color?
var isActive: Bool = false
let action: () -> Void
var body: some View {
Button(action: self.action) {
Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
.frame(width: 40, height: 40)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
.overlay {
if let tint {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
tint.opacity(self.isActive ? 0.22 : 0.14),
tint.opacity(self.isActive ? 0.08 : 0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
(self.tint ?? .white).opacity(
self.isActive
? 0.34
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))),
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(self.accessibilityLabel)
}
}

View File

@@ -24,8 +24,6 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -45,19 +43,21 @@
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_openclaw-gw._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
<key>NSCalendarsUsageDescription</key>
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
<key>NSCalendarsUsageDescription</key>
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string>OpenClaw uses your calendars to add events when you enable calendar access.</string>
<key>NSCameraUsageDescription</key>
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
<key>NSContactsUsageDescription</key>
<string>OpenClaw uses your contacts so you can search and reference people while using the assistant.</string>
<key>NSLocalNetworkUsageDescription</key>
@@ -67,7 +67,7 @@
<key>NSLocationWhenInUseUsageDescription</key>
<string>OpenClaw uses your location when you allow location sharing.</string>
<key>NSMicrophoneUsageDescription</key>
<string>OpenClaw needs microphone access for voice wake.</string>
<string>OpenClaw uses the microphone for realtime chat, voice wake, and push-to-talk.</string>
<key>NSMotionUsageDescription</key>
<string>OpenClaw may use motion data to support device-aware interactions and automations.</string>
<key>NSPhotoLibraryUsageDescription</key>
@@ -75,9 +75,11 @@
<key>NSRemindersFullAccessUsageDescription</key>
<string>OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>OpenClawPushAPNsEnvironment</key>
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
<key>OpenClawPushDistribution</key>

View File

@@ -111,6 +111,10 @@ final class NodeAppModel {
var gatewayStatusText: String = "Offline"
var nodeStatusText: String = "Offline"
var operatorStatusText: String = "Offline"
var isOperatorGatewayConnected: Bool {
self.operatorConnected
}
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
@@ -127,6 +131,7 @@ final class NodeAppModel {
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
private var focusedChatSessionKey: String?
var selectedAgentId: String?
var gatewayDefaultAgentId: String?
var gatewayAgents: [AgentSummary] = []
@@ -291,7 +296,6 @@ final class NodeAppModel {
self.talkMode.attachGateway(self.operatorGateway)
self.refreshLastShareEventFromRelay()
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
// Route through the coordinator so VoiceWake and Talk don't fight over the microphone.
self.setTalkEnabled(talkEnabled)
// Wire up deep links from canvas taps
@@ -452,7 +456,7 @@ final class NodeAppModel {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
self.operatorConnected = false
self.setOperatorConnected(false)
self.gatewayConnected = false
// Foreground recovery must actively restart the saved gateway config.
// Disconnecting stale sockets alone can leave us idle if the old
@@ -543,7 +547,7 @@ final class NodeAppModel {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
self.operatorConnected = false
self.setOperatorConnected(false)
self.gatewayConnected = false
self.talkMode.updateGatewayConnected(false)
if self.isBackgrounded {
@@ -690,6 +694,11 @@ final class NodeAppModel {
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "talk_permission_poll_connected")
}
func setTalkSpeakerphoneEnabled(_ enabled: Bool) {
UserDefaults.standard.set(enabled, forKey: TalkDefaults.speakerphoneEnabledKey)
self.talkMode.applyAudioRoutePreferenceChanged()
}
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
guard mode != .off else { return true }
let status = await self.locationService.ensureAuthorization(mode: mode)
@@ -892,7 +901,7 @@ final class NodeAppModel {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
self.operatorConnected = false
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
@@ -1840,9 +1849,25 @@ extension NodeAppModel {
}
var chatSessionKey: String {
if let focused = self.focusedChatSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines),
!focused.isEmpty
{
return focused
}
// Keep chat aligned with the gateway's resolved main session key.
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
self.mainSessionKey
return self.mainSessionKey
}
func openChat(sessionKey: String?) {
self.focusChatSession(sessionKey)
self.openChatRequestID &+= 1
}
func focusChatSession(_ sessionKey: String?) {
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.focusedChatSessionKey = trimmed.isEmpty ? nil : trimmed
self.talkMode.updateMainSessionKey(self.chatSessionKey)
}
var activeAgentName: String {
@@ -1864,13 +1889,13 @@ extension NodeAppModel {
token: String?,
bootstrapToken: String?,
password: String?,
connectOptions: GatewayConnectOptions)
connectOptions: GatewayConnectOptions,
forceReconnect: Bool = false)
{
let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let effectiveStableID = stableID.isEmpty ? url.absoluteString : stableID
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
self.activeGatewayConnectConfig = GatewayConnectConfig(
let nextConfig = GatewayConnectConfig(
url: url,
stableID: stableID,
tls: tls,
@@ -1878,13 +1903,24 @@ extension NodeAppModel {
bootstrapToken: bootstrapToken,
password: password,
nodeOptions: connectOptions)
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
if self.shouldStartOperatorGatewayLoop(
let operatorLoopRequired = self.shouldStartOperatorGatewayLoop(
token: token,
bootstrapToken: bootstrapToken,
password: password,
stableID: effectiveStableID)
if let activeConfig = self.activeGatewayConnectConfig,
activeConfig.hasSameConnectionInputs(as: nextConfig),
self.nodeGatewayTask != nil,
self.operatorGatewayTask != nil || !operatorLoopRequired,
!forceReconnect
{
self.gatewayAutoReconnectEnabled = true
return
}
self.activeGatewayConnectConfig = nextConfig
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
if operatorLoopRequired {
self.startOperatorGatewayLoop(
url: url,
stableID: effectiveStableID,
@@ -1908,8 +1944,7 @@ extension NodeAppModel {
}
/// Preferred entry-point: apply a single config object and start both sessions.
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig) {
self.activeGatewayConnectConfig = cfg
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
self.connectToGateway(
url: cfg.url,
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
@@ -1919,7 +1954,8 @@ extension NodeAppModel {
token: cfg.token,
bootstrapToken: cfg.bootstrapToken,
password: cfg.password,
connectOptions: cfg.nodeOptions)
connectOptions: cfg.nodeOptions,
forceReconnect: forceReconnect)
}
func disconnectGateway() {
@@ -1945,7 +1981,7 @@ extension NodeAppModel {
self.connectedGatewayID = nil
self.activeGatewayConnectConfig = nil
self.gatewayConnected = false
self.operatorConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
@@ -1968,7 +2004,7 @@ extension NodeAppModel {
self.gatewayRemoteAddress = nil
self.connectedGatewayID = stableID
self.gatewayConnected = false
self.operatorConnected = false
self.setOperatorConnected(false)
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
LiveActivityManager.shared.endActivity(reason: "new_gateway_connect")
@@ -2206,7 +2242,7 @@ extension NodeAppModel {
onConnected: { [weak self] in
guard let self else { return }
await MainActor.run {
self.operatorConnected = true
self.setOperatorConnected(true)
self.forceOperatorTalkPermissionUpgradeRequest = false
self.talkMode.updateGatewayConnected(true)
}
@@ -2224,7 +2260,7 @@ extension NodeAppModel {
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
self.operatorConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
}
@@ -2491,7 +2527,7 @@ extension NodeAppModel {
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
self.gatewayConnected = false
self.operatorConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
@@ -2566,6 +2602,11 @@ extension NodeAppModel {
private func isOperatorConnected() async -> Bool {
self.operatorConnected
}
private func setOperatorConnected(_ connected: Bool) {
self.operatorConnected = connected
self.operatorStatusText = connected ? "Connected" : "Offline"
}
}
extension NodeAppModel {
@@ -3908,7 +3949,7 @@ extension NodeAppModel {
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
self.operatorConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.stopGatewayHealthMonitor()
@@ -3977,7 +4018,7 @@ extension NodeAppModel {
self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
self.operatorConnected = false
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)

View File

@@ -24,11 +24,16 @@ enum OnboardingStateStore {
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
@MainActor
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
static func shouldPresentOnLaunch(
appModel: NodeAppModel,
defaults: UserDefaults = .standard,
hasSavedGatewayConnection: Bool? = nil)
-> Bool
{
if defaults.bool(forKey: self.completedDefaultsKey) { return false }
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
// should handle reconnecting, and users can always open onboarding manually if needed.
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
let hasSavedGatewayConnection =
hasSavedGatewayConnection ?? (GatewaySettingsStore.loadLastGatewayConnection() != nil)
if hasSavedGatewayConnection { return false }
return appModel.gatewayServerName == nil
}

View File

@@ -0,0 +1,186 @@
import SwiftUI
struct OnboardingIntroStep: View {
let onContinue: () -> Void
var body: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "iphone.gen3")
.font(.system(size: 60, weight: .semibold))
.foregroundStyle(.tint)
.padding(.bottom, 18)
Text("Welcome to OpenClaw")
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.bottom, 10)
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.bottom, 24)
VStack(alignment: .leading, spacing: 14) {
Label("Connect to your gateway", systemImage: "link")
Label("Choose device permissions", systemImage: "hand.raised")
Label("Use OpenClaw from your phone", systemImage: "message.fill")
}
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
.padding(.bottom, 16)
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3.weight(.semibold))
.foregroundStyle(.orange)
.frame(width: 24)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text("Security notice")
.font(.headline)
Text(
"The connected OpenClaw agent can use device capabilities you enable, "
+ "such as camera, microphone, photos, contacts, calendar, and location. "
+ "Continue only if you trust the gateway and agent you connect to.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
Spacer()
Button {
self.onContinue()
} label: {
Text("Continue")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
}
struct OnboardingWelcomeStep: View {
let statusLine: String
let onScanQRCode: () -> Void
let onManualSetup: () -> Void
var body: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 64))
.foregroundStyle(.tint)
.padding(.bottom, 20)
Text("Connect Gateway")
.font(.largeTitle.weight(.bold))
.padding(.bottom, 8)
Text("Scan a QR code from your OpenClaw gateway or continue with manual setup.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
VStack(alignment: .leading, spacing: 8) {
Text("How to pair")
.font(.headline)
Text("In your OpenClaw chat, run")
.font(.footnote)
.foregroundStyle(.secondary)
Text("/pair qr")
.font(.system(.footnote, design: .monospaced).weight(.semibold))
Text("Then scan the QR code here to connect this iPhone.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
.padding(.top, 20)
Spacer()
VStack(spacing: 12) {
Button {
self.onScanQRCode()
} label: {
Label("Scan QR Code", systemImage: "qrcode")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
self.onManualSetup()
} label: {
Text("Set Up Manually")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.bottom, 12)
Text(self.statusLine)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
}
}
struct OnboardingModeRow: View {
let title: String
let subtitle: String
let selected: Bool
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(self.title)
.font(.body.weight(.semibold))
Text(self.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
}
}

View File

@@ -284,154 +284,19 @@ struct OnboardingWizardView: View {
}
private var introStep: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "iphone.gen3")
.font(.system(size: 60, weight: .semibold))
.foregroundStyle(.tint)
.padding(.bottom, 18)
Text("Welcome to OpenClaw")
.font(.largeTitle.weight(.bold))
.multilineTextAlignment(.center)
.padding(.bottom, 10)
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.padding(.bottom, 24)
VStack(alignment: .leading, spacing: 14) {
Label("Connect to your gateway", systemImage: "link")
Label("Choose device permissions", systemImage: "hand.raised")
Label("Use OpenClaw from your phone", systemImage: "message.fill")
}
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
.padding(.bottom, 16)
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3.weight(.semibold))
.foregroundStyle(.orange)
.frame(width: 24)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text("Security notice")
.font(.headline)
Text(
"The connected OpenClaw agent can use device capabilities you enable, "
+ "such as camera, microphone, photos, contacts, calendar, and location. "
+ "Continue only if you trust the gateway and agent you connect to.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
Spacer()
Button {
self.advanceFromIntro()
} label: {
Text("Continue")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
OnboardingIntroStep(onContinue: self.advanceFromIntro)
}
private var welcomeStep: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "qrcode.viewfinder")
.font(.system(size: 64))
.foregroundStyle(.tint)
.padding(.bottom, 20)
Text("Connect Gateway")
.font(.largeTitle.weight(.bold))
.padding(.bottom, 8)
Text("Scan a QR code from your OpenClaw gateway or continue with manual setup.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
VStack(alignment: .leading, spacing: 8) {
Text("How to pair")
.font(.headline)
Text("In your OpenClaw chat, run")
.font(.footnote)
.foregroundStyle(.secondary)
Text("/pair qr")
.font(.system(.footnote, design: .monospaced).weight(.semibold))
Text("Then scan the QR code here to connect this iPhone.")
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(uiColor: .secondarySystemBackground))
}
.padding(.horizontal, 24)
.padding(.top, 20)
Spacer()
VStack(spacing: 12) {
Button {
self.statusLine = "Opening QR scanner…"
self.showQRScanner = true
} label: {
Label("Scan QR Code", systemImage: "qrcode")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
self.step = .mode
} label: {
Text("Set Up Manually")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
.padding(.bottom, 12)
Text(self.statusLine)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.bottom, 48)
}
OnboardingWelcomeStep(
statusLine: self.statusLine,
onScanQRCode: {
self.statusLine = "Opening QR scanner"
self.showQRScanner = true
},
onManualSetup: {
self.step = .mode
})
}
@ViewBuilder
@@ -702,7 +567,9 @@ struct OnboardingWizardView: View {
.padding(.bottom, 48)
}
}
}
extension OnboardingWizardView {
private func manualConnectionFieldsSection(title: String) -> some View {
Section(title) {
TextField("Host", text: self.$manualHost)
@@ -1057,28 +924,3 @@ struct OnboardingWizardView: View {
await self.retryLastAttempt()
}
}
private struct OnboardingModeRow: View {
let title: String
let subtitle: String
let selected: Bool
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(self.title)
.font(.body.weight(.semibold))
Text(self.subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
}
}

View File

@@ -602,6 +602,8 @@ extension NodeAppModel {
struct OpenClawApp: App {
@State private var appModel: NodeAppModel
@State private var gatewayController: GatewayConnectionController
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
@UIApplicationDelegateAdaptor(OpenClawAppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
@@ -616,26 +618,62 @@ struct OpenClawApp: App {
var body: some Scene {
WindowGroup {
RootCanvas()
RootTabs()
.preferredColorScheme(self.appearancePreference.colorScheme)
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
.task {
self.appDelegate.appModel = self.appModel
self.applyAppearancePreference()
self.gatewayController.setScenePhase(self.scenePhase)
}
.onOpenURL { url in
Task { await self.appModel.handleDeepLink(url: url) }
Task { await self.handleOpenURL(url) }
}
.onChange(of: self.appearancePreferenceRaw) { _, _ in
self.applyAppearancePreference()
}
.onChange(of: self.scenePhase) { _, newValue in
self.appModel.setScenePhase(newValue)
self.gatewayController.setScenePhase(newValue)
self.appDelegate.scenePhaseChanged(newValue)
self.applyAppearancePreference()
}
}
}
private var appearancePreference: AppAppearancePreference {
AppAppearancePreference.launchArgumentPreference
?? AppAppearancePreference(rawValue: self.appearancePreferenceRaw)
?? .system
}
@MainActor
private func applyAppearancePreference() {
let style = self.appearancePreference.userInterfaceStyle
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
.forEach { window in
window.overrideUserInterfaceStyle = style
}
}
}
extension OpenClawApp {
@MainActor
private func handleOpenURL(_ url: URL) async {
guard let route = DeepLinkParser.parse(url) else { return }
switch route {
case .agent, .dashboard:
await self.appModel.handleDeepLink(url: url)
case .gateway:
break
}
}
private static func installUncaughtExceptionLogger() {
NSLog("OpenClaw: installing uncaught exception handler")
NSSetUncaughtExceptionHandler { exception in

View File

@@ -1,725 +0,0 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
import UIKit
struct RootCanvas: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(\.colorScheme) private var systemColorScheme
@Environment(\.scenePhase) private var scenePhase
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@State private var presentedSheet: PresentedSheet?
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var showOnboarding: Bool = false
@State private var onboardingAllowSkip: Bool = true
@State private var didEvaluateOnboarding: Bool = false
@State private var didAutoOpenSettings: Bool = false
private enum PresentedSheet: Identifiable {
case settings
case chat
case quickSetup
var id: Int {
switch self {
case .settings: 0
case .chat: 1
case .quickSetup: 2
}
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
{
if gatewayConnected {
return .none
}
// On first run or explicit launch onboarding state, onboarding always wins.
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
// Settings auto-open is a recovery path for previously-connected installs only.
if !hasExistingGatewayConfig {
return .settings
}
return .none
}
static func shouldPresentQuickSetup(
quickSetupDismissed: Bool,
showOnboarding: Bool,
hasPresentedSheet: Bool,
gatewayConnected: Bool,
hasExistingGatewayConfig: Bool,
discoveredGatewayCount: Int) -> Bool
{
guard !quickSetupDismissed else { return false }
guard !showOnboarding else { return false }
guard !hasPresentedSheet else { return false }
guard !gatewayConnected else { return false }
// If a gateway target is already configured (manual or last-known), skip quick setup.
guard !hasExistingGatewayConfig else { return false }
return discoveredGatewayCount > 0
}
var body: some View {
ZStack {
CanvasContent(
systemColorScheme: self.systemColorScheme,
gatewayStatus: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
voiceWakeToastText: self.voiceWakeToastText,
cameraHUDText: self.appModel.cameraHUDText,
cameraHUDKind: self.appModel.cameraHUDKind,
openChat: {
self.presentedSheet = .chat
},
openSettings: {
self.presentedSheet = .settings
},
retryGatewayConnection: {
Task { await self.gatewayController.connectLastKnown() }
},
resetOnboarding: {
self.resetOnboardingFromGatewayProblem()
})
.preferredColorScheme(.dark)
if self.appModel.cameraFlashNonce != 0 {
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
}
}
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog()
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .settings:
SettingsTab()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
case .chat:
ChatSheet(
// Chat RPCs run on the operator session (read/write scopes).
gateway: self.appModel.operatorSession,
sessionKey: self.appModel.chatSessionKey,
agentName: self.appModel.activeAgentName,
userAccent: self.appModel.seamColor)
case .quickSetup:
GatewayQuickSetupSheet()
.environment(self.appModel)
.environment(self.gatewayController)
}
}
.fullScreenCover(isPresented: self.$showOnboarding) {
OnboardingWizardView(
allowSkip: self.onboardingAllowSkip,
onClose: {
self.showOnboarding = false
})
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
}
.onAppear { self.updateIdleTimer() }
.onAppear { self.updateHomeCanvasState() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, newValue in
self.updateIdleTimer()
self.updateHomeCanvasState()
guard newValue == .active else { return }
Task {
await self.appModel.refreshGatewayOverviewIfConnected()
await MainActor.run {
self.updateHomeCanvasState()
}
}
}
.onAppear { self.maybeShowQuickSetup() }
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onAppear { self.updateCanvasDebugStatus() }
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.appModel.gatewayStatusText) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayServerName) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.showOnboarding = false
}
}
.onChange(of: self.onboardingRequestID) { _, _ in
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in
self.updateCanvasDebugStatus()
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in
self.updateHomeCanvasState()
}
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.onboardingComplete = true
self.hasConnectedOnce = true
OnboardingStateStore.markCompleted(mode: nil)
}
self.maybeAutoOpenSettings()
}
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.presentedSheet = .chat
}
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
withAnimation(.easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
}
}
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
}
}
private var gatewayStatus: StatusPill.GatewayState {
GatewayStatusBuilder.build(appModel: self.appModel)
}
private func updateIdleTimer() {
UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep)
}
private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
private func updateHomeCanvasState() {
let payload = self.makeHomeCanvasPayload()
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else {
self.appModel.screen.updateHomeCanvasState(json: nil)
return
}
self.appModel.screen.updateHomeCanvasState(json: json)
}
private func makeHomeCanvasPayload() -> HomeCanvasPayload {
let gatewayName = self.normalized(self.appModel.gatewayServerName)
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
let activeAgentID = self.resolveActiveAgentID()
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
switch self.gatewayStatus {
case .connected:
return HomeCanvasPayload(
gatewayState: "connected",
eyebrow: "Connected to \(gatewayLabel)",
title: "Your agents are ready",
subtitle:
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
activeAgentCaption: "Selected on this phone",
agentCount: agents.count,
agents: Array(agents.prefix(6)),
footer: "The overview refreshes on reconnect and when the app returns to foreground.")
case .connecting:
return HomeCanvasPayload(
gatewayState: "connecting",
eyebrow: "Reconnecting",
title: "OpenClaw is syncing back up",
subtitle:
"The gateway session is coming back online. "
+ "Agent shortcuts should settle automatically in a moment.",
gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: "OC",
activeAgentCaption: "Gateway session in progress",
agentCount: agents.count,
agents: Array(agents.prefix(4)),
footer: "If the gateway is reachable, reconnect should complete without intervention.")
case .error, .disconnected:
return HomeCanvasPayload(
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
eyebrow: "Welcome to OpenClaw",
title: "Your phone stays quiet until it is needed",
subtitle:
"Pair this device to your gateway to wake it only for real work, "
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
gatewayLabel: gatewayLabel,
activeAgentName: "Main",
activeAgentBadge: "OC",
activeAgentCaption: "Connect to load your agents",
agentCount: agents.count,
agents: Array(agents.prefix(4)),
footer:
"When connected, the gateway can wake the phone with a silent push "
+ "instead of holding an always-on session.")
}
}
private func resolveActiveAgentID() -> String {
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
if !selected.isEmpty {
return selected
}
return self.resolveDefaultAgentID()
}
private func resolveDefaultAgentID() -> String {
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
}
private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] {
let defaultAgentID = self.resolveDefaultAgentID()
let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
return HomeCanvasAgentCard(
id: agent.id,
name: self.homeCanvasName(for: agent),
badge: self.homeCanvasBadge(for: agent),
caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"),
isActive: isActive)
}
return cards.sorted { lhs, rhs in
if lhs.isActive != rhs.isActive {
return lhs.isActive
}
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
private func homeCanvasName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
}
private func homeCanvasBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
let normalizedEmoji = normalized(emoji)
{
return normalizedEmoji
}
let words = self.homeCanvasName(for: agent)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2)
let initials = words.compactMap(\.first).map(String.init).joined()
if !initials.isEmpty {
return initials.uppercased()
}
return "OC"
}
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func evaluateOnboardingPresentation(force: Bool) {
if force {
self.onboardingAllowSkip = true
self.showOnboarding = true
return
}
guard !self.didEvaluateOnboarding else { return }
self.didEvaluateOnboarding = true
let route = Self.startupPresentationRoute(
gatewayConnected: self.appModel.gatewayServerName != nil,
hasConnectedOnce: self.hasConnectedOnce,
onboardingComplete: self.onboardingComplete,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
switch route {
case .none:
break
case .onboarding:
self.onboardingAllowSkip = true
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
}
private func hasExistingGatewayConfig() -> Bool {
if self.appModel.activeGatewayConnectConfig != nil { return true }
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
let preferredStableID = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferredStableID.isEmpty { return true }
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
return self.manualGatewayEnabled && !manualHost.isEmpty
}
private func maybeAutoOpenSettings() {
guard !self.didAutoOpenSettings else { return }
guard !self.showOnboarding else { return }
let route = Self.startupPresentationRoute(
gatewayConnected: self.appModel.gatewayServerName != nil,
hasConnectedOnce: self.hasConnectedOnce,
onboardingComplete: self.onboardingComplete,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
shouldPresentOnLaunch: false)
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.presentedSheet = .settings
}
private func maybeShowQuickSetup() {
let shouldPresent = Self.shouldPresentQuickSetup(
quickSetupDismissed: self.quickSetupDismissed,
showOnboarding: self.showOnboarding,
hasPresentedSheet: self.presentedSheet != nil,
gatewayConnected: self.appModel.gatewayServerName != nil,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
discoveredGatewayCount: self.gatewayController.gateways.count)
guard shouldPresent else { return }
self.presentedSheet = .quickSetup
}
private func resetOnboardingFromGatewayProblem() {
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
self.presentedSheet = nil
self.onboardingAllowSkip = false
self.showOnboarding = true
}
}
private struct HomeCanvasPayload: Codable {
var gatewayState: String
var eyebrow: String
var title: String
var subtitle: String
var gatewayLabel: String
var activeAgentName: String
var activeAgentBadge: String
var activeAgentCaption: String
var agentCount: Int
var agents: [HomeCanvasAgentCard]
var footer: String
}
private struct HomeCanvasAgentCard: Codable {
var id: String
var name: String
var badge: String
var caption: String
var isActive: Bool
}
private struct CanvasContent: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(GatewayConnectionController.self) private var gatewayController
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
@State private var showTalkPermissionPrompt: Bool = false
@State private var showTalkPermissionTray: Bool = false
var systemColorScheme: ColorScheme
var gatewayStatus: StatusPill.GatewayState
var voiceWakeEnabled: Bool
var voiceWakeToastText: String?
var cameraHUDText: String?
var cameraHUDKind: NodeAppModel.CameraHUDKind?
var openChat: () -> Void
var openSettings: () -> Void
var retryGatewayConnection: () -> Void
var resetOnboarding: () -> Void
private var brightenButtons: Bool {
self.systemColorScheme == .light
}
private var talkActive: Bool {
(self.appModel.talkMode.isEnabled || self.talkEnabled) && !self.talkPermissionBlocksStart
}
private var talkPermissionBlocksStart: Bool {
self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction
}
private var showTalkTray: Bool {
self.talkActive ||
self.showTalkPermissionTray ||
self.appModel.talkMode.gatewayTalkPermissionState.isApprovalRequestInProgress
}
var body: some View {
ZStack {
ScreenTab()
}
.safeAreaInset(edge: .bottom, spacing: 0) {
VStack(spacing: 0) {
if self.showTalkTray {
TalkToolbarTray(
brighten: self.brightenButtons,
tint: self.appModel.seamColor,
statusText: self.appModel.talkMode.statusText,
agentName: self.appModel.activeAgentName,
micLevel: self.appModel.talkMode.micLevel,
isListening: self.appModel.talkMode.isListening,
isSpeaking: self.appModel.talkMode.isSpeaking,
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
permissionState: self.appModel.talkMode.gatewayTalkPermissionState,
voiceModeTitle: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
voiceModeSubtitle: self.appModel.talkMode.gatewayTalkVoiceModeSubtitle,
onEnableTalk: {
self.showTalkPermissionPrompt = true
},
onStopTalk: {
self.showTalkPermissionTray = false
self.stopTalk()
})
.transition(.move(edge: .bottom).combined(with: .opacity))
}
HomeToolbar(
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
brighten: self.brightenButtons,
talkButtonEnabled: self.talkButtonEnabled,
talkActive: self.talkActive,
talkTint: self.appModel.seamColor,
onStatusTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else if self.appModel.lastGatewayProblem != nil {
self.showGatewayProblemDetails = true
} else {
self.openSettings()
}
},
onChatTap: {
self.openChat()
},
onTalkTap: {
self.handleTalkToolbarTap()
},
onSettingsTap: {
self.openSettings()
})
}
.animation(.spring(response: 0.28, dampingFraction: 0.86), value: self.showTalkTray)
}
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
self.handleGatewayProblemPrimaryAction(gatewayProblem)
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(
command: voiceWakeToastText,
brighten: self.brightenButtons)
.padding(.leading, 10)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.gatewayActionsDialog(
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.openSettings() })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
self.handleGatewayProblemPrimaryAction(gatewayProblem)
})
}
}
.sheet(isPresented: self.$showTalkPermissionPrompt) {
NavigationStack {
TalkPermissionPromptView(
style: .sheet,
onPermissionReady: {
self.showTalkPermissionPrompt = false
self.showTalkPermissionTray = false
self.startTalk()
})
.padding()
.navigationTitle("Enable Talk")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Not Now") {
self.showTalkPermissionPrompt = false
}
}
}
}
.presentationDetents([.medium, .large])
}
.onAppear {
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
if self.talkPermissionBlocksStart, self.talkEnabled || self.appModel.talkMode.isEnabled {
self.stopTalk()
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
self.appModel.setTalkEnabled(self.talkEnabled)
}
}
}
private var statusActivity: StatusPill.Activity? {
StatusActivityBuilder.build(
appModel: self.appModel,
voiceWakeEnabled: self.voiceWakeEnabled,
cameraHUDText: self.cameraHUDText,
cameraHUDKind: self.cameraHUDKind)
}
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
if problem.canTrustRotatedCertificate { return "Trust certificate" }
if problem.suggestsOnboardingReset { return "Reset onboarding" }
return problem.retryable ? "Retry" : "Open Settings"
}
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) {
if problem.canTrustRotatedCertificate {
Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) }
} else if problem.suggestsOnboardingReset {
self.resetOnboarding()
} else if problem.retryable {
self.retryGatewayConnection()
} else {
self.openSettings()
}
}
private func handleTalkToolbarTap() {
GatewayDiagnostics.log(
"talk.timeline tap active=\(self.talkActive) permissionBlocked=\(self.talkPermissionBlocksStart)")
if self.talkActive {
self.showTalkPermissionTray = false
self.stopTalk()
return
}
if self.talkPermissionBlocksStart {
self.stopTalk()
self.showTalkPermissionTray = true
Task {
await self.appModel.pollTalkPermissionUpgrade()
if !self.talkPermissionBlocksStart {
self.showTalkPermissionTray = false
self.startTalk()
}
}
return
}
self.showTalkPermissionTray = false
self.startTalk()
}
private func startTalk() {
GatewayDiagnostics.log("talk.timeline start requested from toolbar")
self.talkEnabled = true
self.appModel.setTalkEnabled(true)
}
private func stopTalk() {
GatewayDiagnostics.log("talk.timeline stop requested from toolbar")
self.talkEnabled = false
self.appModel.setTalkEnabled(false)
}
}
private struct CameraFlashOverlay: View {
var nonce: Int
@State private var opacity: CGFloat = 0
@State private var task: Task<Void, Never>?
var body: some View {
Color.white
.opacity(self.opacity)
.ignoresSafeArea()
.allowsHitTesting(false)
.onChange(of: self.nonce) { _, _ in
self.task?.cancel()
self.task = Task { @MainActor in
withAnimation(.easeOut(duration: 0.08)) {
self.opacity = 0.85
}
try? await Task.sleep(nanoseconds: 110_000_000)
withAnimation(.easeOut(duration: 0.32)) {
self.opacity = 0
}
}
}
}
}

View File

@@ -1,135 +1,628 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
import UIKit
struct RootTabs: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
@State private var selectedTab: Int = 0
@Environment(\.scenePhase) private var scenePhase
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
@State private var selectedTab: AppTab = Self.initialTab
@State private var voiceWakeToastText: String?
@State private var toastDismissTask: Task<Void, Never>?
@State private var presentedSheet: PresentedSheet?
@State private var showGatewayActions: Bool = false
@State private var showGatewayProblemDetails: Bool = false
@State private var showOnboarding: Bool = false
@State private var onboardingAllowSkip: Bool = true
@State private var didEvaluateOnboarding: Bool = false
@State private var didAutoOpenSettings: Bool = false
@State private var didApplyInitialAppearance: Bool = false
@State private var didApplyInitialChatSession: Bool = false
private enum AppTab: Hashable {
case control
case chat
case agent
case settings
}
private static var initialTab: AppTab {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else {
return .control
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else {
return .control
}
switch arguments[valueIndex].lowercased() {
case "chat":
return .chat
case "agent", "agents":
return .agent
case "settings":
return .settings
default:
return .control
}
}
private static var initialChatSessionKey: String? {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
let trimmed = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private enum PresentedSheet: Identifiable {
case quickSetup
var id: Int {
switch self {
case .quickSetup: 0
}
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
{
if gatewayConnected {
return .none
}
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
return .onboarding
}
if !hasExistingGatewayConfig {
return .settings
}
return .none
}
static func shouldPresentQuickSetup(
quickSetupDismissed: Bool,
showOnboarding: Bool,
hasPresentedSheet: Bool,
gatewayConnected: Bool,
hasExistingGatewayConfig: Bool,
discoveredGatewayCount: Int) -> Bool
{
guard !quickSetupDismissed else { return false }
guard !showOnboarding else { return false }
guard !hasPresentedSheet else { return false }
guard !gatewayConnected else { return false }
guard !hasExistingGatewayConfig else { return false }
return discoveredGatewayCount > 0
}
var body: some View {
self.rootPresentation(
self.rootLifecycle(
self.rootOverlays(
self.tabContent
.tint(OpenClawBrand.accent))))
}
private var tabContent: some View {
TabView(selection: self.$selectedTab) {
ScreenTab()
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
.tag(0)
CommandCenterTab(
openChat: { self.selectedTab = .chat },
openSettings: { self.selectedTab = .settings })
.tabItem { Label("Command", systemImage: "target") }
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
.tag(AppTab.control)
VoiceTab()
.tabItem { Label("Voice", systemImage: "mic") }
.tag(1)
ChatProTab()
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
.tag(AppTab.chat)
SettingsTab()
.tabItem { Label("Settings", systemImage: "gearshape") }
.tag(2)
AgentProTab()
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
SettingsProTab()
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(AppTab.settings)
}
.overlay(alignment: .topLeading) {
StatusPill(
gateway: self.gatewayStatus,
voiceWakeEnabled: self.voiceWakeEnabled,
activity: self.statusActivity,
onTap: {
if self.gatewayStatus == .connected {
self.showGatewayActions = true
} else if self.appModel.lastGatewayProblem != nil {
self.showGatewayProblemDetails = true
} else {
self.selectedTab = 2
}
})
.padding(.leading, 10)
.safeAreaPadding(.top, 10)
}
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
self.handleGatewayProblemPrimaryAction(gatewayProblem)
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
private func rootOverlays(_ content: some View) -> some View {
content
.overlay(alignment: .top) {
if let gatewayProblem = self.appModel.lastGatewayProblem,
self.gatewayStatus != .connected
{
GatewayProblemBanner(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
self.handleGatewayProblemPrimaryAction(gatewayProblem)
},
onShowDetails: {
self.showGatewayProblemDetails = true
})
.padding(.horizontal, 12)
.safeAreaPadding(.top, 10)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.transition(.move(edge: .top).combined(with: .opacity))
.overlay(alignment: .topLeading) {
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
VoiceWakeToast(command: voiceWakeToastText)
.padding(.leading, 10)
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
.overlay {
if self.appModel.cameraFlashNonce != 0 {
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
}
}
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
private func rootLifecycle(_ content: some View) -> some View {
self.rootRequestLifecycle(
self.rootGatewayLifecycle(
self.rootAppearLifecycle(
self.rootVoiceWakeLifecycle(content))))
}
private func rootVoiceWakeLifecycle(_ content: some View) -> some View {
content
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
guard let newValue else { return }
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.toastDismissTask?.cancel()
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
self.voiceWakeToastText = trimmed
}
self.toastDismissTask = Task {
try? await Task.sleep(nanoseconds: 2_300_000_000)
await MainActor.run {
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
self.voiceWakeToastText = nil
}
}
}
}
}
.onDisappear {
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
}
.gatewayActionsDialog(
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.selectedTab = 2 })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
self.handleGatewayProblemPrimaryAction(gatewayProblem)
})
}
}
}
private var gatewayStatus: StatusPill.GatewayState {
private func rootAppearLifecycle(_ content: some View) -> some View {
content
.onAppear { self.updateIdleTimer() }
.onAppear { self.updateCanvasState() }
.onAppear { self.evaluateOnboardingPresentation(force: false) }
.onAppear { self.maybeAutoOpenSettings() }
.onAppear { self.maybeShowQuickSetup() }
.onAppear { self.applyInitialAppearanceIfNeeded() }
.onAppear { self.applyInitialChatSessionIfNeeded() }
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
.onChange(of: self.appModel.talkMode.isEnabled) { _, _ in self.updateIdleTimer() }
.onChange(of: self.scenePhase) { _, newValue in
self.updateIdleTimer()
self.updateHomeCanvasState()
guard newValue == .active else { return }
Task {
await self.appModel.refreshGatewayOverviewIfConnected()
await MainActor.run {
self.updateHomeCanvasState()
}
}
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
}
}
private func rootGatewayLifecycle(_ content: some View) -> some View {
content
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
if newValue != nil {
self.showOnboarding = false
self.onboardingComplete = true
self.hasConnectedOnce = true
OnboardingStateStore.markCompleted(mode: nil)
}
self.maybeAutoOpenSettings()
self.maybeShowQuickSetup()
self.updateCanvasState()
}
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasState() }
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasState() }
.onChange(of: self.appModel.gatewayDisplayStatusText) { _, _ in self.updateCanvasState() }
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in self.updateHomeCanvasState() }
.onChange(of: self.appModel.gatewayAgents.count) { _, _ in self.updateHomeCanvasState() }
.onChange(of: self.appModel.selectedAgentId) { _, _ in self.updateHomeCanvasState() }
.onChange(of: self.appModel.gatewayDefaultAgentId) { _, _ in self.updateHomeCanvasState() }
.onChange(of: self.appModel.activeAgentName) { _, _ in self.updateHomeCanvasState() }
.onChange(of: self.appModel.connectedGatewayID) { _, _ in
self.updateCanvasState()
}
}
private func rootRequestLifecycle(_ content: some View) -> some View {
content
.onChange(of: self.onboardingRequestID) { _, _ in
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectedTab = .chat
}
}
private func rootPresentation(_ content: some View) -> some View {
content
.gatewayActionsDialog(
isPresented: self.$showGatewayActions,
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.selectedTab = .settings })
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
self.handleGatewayProblemPrimaryAction(gatewayProblem)
})
}
}
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .quickSetup:
GatewayQuickSetupSheet()
.environment(self.appModel)
.environment(self.gatewayController)
.openClawSheetChrome()
.preferredColorScheme(self.appearancePreference.colorScheme)
}
}
.fullScreenCover(isPresented: self.$showOnboarding) {
OnboardingWizardView(
allowSkip: self.onboardingAllowSkip,
onClose: {
self.showOnboarding = false
})
.environment(self.appModel)
.environment(self.voiceWake)
.environment(self.gatewayController)
.preferredColorScheme(self.appearancePreference.colorScheme)
}
.gatewayTrustPromptAlert()
.deepLinkAgentPromptAlert()
.execApprovalPromptDialog()
}
private var appearancePreference: AppAppearancePreference {
AppAppearancePreference.launchArgumentPreference
?? AppAppearancePreference(rawValue: self.appearancePreferenceRaw)
?? .system
}
private var gatewayStatus: GatewayDisplayState {
GatewayStatusBuilder.build(appModel: self.appModel)
}
private var statusActivity: StatusPill.Activity? {
StatusActivityBuilder.build(
appModel: self.appModel,
voiceWakeEnabled: self.voiceWakeEnabled,
cameraHUDText: self.appModel.cameraHUDText,
cameraHUDKind: self.appModel.cameraHUDKind)
private func updateIdleTimer() {
UIApplication.shared.isIdleTimerDisabled =
self.scenePhase == .active && (self.preventSleep || self.appModel.talkMode.isEnabled)
}
private func updateCanvasState() {
self.updateHomeCanvasState()
self.updateCanvasDebugStatus()
}
private func updateCanvasDebugStatus() {
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
guard self.canvasDebugStatusEnabled else { return }
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
}
private func updateHomeCanvasState() {
let payload = self.makeHomeCanvasPayload()
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else {
self.appModel.screen.updateHomeCanvasState(json: nil)
return
}
self.appModel.screen.updateHomeCanvasState(json: json)
}
private func makeHomeCanvasPayload() -> RootTabsHomeCanvasPayload {
let gatewayName = self.normalized(self.appModel.gatewayServerName)
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
let activeAgentID = self.resolveActiveAgentID()
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
switch self.gatewayStatus {
case .connected:
return RootTabsHomeCanvasPayload(
gatewayState: "connected",
eyebrow: "\(gatewayLabel) online",
title: "Command center",
subtitle:
"Use Chat for code work, Talk for realtime voice, and gateway tools for approved device actions.",
gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
activeAgentCaption: "Routes chat and talk",
agentCount: agents.count,
agents: Array(agents.prefix(6)),
footer: "OpenClaw only runs phone-side capabilities while the app is connected and permitted.")
case .connecting:
return RootTabsHomeCanvasPayload(
gatewayState: "connecting",
eyebrow: "Gateway handshake",
title: "Reconnecting",
subtitle:
"Restoring the local node session, agent list, voice config, and device capability state.",
gatewayLabel: gatewayLabel,
activeAgentName: self.appModel.activeAgentName,
activeAgentBadge: "OC",
activeAgentCaption: "Session in progress",
agentCount: agents.count,
agents: Array(agents.prefix(4)),
footer: "If the gateway is reachable, the local node should recover without re-pairing.")
case .error, .disconnected:
return RootTabsHomeCanvasPayload(
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
eyebrow: self.gatewayStatus == .error ? "Gateway needs attention" : "OpenClaw iOS",
title: "Pair a gateway",
subtitle:
"Connect this phone as a local node for chat, realtime voice, share intake, and approved device tools.",
gatewayLabel: gatewayLabel,
activeAgentName: "Main",
activeAgentBadge: "OC",
activeAgentCaption: "Connect to load your agents",
agentCount: agents.count,
agents: Array(agents.prefix(4)),
footer:
"Use Settings to scan a pairing QR code or paste a setup code from your OpenClaw gateway.")
}
}
private func resolveActiveAgentID() -> String {
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
if !selected.isEmpty {
return selected
}
return self.resolveDefaultAgentID()
}
private func resolveDefaultAgentID() -> String {
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
}
private func homeCanvasAgents(activeAgentID: String) -> [RootTabsHomeCanvasAgentCard] {
let defaultAgentID = self.resolveDefaultAgentID()
let cards = self.appModel.gatewayAgents.map { agent -> RootTabsHomeCanvasAgentCard in
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
return RootTabsHomeCanvasAgentCard(
id: agent.id,
name: self.homeCanvasName(for: agent),
badge: self.homeCanvasBadge(for: agent),
caption: isActive ? "Routed on this phone" : (isDefault ? "Gateway default" : "Available"),
isActive: isActive)
}
return cards.sorted { lhs, rhs in
if lhs.isActive != rhs.isActive {
return lhs.isActive
}
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
private func homeCanvasName(for agent: AgentSummary) -> String {
self.normalized(agent.name) ?? agent.id
}
private func homeCanvasBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
let normalizedEmoji = normalized(emoji)
{
return normalizedEmoji
}
let words = self.homeCanvasName(for: agent)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
.prefix(2)
let initials = words.compactMap(\.first).map(String.init).joined()
if !initials.isEmpty {
return initials.uppercased()
}
return "OC"
}
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
problem.canTrustRotatedCertificate ? "Trust certificate" : "Open Settings"
if problem.canTrustRotatedCertificate { return "Trust certificate" }
return problem.retryable ? "Retry" : "Open Settings"
}
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) {
if problem.canTrustRotatedCertificate {
Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) }
} else if problem.retryable {
Task { await self.gatewayController.connectLastKnown() }
} else {
self.selectedTab = 2
self.selectedTab = .settings
}
}
private func evaluateOnboardingPresentation(force: Bool) {
if force {
self.onboardingAllowSkip = true
self.showOnboarding = true
return
}
guard !self.didEvaluateOnboarding else { return }
self.didEvaluateOnboarding = true
let route = Self.startupPresentationRoute(
gatewayConnected: self.appModel.gatewayServerName != nil,
hasConnectedOnce: self.hasConnectedOnce,
onboardingComplete: self.onboardingComplete,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
switch route {
case .none:
break
case .onboarding:
self.onboardingAllowSkip = true
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.selectedTab = .settings
}
}
private func hasExistingGatewayConfig() -> Bool {
if self.appModel.activeGatewayConnectConfig != nil { return true }
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
let preferredStableID = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferredStableID.isEmpty { return true }
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
return self.manualGatewayEnabled && !manualHost.isEmpty
}
private func maybeAutoOpenSettings() {
guard !self.didAutoOpenSettings else { return }
guard !self.showOnboarding else { return }
let route = Self.startupPresentationRoute(
gatewayConnected: self.appModel.gatewayServerName != nil,
hasConnectedOnce: self.hasConnectedOnce,
onboardingComplete: self.onboardingComplete,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
shouldPresentOnLaunch: false)
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.selectedTab = .settings
}
private func applyInitialChatSessionIfNeeded() {
guard !self.didApplyInitialChatSession else { return }
self.didApplyInitialChatSession = true
self.appModel.focusChatSession(Self.initialChatSessionKey)
}
private func applyInitialAppearanceIfNeeded() {
guard !self.didApplyInitialAppearance else { return }
self.didApplyInitialAppearance = true
guard let preference = AppAppearancePreference.launchArgumentPreference else { return }
self.appearancePreferenceRaw = preference.rawValue
}
private func maybeShowQuickSetup() {
let shouldPresent = Self.shouldPresentQuickSetup(
quickSetupDismissed: self.quickSetupDismissed,
showOnboarding: self.showOnboarding,
hasPresentedSheet: self.presentedSheet != nil,
gatewayConnected: self.appModel.gatewayServerName != nil,
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
discoveredGatewayCount: self.gatewayController.gateways.count)
guard shouldPresent else { return }
self.presentedSheet = .quickSetup
}
}
private struct RootTabsHomeCanvasPayload: Codable {
var gatewayState: String
var eyebrow: String
var title: String
var subtitle: String
var gatewayLabel: String
var activeAgentName: String
var activeAgentBadge: String
var activeAgentCaption: String
var agentCount: Int
var agents: [RootTabsHomeCanvasAgentCard]
var footer: String
}
private struct RootTabsHomeCanvasAgentCard: Codable {
var id: String
var name: String
var badge: String
var caption: String
var isActive: Bool
}
private struct RootCameraFlashOverlay: View {
var nonce: Int
@State private var opacity: CGFloat = 0
@State private var task: Task<Void, Never>?
var body: some View {
Color.white
.opacity(self.opacity)
.ignoresSafeArea()
.allowsHitTesting(false)
.onChange(of: self.nonce) { _, _ in
self.task?.cancel()
self.task = Task { @MainActor in
withAnimation(.easeOut(duration: 0.08)) {
self.opacity = 0.85
}
try? await Task.sleep(nanoseconds: 110_000_000)
withAnimation(.easeOut(duration: 0.32)) {
self.opacity = 0
}
}
}
.onDisappear {
self.task?.cancel()
self.task = nil
}
}
}

View File

@@ -1,7 +1,15 @@
import SwiftUI
struct RootView: View {
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
var body: some View {
RootCanvas()
RootTabs()
.preferredColorScheme(self.appearancePreference.colorScheme)
}
private var appearancePreference: AppAppearancePreference {
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
}
}

View File

@@ -1,27 +0,0 @@
import OpenClawKit
import SwiftUI
struct ScreenTab: View {
@Environment(NodeAppModel.self) private var appModel
var body: some View {
ZStack(alignment: .top) {
ScreenWebView(controller: self.appModel.screen)
.ignoresSafeArea(.container, edges: [.top, .leading, .trailing])
.overlay(alignment: .top) {
if let errorText = self.appModel.screen.errorText,
self.appModel.gatewayServerName == nil
{
Text(errorText)
.font(.footnote)
.padding(10)
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding()
}
}
}
}
// Navigation is agent-driven; no local URL bar here.
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,16 @@
import Foundation
import OpenClawKit
enum GatewayDisplayState: Equatable {
case connected
case connecting
case error
case disconnected
}
enum GatewayStatusBuilder {
@MainActor
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
static func build(appModel: NodeAppModel) -> GatewayDisplayState {
self.build(
gatewayServerName: appModel.gatewayServerName,
lastGatewayProblem: appModel.lastGatewayProblem,
@@ -13,7 +20,7 @@ enum GatewayStatusBuilder {
static func build(
gatewayServerName: String?,
lastGatewayProblem: GatewayConnectionProblem?,
gatewayStatusText: String) -> StatusPill.GatewayState
gatewayStatusText: String) -> GatewayDisplayState
{
if gatewayServerName != nil { return .connected }
if let lastGatewayProblem, lastGatewayProblem.pauseReconnect { return .error }

View File

@@ -1,95 +0,0 @@
import SwiftUI
enum StatusActivityBuilder {
@MainActor
static func build(
appModel: NodeAppModel,
voiceWakeEnabled: Bool,
cameraHUDText: String?,
cameraHUDKind: NodeAppModel.CameraHUDKind?) -> StatusPill.Activity?
{
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
if appModel.isBackgrounded {
return StatusPill.Activity(
title: "Foreground required",
systemImage: "exclamationmark.triangle.fill",
tint: .orange)
}
if let gatewayProblem = appModel.lastGatewayProblem {
switch gatewayProblem.kind {
case .pairingRequired,
.pairingRoleUpgradeRequired,
.pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
return StatusPill.Activity(
title: "Approval pending",
systemImage: "person.crop.circle.badge.clock",
tint: .orange)
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
return StatusPill.Activity(
title: "Check network",
systemImage: "wifi.exclamationmark",
tint: .orange)
default:
if gatewayProblem.pauseReconnect {
return StatusPill.Activity(
title: "Action required",
systemImage: "exclamationmark.triangle.fill",
tint: .orange)
}
}
}
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
}
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
}
// Avoid duplicating the primary gateway status ("Connecting") in the activity slot.
if appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
}
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
let systemImage: String
let tint: Color?
switch cameraHUDKind {
case .photo:
systemImage = "camera.fill"
tint = nil
case .recording:
systemImage = "video.fill"
tint = .red
case .success:
systemImage = "checkmark.circle.fill"
tint = .green
case .error:
systemImage = "exclamationmark.triangle.fill"
tint = .red
}
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
}
if voiceWakeEnabled {
let voiceStatus = appModel.voiceWake.statusText
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
}
if voiceStatus == "Paused" {
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
if appModel.talkMode.isEnabled {
return nil
}
let suffix = appModel.isBackgrounded ? " (background)" : ""
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
}
}
return nil
}
}

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