Compare commits

..

606 Commits

Author SHA1 Message Date
Vincent Koc
8ba9add23e fix(agents): avoid constructing lean local model tools 2026-05-29 06:17:30 +02:00
Ramrajprabu
f3cfd752d3 feat(copilot): add GitHub Copilot agent runtime
Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.

Verification:
- Local: git diff --check; fnm exec --using 24.15.0 pnpm tsgo:extensions; fnm exec --using 24.15.0 pnpm check:test-types; fnm exec --using 24.15.0 pnpm build.
- Autoreview local: clean for the replay-safety fix; branch autoreview engine returned empty output twice, so local autoreview plus local/Crabbox/CI proof was used.
- Crabbox focused Copilot: run_2c0db9f48a4a, 19 files / 485 tests passed.
- Crabbox additional boundary shard: run_26a246a1aa24, prompt snapshots and plugin SDK boundary/export checks passed.
- Crabbox live Copilot: run_d128e4048b4e, real gpt-4.1 turn with live_echo phase-1-green and clean session-file check.
- GitHub checks: green on head 7cc8657e0d, including Dependency Guard after exact-head approval.

Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
2026-05-29 05:15:22 +01:00
Peter Steinberger
15772c527a fix(memory-wiki): default non-finite search limits 2026-05-29 00:14:26 -04:00
Peter Steinberger
846ca1e5bd fix(memory-wiki): default non-finite page line options 2026-05-29 00:11:30 -04:00
Vincent Koc
dc0d833efc fix(scripts): reject loose changed bench workers 2026-05-29 06:10:03 +02:00
Peter Steinberger
9596b7bd7a fix(memory): default non-finite read window options 2026-05-29 00:07:49 -04:00
Peter Steinberger
adabff1bf0 fix(browser): centralize non-finite tool timeouts 2026-05-29 00:04:04 -04:00
Peter Steinberger
0bacc93208 fix(qa-lab): keep package telegram harness off private sdk 2026-05-29 05:03:10 +01:00
Peter Steinberger
dac13d9a69 fix(browser): default non-finite navigation timeouts 2026-05-29 00:00:44 -04:00
Gio Della-Libera
af64a824a1 Policy: add sandbox posture conformance checks (#85572)
Policy: add sandbox posture conformance checks (#85572)

Merged via squash.

Prepared head SHA: 1cf1953d8c
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-05-28 21:00:24 -07:00
Vincent Koc
c037ab5c74 fix(doctor): report failed MCP tool schema loading 2026-05-29 05:57:24 +02:00
Vincent Koc
58149e41dc fix(scripts): reject loose startup bench budgets 2026-05-29 05:56:59 +02:00
Peter Steinberger
00c9f81171 fix: retry transient recurring cron failures 2026-05-29 04:54:42 +01:00
Peter Steinberger
3c8ad8cbaa fix(browser): default non-finite fetch timeouts 2026-05-28 23:52:40 -04:00
Peter Steinberger
b2bdad5bee fix(browser): default non-finite snapshot timeouts 2026-05-28 23:48:33 -04:00
Vincent Koc
27b15a19e8 refactor(voice): catalog voice models through providers (#87794)
* refactor(providers): catalog voice models

* feat(tts): route speech through voice models

* refactor(tts): rename speaker selection fields

* refactor(tts): mark default speech models

* test(tts): type migrated speaker config assertions

* refactor(providers): avoid catalog merge map spread

* fix(tts): honor voice model fallbacks

* refactor(tts): move speech core into package

* chore(tts): register speech core knip workspace

* fix(tts): show migrated speaker voice in status

* fix(tts): satisfy speech core lint

* fix(tts): preserve explicit model aliases

* test(tts): narrow provider config assertion

* test(doctor): allow slow commitments repair check

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-29 04:46:45 +01:00
Vincent Koc
398b98dcbe refactor: share acpx lazy runtime proxy 2026-05-29 05:45:20 +02:00
Vincent Koc
9ec4e94c48 fix(scripts): reject loose test perf budgets 2026-05-29 05:44:56 +02:00
Peter Steinberger
18ef59bb33 fix(browser): default non-finite dialog arm timeouts 2026-05-28 23:44:42 -04:00
Peter Steinberger
e7fb8cabb6 fix(discord): default non-finite identify concurrency 2026-05-28 23:40:43 -04:00
Peter Steinberger
6f9d5e1b95 fix(channels): default non-finite typing options 2026-05-28 23:37:28 -04:00
Peter Steinberger
2209faef40 feat: improve cron create delivery ergonomics
Summary:
- Add Hermes-style schedule-first cron create parsing while preserving flagged create options.
- Support webhook create/edit delivery and clear stale webhook/chat delivery fields across mode changes.
- Update cron docs and schedule identity normalization tests.

Verification:
- pnpm test src/cron/schedule-identity.test.ts src/cli/cron-cli.test.ts src/cron/service.jobs.test.ts -- --reporter=verbose
- pnpm test src/cli/cron-cli.test.ts src/cron/service.jobs.test.ts -- --reporter=verbose
- pnpm check:test-types
- pnpm check:import-cycles
- pnpm check:docs
- pnpm check:changed via Crabbox run_8c44bcb158da, exit 0
- autoreview branch diff clean
2026-05-29 04:34:50 +01:00
Vincent Koc
4b18234fc1 fix(scripts): enforce plugin sdk surface budgets 2026-05-29 05:32:12 +02:00
Vincent Koc
bf30361bc8 refactor: dedupe voice stream frame adapter 2026-05-29 05:31:50 +02:00
Peter Steinberger
cb085ec5f1 fix(discord): default non-finite REST numeric options 2026-05-28 23:30:47 -04:00
Dallin Romney
5a6472718d ci: add dependency guard backfill label trigger (#87866) 2026-05-28 20:26:32 -07:00
Peter Steinberger
fd643139b1 fix(memory): validate non-finite lancedb numeric config 2026-05-28 23:22:32 -04:00
Peter Steinberger
d8f2437cf4 test(doctor): bound config flow schema warnings 2026-05-29 04:22:23 +01:00
Vincent Koc
ffd4a80145 refactor: share live transport QA CLI helpers 2026-05-29 05:21:23 +02:00
clawsweeper[bot]
4df1fcf7b3 feat(discord): show commentary in progress drafts (#85200)
Adds opt-in Discord progress-draft commentary for assistant preambles while keeping commentary hidden by default and final delivery unchanged.
Keeps commentary config Discord-specific, strips directive tags/NO_REPLY, and clears stale commentary rows without stopping the active draft stream.
Thanks @bryanpearson.

Co-authored-by: bryanpearson <bryanmpearson@gmail.com>
2026-05-29 04:21:06 +01:00
Forrest 0x59
5c7f960125 fix(test): resolve temp dir outside Windows mock to prevent dirty folders on Linux (#85677)
Merged via squash.

Prepared head SHA: ddd6291bde
Co-authored-by: forrest0x59 <250948165+forrest0x59@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-05-29 00:18:43 -03:00
Peter Steinberger
5a84869c06 test: remove duplicate catalog write expectation 2026-05-29 04:15:27 +01:00
Peter Steinberger
bca6a91fc4 fix: harden smart-quoted argument repair (#86611) 2026-05-29 04:15:27 +01:00
Fermin Quant
059bed7731 fix(agents): repair smart-quoted edit arrays 2026-05-29 04:15:27 +01:00
Fermin Quant
d4543ac8e4 fix(agents): satisfy smart quote lint 2026-05-29 04:15:27 +01:00
Fermin Quant
fae58591cd fix(agents): decode smart-quoted arg escapes 2026-05-29 04:15:27 +01:00
Fermin Quant
d560588e1e fix(agents): handle exact smart-quoted args 2026-05-29 04:15:27 +01:00
Fermin Quant
1c0b8f6a6b fix(agents): repair smart-quoted tool args 2026-05-29 04:15:27 +01:00
Peter Steinberger
5f301e09ea fix(sandbox): default non-finite novnc token ttl 2026-05-28 23:08:57 -04:00
Peter Steinberger
f2dfb67f2c fix(agents): default non-finite run wait timeouts 2026-05-28 23:05:26 -04:00
Peter Steinberger
01d9963e4e fix(models): default non-finite catalog browse timeout 2026-05-28 23:01:45 -04:00
samzong
c237de552a [Fix] Prefer external session delivery context (#87476)
* fix(sessions): prefer external delivery context

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

* fix: route Feishu session announces from delivery context

* fix: accept normalized cron schedule inputs

---------

Signed-off-by: samzong <samzong.lu@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-29 03:59:54 +01:00
Peter Steinberger
3cf9877d0c fix(agents): alias typebox format for extensions 2026-05-29 03:58:59 +01:00
Peter Steinberger
d503ec52d8 feat: add Fal Krea image model schemas (#87845)
* feat: add fal krea image model schemas

* fix: support fal model-specific aspect ratios

* fix: preserve fal native auto aspect ratio

* fix: honor image model-specific geometry
2026-05-29 03:58:46 +01:00
Peter Steinberger
c91cbf3f71 fix(codex): default non-finite app-server timeouts 2026-05-28 22:58:14 -04:00
Vincent Koc
b012ae46aa refactor: dedupe migrate selection helpers 2026-05-29 04:55:42 +02:00
Peter Steinberger
ee3efc0152 test(plugins): alias gateway workspace packages in plugin loader 2026-05-29 03:54:10 +01:00
Peter Steinberger
45892a6595 fix(heartbeat): default non-finite schedule inputs 2026-05-28 22:53:53 -04:00
Peter Steinberger
9d84a13bb8 fix(slack): default non-finite thread cache options 2026-05-28 22:51:16 -04:00
Peter Steinberger
9dd3bce549 feat: add codex supervisor extension
* feat: add codex supervisor plugin

* fix: restore merged branch checks

* fix: unblock supervisor extension CI

* fix: restore merged agent checks
2026-05-29 03:49:43 +01:00
Peter Steinberger
c8cc010e09 fix(infra): centralize non-finite numeric option bounds 2026-05-28 22:48:19 -04:00
Peter Steinberger
6e25112aad fix(collection): preserve maps for non-finite upper bounds 2026-05-28 22:42:43 -04:00
Peter Steinberger
a4ff3e19ea test: repair gateway client boundary snapshots 2026-05-29 03:40:08 +01:00
Vincent Koc
9ca791288c fix(scripts): parse startup bench gateway ports 2026-05-29 04:39:37 +02:00
Peter Steinberger
564ccf1faa fix(dedupe): bound non-finite retention options 2026-05-28 22:39:09 -04:00
Vincent Koc
47e86bc1ac refactor: share task sqlite store helpers 2026-05-29 04:35:45 +02:00
Peter Steinberger
7f6579e416 fix(shared): default non-finite string sample limits 2026-05-28 22:35:00 -04:00
Peter Steinberger
19d9e71b84 fix(shared): bound non-finite expiring cache options 2026-05-28 22:33:10 -04:00
Peter Steinberger
dbf711c2ea fix(acp): default non-finite session rate limits 2026-05-28 22:31:17 -04:00
Peter Steinberger
c7a1e909a3 fix(plugin-sdk): default non-finite webhook guard limits 2026-05-28 22:29:04 -04:00
Peter Steinberger
fce00ccb6e fix(acp): ignore non-finite retention options 2026-05-28 22:26:20 -04:00
Peter Steinberger
2f8b1a8c0e fix(gateway): default non-finite readiness waits 2026-05-28 22:24:00 -04:00
Peter Steinberger
51b5f75b92 refactor: move plugin model catalogs into plugin state 2026-05-29 03:23:57 +01:00
Peter Steinberger
94db48d028 fix(sandbox): skip non-finite docker resource limits 2026-05-28 22:20:49 -04:00
Peter Steinberger
2dcca3ec8a test(vitest): alias gateway client package to source 2026-05-29 03:17:06 +01:00
Vincent Koc
91df558e69 fix(qa): reject loose otel size limits 2026-05-29 04:16:15 +02:00
Peter Steinberger
6f2add2cc6 fix(gateway): centralize safe timeout delays 2026-05-28 22:15:15 -04:00
Peter Steinberger
bb2254520d test: fix cron schedule identity legacy fixture types 2026-05-29 03:12:56 +01:00
Peter Steinberger
d5bbf3033c perf: avoid full session snapshots for entry reads 2026-05-29 03:12:56 +01:00
Peter Steinberger
c36ba9ea7a fix(memory): keep qmd numeric overrides positive 2026-05-28 22:11:58 -04:00
Peter Steinberger
185e62a9ae fix: show reasoning previews in Slack 2026-05-29 03:08:48 +01:00
Peter Steinberger
66bf324256 fix: default non-finite matrix timeouts 2026-05-28 22:07:49 -04:00
Peter Steinberger
0d189102f5 fix: clamp web provider subsecond timeouts 2026-05-28 22:04:22 -04:00
Vincent Koc
60392a1136 fix(scripts): reject loose memory fd limits 2026-05-29 03:59:04 +02:00
Peter Steinberger
d7aa368776 fix: reject negative cron timeouts 2026-05-28 21:58:00 -04:00
Peter Steinberger
025e6ac31d refactor: tighten gateway client test boundary 2026-05-29 02:56:51 +01:00
Peter Steinberger
f5cb6177e4 fix: align message numeric schemas 2026-05-28 21:54:23 -04:00
Vincent Koc
c3e629cbf4 refactor: share non-interactive onboard config writes 2026-05-29 03:51:51 +02:00
Peter Steinberger
edda0608ac fix: advertise telegram poll duration integer 2026-05-28 21:51:28 -04:00
Peter Steinberger
b425438a58 fix(memory-wiki): narrow synthesis confidence normalization 2026-05-29 02:49:06 +01:00
Peter Steinberger
c0094a232d fix: validate feishu bitable page size 2026-05-28 21:48:06 -04:00
Peter Steinberger
d6c76eb5bf perf: prefer bundled plugin dist entries 2026-05-29 02:47:30 +01:00
Peter Steinberger
d33c2eefce fix: validate feishu chat page size 2026-05-28 21:45:20 -04:00
Peter Steinberger
d2fbc8c0e7 fix: validate message poll duration hours 2026-05-28 21:43:04 -04:00
Vincent Koc
4835a7ecd9 fix(e2e): reject loose parallels limits 2026-05-29 03:41:25 +02:00
Peter Steinberger
b779bdb5a0 fix: centralize cron schedule number coercion 2026-05-28 21:39:06 -04:00
Vincent Koc
a087dbd9e9 fix(doctor): validate tool schemas with model context 2026-05-29 03:32:34 +02:00
Peter Steinberger
6bdaada782 fix: normalize memory wiki confidence 2026-05-28 21:32:10 -04:00
Vincent Koc
417b6e72c4 fix(context-engine): expose fallback metadata after quarantine 2026-05-29 02:32:06 +01:00
Vincent Koc
14ce8733fe fix(context-engine): quarantine broken plugin engines 2026-05-29 02:32:06 +01:00
Vincent Koc
9813ff2f0a refactor: share channel setup promotion keys 2026-05-29 03:29:32 +02:00
Peter Steinberger
9b692f0a5b test(plugins): expect openclaw npm metadata lookup 2026-05-29 02:29:07 +01:00
Peter Steinberger
4ac6bb1964 fix: validate memory search min score 2026-05-28 21:28:02 -04:00
Dallin Romney
e0aa820257 ci: rename dependency guard workflow (#87842) 2026-05-28 18:26:49 -07:00
Peter Steinberger
fe76bae1ed fix: validate lancedb memory importance 2026-05-28 21:25:05 -04:00
Peter Steinberger
b1117d9862 refactor: extract gateway client package (#87797)
* refactor: extract gateway client package

* chore: drop generated gateway package artifacts

* refactor: move gateway protocol package

* refactor: remove old gateway protocol tree

* test: keep auth compat split in run mode

* test: expose gateway wrapper options for internals

* fix: watch moved gateway package sources

* test: normalize slash command import guard

* chore: teach knip gateway package entries

* ci: route gateway client package checks

* fix: reuse ipaddr for gateway client hosts

* fix: sync gateway protocol usage schema
2026-05-29 02:23:42 +01:00
Peter Steinberger
fd8353012f fix: parse diffs numeric options 2026-05-28 21:22:05 -04:00
Peter Steinberger
c0d525c8a0 fix: validate whatsapp login timeout 2026-05-28 21:18:29 -04:00
Vincent Koc
c66c404d58 fix(e2e): reject loose pty env limits 2026-05-29 03:17:12 +02:00
Peter Steinberger
10a3417bd3 fix: validate browser act numeric params 2026-05-28 21:16:12 -04:00
Dallin Romney
c8f2bbf76d ci: guard dependency graph PR changes (#87791) 2026-05-28 18:13:54 -07:00
Peter Steinberger
efc93bf282 fix: validate google meet numeric params 2026-05-28 21:11:53 -04:00
Josh Avant
92051f6746 fix: probe stale rate-limit cooldown primaries (#87833) 2026-05-28 18:11:14 -07:00
Shakker
73cf516def fix: preserve embedded base system prompts
Preserve OpenClaw-owned embedded system prompts after active tool selection in both normal embedded attempts and compaction. Adds an exact base prompt path on AgentSession that keeps active tool prompt metadata current for extension hooks.

Fixes #87807.

Verification:
- mise exec node@24.16.0 -- node scripts/run-vitest.mjs src/agents/sessions/sdk.test.ts src/agents/embedded-agent-runner/system-prompt.test.ts src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts src/agents/embedded-agent-runner/compact.hooks.test.ts --reporter=dot
- mise exec node@24.16.0 -- pnpm tsgo:core
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main

Thanks @shakkernerd.
2026-05-29 02:09:15 +01:00
Dallin Romney
93c30de17b ci: restore timing summary artifact (#87832)
* ci: restore timing summary artifact

* ci: report pnpm warmup fanout timing

* ci: run timing summary from trusted base
2026-05-28 18:08:12 -07:00
Merlin
00067563a6 fix(doctor): handle gateway SecretRefs in auth checks
Handle exec-backed Gateway SecretRefs in doctor, lint, and health probing without executing providers by default.

- Add `openclaw doctor --allow-exec` for explicit SecretRef execution during lint/doctor checks.
- Skip only the active exec-backed gateway probe path and avoid local service diagnostics for remote-only skipped health.
- Keep env-winning and dormant fallback credentials probeable, stabilize related tests, and remove a stale live-shard fixture left by the moving base.

Verification:
- `node scripts/run-vitest.mjs src/commands/doctor-gateway-auth-token.test.ts src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts src/gateway/credentials.test.ts src/gateway/probe-auth.test.ts src/commands/doctor-gateway-daemon-flow.test.ts test/scripts/test-live-shard.test.ts --reporter=verbose`
- `mise x node@24.13.0 -- pnpm prompt:snapshots:check`
- `pnpm tsgo:prod`
- `pnpm build`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Crabbox AWS live config proof: `run_f44a4d9dae4e`
- GitHub CI: green on final head `88d24abdbf9529a59d75d1d5e04eac74bbbbc267` after rerunning a stale in-progress Security High workflow.

Co-authored-by: Merlin <258679497+funmerlin@users.noreply.github.com>
2026-05-29 02:07:50 +01:00
Peter Steinberger
3aae25358e fix: validate lobster numeric options 2026-05-28 21:06:23 -04:00
Peter Steinberger
5d8cf28578 fix: validate file transfer numeric params 2026-05-28 21:03:39 -04:00
Alix-007
99bd275359 fix(ui): scope usage by agent filter
Fixes #87132.

Default Usage now requests all configured agents with `agentScope: "all"`, while selecting a specific agent sends `agentId` consistently to both session usage and cost usage calls. The gateway now supports explicit all-agent session usage, aggregates all-agent cost summaries across configured agents, and keeps scoped cache entries separate. Legacy gateway fallbacks remain for older `agentId` / `agentScope` support, with protocol docs/schema and Swift generated models updated.

Verification:
- `node scripts/run-vitest.mjs ui/src/ui/controllers/usage.node.test.ts ui/src/ui/app-render-usage-tab.test.ts ui/src/ui/views/usage.test.ts --reporter=dot`
- `node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway-methods.config.ts src/gateway/server-methods/usage.test.ts src/gateway/server-methods/usage.cost-usage-cache.test.ts src/gateway/server-methods/usage.sessions-usage.test.ts --reporter=dot`
- `pnpm check:test-types`
- `pnpm protocol:check`
- targeted `node scripts/run-oxlint.mjs ...`
- `git diff --check`
- autoreview clean after Swift compatibility fix
- PR CI green at head `d67156a3c552c4f9c8b6edf8516b6242bf5cdd26`

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-29 02:03:33 +01:00
Vincent Koc
d4021d1d54 fix(e2e): reject loose bundled sweep limits 2026-05-29 03:02:21 +02:00
Peter Steinberger
f927e532da perf: cache installed package paths 2026-05-29 02:00:21 +01:00
Peter Steinberger
0ae1ac17ea fix: validate web guarded fetch timeouts 2026-05-28 20:59:02 -04:00
David
37c5003ed9 fix(auth): harden Codex auth probes (#87559)
* fix(auth): harden Codex auth probes

* fix: preserve Codex probe auth overlay (#87559)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-29 01:58:48 +01:00
Vincent Koc
ca41fa293f refactor: share live transport scenario helpers 2026-05-29 02:58:05 +02:00
Peter Steinberger
7bf100b028 fix: validate embedded chat history limits 2026-05-28 20:56:45 -04:00
Peter Steinberger
f7b0b5429e fix: validate llm task numeric options 2026-05-28 20:54:18 -04:00
Vincent Koc
8bd4736f03 fix(ui): replay pending cron filter reloads 2026-05-29 02:50:58 +02:00
Peter Steinberger
27d1c08c51 fix: normalize web provider numeric params 2026-05-28 20:49:19 -04:00
Sebastien Tardif
b998a921c7 fix(codex): preserve reasoning stream snapshots
Keep Codex reasoning updates as accumulated snapshots and mark the stream payload so channel consumers can distinguish snapshots from deltas.

This prevents Discord and Teams progress previews from duplicating accumulated reasoning text while preserving delta-style reasoning for legacy producers.

Refs #86708
Thanks @SebTardif.

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-05-29 01:48:51 +01:00
Peter Steinberger
61cf005437 fix: normalize canvas numeric params 2026-05-28 20:45:27 -04:00
Peter Steinberger
61c538e2fc fix: validate memory recall limits 2026-05-28 20:42:42 -04:00
Peter Steinberger
f09b69a78f test: drop removed gateway live shard fixture 2026-05-28 20:41:11 -04:00
Peter Steinberger
091e15139b refactor: centralize numeric tool schemas 2026-05-28 20:39:51 -04:00
Vincent Koc
c903b271cf fix(e2e): reject loose mock config limits 2026-05-29 02:38:29 +02:00
Dallin Romney
5cccfe1c17 docs: correct ci timing summary guidance (#87813) 2026-05-28 17:36:54 -07:00
Peter Steinberger
7e04680e23 fix: parse browser top-level timeouts 2026-05-28 20:36:34 -04:00
Peter Steinberger
3a20a0cd4f fix: remove unused voice agent helper 2026-05-28 20:35:22 -04:00
Peter Steinberger
1901b832eb perf: cache installed package metadata 2026-05-29 01:32:11 +01:00
Peter Steinberger
5869131eea test: refresh numeric tool prompt snapshots 2026-05-28 20:29:06 -04:00
Peter Steinberger
101cb70844 test: type message gateway options 2026-05-28 20:25:18 -04:00
Peter Steinberger
913241ebf9 fix: parse browser snapshot numeric params 2026-05-28 20:25:18 -04:00
Peter Steinberger
e12a6d6a67 refactor(agents): own system prompt assembly 2026-05-29 01:22:09 +01:00
Peter Steinberger
c3ff31e770 fix: validate slack read limits 2026-05-28 20:20:51 -04:00
Peter Steinberger
7bcef07297 test: update numeric tool schema snapshots 2026-05-28 20:18:49 -04:00
Peter Steinberger
ecbb5cd9b6 fix: preserve cron gateway timeout parsing 2026-05-28 20:18:49 -04:00
Vincent Koc
ffd517b513 test: dedupe schtasks startup fallback helpers 2026-05-29 02:17:18 +02:00
Vincent Koc
5685238656 fix(e2e): reject loose telegram rtt limits 2026-05-29 02:16:54 +02:00
Peter Steinberger
f8d63f4b24 fix: centralize gateway timeout schema 2026-05-28 20:15:36 -04:00
Peter Steinberger
592277cd77 ci(release): bound cross-os baseline packing 2026-05-29 01:13:10 +01:00
Peter Steinberger
fc8b57e0cf fix: validate gateway rpc timeouts 2026-05-28 20:10:06 -04:00
Peter Steinberger
92a405b536 chore: remove unused plugin source loader 2026-05-28 20:08:42 -04:00
Peter Steinberger
c4e4d122e9 fix: validate gateway restart delays 2026-05-28 20:06:50 -04:00
Peter Steinberger
9119e8d99c fix: validate image caps 2026-05-28 20:02:51 -04:00
Vincent Koc
f9f4c4959b fix(e2e): reject loose helper env limits 2026-05-29 02:02:23 +02:00
Vincent Koc
d264119c75 test: dedupe gateway context fixture 2026-05-29 02:01:14 +02:00
Peter Steinberger
a92eb02ec3 fix: validate pdf byte cap 2026-05-28 19:59:42 -04:00
Peter Steinberger
f77a2687b6 test: refresh codex dynamic tool snapshots 2026-05-28 19:59:13 -04:00
Peter Steinberger
661a9ba559 fix: validate node command timeouts 2026-05-28 19:56:23 -04:00
Peter Steinberger
068e02684b fix: validate node media numeric params 2026-05-28 19:53:58 -04:00
Peter Steinberger
3cb4f33d3c fix: validate node photo limit 2026-05-28 19:50:10 -04:00
Peter Steinberger
0296f0a779 perf: load provider discovery entries natively 2026-05-29 00:49:20 +01:00
Peter Steinberger
49f36ab58d fix: validate node media duration 2026-05-28 19:46:06 -04:00
Peter Steinberger
28597d2790 fix: validate cron context count 2026-05-28 19:43:27 -04:00
Vincent Koc
72de534a93 test: dedupe realtime WebRTC helpers 2026-05-29 01:43:07 +02:00
Vincent Koc
7c16af4933 fix(e2e): reject loose fixture config limits 2026-05-29 01:42:49 +02:00
Peter Steinberger
0e40408375 perf: speed up launcher version output 2026-05-29 00:41:40 +01:00
Peter Steinberger
9a4aa438bb fix: validate session spawn timeout 2026-05-28 19:41:11 -04:00
Peter Steinberger
f2843d3d79 fix: validate session send timeout 2026-05-28 19:38:35 -04:00
Peter Steinberger
d7fca5794d fix: validate image numeric options 2026-05-28 19:35:50 -04:00
Peter Steinberger
4c49ca75d9 fix: validate session tool numeric params 2026-05-28 19:32:00 -04:00
Gio Della-Libera
82cb02a4fd fix(cli): preserve Discord voice outbound helper (#85529)
Merged via squash.

Prepared head SHA: e5f5e3d23f
Co-authored-by: giodl73-repo <giodl73-repo@users.noreply.github.com>
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Reviewed-by: @giodl73-repo
2026-05-28 16:29:08 -07:00
Peter Steinberger
30de7874cf fix: validate memory wiki numeric params 2026-05-28 19:27:51 -04:00
Dallin Romney
2ba725ef48 fix: stabilize code mode timeout and prompt snapshots (#87809)
* fix: normalize code mode timeout interrupts

* test: refresh firecrawl prompt snapshots
2026-05-28 16:26:12 -07:00
Peter Steinberger
36de671cad fix: validate perplexity token budgets 2026-05-28 19:25:20 -04:00
Dallin Romney
aeeccdf27f fix(ci): bound ClawHub docs checkout (#87811) 2026-05-28 16:25:09 -07:00
Peter Steinberger
46546e6817 test(slack): serialize shared-global media tests 2026-05-29 00:23:48 +01:00
Peter Steinberger
6a65cc5e9c fix(e2e): fail fast when gateway exits before readiness 2026-05-29 00:23:48 +01:00
Peter Steinberger
b4f03c2e64 fix: validate subagents recent minutes 2026-05-28 19:23:08 -04:00
Vincent Koc
38fd443677 fix(e2e): reject loose gateway network timeouts 2026-05-29 01:21:50 +02:00
Peter Steinberger
3c907250b9 fix: validate firecrawl numeric options 2026-05-28 19:20:18 -04:00
Peter Steinberger
1211123fe6 fix(agents): pass agent id to bootstrap preload 2026-05-29 00:18:18 +01:00
Peter Steinberger
e9cca2d1ef fix: validate memory search result counts 2026-05-28 19:17:42 -04:00
Jason (Json)
1610b4983f fix: scope jiti transform cache by OpenClaw install
Scope jiti filesystem transform caches for OpenClaw plugin loaders by package version and package.json install metadata so stale transforms cannot survive upgrades or package reinstalls.

Covers the central plugin module loader and the plugin SDK root alias CJS loader, while preserving jiti filesystem-cache env opt-outs and the TMPDIR cwd guard.

Verification: CI run 26601117143 passed; Real behavior proof run 26601445285 passed; CodeQL selected checks passed in run 26601117126; CodeQL Critical Quality plugin-boundary and plugin-sdk-package-contract passed in run 26601117074; OpenGrep PR diff passed in run 26601117137.

Refs: https://github.com/openclaw/openclaw/pull/87745
Thanks @fuller-stack-dev.
2026-05-29 00:17:04 +01:00
Peter Steinberger
13c1aa7fb9 test(ui): cover cron table filter e2e 2026-05-29 00:15:17 +01:00
Peter Steinberger
8a8767dd1e fix: validate imessage action integers 2026-05-28 19:14:45 -04:00
Vincent Koc
9dd8ffd767 refactor: dedupe session storage indexing 2026-05-29 01:14:34 +02:00
Peter Steinberger
46a67eea4c fix: throttle voice wake meter preview 2026-05-29 00:14:05 +01:00
Peter Steinberger
361753908e fix: validate memory get ranges 2026-05-28 19:11:37 -04:00
Peter Steinberger
56a5d7e865 fix(codex): defer report-mode plugin approvals
Route Codex app-server report-mode PreToolUse plugin approval requirements through the matching app-server approval request instead of failing closed. Shares duplicate in-flight approvals, preserves block/rewrite fail-closed behavior, and keeps generic plugin allow-always scoped to one Codex request. Supersedes #86978; thanks @clawSean for the original docs clarification.
2026-05-29 00:09:23 +01:00
Peter Steinberger
44dc29f397 fix: validate web fetch max chars 2026-05-28 19:06:39 -04:00
AMARA
3029326a56 fix(memory): compact short-term promotion entries
Compact promoted short-term memory snippets before writing them into MEMORY.md, while keeping the full rehydrated snippet in recall state for ranking/provenance. Adds the deep-dreaming config surface and docs, with the default promoted snippet cap set to 160 estimated tokens.

Verification:
- git diff --check
- fnm exec --using v24.13.0 node scripts/run-vitest.mjs run extensions/memory-core/src/short-term-promotion.test.ts extensions/memory-core/src/dreaming.test.ts src/memory-host-sdk/dreaming.test.ts
- GitHub CI run 26605272497
- CodeQL security run 26605272404

Co-authored-by: AMARA <amara@eyeinthesky.pl>
2026-05-29 00:05:54 +01:00
Peter Steinberger
5990524c5f fix: validate google chat reaction limits 2026-05-28 19:04:31 -04:00
Peter Steinberger
b240ce2085 fix: validate discord action integers 2026-05-28 19:02:24 -04:00
Vincent Koc
e32a59bc79 fix(e2e): reject loose lifecycle metric limits 2026-05-29 01:01:18 +02:00
Dallin Romney
ac8c56cc70 test: refresh codex prompt snapshots (#87803) 2026-05-28 15:59:05 -07:00
Peter Steinberger
201fe25dad ci(release): let Telegram QA wait on credential leases 2026-05-28 23:58:52 +01:00
Peter Steinberger
74d5aeae1a fix: validate matrix action integers 2026-05-28 18:53:51 -04:00
Peter Steinberger
7932a4aa74 fix: validate slack action limits 2026-05-28 18:49:51 -04:00
Vincent Koc
6d90e00fa3 refactor: dedupe channel approval forwarding 2026-05-29 00:48:40 +02:00
Peter Steinberger
b0e9569ebd fix: validate telegram action integers 2026-05-28 18:46:26 -04:00
Vincent Koc
444dd19a28 fix(e2e): reject loose codex media limits 2026-05-29 00:46:22 +02:00
Peter Steinberger
59d4327698 fix: validate web search count integers 2026-05-28 18:38:39 -04:00
Peter Steinberger
9a7014ac38 fix: validate tavily integer options 2026-05-28 18:34:43 -04:00
Vincent Koc
7b8ec95108 fix(ci): stabilize agentic drift checks (#87786)
* fix(ci): stabilize agentic drift checks

* fix(ci): refresh opengrep scanner pin

* fix(ci): avoid full-depth opengrep checkout
2026-05-28 23:33:47 +01:00
Vincent Koc
8176bc8a76 fix(e2e): reject loose live plugin timeouts 2026-05-29 00:33:32 +02:00
Peter Steinberger
66d71238a8 fix: validate tool timeout integers 2026-05-28 18:32:17 -04:00
Peter Steinberger
b21b105752 fix: validate video duration option 2026-05-28 18:29:55 -04:00
Peter Steinberger
b877fc58a5 refactor: centralize numeric coercion helpers 2026-05-28 18:27:36 -04:00
Kevin Lin
359c31b7e7 Add WhatsApp approval QA scenarios (#87782)
* test(qa): add WhatsApp approval scenarios

* fix(qa): keep WhatsApp approval scenarios explicit
2026-05-28 15:27:20 -07:00
Peter Steinberger
86d7beab99 fix: keep plugin registry memo fresh for installs 2026-05-28 23:25:12 +01:00
Peter Steinberger
365f551f9d fix: validate music duration option 2026-05-28 18:24:27 -04:00
Vincent Koc
278d04aa4b fix(e2e): reject loose chat tools limits 2026-05-29 00:23:11 +02:00
Peter Steinberger
9184b096bf fix: validate image generation numeric options 2026-05-28 18:21:59 -04:00
Peter Steinberger
4491232874 fix: resolve compatible npm plugin versions
* fix: resolve compatible npm plugin versions

* fix: satisfy plugin install lint

* fix: refresh plugin install tests on latest main
2026-05-28 23:20:32 +01:00
Peter Steinberger
11ef608685 fix: validate firecrawl numeric options 2026-05-28 18:18:33 -04:00
Alix-007
ff21b4e731 fix(cron): complete jobs filters
Server-side cron job list filtering now applies schedule-kind and last-run-status filters before pagination, and the UI only sends table filters for the cron table view.

Fixes #9455.

Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
2026-05-28 23:18:31 +01:00
Peter Steinberger
2df9b2e8ea fix: validate perplexity token budgets 2026-05-28 18:14:36 -04:00
Vincent Koc
7ebd600297 test: dedupe tui pty helpers 2026-05-29 00:14:04 +02:00
Peter Steinberger
bd77ebc761 ci(release): fix release smoke timeouts 2026-05-28 23:13:33 +01:00
Vincent Koc
7be08d0376 fix(cli): keep agents delete local fallback 2026-05-29 00:12:29 +02:00
Peter Steinberger
f5c7d77fb0 fix: validate openrouter stt temperature 2026-05-28 18:12:11 -04:00
Vincent Koc
3ca1135515 fix(e2e): reject loose runtime smoke limits 2026-05-29 00:11:17 +02:00
Peter Steinberger
80f7e36ddc fix: validate lmstudio discovered context lengths 2026-05-28 18:10:55 -04:00
Peter Steinberger
8e806e9125 fix: validate lmstudio preload context length 2026-05-28 18:09:13 -04:00
Peter Steinberger
f77e09f78e fix: validate llm task numeric options 2026-05-28 18:07:37 -04:00
Peter Steinberger
4287cd2e6e fix: validate lmstudio configured token metadata 2026-05-28 18:05:31 -04:00
Peter Steinberger
ac05545dba fix: validate copilot model token limits 2026-05-28 18:03:50 -04:00
Peter Steinberger
aa09f44b47 fix: validate kilocode model token metadata 2026-05-28 18:02:34 -04:00
Peter Steinberger
ef7ad6f744 fix: validate chutes model token metadata 2026-05-28 18:01:07 -04:00
Peter Steinberger
39db00f896 fix: validate vercel gateway model token metadata 2026-05-28 17:59:42 -04:00
Peter Steinberger
423531df50 fix: validate deepinfra model metadata numbers 2026-05-28 17:58:06 -04:00
Peter Steinberger
cb790f77da docs: polish release performance report 2026-05-28 22:57:10 +01:00
Peter Steinberger
938b2a84dd fix: validate byteplus video duration metadata 2026-05-28 17:54:45 -04:00
Aamir Jawaid
04c2982535 fix(msteams): rebase TeamsSDK patterns to simplify Teams Integration (#76262)
* fix(msteams): rebase SDK migration onto current main

Reapply the msteams SDK migration (originally on feat/msteams-sdk-migration)
on top of upstream/main, resolving conflicts with parallel msteams work that
landed upstream during our session.

What got applied vs decisions made:

CLEANLY APPLIED (3-way patch):
- monitor.ts, monitor-handler.ts, polls.ts, reply-stream-controller.ts/.test.ts,
  reply-dispatcher.ts, attachments/download.ts, monitor.lifecycle.test.ts,
  monitor-handler/message-handler.ts, monitor-handler.types.ts, etc.
- streaming-message.ts + .test.ts deletions

WHOLESALE TAKE FROM ORIGINAL BRANCH (partial 3-way left broken cross-refs):
- sdk.ts, sdk.test.ts, messenger.ts, feedback-reflection.ts,
  send-context.ts, send.test.ts

KEPT UPSTREAM (deferred for separate cleanup):
- extensions/msteams/package.json (still has jsonwebtoken/jwks-rsa per
  Peter's b3bc60ae25 incremental approach)
- src/plugins/contracts/package-manifest.contract.test.ts (consistent with
  package.json)
- pnpm-lock.yaml (avoids lockfile churn; pnpm install --frozen-lockfile clean)

ADAPTED:
- Dockerfile matrix-sdk-crypto check now wraps upstream's new retry-loop in
  the if-matrix-bundled gate

KNOWN TEST FAILURES (need eyes — see PR comment):
- attachments.test.ts: 1 fail (pre-existing — warn meta arg shape changed in
  our migration but test wasn't updated)
- reply-dispatcher.test.ts: 6 fails (pre-existing — tests mock old
  TeamsHttpStream, not updated for our ctx.stream rewrite)
- send.test.ts: 4 fails (NEW from merge — upstream's send.ts changed media
  loading; our mocks need updating or take upstream's send.test.ts wholesale)

UPSTREAM COMMITS POTENTIALLY MISSED (in wholesale-take files):
- 08c4af0ddf fix(msteams): accept conversation id allowlists
- e1840b8581 fix(msteams): bind global audience tokens to app id
- Channels turn-kernel refactor (ffe67e9cdc / 1ead1b2d18 / 9a9cd0c0ab) —
  may be partially preserved in cleanly-patched files

Static checks pass: pnpm check:changed is green (typecheck, lint, contract
tests, import cycles, etc.). Manual testing required before merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): preserve thread routing for channel and group-chat replies

- monitor.ts: adaptSdkContext now uses ctx.reply() for channel and groupChat
  conversations (so the SDK threads outbound activities to the inbound's
  replyToId/serviceUrl) and ctx.send() only for personal DMs (where
  reply()'s blockquote-prepend is ugly).
- messenger.ts: sendProactively passes resolvedThreadId on the non-thread
  fallback path so channel @mentions that fall through outbound.ts -> send.ts
  still land in the original thread instead of top-level.

Live-validated: channel @mention -> bot replies in thread, threaded reply
-> bot replies in same thread, no top-level leakage.

* fix(msteams): tag outbound SDK calls with OpenClaw User-Agent

- user-agent.ts: add buildOpenClawUserAgentFragment() that returns just
  'OpenClaw/<version>'. The SDK's Client.clone merges this with its own
  'teams.ts[apps]/<sdk-version>' identifier — passing the full buildUserAgent()
  here would double-print the SDK token.
- sdk.ts: pass the fragment via AppOptions.client.headers['User-Agent'] so
  the Teams backend can identify OpenClaw traffic for usage telemetry.

Final UA looks like 'OpenClaw/<openclaw-version> teams.ts[apps]/<sdk-version>'.

* fix(msteams): handle StreamCancelledError when user presses Stop mid-stream

The new SDK throws StreamCancelledError synchronously from stream.emit/update
when the user pressed Stop in Teams: Teams replies 403 to the next chunk
update, the SDK flips _canceled, and any subsequent emit() throws. The old
custom TeamsHttpStream either swallowed cancel or didn't expose this exception
type, so the migration inherited an SDK behavior the original code didn't have
to handle.

Symptom on 2026-05-05: pressing Stop during a streaming reply caused an
unhandled promise rejection that crashed the Node 24 process. Docker restarted
the gateway about two minutes after each Stop click. Two related bugs surfaced
once the crash was caught: the would-be block fallback re-delivered the full
text as a second message (duplicate after Stop), and the typing-keepalive kept
pulsing in Teams for the rest of the agent run because nothing told it to
stop.

reply-stream-controller.ts:
- Wrap stream.update / stream.emit / stream.close in try/catch that swallows
  StreamCancelledError (matched by .name to dodge tsgo's SDK re-export
  resolution quirk). Latch a wasCanceled flag so subsequent calls
  short-circuit even if stream.canceled is stale.
- preparePayload() returns undefined when the stream was canceled — the
  streamed prefix is already visible to the user, so dropping the payload
  prevents a duplicate block message from overriding the cancel intent.

reply-dispatcher.ts:
- Typing-keepalive gate now also checks streamController.wasCanceled() so
  typing pulses stop firing once Stop is observed. Otherwise the bot keeps
  pulsing for the rest of the (uncancellable) agent run.

reply-stream-controller.test.ts:
- 6 new regression tests cover: cancel-during-emit (the crash scenario),
  cancel-during-update, cancel-during-finalize, non-cancel error propagation,
  post-cancel inactivity, and dropped-payload-on-cancel.

Live-validated: long streaming reply + Stop mid-stream -> stream freezes,
no duplicate message, no zombie typing, container stays healthy.

* fix(msteams): allow Bearer-token retry on Skype CDN attachment downloads

Teams puts inline DM images and clipboard-pasted images on
*.asm.skype.com URLs (e.g. us-api.asm.skype.com/v1/objects/<id>/views/imgo).
The download path in attachments/download.ts already does a plain GET first
and falls back to a Bearer-token retry on 401/403 — but the retry was gated
on the URL being in DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST. asm.skype.com hosts
were in DEFAULT_MEDIA_HOST_ALLOWLIST (download permitted) but not in the
auth-host list, so a 401 plain-GET response skipped the retry and surfaced
as a missing image to the agent.

Add asm.skype.com and ams.skype.com to the auth allowlist so openclaw
attempts the Bearer-token retry consistently, matching how it treats the
other CDN/Bot-Framework hosts already in the list.

Note: this does not unblock all clipboard-pasted DM images — for at least
some tenants asm.skype.com rejects the Bot Framework token (returns 401
even with auth). Routing those URLs through <serviceUrl>/v3/attachments/...
the way #62219 already handles HTML-wrapped attachments is a separate
follow-up. The +button 'Upload from this device' path works today because
Teams generates an attachment with an HTML wrapper that triggers the
existing BF v3 attachments fallback in monitor-handler/inbound-media.ts.

* fix(msteams): align docker-compose msteams port default with plugin default

The plugin defaults webhook.port to 3978 (the Bot Framework standard used in
Microsoft samples) and listens on whatever the operator sets there. The
docker-compose.yml port mapping was exposing ${OPENCLAW_MSTEAMS_PORT:-3000}:3000
which only works for operators who explicitly set webhook.port to 3000.
Default-config users would have the plugin listening on 3978 inside the
container while compose forwarded 3000, causing connection refused.

Realign to ${OPENCLAW_MSTEAMS_PORT:-3978}:3978 so a default-config docker
compose up Just Works with Teams. Operators wanting a custom port override
both webhook.port in openclaw.json and OPENCLAW_MSTEAMS_PORT env var.

* fix(msteams): post-rebase reconciliation with main

Three follow-ups after rebasing the SDK migration onto current main:

- reply-dispatcher.ts: rename createChannelReplyPipeline to its post-rebase
  identifier createChannelMessageReplyPipeline (the plugin-sdk barrel renamed
  it during the 1454-commit rebase window).
- reply-dispatcher.ts: tighten the typing-keepalive onStartError signature to
  (err: unknown) to satisfy upstream's stricter type checks.
- messenger.ts: drop the unconditional thread suffix on the bottom proactive
  fallback. The previous behavior threaded all top-level proactive sends when
  the stored ref had a threadId, which contradicts replyStyle='top-level'
  semantics (and breaks the new upstream test). Threading on the proactive
  path is preserved where it matters — the onRevoked branch within
  replyStyle==='thread' still passes resolvedThreadId, which is the original
  #55198 fix path.
- attachments.test.ts: update the warn-call assertion to match the migration's
  inline message format (host=... error=...) — the structured meta object was
  being dropped by the logger formatter pre-migration.

* feat(msteams): port streaming preview/progress features to ctx.stream

While the SDK migration was open, upstream landed preview/progress/draft
streaming features built on the OLD custom TeamsHttpStream class (which the
migration deletes). This commit ports the user-visible parts of those
features onto the new ctx.stream substrate so the migration doesn't lose
ground:

- pickInformativeStatusText: reads custom labels from
  msteams.streaming.progressDraft config via resolveChannelProgressDraftLabel.
  Falls back to the plugin-sdk default rotation. Pre-rebase used a hardcoded
  4-string array.
- streamMode resolution: "partial" (default, per-token streaming),
  "progress" (no tokens; preview card carries informative label that updates
  as tools run), or "block" (no native streaming). Mode is read from
  cfg.channels.msteams.streaming.preview.
- progress-draft gate: createChannelProgressDraftGate gates informative
  updates so the rotating label only starts firing once meaningful work has
  begun (avoids flicker before the first tool call).
- noteProgressWork() / pushProgressLine(): public methods on the controller
  for callers (typing keepalive ticks, tool-event callbacks) to signal work.
  pushProgressLine appends tool names as bullets above the rotating label
  when streaming.previewToolProgress is enabled. Wiring these into actual
  tool events is a separate follow-up.
- preparePayload progress-mode path: when stream is active but no tokens
  streamed (progress mode) and a final text payload arrives, emit the text
  into the stream so the preview card transitions in place to the final
  reply on close().

reply-dispatcher: pass log + msteamsConfig + a stable progressSeed
(${accountId}:${conversation.id}) to createTeamsReplyStreamController so the
informative-label rotation is consistent across reconnects.

What's NOT ported and why:
- Live-edit-via-replaceInformativeWithFinal: the SDK's HttpStream natively
  accumulates emitted text + entities + channelData and flushes ONE final
  activity at close() using the same activity id as the preview. So the
  separate "replace informative with final" call from upstream is
  unnecessary — we get live-finalization for free via the SDK's design.
- pushProgressLine triggers from tool events: needs reply-pipeline-side
  callbacks the new SDK migration didn't surface yet. Follow-up.

Tests: existing 22 reply-stream-controller tests still pass (the new
behaviors are additive).

* feat(msteams): wire pipeline tool events to streaming progress + fix test debt

Two follow-ups from yesterday's stopping point:

1. Wire pipeline events into the stream controller's progress-draft surface.
   reply-dispatcher's replyOptions now exposes onReasoningStream, onToolStart,
   onItemEvent, onPlanUpdate, onApprovalEvent, onCommandOutput callbacks that
   format each event via the channel-streaming helpers and route through
   streamController.pushProgressLine(). Mirrors the discord adapter's wiring.
   Also:
   - resolveChannelStreamingPreviewToolProgress + ...SuppressDefaultTool... so
     the dispatcher exposes suppressDefaultToolProgressMessages on its
     replyOptions when progress mode is on.
   - Switch disableBlockStreaming resolution to the channel-streaming helpers
     (resolveChannelPreviewStreamMode + resolveChannelStreamingBlockEnabled)
     so streaming.mode='block' and streaming.block.enabled=true are honored
     alongside the legacy blockStreaming boolean.

2. Fix the test debt that the rebase exposed:
   - reply-dispatcher.test.ts: drop the streamInstances + TeamsHttpStream
     mock pattern (file deleted by migration); replace with a streamMock
     provided via context.stream that mirrors the SDK's IStreamer shape
     (update/emit/close/canceled). Update assertions on sendInformativeUpdate
     -> stream.update, stream.update -> stream.emit. Drop the
     resumes-typing-between-segments test (no equivalent in the new
     ctx.stream model — the SDK's HttpStream doesn't have a 'between
     segments' notion; close ends the stream).
   - send.test.ts: fix two stale mock targets — loadOutboundMediaFromUrl
     comes from openclaw/plugin-sdk/outbound-media (not /msteams), and
     resolveMarkdownTableMode comes from openclaw/plugin-sdk/markdown-table-runtime
     (not /config-runtime). The previous mock paths were no-ops post-migration.

All 854 msteams tests now pass (was 17 failing in 4 files yesterday).

* fix(msteams): SDK streaming delta + use app.reply for proactive thread sends

Two narrow regressions exposed by the @microsoft/teams.apps migration:

- The SDK's HttpStream.emit appends each chunk to its internal buffer
  (`this.text += activity.text`), but the channel reply pipeline emits
  cumulative text on each chunk. Forwarding cumulative text into an
  appending sink produced "chunk1 + chunk1chunk2 + chunk1chunk2chunk3..."
  duplication for streamed (DM) replies. Track the emitted prefix length
  in the stream controller and only forward the new tail.
- Replace the manual `${convId};messageid=${msgId}` URL construction in
  the proactive thread fallback with `app.reply()`, which builds the
  threaded conversation id via the SDK's own toThreadedConversationId
  helper. Mechanically equivalent today; removes coupling to Teams' URL
  format and tracks any future SDK changes.

Also adds the `reply` method to the structural MSTeamsApp type so the
refactor typechecks without casts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): bump @microsoft/teams.api and teams.apps to 2.0.10

2.0.10 adds support for the AAD v1 token issuer that the Bot Framework
JWT validator needs. The minor version bump pulls teams.cards / common /
graph along to 2.0.10 too.

Add `@microsoft/teams.*` to `minimumReleaseAgeExclude` in
pnpm-workspace.yaml because 2.0.10 was published <48h ago and the default
`minimumReleaseAge: 2880` (~2 days) would otherwise reject it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* revert(msteams): remove asm.skype.com auth-host allowlist additions

These hosts were added in dfc169d31d for inline DM image auth-retry, but
the commit's own footnote acknowledges it doesn't actually unblock
clipboard-pasted images (asm.skype.com rejects Bot Framework tokens in
at least some tenants). The change is unrelated to the SDK migration and
the user-visible bug it claimed to fix isn't fixed; lifting it out keeps
this PR focused on the migration. Will land as a separate PR if the
auth-allowlist consistency improvement is wanted on its own.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(msteams): typed ExpressAdapter helper, drop unknown-cast pyramid

The monitor's SDK bootstrap had an awkward chain:

  httpServerAdapter: new (
    (await import("@microsoft/teams.apps")) as unknown as {
      ExpressAdapter: new (app: unknown) => unknown;
    }
  ).ExpressAdapter(expressApp) as never,

Three casts (`unknown`, structural shape literal, `never`) were a
defensive workaround from when the SDK's hashed d.ts files tripped up
tsgo. With the SDK's exports now resolving cleanly, the same import can
be done with full types.

- Extend the lazy `loadSdkModules()` cache to include `ExpressAdapter`
  alongside `App` so the dynamic import is shared.
- Add `createMSTeamsExpressAdapter(serverOrApp)` helper in `sdk.ts` that
  encapsulates the lazy import and returns a properly-typed adapter
  instance.
- Replace `httpServerAdapter`'s structural shape on `CreateMSTeamsAppOptions`
  with the SDK's own `IHttpServerAdapter` interface (re-exported from
  `@microsoft/teams.apps`).

The call site in `monitor.ts` becomes a single typed call with no `any`,
no `unknown`, no `as never`. The lazy-load behavior is preserved: nothing
imports `@microsoft/teams.apps` at module load time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): unbreak tsgo:extensions on the ExpressAdapter helper

CI's check-prod-types failed because the previous commit's typed helper
used `typeof import("@microsoft/teams.apps").ExpressAdapter`, which
tsc/tsgo's NodeNext resolution can't follow through the SDK's chained
`export *` barrel:

    @microsoft/teams.apps/dist/index.d.ts:
        export * from "./http";          // folder with index.d.ts
        export * from "./app";           // single .d.ts file

The folder re-export drops `ExpressAdapter` and `IHttpServerAdapter` from
the namespace shape under `tsconfig.extensions.json` (passes under the
per-extension `tsconfig.json` because of inherited `paths`). Same root
cause as why we already model `MSTeamsApp` structurally (line 47 comment).

Switch the ExpressAdapter side to the same structural-shape pattern:
- Define `MSTeamsHttpServerAdapter` and `MSTeamsExpressAdapterCtor` locally.
- Cast `m.ExpressAdapter` once inside `loadSdkModules` (the runtime export
  is fine; only the type surface is hidden).
- `httpServerAdapter` on `CreateMSTeamsAppOptions` and the return type of
  `createMSTeamsExpressAdapter` use the local structural type.

Net result: the call site in `monitor.ts` stays the cast-free single line
the previous commit landed; the one remaining cast is confined to the
SDK-loading helper with an explanatory comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): drop unused jsonwebtoken/jwks-rsa deps

The SDK migration removed all `import "jsonwebtoken"` / `import "jwks-rsa"`
from source code (the SDK does JWT validation internally now), but the
package.json entries and the matching `package-manifest.contract.test.ts`
expectation were left orphaned. Drop both:

- `extensions/msteams/package.json`: remove `jsonwebtoken` (^9), `jwks-rsa`
  (^4) from `dependencies` and `@types/jsonwebtoken` from `devDependencies`.
- `src/plugins/contracts/package-manifest.contract.test.ts`: remove the
  two entries from msteams's `pluginLocalRuntimeDeps` expectation.
- `monitor.lifecycle.test.ts`: extend the `./sdk.js` mock with the
  `createMSTeamsExpressAdapter` export added in the typed-helper cleanup,
  so the lifecycle suite still mounts after the deps drop.

Lockfile regenerates accordingly. All msteams tests (865) pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(msteams): drop unused @microsoft/teams.api direct dep

CI's deadcode:dependencies (knip) flagged @microsoft/teams.api as
unused in extensions/msteams. The plugin source uses structural type
aliases (MSTeamsActivityParams, MSTeamsActivityLike, etc.) to dodge
tsgo resolution bugs with teams.api's hashed d.ts files, so it never
imports teams.api directly. The package is brought in transitively
via @microsoft/teams.apps; the only other reference is
probe.test.ts's vi.mock("@microsoft/teams.api"), which works on the
import-path string and doesn't require a direct dep declaration.

Lockfile regenerates accordingly. tsgo:extensions, knip, and all
865 msteams tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): clear three CI gate failures (lint, contract, deprecated config API)

Three CI checks flagged on the latest run; all three are msteams-local
and unrelated to one another:

- **check-lint** / **check-additional-extension-bundled**:
  `oxlint` flagged a redundant `as string[]` assertion in
  `reply-dispatcher.ts:431`. The preceding `every((s: unknown) => typeof
  s === "string")` already narrows the array type, so the cast does
  nothing. Drop it.

- **checks-fast-contracts-plugins-c**: the
  `package-manifest.contract.test.ts` `pluginLocalRuntimeDeps` for
  msteams still expected `@microsoft/teams.api`, but the deadcode
  cleanup commit (8f4050f51a) dropped it from
  `extensions/msteams/package.json`. Remove it from the contract test
  too — `teams.api` is only present transitively via `teams.apps`,
  which is the reason knip flagged it.

- **check-additional-runtime-topology-architecture**: the deprecated
  internal config API guard caught `messenger.ts:223` calling
  `getMSTeamsRuntime().config.loadConfig()`. Switch to
  `config.current()` to match the pattern used by phone-control,
  synology-chat, and matrix.

Pre-existing failures on this run that are NOT msteams-related and not
caused by this PR: `check-test-types` (errors in
`src/agents/openai-transport-stream.test.ts` and
`pi-embedded-runner/openai-stream-wrappers.test.ts`) and `macos-swift`
(`hoistAwait` in `MacNodeRuntime.swift`). Leaving those for upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): cast config.current() return to OpenClawConfig

The previous commit switched `messenger.ts:223` from the deprecated
`config.loadConfig()` to `config.current()` to satisfy the architecture
guard, but `config.current()` returns a deeply-readonly type that's not
assignable to the `Partial<OpenClawConfig>` parameter
`resolveMarkdownTableMode` expects (a mutable type from the SDK
contract). Phone-control, synology-chat, and matrix all cast at this
seam — adopt the same pattern.

Verified locally: tsgo:core, tsgo:extensions, check:architecture, and
test:extensions:package-boundary:compile all pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(msteams): address PR review — pre-auth body limit, allowlist log level, /api/messages forwarder, narrow release-age exclude

Four narrow fixes from the PR review (BradGroux + clawsweeper bot +
galiniliev's plan), each its own concern:

- **pre-auth-body-limit** (monitor.ts) — install
  `express.json({ limit: DEFAULT_WEBHOOK_MAX_BODY_BYTES })` before the
  bearer-presence gate and SDK route. Express memoizes the parsed body
  on the request, so the SDK's later `json()` becomes a no-op and our
  limit applies before any handler parses bodies. Closes the gap where
  a `Bearer garbage`-shaped attacker could force unbounded JSON parsing
  before token validation.

- **allowlist-error-logging** (monitor.ts) — restore main's `runtime.error`
  level for the `msteams resolve failed` catch (was downgraded to
  `runtime.log` mid-merge). Graph allowlist resolution failures are
  security-relevant; they need to surface to operators.

- **legacy-messages-route** (monitor.ts) — when `webhook.path` is set
  to a custom value, also accept POSTs on the legacy `/api/messages`
  path with a one-time deprecation warning, then re-enter the Express
  middleware chain on the configured path. Keeps existing Azure Bot
  registrations working through the transition. Cast-free
  (`expressApp(req, res, next)` works because `Application extends
  IRouter extends RequestHandler`).

- **release-age-scope** (pnpm-workspace.yaml) — narrow
  `@microsoft/teams.*` glob to the single direct dep
  `@microsoft/teams.apps`. Future scoped packages no longer get a
  freshness-guard pass.

Tests + checks: msteams suite (867), tsgo:core, tsgo:extensions,
tsgo:test, lint:extensions, check:architecture, knip --dependencies,
package-manifest contract, all green.

Still pending from the review (separate commits):
- auth-coverage-tests (Brad #1 + comment) — tests proving the SDK accepts
  `aud=<bot app id>` and rejects `aud=api.botframework.com`.
- invoke-response-handling (Brad #2, codex P2) — file-consent invoke ack
  must return through the SDK invoke handler, not `ctx.sendActivity`.
- stream-failure-fallback (codex P2, galin F5) — `streamFailed` latch so
  partial streams fall back to block delivery on non-cancel errors.
- serviceurl-routing (Brad #4, codex P2) — proposed rebuttal pending
  empirical confirmation that `smba.trafficmanager.net/teams` routes to
  non-default-region conversations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(msteams): lock SDK auth contract — aud + v1/v2 issuer coverage

Adds extensions/msteams/src/auth-coverage.test.ts driving ServiceTokenValidator
and createEntraTokenValidator directly with jose-minted RS256 tokens against an
in-memory JWKS (via JwksClient.prototype patch). Locks in the three contract
cases @BradGroux flagged on #76262: aud=<bot app id> accepted, aud=api.botframework.com
rejected even when appid/azp match, and v1/v2 issuers accepted for allowed tenant
(disallowed tenant rejected).

Drops a stale ambient module declaration in src/types/microsoft-teams-sdk.d.ts
that was shadowing the SDK's real jwt-validator types with a long-renamed
createServiceTokenValidator surface.

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

* fix(msteams): route file-consent invokes through typed app.on, drop broken invokeResponse send

Brad #2 / codex #4 on PR #76262 — `ctx.sendActivity({ type: "invokeResponse", ... })`
no longer reaches Teams as an HTTP InvokeResponse on the new SDK; it becomes
an outbound Bot Framework activity instead. Move file-consent accept/decline
to typed `app.on("file.consent.accept|decline", ...)` handlers. The SDK's
typed-route layer wraps a void return into `{ status: 200 }`
(`app.process.js:130`), so the manual ack disappears.

While in here, type `MSTeamsApp.on` properly. Borrowing the SDK's `App.on`
directly fails because that function carries a `this: App<TPlugin>`
constraint our structural alias can't satisfy, so we model an equivalent
generic over `IRoutes` with route-specific overloads (`card.action`,
`file.consent.*`, `activity`). The overloads work around a tsgo bug — the
`@microsoft/teams.api` `Activity` discriminated union collapses to `any`,
turning `ActivityRoutes` into a `[string]: RouteHandler<X, void>` index
signature that swallows every typed `Out` not already void-compatible
(card.action returns `AdaptiveCardActionResponse`; the others happen to
include `void`). Real tsc resolves cleanly. Linked upstream:
https://github.com/microsoft/typescript-go/issues/1057.

Other cleanups:
- Cast-free call sites for `adaptSdkContext` (now returns
  `MSTeamsTurnContext` instead of `unknown`).
- card.action error responses include `innerHttpError` per the SDK's
  `HttpError` shape requirement.
- Activity catch-all also skips `fileConsent/invoke` now that it's
  typed-routed (parallel to the existing `adaptiveCard/action` skip).

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

* fix(msteams): route SSO sign-in invokes through typed app.on, drop broken invokeResponse send

Brad #2 / codex #4 on PR #76262, SSO half. Continue the typed-route migration:
`signin/tokenExchange` and `signin/verifyState` now register via
`app.on("signin.token-exchange" | "signin.verify-state", ...)`. Per the
SDK's router, registering a user route with the same name as a system
route removes the system default — so the SDK's built-in handlers (which
would call `api.users.token.exchange` themselves and emit a `signin` event
nobody currently subscribes to) are silenced, and only ours runs. The SDK
wraps a void return into the HTTP 200 InvokeResponse, so the legacy
`ctx.sendActivity({ type: "invokeResponse", ... })` ack — broken on the new
SDK because it becomes an outbound BF activity instead of the HTTP
response — is gone.

The handler body is extracted from the activity-catch-all dispatch in
`monitor-handler.ts` to a new `signin-invoke.ts`, parallel to
`file-consent-invoke.ts`. `isSigninInvokeAuthorized` is now exported from
`monitor-handler.ts` so the new handler can reuse it. The activity
catch-all skips the SSO invoke names alongside the existing skips for
`adaptiveCard/action` and `fileConsent/invoke`.

`MSTeamsAppOn` overloads now cover the two SSO routes with their typed
ctx (`ISignInTokenExchangeInvokeActivity` / `ISignInVerifyStateInvokeActivity`).
Tests in `monitor-handler.sso.test.ts` were rewritten to call the
extracted handler directly — the `registered.run(ctx)` shape no longer
covers SSO, and the `expect(ctx.sendActivity).toHaveBeenCalledWith({ type:
"invokeResponse" })` assertions were dropped to match the new contract
(the SDK ack happens via the typed-route return value).

Note on overlap with #77784 (Stefan Stüben, Microsoft): that PR is doing
a much bigger SSO rework (sign-in card / sign-in-link / six-digit-code
fallbacks plus a `ctx.auth` plumbed to plugin tools). This change is
the small migration-correctness fix and is structured so #77784's SSO
body changes drop into the typed-route registrations cleanly on rebase.

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

* fix(msteams): route message-submit (feedback) invokes through typed app.on

Last invoke off the activity catch-all dispatch. `message/submitAction`
(thumbs up/down on AI-generated messages) now registers via
`app.on("message.submit", ...)`. Same shape as file-consent and SSO:
handler body extracted to a new `feedback-invoke.ts`, the SDK wraps a
void return into the HTTP 200 InvokeResponse, the broken
`ctx.sendActivity({ type: "invokeResponse", ... })` line is gone, and
the activity catch-all skips this invoke name alongside the others.

`isFeedbackInvokeAuthorized` is exported from `monitor-handler.ts` so
`feedback-invoke.ts` can reuse it. Tests in
`monitor-handler.feedback-authz.test.ts` were rewritten to call the
extracted handler directly — the old `handler.run(ctx)` shape no longer
intercepts feedback, and `originalRun` was removed because the typed
route is the dispatch point now.

`MSTeamsAppOn` overload added with the typed
`IMessageSubmitActionInvokeActivity` ctx, slotted between the SSO
overloads and the `activity` catch-all so `activity` stays last.

This leaves only `message`, `conversationUpdate`, and `messageReaction`
flowing through `app.on("activity", ...)` → `handler.run`. Promoting
those is the path to deleting the catch-all entirely.

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

* fix(msteams): fall back to block delivery when partial-mode stream fails mid-flight

codex #5 / Galin F5 on PR #76262. `reply-stream-controller.ts` previously
re-threw any non-cancel error from `stream.emit` during partial streaming
and from `stream.emit`/`stream.close` during finalize. Combined with
`preparePayload` suppressing block delivery once `tokensEmitted` was
true, that meant a network blip or API error mid-stream produced a
truncated reply with no recovery — the user saw the prefix that made it
through and nothing else.

Add a `streamFailed` latch parallel to `canceledLocally` / `tokensEmitted`:

- `onPartialReply`: catch non-cancel errors, set `streamFailed = true`,
  log a warn, don't propagate (the pipeline must keep running so
  `preparePayload` can decide).
- `preparePayload`: when `tokensEmitted && streamFailed`, fall through to
  block delivery instead of suppressing. The user may see a duplicate
  (streamed prefix + full block reply); intentional — matches the
  pre-migration `TeamsHttpStream.hasContent` recovery and is better than
  truncated-only.
- `finalize`: same latch + warn on non-cancel close failure, swallow
  rather than throw. The streamed content already reached the user; the
  closing activity (AI-Generated marker, feedback channelData) is the
  only loss, not worth blowing up the dispatcher.
- `isStreamActive` returns false once the stream has failed.

New tests cover crash-mid-stream after tokens were emitted (assert block
delivery payload is returned), happy-path no-duplicate behavior (assert
`preparePayload` still suppresses when nothing failed), and finalize
close-failure (assert no throw). The pre-existing "re-throws non-cancel"
test was inverted to assert non-throwing latch behavior.

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

* fix(msteams): declare @microsoft/teams.api as a runtime dependency

Type-only `import("@microsoft/teams.api/dist/...").TypeName` references
in `sdk.ts` (added when typed `MSTeamsApp.on` overloads were introduced)
are picked up by the `extension-runtime-dependencies` contract test as
genuine runtime imports. Declaring `@microsoft/teams.api` as a direct
dep makes the contract pass; the package was already coming in
transitively via `@microsoft/teams.apps`.

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

* fix(msteams): keep SSO on SDK signin routes

* test(msteams): avoid redundant signin handler assertion

* docs(msteams): clarify Teams cloud support

* fix(msteams): use current SDK string helper

* fix(msteams): gate SDK invoke side effects

* test(msteams): avoid implicit any in lifecycle tests

* fix(msteams): preserve SDK user agent and matrix check

* fix(msteams): expose SDK common dependency

* fix(msteams): use SDK user agent merge

* fix(msteams): fall back when stream close no-ops

* chore(msteams): drop unrelated merge artifacts

* chore(msteams): restore unrelated main files

* chore(msteams): restore unrelated main files

* chore(msteams): restore unrelated main files

* test(msteams): type stream close mock result

* fix(msteams): configure Teams cloud service URL

* chore(msteams): refresh shrinkwrap

* chore(deps): refresh shrinkwrap locks

* chore(ci): rerun guards after main sync

* chore(deps): refresh shrinkwrap for node 24

* chore(config): refresh docs baseline

* fix(msteams): preserve Teams SDK proactive references

* fix(msteams): harden SDK proactive sends

* fix(msteams): align service url contract

* test: fix bonjour beacon type narrowing

* fix(msteams): ignore ambient service url

* fix(msteams): fall through submit invokes

* test: align shrinkwrap override policy with Teams SDK deps

* fix(msteams): ack invoke routes promptly

* fix(msteams): support china cloud boundaries

* test: sync PR with current CI gates

* test: isolate channel setup registry metadata

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-28 22:54:34 +01:00
Peter Steinberger
912e9dd0a6 fix: validate pixverse video response ids 2026-05-28 17:52:04 -04:00
Peter Steinberger
1fd73947d1 fix: validate pixverse video seed metadata 2026-05-28 17:49:58 -04:00
Peter Steinberger
4dbe2a3d2b fix: validate deepinfra video seed metadata 2026-05-28 17:48:09 -04:00
Peter Steinberger
1e913580d4 fix: validate fal seedance durations 2026-05-28 17:46:16 -04:00
Vincent Koc
dcecda5596 fix(e2e): reject loose journey limits 2026-05-28 23:45:26 +02:00
Vincent Koc
b8311ad6ea refactor: dedupe script validation helpers 2026-05-28 23:44:34 +02:00
Peter Steinberger
bab9a8dc37 fix: validate together video durations 2026-05-28 17:44:06 -04:00
Peter Steinberger
6d39b94a7b ci(release): serialize Telegram CI bot consumers 2026-05-28 22:42:06 +01:00
Paul Frederiksen
e69855e68c fix(codex): recover raw missing-thread compaction failures (#87738)
Recover Codex compaction paths when a stale app-server thread binding returns an unstructured `thread not found` failure. The raw missing-thread response now shares the same recovery behavior as structured missing/stale binding failures for preflight, queued compaction, and CLI fallback.

Fixes #87736.

Co-authored-by: Paul Frederiksen <paul@paulfrederiksen.com>
2026-05-28 22:41:44 +01:00
Peter Steinberger
2bc3c7ad5a fix: validate byteplus video durations 2026-05-28 17:40:33 -04:00
Jason (Json)
0dbdaf98ea fix: release session lock before runtime teardown (#87747)
Summary:
- The PR reorders embedded attempt cleanup to release the session write lock before session/MCP/LSP teardown, treats sessions_yield cleanup as abort-like for flush timing, and adds focused regression tests.
- PR surface: Source +14, Tests +71. Total +85 across 3 files.
- Reproducibility: yes. Source inspection shows current main releases the cleanup lock only after runtime tear ... R body’s terminal proof exercises the same ordering with production cleanup and filesystem lock primitives.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Merge branch 'main' into fix/session-lock-release-before-teardown

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

Prepared head SHA: 178192fa0e
Review: https://github.com/openclaw/openclaw/pull/87747#issuecomment-4566994280

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-28 21:40:06 +00:00
Peter Steinberger
59997d8689 perf: avoid no-op session store rewrites 2026-05-28 22:37:56 +01:00
Peter Steinberger
a2d386638c fix: validate byteplus video seeds 2026-05-28 17:37:52 -04:00
Dallin Romney
563ad77d13 ci: sparse checkout CodeQL actions shard (#87775) 2026-05-28 14:36:31 -07:00
Vincent Koc
b05aefa3cf fix(release): bound beta smoke waits 2026-05-28 23:35:06 +02:00
Peter Steinberger
fc6fd9aa36 fix: validate inworld speech temperature 2026-05-28 17:34:49 -04:00
Peter Steinberger
769de93f9c fix: validate xai speech speed 2026-05-28 17:32:39 -04:00
Peter Steinberger
e04158a028 fix: validate volcengine speech speed ratio 2026-05-28 17:30:33 -04:00
Jason (Json)
8a007c987d fix(agents): fallback when generated media handoff locks
Generated-media completions now use the existing idempotent direct-media fallback when active requester wake has already failed and the requester-agent handoff hits a session write-lock-shaped no-response error. Generic requester-agent handoff errors still fail visibly instead of direct-sending after an unknown side effect.

Release-note context: fixes a message-delivery loss path for generated images, music, and video where the artifact had been created but the final handoff could be reported as failed after a session write lock.

Verification:
- GitHub CI run 26601111985 passed at b0be994332.
- Blacksmith Testbox through Crabbox tbx_01ksr2jtt3fnz0zqvwmqq513h7 covered the exact lock fallback and qa-channel generated-media smoke.
- git diff --check origin/main...refs/remotes/pull/87741/head passed before merge.

Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-28 22:30:13 +01:00
Peter Steinberger
51b69497a3 fix: cover explicit live model discovery 2026-05-28 22:27:35 +01:00
Peter Steinberger
c84d53ccfe fix: validate minimax speech voice settings 2026-05-28 17:26:29 -04:00
Peter Steinberger
d9452e6acb fix: validate openrouter video seeds 2026-05-28 17:16:51 -04:00
Peter Steinberger
ee6f26406f fix(release): keep mock release lanes offline
(cherry picked from commit 6ef61e4daf2fda31b84c3652feafb04677415371)
2026-05-28 22:15:42 +01:00
Peter Steinberger
4512363e85 fix: validate pixverse video seeds 2026-05-28 17:12:22 -04:00
Peter Steinberger
a5241782ca fix(nostr): close relay pool after subscription shutdown 2026-05-28 22:10:43 +01:00
IWhatsskill
8a76cc3470 fix(nostr): keep dm subscriptions alive until abort 2026-05-28 22:10:43 +01:00
Peter Steinberger
22515eea44 fix: validate google live thinking budget 2026-05-28 17:09:36 -04:00
Peter Steinberger
017e241162 fix: validate google live vad timing 2026-05-28 17:07:08 -04:00
Peter Steinberger
09c3768cdd fix: validate elevenlabs latency tiers 2026-05-28 17:04:07 -04:00
Vincent Koc
f0207d3ea0 fix(security): bound prod audit registry responses 2026-05-28 23:02:24 +02:00
Peter Steinberger
b5202f975b fix: validate elevenlabs speech seeds 2026-05-28 17:01:59 -04:00
Peter Steinberger
516be11db9 docs: add shrinkwrap and release performance report 2026-05-28 22:00:55 +01:00
Vincent Koc
3807a01542 test: dedupe gateway benchmark teardown tests 2026-05-28 23:00:29 +02:00
Peter Steinberger
1d965d9a6f fix: validate elevenlabs voice settings 2026-05-28 16:59:08 -04:00
Peter Steinberger
ec4a00beae fix: validate openai speech speeds 2026-05-28 16:56:01 -04:00
Peter Steinberger
3533297cd9 fix: validate openai realtime voice numeric config 2026-05-28 16:49:10 -04:00
Vincent Koc
db66004b31 fix(agents): stream assistant deltas incrementally (#87671) 2026-05-28 21:48:33 +01:00
Peter Steinberger
2b69cfe030 fix: validate openai realtime transcription vad 2026-05-28 16:46:15 -04:00
Super Zheng
0c716d7717 perf(plugins): reuse facade manifest snapshots
Reuses the current plugin metadata snapshot in facade activation checks when the resolved boundary config matches, avoiding repeated manifest registry loads on the facade path.

Falls back to manifest registry loading when the current snapshot is missing or belongs to a different config/environment. Adds regression coverage for snapshot mismatch, snapshot reuse, and Windows path normalization.

Co-authored-by: 郑苏波 (Super Zheng) <superzheng@tencent.com>
2026-05-28 21:45:17 +01:00
Peter Steinberger
9a21e4e6c2 perf: cache plugin registry snapshots 2026-05-28 21:45:09 +01:00
Peter Steinberger
b5d90ae4ec fix: validate voice-call legacy streaming numbers 2026-05-28 16:43:31 -04:00
Vincent Koc
b3fbe5325e refactor: dedupe approval and benchmark helpers 2026-05-28 22:41:26 +02:00
Peter Steinberger
607e6c206f fix: validate elevenlabs realtime numeric config 2026-05-28 16:39:25 -04:00
Peter Steinberger
48291462ef fix: validate runway video durations 2026-05-28 16:35:09 -04:00
Peter Steinberger
ccf3476a4a fix: validate deepinfra video seeds 2026-05-28 16:32:58 -04:00
Peter Steinberger
6966c202b9 fix: validate media size dimensions 2026-05-28 16:30:23 -04:00
Fermin Quant
205d6b730f fix(agents): attribute embedded tool logs to channels
Fixes #50565.
2026-05-28 21:29:26 +01:00
Peter Steinberger
a661506b0f fix(release): satisfy lint for beta preflight 2026-05-28 21:28:50 +01:00
Dallin Romney
2be9eb1e97 ci: consolidate Blacksmith runner defaults (#87503) 2026-05-28 13:28:22 -07:00
Peter Steinberger
714ff554fd fix: validate provider retry attempts 2026-05-28 16:28:07 -04:00
Peter Steinberger
b1c95a82a0 fix: refresh live Together and Vydra coverage 2026-05-28 21:27:50 +01:00
Vincent Koc
9268f9fe8a fix(e2e): drop unused codex media event buffer 2026-05-28 22:27:39 +02:00
Peter Steinberger
90c2ac3b6a fix: validate memory retry attempts 2026-05-28 16:26:08 -04:00
ooiuuii
f49a3e4c26 fix: bound aggregate prompt tool results
Bound aggregate tool-result history at the provider prompt boundary without rewriting persisted session entries.

Provider-visible prompt history now trims older aggregate tool results before newer evidence, while canonical session history, slash/extension command handlers, and context-engine afterTurn snapshots stay unmodified.

Co-authored-by: luyifan <al3060388206@gmail.com>
2026-05-28 21:25:02 +01:00
Peter Steinberger
4cbce8458d fix: validate acp prompt timeout metadata 2026-05-28 16:23:59 -04:00
Peter Steinberger
80c50c2370 test(whatsapp): fix remote media header mocks 2026-05-28 21:22:17 +01:00
Peter Steinberger
898f74c27e test(release): align runtime alias expectation 2026-05-28 21:22:17 +01:00
Peter Steinberger
a8dec44f56 fix(release): accept openclaw qa runtime alias 2026-05-28 21:22:17 +01:00
Peter Steinberger
2267ddc3a0 fix(release): accept openclaw runtime alias 2026-05-28 21:22:17 +01:00
Tideclaw
05202c1f8a test: tighten imessage notification callback type 2026-05-28 21:22:16 +01:00
Vincent Koc
9803261f71 fix(media): cancel ignored input fetch bodies 2026-05-28 21:22:16 +01:00
Vincent Koc
5e68d2f811 fix(media): cancel oversized fetch responses 2026-05-28 21:22:16 +01:00
Peter Steinberger
6e3f38d033 fix: validate telegram throttle ids 2026-05-28 16:21:35 -04:00
Peter Steinberger
e85231d63d fix: validate google oauth token expiry 2026-05-28 16:19:34 -04:00
Vincent Koc
686751f639 test(agents): add small model live profile (#87638) 2026-05-28 21:17:40 +01:00
Peter Steinberger
f7507fd921 fix: validate msteams error status codes 2026-05-28 16:16:43 -04:00
Peter Steinberger
ea682182d0 fix: isolate npm plugin installs per package (#87647)
* fix: isolate npm plugin installs per package

* test: assert isolated npm plugin projects in upgrade survivor

* test: assert plugin lifecycle npm project roots

* test: resolve npm project deps in live assertions

* fix: resolve codex bins from isolated npm projects

* docs: document isolated npm plugin projects

* ci: configure testbox workflow for crabbox

* fix: stabilize npm project fingerprint

* fix: keep fetch runtime import side-effect free

* test: keep dynamic live model unit hermetic

* ci: handle empty node toolcache roots

* test: make nounset toolcache probe deterministic
2026-05-28 21:16:07 +01:00
Vincent Koc
2b587be44d fix(e2e): bound tool search fetch bodies 2026-05-28 22:14:46 +02:00
Vincent Koc
50e6bd307d test(agents): narrow Codex bootstrap assertions 2026-05-28 22:14:38 +02:00
Peter Steinberger
7bc871139d fix: validate pricing content length headers 2026-05-28 16:14:25 -04:00
Dallin Romney
c629270f23 ci: filter release workflow matrices (#87508) 2026-05-28 13:12:54 -07:00
Dallin Romney
3dee915b3b ci: warm pnpm store before node fanout (#87518)
* ci: warm pnpm store before node fanout

* test: update pnpm cache acceptance key

* ci: clarify pnpm cache save controls
2026-05-28 13:11:20 -07:00
Peter Steinberger
09c5b2dd37 fix: validate discord component numeric limits 2026-05-28 16:10:07 -04:00
Vincent Koc
59205bd63c fix(e2e): bound Telegram Bot API helper bodies 2026-05-28 22:01:36 +02:00
Agustin Rivera
6fd4aa8a27 fix(nvidia): load featured model catalog (#80775)
* fix(nvidia): load featured model catalog

Co-authored-by: CaptainTimon <CaptainTimon@users.noreply.github.com>

* fix(nvidia): widen catalog fetch timeout

* fix(nvidia): cover catalog registration

* fix(picker): include provider catalog loader

* fix(nvidia): guard featured catalog fetch

* fix(nvidia): sync bundled catalog with live API

Replace minimaxai/minimax-m2.5 (MiniMax M2.5) with minimaxai/minimax-m2.7 (Minimax M2.7) and z-ai/glm5 (GLM-5) with z-ai/glm-5.1 (GLM 5.1) in the bundled fallback catalog to match NVIDIA's public featured-models endpoint.

Update docs table and all extension test expectations.

* fix(nvidia): retain shipped catalog refs

* fix(picker): keep alias catalog rows

* fix(nvidia): restore live catalog priority

---------

Co-authored-by: CaptainTimon <CaptainTimon@users.noreply.github.com>
2026-05-28 12:59:55 -07:00
Peter Steinberger
409356fc66 fix: validate synology rate limits 2026-05-28 15:57:51 -04:00
Peter Steinberger
c0946e6e58 chore: remove stale dependency ownership records 2026-05-28 20:56:51 +01:00
Peter Steinberger
fcbc254d0d fix: validate feishu action count params 2026-05-28 15:55:19 -04:00
Vincent Koc
9cb4e48018 test(infra): use bonjour beacon type in discovery test
Replace the loose bonjour discovery test record shape with the exported GatewayBonjourBeacon contract so invalid-port regression coverage keeps passing the core test typecheck gate.

Verification:
- node scripts/run-vitest.mjs run src/infra/bonjour-discovery.test.ts --reporter=verbose
- node scripts/run-oxlint.mjs src/infra/bonjour-discovery.test.ts
- ./node_modules/oxfmt/bin/oxfmt --check --threads=1 src/infra/bonjour-discovery.test.ts
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode local
- AWS Crabbox corepack pnpm check:changed: run_1af313cdd0bb, cbx_f0ef52388e7c, provider aws, exit 0

PR: https://github.com/openclaw/openclaw/pull/87765
2026-05-28 20:53:02 +01:00
Peter Steinberger
46f023d097 fix: validate nextcloud talk numeric flags 2026-05-28 15:52:39 -04:00
Peter Steinberger
8b180fe829 fix: reject malformed tlon sse event ids 2026-05-28 15:50:40 -04:00
Vincent Koc
b84078a975 fix(e2e): bound Telegram RTT driver API bodies 2026-05-28 21:48:45 +02:00
Peter Steinberger
1dcb677985 fix: reject partial qq reminder durations 2026-05-28 15:48:30 -04:00
Peter Steinberger
c42664f9b2 fix: require integer qmd line metadata 2026-05-28 15:46:23 -04:00
Peter Steinberger
04a6fd7fde fix: validate debug proxy connect ports 2026-05-28 15:44:19 -04:00
Peter Steinberger
483b06fb86 fix(ci): serialize oxlint on constrained runners 2026-05-28 20:42:37 +01:00
Peter Steinberger
d487c58c6f fix: validate browser profile ports 2026-05-28 15:40:16 -04:00
Peter Steinberger
1e67387475 fix: validate browser responsebody limits 2026-05-28 15:37:59 -04:00
Peter Steinberger
8ed9330a30 perf: defer Slack full startup (#87760) 2026-05-28 20:37:09 +01:00
Vincent Koc
605e2976ed fix(e2e): bound release fixture response bodies 2026-05-28 21:36:25 +02:00
Peter Steinberger
8fbdfc0a76 fix: validate browser geolocation numbers 2026-05-28 15:35:14 -04:00
Peter Steinberger
503d8d5542 fix: validate browser snapshot numbers 2026-05-28 15:32:25 -04:00
Peter Steinberger
f99259d25c fix(perf): preserve gateway health benchmark auth 2026-05-28 20:29:24 +01:00
Peter Steinberger
ec8ff27803 fix: validate browser viewport dimensions 2026-05-28 15:29:12 -04:00
Peter Steinberger
afb56ea972 fix: reject invalid browser tab indexes 2026-05-28 15:26:33 -04:00
clawsweeper[bot]
3617247c65 fix(tui): force repaint final chat events (#87423)
Summary:
- The PR changes three TUI final chat-event early returns to call `tui.requestRender(true)` and adds focused event-handler assertions for those branches.
- PR surface: Source 0, Tests +25. Total +25 across 2 files.
- Reproducibility: yes. Current main and the latest release still have the three unforced final-event repaint calls, and the linked source PR includes PTY terminal proof showing the changed behavior after the patch.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(tui): force repaint final chat events

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

Prepared head SHA: 570dc3af86
Review: https://github.com/openclaw/openclaw/pull/87423#issuecomment-4558845936

Co-authored-by: Ted Li <tl2493@columbia.edu>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-28 19:26:05 +00:00
Rohit
376b03f8ea fix(plugins): reject incompatible package plugin API installs (#87477)
* fix(plugins): enforce package plugin API compatibility

* fix(plugins): preserve plugin API prerelease floors

* fix(plugins): gate persisted plugin api compatibility

* fix(plugins): skip incompatible package discovery

* fix(plugins): check api compatibility before package shape

* fix(plugins): gate bundle package api compatibility

* docs(plugins): clarify plugin API release sync

* test(agents): keep dynamic live model unit test runtime-free

* fix(plugins): normalize correction plugin api floors

* test(agents): align dynamic normalizer expectation

* fix(plugins): reject malformed plugin api metadata

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-28 20:25:30 +01:00
Vincent Koc
4d5b317ace fix(e2e): bound ClawHub preflight response bodies 2026-05-28 21:24:18 +02:00
Vincent Koc
396a8ef6f8 fix(agents): loosen abort settle env typing
Narrow the abort-settle timeout helper to the env keys it reads and keep the dynamic live-model hook unit test from loading provider normalization/runtime plugins.\n\nProof: focused Vitest for live-model-dynamic-candidates, oxfmt/oxlint/diff checks, autoreview clean, AWS Crabbox run_8a485e593c2e corepack pnpm check:changed exit 0, and PR CI green.
2026-05-28 20:24:06 +01:00
Peter Steinberger
5eed10fd6e fix: reject invalid discovery ports 2026-05-28 15:23:47 -04:00
Peter Steinberger
76130fd988 fix: parse tar verbose sizes strictly 2026-05-28 15:19:53 -04:00
Peter Steinberger
73168d37ac feat: support encrypted PDF extraction (#87751) 2026-05-28 20:19:49 +01:00
Peter Steinberger
41366d3f51 fix: ignore unsafe timestamp values 2026-05-28 15:17:13 -04:00
Peter Steinberger
bd773d2f61 fix: parse subagent depth strings strictly 2026-05-28 15:15:06 -04:00
Peter Steinberger
2a5a9fd720 fix: parse usage query numbers strictly 2026-05-28 15:13:27 -04:00
Vincent Koc
4fb904ca63 fix(e2e): bound Parallels host server stderr 2026-05-28 21:11:44 +02:00
clawsweeper[bot]
dfe9774387 fix(minimax): stream music generation responses (#84764)
Summary:
- The PR updates the bundled MiniMax music provider to request streaming hex responses, decode SSE/audio bodie ... while preserving JSON/url fallbacks, and adds provider tests for streaming, fallback, and timeout behavior.
- PR surface: Source +148, Tests +152. Total +300 across 2 files.
- Reproducibility: yes. by source inspection and live proof, though I did not run a fresh live reproduction. C ... s provider fallback, and the source PR reports a 130s live MiniMax provider run succeeding after the patch.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(minimax): stream music generation responses
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8456…

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

Prepared head SHA: 806b0b40f2
Review: https://github.com/openclaw/openclaw/pull/84764#issuecomment-4504175527

Co-authored-by: Neerav Makwana <261249544+neeravmakwana@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-28 19:11:37 +00:00
Peter Steinberger
663cf97bea fix: parse sessions filters strictly 2026-05-28 15:11:08 -04:00
Peter Steinberger
7c4601ec73 feat(slack): render progress as native task cards
Render Slack progress-mode updates as native task-card progress blocks, with bounded Slack chunk text and stable fallback behavior.

Also deep-merge Slack account streaming objects over top-level defaults while preserving legacy scalar account overrides, and keep the plugin SDK fetch runtime import path from evaluating guarded-fetch dispatcher code.

Verification:
- pnpm test extensions/slack/src/progress-blocks.test.ts extensions/slack/src/accounts.test.ts src/plugin-sdk/fetch-runtime.test.ts
- pnpm lint --threads=8
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub PR checks green on #87748 at 4803e98820

Refs #82258

Co-authored-by: Simon van Laak <32648751+simonvanlaak@users.noreply.github.com>
2026-05-28 20:08:51 +01:00
Peter Steinberger
588078224b fix: parse session lock env timeouts strictly 2026-05-28 15:06:57 -04:00
Peter Steinberger
a0fcb91670 fix(release): keep private test helpers out of npm pack 2026-05-28 20:05:50 +01:00
Dallin Romney
2d8cebba5c test: surface broad local Vitest runs (#87757) 2026-05-28 12:05:19 -07:00
Peter Steinberger
1ac8c71cf5 fix: reject unsafe memory duration values 2026-05-28 15:04:27 -04:00
Dallin Romney
9f0fccd3a5 test(auto-reply): narrow directive model test dependencies (#87519)
* test(auto-reply): narrow directive model test dependencies

* test(auto-reply): stabilize directive model test mocks
2026-05-28 12:03:08 -07:00
Peter Steinberger
490c226202 fix: parse matrix no-reply window strictly 2026-05-28 15:02:14 -04:00
Peter Steinberger
a2595f16d4 fix: reject unsafe bash env integers 2026-05-28 15:00:08 -04:00
Vincent Koc
1a926d19b0 fix(e2e): bound RTT credential broker bodies 2026-05-28 20:58:01 +02:00
Peter Steinberger
d23e4111b0 fix: parse matrix qa env timeouts strictly 2026-05-28 14:57:52 -04:00
Peter Steinberger
a691d52329 fix: parse slack cache ttl strictly 2026-05-28 14:54:02 -04:00
Vincent Koc
f9834a3f95 ci: pin macOS runner labels 2026-05-28 20:52:52 +02:00
Peter Steinberger
43e243f436 fix: support grouped skill folders
Support grouped skill folders while keeping skill invocation flat via frontmatter names.

Includes bounded nested SKILL.md discovery, refresh/watch coverage for grouped folders, plugin symlink containment, and docs for grouped skill organization.

Verification:
- Node 24 targeted skill discovery and refresh tests passed locally.
- Docs checks passed locally and in CI.
- Autoreview clean.
- Crabbox live OpenAI proof showed nested foo/bar skills listed and visible in the agent system prompt.
- CI run 26595118581 passed.
2026-05-28 19:52:27 +01:00
Peter Steinberger
4b8c260444 fix: parse browser action timeouts strictly 2026-05-28 14:51:23 -04:00
alkor2000
b3db1dba85 fix(anthropic): stop migrating current claude-haiku-4-5 to sonnet (#87719)
Summary:
- The branch preserves current Claude Haiku 4.5 refs in the Anthropic resolver and doctor migration, repoints the bare `haiku` family alias to `claude-haiku-4-5`, and updates regression tests.
- PR surface: Source +5, Tests +21. Total +26 across 4 files.
- Reproducibility: yes. Current main source maps the bare `haiku` alias and explicit Haiku 4.5 migration path  ... de-sonnet-4-6`; the PR body also supplies before/after terminal proof for the resolver and migration tests.

Automerge notes:
- No ClawSweeper repair was needed after automerge opt-in.

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

Prepared head SHA: 64429e23b3
Review: https://github.com/openclaw/openclaw/pull/87719#issuecomment-4566419633

Co-authored-by: alkor2000 <200923177@qq.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-28 18:50:36 +00:00
Peter Steinberger
0786f586af fix: parse memory wiki cli numbers strictly 2026-05-28 14:49:02 -04:00
Peter Steinberger
f0bfa650dc fix: parse browser cli numbers strictly 2026-05-28 14:46:34 -04:00
NVIDIAN
6fbdae1c51 fix(memory-core): cap Dreaming short-term recall growth
Cap Dreaming short-term recall stores so repeated recall recording, repair, and promotion application cannot grow the JSON artifact without bound.

The fix keeps full normalized snippets for recall identity and contamination checks before truncating persisted snippets, exposes the new overflow audit code through the SDK facade, and adds regression coverage for recording, repair, promotion rehydration, and deterministic retention ties.

Fixes #87095.

Verification:
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs extensions/memory-core/src/short-term-promotion.test.ts src/commands/doctor-memory-search.test.ts src/plugin-sdk/memory-core-engine-runtime.test.ts
- pnpm tsgo:prod
- pnpm check:test-types
- pnpm lint --threads=8
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- PR CI run 26594527697: unrelated current-main failures only in checks-node-agentic-plugin-sdk and checks-node-agentic-agents; same failures reproduced on main run 26594198639.

Co-authored-by: ai-hpc <mail.speedy.hpc@hotmail.com>
2026-05-28 19:44:58 +01:00
Peter Steinberger
e8f29087ae fix: validate configure gateway ports strictly 2026-05-28 14:43:19 -04:00
Peter Steinberger
b2fdbc53e8 fix: parse qa parent pid strictly 2026-05-28 14:41:02 -04:00
Peter Steinberger
528371e7a4 docs(changelog): refresh 2026.5.28 notes 2026-05-28 19:39:42 +01:00
Peter Steinberger
5e33d7dff9 fix: parse discord gateway timeouts strictly 2026-05-28 14:39:05 -04:00
Peter Steinberger
cd80b4efca fix: parse qa cli integers strictly 2026-05-28 14:36:47 -04:00
Peter Steinberger
68ff0b9881 fix: parse memory cli numbers strictly 2026-05-28 14:33:18 -04:00
Peter Steinberger
8ec4a72f64 fix: parse voice call cli integers strictly 2026-05-28 14:31:24 -04:00
Vincent Koc
8338986a59 fix(e2e): bound telegram credential broker bodies 2026-05-28 20:29:16 +02:00
Peter Steinberger
d23e4aea6f fix: parse google meet cli numbers strictly 2026-05-28 14:28:49 -04:00
Peter Steinberger
a82dfb8e58 fix: parse google meet env numbers strictly 2026-05-28 14:25:58 -04:00
Peter Steinberger
2afff85ca4 fix: parse signal archive length strictly 2026-05-28 14:23:26 -04:00
Peter Steinberger
b87510957f docs: clarify Codex native hook relay recovery 2026-05-28 19:21:50 +01:00
Peter Steinberger
4ad9f0bdbb refactor: route node proxy agents through proxyline 2026-05-28 19:21:50 +01:00
Peter Steinberger
2305bca782 fix: parse discord rate limit headers strictly 2026-05-28 14:21:29 -04:00
Peter Steinberger
bcf354eac1 fix: parse codex retry headers strictly 2026-05-28 14:19:47 -04:00
Vincent Koc
21e69fdd4f fix(ollama): promote plain text tool calls
Wrap Ollama native streams with the shared plain-text tool-call compatibility wrapper so local/plain-text tool requests are delivered as structured toolCall events when matching tools are available.

Verified with live local Ollama proof, focused Testbox Vitest, Testbox check:changed, and autoreview.
2026-05-28 19:18:41 +01:00
Peter Steinberger
7859ee396e fix: parse provider retry dates strictly 2026-05-28 14:17:36 -04:00
Peter Steinberger
5eee488d93 fix: parse discord api retry headers strictly 2026-05-28 14:12:33 -04:00
Peter Steinberger
1d28dd87a5 fix: parse discord retry delays strictly 2026-05-28 14:08:04 -04:00
Peter Steinberger
a8991e02d8 fix: parse feishu startup timeout env strictly 2026-05-28 14:05:23 -04:00
Peter Steinberger
99f70284bf fix: parse feishu timeout env strictly 2026-05-28 14:03:06 -04:00
Peter Steinberger
21db3ff11c fix: parse telegram qa timeout env strictly 2026-05-28 14:00:56 -04:00
Peter Steinberger
19d1c217dc fix: parse qa credential integer env strictly 2026-05-28 13:57:33 -04:00
Lior Balmas
492105db5a fix(media): compact whatsapp terminal qr (#87581) 2026-05-28 10:57:03 -07:00
Peter Steinberger
d3b5413a01 fix: parse qa worker stagger env strictly 2026-05-28 13:55:13 -04:00
Peter Steinberger
2e8b3445fb fix: parse qa transport timeout env strictly 2026-05-28 13:53:18 -04:00
Peter Steinberger
339a74a342 fix: parse qa process metrics strictly 2026-05-28 13:51:33 -04:00
Peter Steinberger
5b79ab0901 fix: parse codex computer use timeout env strictly 2026-05-28 13:49:38 -04:00
Peter Steinberger
929b3a4f16 fix: parse codex migration timeout env strictly 2026-05-28 13:45:05 -04:00
Peter Steinberger
2cde331772 fix: parse qa suite concurrency env strictly 2026-05-28 13:41:28 -04:00
Dallin Romney
5f9d71f8af fix(ci): raise plugin sdk strict smoke heap (#87729) 2026-05-28 10:39:32 -07:00
Peter Steinberger
df4475d232 fix: parse embedded abort settle timeout strictly 2026-05-28 13:38:19 -04:00
Peter Steinberger
f90e266416 fix: parse sdk retry wait env strictly 2026-05-28 13:36:34 -04:00
Vincent Koc
bbc9a7d3fa fix(e2e): bound OpenWebUI probe response bodies 2026-05-28 19:35:12 +02:00
Peter Steinberger
d47eee4407 fix: parse queue caps strictly 2026-05-28 13:34:47 -04:00
Peter Steinberger
2122dccb91 fix: parse gateway usage days strictly 2026-05-28 13:31:45 -04:00
Peter Steinberger
d08bcb427e fix: parse http idle timeout strings strictly 2026-05-28 13:29:15 -04:00
Peter Steinberger
42688f5aae fix: parse cleanup timeout env strictly 2026-05-28 13:27:20 -04:00
Peter Steinberger
d6c8e05de9 fix: parse handshake timeout env strictly 2026-05-28 13:25:12 -04:00
Peter Steinberger
ca87241289 fix: parse cron stagger strings strictly 2026-05-28 13:22:54 -04:00
Peter Steinberger
ed9299a216 fix: reject invalid cron epoch timestamps 2026-05-28 13:20:09 -04:00
Peter Steinberger
3bf86877c2 fix: parse cron task run ids strictly 2026-05-28 13:17:57 -04:00
clawsweeper[bot]
202ccf4cf7 fix(native-hook-relay): prune stale bridge files on registration (#87706)
Summary:
- The PR adds registration-time pruning of expired or ESRCH-dead native-hook relay bridge JSON files and regression tests for dead, expired, live, and unknown-liveness foreign records.
- PR surface: Source +59, Tests +148. Total +207 across 2 files.
- Reproducibility: yes. The linked source PR includes a concrete live WSL2/systemd reproduction with stale bri ...  hook failures, and current source shows the native hook CLI fails closed when the relay cannot be reached.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(native-hook-relay): cover stale bridge pruning
- PR branch already contained follow-up commit before automerge: ci: raise plugin sdk strict smoke heap
- PR branch already contained follow-up commit before automerge: test(native-hook-relay): satisfy process kill mock types
- PR branch already contained follow-up commit before automerge: fix(native-hook-relay): prune stale bridge files on registration

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

Prepared head SHA: 65c17cdf6e
Review: https://github.com/openclaw/openclaw/pull/87706#issuecomment-4566131519

Co-authored-by: Applied-AI-Solutions-hub <Applied-AI-Solutions-hub@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-28 17:17:28 +00:00
Peter Steinberger
27dfb9149f test: cover dynamic live model refs 2026-05-28 18:16:45 +01:00
Peter Steinberger
b2fc8af1b1 fix: reject malformed media content length 2026-05-28 13:15:25 -04:00
Peter Steinberger
f3a23f8f5d fix: parse ffprobe sample rates strictly 2026-05-28 13:12:42 -04:00
Peter Steinberger
c2c29588f4 chore: update dependency pins 2026-05-28 18:10:39 +01:00
Peter Steinberger
0311171350 fix: parse ps cpu time formats 2026-05-28 13:10:28 -04:00
Peter Steinberger
5393240441 fix: clamp read tool line limits 2026-05-28 13:06:21 -04:00
Peter Steinberger
5ebf3b0396 fix: parse sandbox stat fields strictly 2026-05-28 13:03:14 -04:00
Vincent Koc
ea0b6bcb1f fix(scripts): give boundary root shims macos headroom 2026-05-28 19:00:45 +02:00
Vincent Koc
5fc5aa8f81 fix(e2e): bound kitchen sink rpc probe bodies 2026-05-28 19:00:45 +02:00
Peter Steinberger
a23a668d91 fix: honor bare ipv6 no_proxy entries 2026-05-28 12:54:13 -04:00
Peter Steinberger
e205888fa7 fix: honor ipv6 no_proxy entries 2026-05-28 12:50:59 -04:00
Nachiket Torwekar
53475c21b8 perf: reduce latency across async I/O hot paths
Improves gateway/device-auth/session discovery latency by caching unchanged device-auth reads, deduping session root realpaths, cleaning temp dirs in parallel, and bulk-loading APNs registrations for iOS exec approval delivery.

The maintainer fixup replaces per-device APNs registration reads with a single canonical store snapshot, preserving empty-target skip behavior and requested target ordering while avoiding delayed read failures from the bounded queue path.

Verification:
- node scripts/run-vitest.mjs src/gateway/exec-approval-ios-push.test.ts src/infra/push-apns.store.test.ts src/infra/device-auth-store.test.ts src/config/sessions/targets.test.ts src/test-utils/tracked-temp-dirs.test.ts src/utils/run-with-concurrency.test.ts
- env -u OPENCLAW_TESTBOX pnpm check:changed
- env -u OPENCLAW_TESTBOX pnpm test:changed
- pnpm exec oxfmt --check --threads=1 on touched files
- autoreview clean: no accepted/actionable findings
- before/after 500-device APNs discovery benchmark: p50 189.89 ms -> 2.03 ms
- GitHub Actions CI 26588266247 green after rerun; Real behavior proof 26588276271 green

Co-authored-by: Nachiket Torwekar <nachiket.torwekar@gmail.com>
2026-05-28 17:47:26 +01:00
Peter Steinberger
9e1faf81ab fix: count qmd output caps by code point 2026-05-28 12:47:08 -04:00
Peter Steinberger
f4f059ef94 fix: cap chrome mcp stderr by utf8 bytes 2026-05-28 12:42:18 -04:00
Peter Steinberger
75c3b53038 [codex] Use clawpdf for PDF extraction (#87670)
* feat: use clawpdf for PDF extraction

* fix: align approval action prompt typing

* chore: use clawpdf 0.2.0

* fix: lazily load clawpdf backend
2026-05-28 17:35:39 +01:00
Peter Steinberger
478e0ec3f8 fix: keep stderr tail within utf8 byte cap 2026-05-28 12:35:00 -04:00
clawsweeper[bot]
51e240123b fix(images): skip CLI image cache refs (#87523)
Summary:
- The branch filters OpenClaw CLI image-cache paths out of prompt image-reference detection and adds parser/helper regression tests.
- PR surface: Source +17, Tests +65. Total +82 across 3 files.
- Reproducibility: yes. source-level reproduction is high confidence: current main still scans replayed prompt ... ectImageReferences and has no cache-path exclusion before loadPromptRefImages can reload stale image paths.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(images): skip CLI image cache refs
- PR branch already contained follow-up commit before automerge: fix(clawsweeper): address review for automerge-openclaw-openclaw-8750…

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

Prepared head SHA: dfe0408df8
Review: https://github.com/openclaw/openclaw/pull/87523#issuecomment-4560945125

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-28 16:34:54 +00:00
clawsweeper[bot]
e9655b9fdc fix(ui): preserve session picker on empty search blur (#87682)
Summary:
- The PR changes the Control UI chat session picker blur handler to skip empty-query search application and adds a regression test that picker options remain clickable after an empty search blur.
- PR surface: Source +4, Tests +52. Total +56 across 2 files.
- Reproducibility: yes. The issue steps, before recording, and current-main source path all point to the same  ... r clearing picker state before click delivery; I did not rerun a live browser repro in this read-only pass.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(ui): preserve session picker on empty search blur

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

Prepared head SHA: bb14687756
Review: https://github.com/openclaw/openclaw/pull/87682#issuecomment-4565441074

Co-authored-by: Ryan Weng <14496969+ryan4559@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-28 16:34:50 +00:00
Peter Steinberger
0d66710539 fix: parse lsp content length by byte 2026-05-28 12:29:45 -04:00
rain
2df8021cda fix(agents): surface MCP structured content in tool results
Surface inbound bundle-MCP structuredContent as the model-visible result when present so agents can read Codex MCP threadId values and continue with codex-reply. Preserve non-structured content behavior, preserve the empty-result fallback, and keep details.structuredContent for internal consumers.

Also remove an unused secrets path helper that was breaking the latest prod-type gate on main.

Fixes #87511.

Verification:
- node scripts/run-vitest.mjs src/agents/agent-bundle-mcp-tools.materialize.test.ts
- pnpm exec oxfmt --check src/secrets/path-utils.ts src/agents/agent-bundle-mcp-materialize.ts src/agents/agent-bundle-mcp-tools.materialize.test.ts
- pnpm tsgo:prod
- local check-guards shard commands
- live Codex MCP smoke with codex__codex and codex__codex-reply same-thread continuation
- autoreview clean
- CI run 26587222874 green

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 17:29:29 +01:00
clawsweeper[bot]
cd0b692b61 fix(voice-call): make webhook replays token-safe
Fix replay handling for voice-call webhooks so duplicate signed requests do not mint or expose realtime stream tokens.

- Return token-free Twilio replay TwiML before realtime setup shortcuts.
- Cache bounded non-Twilio first responses for idempotent replay XML while skipping duplicate side effects.
- Cover Twilio realtime replay and Plivo replay behavior with regression tests.
- Remove an unused secrets path helper that was tripping latest-main prod type CI.

Fixes #87497.

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
2026-05-28 17:29:15 +01:00
Vincent Koc
716fd67e03 fix(scripts): bound Z.AI fallback repro output 2026-05-28 18:19:34 +02:00
Peter Steinberger
a85ff92c05 perf: cache bundled channel entry resolution 2026-05-28 17:18:51 +01:00
Val Alexander
96635c7c27 fix(webchat): preserve sends through reconnect (#87531)
* fix(webchat): preserve sends through reconnect

* fix(webchat): scope queued sends by session

* fix(webchat): localize queue retry labels

* fix(secrets): remove unused path helper

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-28 17:18:24 +01:00
Peter Steinberger
c00ac952a8 fix: reject malformed inspected tcp ports 2026-05-28 12:15:23 -04:00
Peter Steinberger
982e2cf0ef fix: reject malformed marketplace content length 2026-05-28 12:11:50 -04:00
Peter Steinberger
03e6181f9f fix: reject exponent provider integer options 2026-05-28 12:08:09 -04:00
Andy Ye
5f88932806 fix(sessions): recover empty preflight compaction
Fixes #87016.

Empty preflight compaction recovery now resets stale token snapshots immediately, preserves valid legacy transcript rows during cleanup, and avoids re-persisting stale context-budget or compaction metadata after a successful retry.

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-28 17:06:38 +01:00
Peter Steinberger
fb80d3a491 perf: reduce gateway startup sidecar overhead 2026-05-28 17:05:19 +01:00
Peter Steinberger
e67ff0c43e fix: canonicalize secret target array indexes 2026-05-28 12:02:21 -04:00
Peter Steinberger
c9c53e3153 fix: harden config array index parsing 2026-05-28 11:58:40 -04:00
David
7a36bb37af feat(gateway): show warm MCP tools in effective inventory
Add read-only MCP visibility to `tools.effective` by projecting MCP tools only after a session catalog has already been warmed by an agent turn. Keep the gateway additive: no `tools.effective.refresh`, no forced MCP startup, and no behavior change for MCP loading.

Verification:
- `git diff --check origin/main..HEAD`
- `node scripts/run-vitest.mjs run --config test/vitest/vitest.agents.config.ts --reporter=verbose src/agents/tools-effective-inventory.test.ts`
- GitHub checks green on `a8a7f8442adb216f60da24d50118374a15c62e06`, including `Real behavior proof`, `check-guards`, `check-prod-types`, `check-test-types`, `build-artifacts`, `Critical Quality (gateway-runtime-boundary)`, and `Critical Quality (network-runtime-boundary)`.

Co-authored-by: David Huang <nxmxbbd@gmail.com>
2026-05-28 16:52:53 +01:00
Vincent Koc
b261e9e6dd fix(approvals): restore reaction command prompt lines 2026-05-28 17:32:58 +02:00
Vincent Koc
e707b452c0 fix(scripts): bound control UI i18n process output 2026-05-28 17:32:58 +02:00
Peter Steinberger
79e733cc34 docs: remove public GHSA fix mechanism details 2026-05-28 16:30:39 +01:00
Peter Steinberger
f8c8c0d41e fix(agents): handle seeded Anthropic signatures 2026-05-28 16:28:36 +01:00
Jerry Xin
8dc9cfe734 fix(agents): concatenate signature_delta chunks in transport stream
The anthropic-transport-stream was overwriting thinkingSignature on each
signature_delta event instead of appending. Since Anthropic sends the
thinking block signature across multiple streaming chunks, only the last
chunk survived. The truncated signature was persisted to session JSONL,
causing all subsequent replay attempts to fail with HTTP 400:

  thinking or redacted_thinking blocks in the latest assistant message
  cannot be modified

This permanently bricked sessions with no user recovery path.

Fix: accumulate signature_delta values by concatenating instead of
overwriting, matching the correct implementation in the LLM provider
layer (src/llm/providers/anthropic.ts:629-634).

Includes real-scenario proof against live Anthropic API validating that
correct signatures replay successfully while truncated signatures are
rejected.

Fixes #87574
Refs #80625, #85781, #87475
2026-05-28 16:28:36 +01:00
Peter Steinberger
e5adde9fe3 fix(auto-reply): respect provider for directive persistence (#87683) 2026-05-28 16:27:19 +01:00
rain
ad3e3cb7d2 fix(agents): preserve reasoning_content replay across DeepSeek tier suffixes (#87593)
* 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>

* 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>

* test(opencode): add live DeepSeek replay probe

* test(opencode): avoid forced tool choice in live replay

---------

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-28 16:25:54 +01:00
clawsweeper[bot]
5216841a9e docs: treat CLI setup flows as API contracts (#87685)
Co-authored-by: ClawSweeper <clawsweeper@users.noreply.github.com>
2026-05-28 16:17:42 +01:00
Peter Steinberger
b601550c97 docs: harden GHSA wording guidance 2026-05-28 16:16:10 +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
2262 changed files with 119458 additions and 30352 deletions

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

@@ -1,6 +1,6 @@
---
name: openclaw-ghsa-maintainer
description: Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state.
description: "Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state."
---
# OpenClaw GHSA Maintainer
@@ -85,3 +85,4 @@ jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.
- Public hardening/no-publish comments and draft text should avoid raw commit hashes, PR titles/numbers, and fix-mechanism summaries. Prefer patched-version fields or release-only wording; keep SHAs, PRs, and implementation notes in internal evidence.

View File

@@ -213,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:
@@ -236,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

@@ -1,6 +1,6 @@
---
name: security-triage
description: Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof.
description: "Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof."
---
# Security Triage
@@ -87,11 +87,19 @@ When preparing a maintainer-ready close reply:
- exact reason for close
- exact code refs
- exact shipped tag / release facts
- exact fix commit or canonical duplicate GHSA when applicable
- fix provenance or canonical duplicate GHSA when applicable
- optional hardening note only if worthwhile and functionality-preserving
Keep tone firm, specific, non-defensive.
## Public Wording Hygiene
- Keep raw commit hashes, PR titles/numbers, and fix-mechanism summaries out of public advisory text. Use the patched release/version field only.
- Keep exact commit SHAs, PRs, and implementation notes in internal notes and verification files.
- For hardening/no-publish outcomes, do not add exploit-heavy details, "Fixed by" text, or a "Fix Commit(s)" section. Thank reporters, preserve credit, state the `SECURITY.md` boundary, and say clearly that the GHSA will close without publication.
- For published CVE/GHSA text, prefer `### Patched Versions` with the fixed release. Do not explain how the patch works unless Peter explicitly asks for that public detail.
- Keep GHSA ids out of changelog and release-note wording unless Peter explicitly asks.
## Discussion Mode
When Peter is manually posting GHSA comments, use this flow:

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

@@ -29,6 +29,11 @@ actions:
- openclaw
runnerVersion: latest
ephemeral: true
blacksmith:
org: openclaw
workflow: .github/workflows/ci-check-testbox.yml
job: check
ref: main
aws:
region: eu-west-1
rootGB: 400

8
.github/CODEOWNERS vendored
View File

@@ -11,8 +11,10 @@
/.github/workflows/codeql.yml @openclaw/openclaw-secops
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
/package-lock.json @openclaw/openclaw-secops
/npm-shrinkwrap.json @openclaw/openclaw-secops
/extensions/*/package-lock.json @openclaw/openclaw-secops
@@ -29,7 +31,7 @@
/src/gateway/**/*secret*.ts @openclaw/openclaw-secops
/src/gateway/security-path*.ts @openclaw/openclaw-secops
/src/gateway/resolve-configured-secret-input-string*.ts @openclaw/openclaw-secops
/src/gateway/protocol/**/*secret*.ts @openclaw/openclaw-secops
/packages/gateway-protocol/src/**/*secret*.ts @openclaw/openclaw-secops
/src/gateway/server-methods/secrets*.ts @openclaw/openclaw-secops
/src/agents/*auth*.ts @openclaw/openclaw-secops
/src/agents/**/*auth*.ts @openclaw/openclaw-secops

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

@@ -14,6 +14,10 @@ self-hosted-runner:
- blacksmith-16vcpu-ubuntu-2404-arm
- blacksmith-6vcpu-macos-latest
- blacksmith-12vcpu-macos-latest
- blacksmith-6vcpu-macos-15
- blacksmith-12vcpu-macos-15
- blacksmith-6vcpu-macos-26
- blacksmith-12vcpu-macos-26
# Ignore patterns for known issues
paths:

View File

@@ -35,17 +35,29 @@ runs:
exit 0
fi
# Check if any changed file is a doc
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
if [ -n "$DOCS" ]; then
docs_changed=false
non_docs=false
while IFS= read -r changed_path; do
case "$changed_path" in
test/fixtures/*)
non_docs=true
;;
docs/* | *.md | *.mdx)
docs_changed=true
;;
*)
non_docs=true
;;
esac
done <<< "$CHANGED"
if [ "$docs_changed" = "true" ]; then
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
else
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
fi
# Check if all changed files are docs or markdown
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
if [ -z "$NON_DOCS" ]; then
if [ "$non_docs" = "false" ]; then
echo "docs_only=true" >> "$GITHUB_OUTPUT"
echo "Docs-only change detected — skipping heavy jobs"
else

View File

@@ -20,9 +20,13 @@ inputs:
required: false
default: "true"
use-actions-cache:
description: Whether to restore and save the pnpm store with actions/cache.
description: Whether to restore the pnpm store with actions/cache.
required: false
default: "true"
save-actions-cache:
description: Whether to save the pnpm store with actions/cache after install when no exact cache restored.
required: false
default: "false"
runs:
using: composite
steps:
@@ -45,6 +49,7 @@ runs:
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
- name: Setup pnpm
id: setup-pnpm
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: ${{ inputs.node-version }}
@@ -130,3 +135,10 @@ runs:
ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
fi
- name: Save pnpm store cache
if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }}
uses: actions/cache/save@v5
with:
path: ${{ steps.setup-pnpm.outputs.store-path }}
key: ${{ steps.setup-pnpm.outputs.store-cache-primary-key }}

View File

@@ -14,7 +14,7 @@ inputs:
required: false
default: ""
use-actions-cache:
description: Whether actions/cache should cache the pnpm store.
description: Whether actions/cache should restore the pnpm store.
required: false
default: "true"
outputs:
@@ -24,6 +24,15 @@ outputs:
project-dir:
description: Directory containing the packageManager file used for pnpm resolution.
value: ${{ steps.setup-pnpm.outputs.project-dir }}
store-cache-hit:
description: Whether the pnpm store cache restored an exact key.
value: ${{ steps.pnpm-store-cache.outputs.cache-hit }}
store-cache-primary-key:
description: Exact pnpm store cache key used for restore/save.
value: ${{ steps.pnpm-store-cache.outputs.cache-primary-key }}
store-path:
description: Resolved pnpm store path.
value: ${{ steps.pnpm-store.outputs.path }}
runs:
using: composite
steps:
@@ -81,14 +90,15 @@ runs:
echo "path=$store_path" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
id: pnpm-store-cache
if: ${{ inputs.use-actions-cache == 'true' && runner.os != 'Windows' }}
uses: actions/cache@v5
uses: actions/cache/restore@v5
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-${{ hashFiles(inputs.lockfile-path) }}
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-${{ hashFiles(inputs.lockfile-path) }}
restore-keys: |
pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-
pnpm-store-${{ runner.os }}-
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-
- name: Record pnpm version
id: pnpm-version

View File

@@ -95,7 +95,7 @@ openclaw_find_toolcache_node() {
done
local node_root candidate candidate_version
for node_root in "${roots[@]}"; do
for node_root in ${roots[@]+"${roots[@]}"}; do
while IFS= read -r candidate; do
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
if openclaw_node_version_matches "$candidate_version" "$requested_node"; then

View File

@@ -19,7 +19,7 @@ paths:
- src/config/types.channel*.ts
- src/gateway/server-channel*.ts
- src/gateway/server-methods/channels.ts
- src/gateway/protocol/schema/channels.ts
- packages/gateway-protocol/src/schema/channels.ts
- src/infra/channel-*.ts
- src/infra/exec-approval-channel-runtime.ts
- src/infra/outbound/channel-*.ts

View File

@@ -30,7 +30,7 @@ paths:
- src/gateway/**/*auth*.ts
- src/gateway/*secret*.ts
- src/gateway/**/*secret*.ts
- src/gateway/protocol/**/*secret*.ts
- packages/gateway-protocol/src/**/*secret*.ts
- src/gateway/resolve-configured-secret-input-string*.ts
- src/gateway/security-path*.ts
- src/gateway/server-methods/secrets*.ts

View File

@@ -30,7 +30,7 @@ paths:
- src/gateway/**/*auth*.ts
- src/gateway/*secret*.ts
- src/gateway/**/*secret*.ts
- src/gateway/protocol/**/*secret*.ts
- packages/gateway-protocol/src/**/*secret*.ts
- src/gateway/resolve-configured-secret-input-string*.ts
- src/gateway/security-path*.ts
- src/gateway/server-methods/secrets*.ts

View File

@@ -15,7 +15,7 @@ query-filters:
paths:
- src/gateway/method-scopes.ts
- src/gateway/protocol
- packages/gateway-protocol/src
- src/gateway/server-methods
- src/gateway/server-methods.ts
- src/gateway/server-methods-list.ts

View File

@@ -76,6 +76,8 @@ predicate allowedRawSocketClientCall(Expr call) {
or
allowedOwnerScope(call, "src/proxy-capture/proxy-server.ts", "startDebugProxyServer")
or
allowedOwnerScope(call, "extensions/codex-supervisor/src/json-rpc-client.ts", "connectCodexSupervisorUnixSocket")
or
allowedOwnerScope(call, "extensions/irc/src/client.ts", "connectIrcClient")
or
allowedOwnerScope(call, "extensions/qa-lab/src/lab-server-capture.ts", "probeTcpReachability")

14
.github/labeler.yml vendored
View File

@@ -188,7 +188,7 @@
- "ui/**"
- "src/gateway/control-ui.ts"
- "src/gateway/control-ui-shared.ts"
- "src/gateway/protocol/**"
- "packages/gateway-protocol/src/**"
- "src/gateway/server-methods/chat.ts"
- "src/infra/control-ui-assets.ts"
@@ -196,6 +196,7 @@
- changed-files:
- any-glob-to-any-file:
- "src/gateway/**"
- "packages/gateway-protocol/src/**"
- "src/daemon/**"
- "docs/gateway/**"
@@ -398,6 +399,17 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/codex/**"
"extensions: codex-supervisor":
- changed-files:
- any-glob-to-any-file:
- "extensions/codex-supervisor/**"
- "docs/plugins/reference/codex-supervisor.md"
- "docs/specs/claw-supervisor.md"
"extensions: copilot":
- changed-files:
- any-glob-to-any-file:
- "extensions/copilot/**"
- "docs/plugins/copilot.md"
"extensions: kimi-coding":
- changed-files:
- any-glob-to-any-file:

View File

@@ -414,13 +414,73 @@ jobs:
- name: Audit production dependencies
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
# Warm the lockfile- and pnpm-pinned store once before Linux Node shards fan out.
# On a cold key this job owns the save, so later shards restore the exact key.
pnpm-store-warmup:
permissions:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
save-actions-cache: "true"
# Build dist once for Node-relevant changes and share it with downstream jobs.
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
# test/build feedback sooner instead of waiting behind a full `check` pass.
build-artifacts:
permissions:
contents: read
needs: [preflight]
needs: [preflight, pnpm-store-warmup]
if: needs.preflight.outputs.run_build_artifacts == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
@@ -490,9 +550,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
@@ -585,6 +642,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 \
@@ -599,10 +670,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]}"
@@ -645,7 +712,7 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
needs: [preflight, pnpm-store-warmup]
if: needs.preflight.outputs.run_checks_fast_core == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 60
@@ -734,7 +801,7 @@ jobs:
permissions:
contents: read
name: ${{ matrix.checkName }}
needs: [preflight]
needs: [preflight, pnpm-store-warmup]
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 60
@@ -814,7 +881,7 @@ jobs:
permissions:
contents: read
name: ${{ matrix.checkName }}
needs: [preflight]
needs: [preflight, pnpm-store-warmup]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 60
@@ -966,9 +1033,9 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
needs: [preflight, pnpm-store-warmup]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1072,9 +1139,9 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04') }}
needs: [preflight, pnpm-store-warmup]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' && needs.pnpm-store-warmup.result == 'success' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1165,7 +1232,7 @@ jobs:
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
;;
prod-types)
pnpm tsgo:prod
@@ -1203,8 +1270,8 @@ jobs:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
needs: [preflight, pnpm-store-warmup]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' && needs.pnpm-store-warmup.result == 'success' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
strategy:
@@ -1370,7 +1437,7 @@ jobs:
check-docs:
permissions:
contents: read
needs: [preflight]
needs: [preflight, pnpm-store-warmup]
if: needs.preflight.outputs.run_check_docs == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
@@ -1427,11 +1494,44 @@ jobs:
- name: Checkout ClawHub docs source
run: |
set -euo pipefail
git init clawhub-source
git -C clawhub-source config gc.auto 0
git -C clawhub-source remote add origin "https://github.com/openclaw/clawhub.git"
git -C clawhub-source fetch --no-tags --depth=1 origin "+HEAD:refs/remotes/origin/checkout"
git -C clawhub-source checkout --detach refs/remotes/origin/checkout
workdir="$GITHUB_WORKSPACE/clawhub-source"
started_at="$(date +%s)"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git -C "$workdir" config gc.auto 0
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
git -C "$workdir" checkout --force --detach refs/remotes/origin/checkout || return 1
echo "ClawHub checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
elapsed="$(( $(date +%s) - started_at ))"
echo "ClawHub checkout completed in ${elapsed}s"
exit 0
fi
echo "ClawHub checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "ClawHub checkout failed after 5 attempts" >&2
exit 1
- name: Check docs
env:
@@ -1588,7 +1688,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-latest' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-15' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-15' || 'macos-15') }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1637,7 +1737,7 @@ jobs:
name: "macos-swift"
needs: [preflight]
if: needs.preflight.outputs.run_macos_swift == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-26') }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -1860,3 +1960,53 @@ jobs:
exit 1
;;
esac
ci-timings-summary:
permissions:
actions: read
contents: read
name: ci-timings-summary
needs:
- preflight
- security-fast
- pnpm-store-warmup
- build-artifacts
- checks-fast-core
- checks-fast-plugin-contracts-shard
- checks-fast-channel-contracts-shard
- checks-node-compat
- checks-node-core-test-nondist-shard
- check-shard
- check-additional-shard
- check-docs
- skills-python
- checks-windows
- macos-node
- macos-swift
- android
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout timing summary helper
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || needs.preflight.outputs.checkout_revision || github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Write CI timing summary
env:
GH_TOKEN: ${{ github.token }}
run: |
node scripts/ci-run-timings.mjs "$GITHUB_RUN_ID" --limit 25 > ci-timings-summary.txt
cat ci-timings-summary.txt >> "$GITHUB_STEP_SUMMARY"
- name: Upload CI timing summary
uses: actions/upload-artifact@v7
with:
name: ci-timings-summary
path: ci-timings-summary.txt
retention-days: 14

View File

@@ -106,13 +106,13 @@ on:
- "src/gateway/**/*auth*.ts"
- "src/gateway/*secret*.ts"
- "src/gateway/**/*secret*.ts"
- "src/gateway/protocol/**/*secret*.ts"
- "packages/gateway-protocol/src/**/*secret*.ts"
- "src/gateway/resolve-configured-secret-input-string*.ts"
- "src/gateway/security-path*.ts"
- "src/gateway/server-methods/secrets*.ts"
- "src/gateway/server-startup-memory.ts"
- "src/gateway/method-scopes.ts"
- "src/gateway/protocol/**"
- "packages/gateway-protocol/src/**"
- "src/gateway/server-methods/**"
- "src/gateway/server-methods.ts"
- "src/gateway/server-methods-list.ts"
@@ -244,14 +244,14 @@ jobs:
src/config/*)
config=true
;;
src/gateway/protocol/*secret*.ts|src/gateway/server-methods/secrets*.ts)
packages/gateway-protocol/src/*secret*.ts|packages/gateway-protocol/src/**/*secret*.ts|src/gateway/server-methods/secrets*.ts)
core_auth_secrets=true
gateway=true
;;
src/agents/*auth*.ts|src/agents/auth-health*.ts|src/agents/auth-profiles|src/agents/auth-profiles/*|src/agents/bash-tools.exec-host-shared.ts|src/agents/sandbox|src/agents/sandbox.ts|src/agents/sandbox-*.ts|src/agents/sandbox/*|src/cron/service/jobs.ts|src/cron/stagger.ts|src/gateway/*auth*.ts|src/gateway/*secret*.ts|src/gateway/resolve-configured-secret-input-string*.ts|src/gateway/security-path*.ts|src/infra/secret-file*.ts|src/secrets/*|src/security/*)
core_auth_secrets=true
;;
src/gateway/method-scopes.ts|src/gateway/protocol/*|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
gateway=true
;;
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)

View File

@@ -20,7 +20,7 @@ permissions:
jobs:
macos:
name: Critical Security (macOS)
runs-on: blacksmith-6vcpu-macos-latest
runs-on: blacksmith-6vcpu-macos-15
timeout-minutes: 45
steps:
- name: Checkout

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 * * *"
@@ -76,10 +85,21 @@ jobs:
config_file: ./.github/codeql/codeql-actions-critical-security.yml
steps:
- name: Checkout
if: ${{ matrix.category != 'actions' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
- name: Checkout Actions security sources
if: ${{ matrix.category == 'actions' }}
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: false
sparse-checkout: |
.github/actions
.github/workflows
.github/codeql
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:

View File

@@ -1,176 +0,0 @@
name: Dependency Change Awareness
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only workflow; no checkout or untrusted code execution
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: write
issues: write
concurrency:
group: dependency-change-awareness-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
dependency-change-awareness:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Label and comment on dependency changes
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const marker = "<!-- openclaw:dependency-change-awareness -->";
const labelName = "dependencies-changed";
const maxListedFiles = 25;
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
core.info("No pull_request payload found; skipping.");
return;
}
const isDependencyFile = (filename) =>
filename === "package.json" ||
filename === "package-lock.json" ||
filename === "npm-shrinkwrap.json" ||
filename === "pnpm-lock.yaml" ||
filename === "pnpm-workspace.yaml" ||
filename === "ui/package.json" ||
filename.startsWith("patches/") ||
/^packages\/[^/]+\/package\.json$/u.test(filename) ||
/^extensions\/[^/]+\/package-lock\.json$/u.test(filename) ||
/^extensions\/[^/]+\/npm-shrinkwrap\.json$/u.test(filename) ||
/^extensions\/[^/]+\/package\.json$/u.test(filename);
const sanitizeDisplayValue = (value) =>
String(value)
.replace(/[\u0000-\u001f\u007f]/gu, "?")
.slice(0, 240);
const markdownCode = (value) =>
`\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
const ignoreUnavailableWritePermission = (action) => (error) => {
if (error?.status === 403) {
core.warning(
`Skipping dependency change ${action}; token does not have issue write permission.`,
);
return;
}
if (error?.status === 404 || error?.status === 422) {
core.warning(`Dependency change ${action} is unavailable.`);
return;
}
throw error;
};
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
per_page: 100,
});
const dependencyFiles = files
.map((file) => file.filename)
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
.sort((left, right) => left.localeCompare(right));
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const existingComment = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
);
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const hasLabel = labels.some((label) => label.name === labelName);
if (dependencyFiles.length === 0) {
if (hasLabel) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
name: labelName,
}).catch(ignoreUnavailableWritePermission("label removal"));
}
if (existingComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
}).catch(ignoreUnavailableWritePermission("comment deletion"));
}
await core.summary
.addHeading("Dependency Change Awareness")
.addRaw("No dependency-related file changes detected.")
.write();
core.info("No dependency-related file changes detected.");
return;
}
if (!hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
labels: [labelName],
}).catch(ignoreUnavailableWritePermission(`label "${labelName}" update`));
}
const listedFiles = dependencyFiles.slice(0, maxListedFiles);
const omittedCount = dependencyFiles.length - listedFiles.length;
const fileLines = listedFiles.map((filename) => `- ${markdownCode(filename)}`);
if (omittedCount > 0) {
fileLines.push(`- ${omittedCount} additional dependency-related files not shown`);
}
const body = [
marker,
"",
"### Dependency Changes Detected",
"",
"This PR changes dependency-related files. Maintainers should confirm these changes are intentional.",
"",
"Changed files:",
...fileLines,
"",
"Maintainer follow-up:",
"- Review whether the dependency changes are intentional.",
"- Inspect resolved package deltas when lockfile, shrinkwrap, or workspace dependency policy changes are present.",
"- Treat `package-lock.json` and `npm-shrinkwrap.json` diffs as security-review surfaces.",
"- Run `pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json` locally for detailed release-style evidence.",
].join("\n");
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body,
}).catch(ignoreUnavailableWritePermission("comment update"));
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body,
}).catch(ignoreUnavailableWritePermission("comment creation"));
}
await core.summary
.addHeading("Dependency Change Awareness")
.addRaw(`Detected ${dependencyFiles.length} dependency-related file change(s).`)
.addList(dependencyFiles.map((filename) => markdownCode(filename)))
.write();
core.notice(`Detected ${dependencyFiles.length} dependency-related file change(s).`);

33
.github/workflows/dependency-guard.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Dependency Guard
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
types: [opened, reopened, synchronize, ready_for_review, labeled]
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: dependency-guard-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
dependency-guard:
if: ${{ !github.event.pull_request.draft && (github.event.action != 'labeled' || github.event.label.name == 'dependency-guard-backfill') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Check out trusted base workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Label, comment, and guard dependency changes
env:
GITHUB_TOKEN: ${{ github.token }}
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
run: node scripts/github/dependency-guard.mjs

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,27 @@ 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:
@@ -206,6 +235,26 @@ jobs:
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]
@@ -217,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
@@ -244,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}"
@@ -261,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)
@@ -304,6 +361,27 @@ 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:
@@ -348,6 +426,26 @@ jobs:
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]
@@ -382,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
@@ -404,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]
@@ -460,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
@@ -1450,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 }}
@@ -1460,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
@@ -1483,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
@@ -1509,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

@@ -143,7 +143,7 @@ jobs:
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
const absolute = path.join(\"/app\", rel);
if (!fs.existsSync(absolute)) {
throw new Error(`missing patch for ${dep}: ${rel}`);
throw new Error(\"missing patch for \" + dep + \": \" + rel);
}
}
"
@@ -337,7 +337,7 @@ jobs:
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
const absolute = path.join(\"/app\", rel);
if (!fs.existsSync(absolute)) {
throw new Error(`missing patch for ${dep}: ${rel}`);
throw new Error(\"missing patch for \" + dep + \": \" + rel);
}
}
"

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:
@@ -375,6 +377,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
@@ -383,6 +386,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

@@ -218,6 +218,7 @@ jobs:
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE: ci
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ inputs.scenario }}

View File

@@ -451,7 +451,7 @@ jobs:
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline
run: |
mkdir -p "${OUTPUT_DIR}"
npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
timeout --preserve-status 300s npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
- name: Capture candidate metadata
id: candidate_metadata

View File

@@ -480,6 +480,35 @@ jobs:
fi
exit 1
plan_release_workflow_matrices:
needs: validate_selected_ref
runs-on: ubuntu-24.04
outputs:
docker_e2e_count: ${{ steps.plan.outputs.docker_e2e_count }}
docker_e2e_matrix: ${{ steps.plan.outputs.docker_e2e_matrix }}
docker_e2e_omitted_json: ${{ steps.plan.outputs.docker_e2e_omitted_json }}
live_models_count: ${{ steps.plan.outputs.live_models_count }}
live_models_matrix: ${{ steps.plan.outputs.live_models_matrix }}
live_models_omitted_json: ${{ steps.plan.outputs.live_models_omitted_json }}
steps:
- name: Checkout trusted release harness
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 1
- name: Plan release workflow matrices
id: plan
env:
DOCKER_LANES: ${{ inputs.docker_lanes }}
INCLUDE_LIVE_SUITES: ${{ inputs.include_live_suites }}
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include_release_path_suites }}
LIVE_MODEL_PROVIDERS: ${{ inputs.live_model_providers }}
LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
RELEASE_TEST_PROFILE: ${{ inputs.release_test_profile }}
run: node scripts/plan-release-workflow-matrix.mjs >> "$GITHUB_OUTPUT"
validate_release_live_cache:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
@@ -636,72 +665,15 @@ jobs:
run: ${{ matrix.command }}
validate_docker_e2e:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
name: Docker E2E (${{ matrix.label }})
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- chunk_id: core
label: core
timeout_minutes: 60
profiles: stable full
- chunk_id: package-update-openai
label: package/update OpenAI install
timeout_minutes: 45
profiles: beta minimum stable full
- chunk_id: package-update-anthropic
label: package/update Anthropic install
timeout_minutes: 60
profiles: beta minimum stable full
- chunk_id: package-update-core
label: package/update core
timeout_minutes: 60
profiles: beta minimum stable full
- chunk_id: plugins-runtime-plugins
label: plugins/runtime plugins
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-services
label: plugins/runtime services
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-install-a
label: plugins/runtime install A
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-install-b
label: plugins/runtime install B
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-install-c
label: plugins/runtime install C
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-install-d
label: plugins/runtime install D
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-install-e
label: plugins/runtime install E
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-install-f
label: plugins/runtime install F
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-install-g
label: plugins/runtime install G
timeout_minutes: 60
profiles: stable full
- chunk_id: plugins-runtime-install-h
label: plugins/runtime install H
timeout_minutes: 60
profiles: stable full
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.docker_e2e_matrix) }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -1631,42 +1603,14 @@ jobs:
validate_live_models_docker:
name: Docker live models (${{ matrix.provider_label }})
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
needs: [validate_selected_ref, prepare_live_test_image, plan_release_workflow_matrices]
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models') && needs.plan_release_workflow_matrices.outputs.live_models_count != '0'
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- provider_label: Anthropic
providers: anthropic
profiles: stable full
- provider_label: Google
providers: google
profiles: stable full
- provider_label: MiniMax
providers: minimax
profiles: stable full
- provider_label: OpenAI
providers: openai
profiles: beta minimum stable full
- provider_label: OpenCode
providers: opencode-go
profiles: full
- provider_label: OpenRouter
providers: openrouter
profiles: full
- provider_label: xAI
providers: xai
profiles: full
- provider_label: Z.ai
providers: zai
profiles: full
- provider_label: Fireworks
providers: fireworks
profiles: full
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.live_models_matrix) }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -1744,6 +1688,8 @@ jobs:
- name: Validate provider credential
if: contains(matrix.profiles, inputs.release_test_profile)
shell: bash
env:
LIVE_MODEL_PROVIDERS: ${{ matrix.providers }}
run: |
set -euo pipefail
@@ -1760,7 +1706,7 @@ jobs:
exit 1
}
case "${{ matrix.providers }}" in
case "${LIVE_MODEL_PROVIDERS}" in
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
minimax) require_any MiniMax MINIMAX_API_KEY ;;
@@ -1771,7 +1717,7 @@ jobs:
zai) require_any Z.ai ZAI_API_KEY Z_AI_API_KEY ;;
fireworks) require_any Fireworks FIREWORKS_API_KEY ;;
*)
echo "Unhandled live model provider shard: ${{ matrix.providers }}" >&2
echo "Unhandled live model provider shard: ${LIVE_MODEL_PROVIDERS}" >&2
exit 1
;;
esac
@@ -2013,7 +1959,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-profiles-openai
label: Native live gateway profiles OpenAI
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=180000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 60
profile_env_only: false
profiles: beta minimum 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

@@ -1207,6 +1207,7 @@ jobs:
env:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
run: |

View File

@@ -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

@@ -32,11 +32,11 @@ jobs:
- name: Install opengrep
env:
# Pin both the install script (by commit SHA) and the binary version.
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
# Bump both together when upgrading.
OPENGREP_VERSION: v1.19.0
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
OPENGREP_VERSION: v1.22.0
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
run: |
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
| bash -s -- -v "$OPENGREP_VERSION"

View File

@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 0
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
@@ -58,11 +58,11 @@ jobs:
- name: Install opengrep
env:
# Pin both the install script (by commit SHA) and the binary version.
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
# Bump both together when upgrading.
OPENGREP_VERSION: v1.19.0
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
OPENGREP_VERSION: v1.22.0
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
run: |
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
| bash -s -- -v "$OPENGREP_VERSION"

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,7 +289,7 @@ jobs:
--runtime-parity-tier live-only \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
--runtime-pair openclaw,codex \
--fast \
--allow-failures \
@@ -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
)
@@ -529,6 +530,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
@@ -555,7 +557,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 +651,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 +748,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 +842,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 \

View File

@@ -90,7 +90,7 @@ jobs:
bash -lc 'apt-get update -y && apt-get install -y curl && bash /tmp/install-cli.sh --prefix /tmp/openclaw --no-onboard --version latest && /tmp/openclaw/bin/openclaw --version'
macos-installer:
runs-on: macos-latest
runs-on: macos-15
steps:
- name: Checkout
uses: actions/checkout@v6

2
.gitignore vendored
View File

@@ -178,6 +178,7 @@ mantis/
/local/
/client_secret_*.json
package-lock.json
!src/commands/copilot-sdk-install-manifest/package-lock.json
.claude/
.agent/
skills-lock.json
@@ -249,6 +250,7 @@ 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)

View File

@@ -35,9 +35,9 @@ Skills own workflows; root owns hard policy and routing.
## Map
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `src/gateway/protocol/*`; docs/apps: `docs/`, `apps/`.
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `packages/gateway-protocol/*`; docs/apps: `docs/`, `apps/`.
- Installers: sibling `../openclaw.ai`.
- Scoped guides: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,gateway/protocol,agents}/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
- Scoped guides: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,agents}/`, `packages/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
## Docs
@@ -57,6 +57,7 @@ Skills own workflows; root owns hard policy and routing.
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
- Tests may use observed examples, but prod literals need a short contract reason.

View File

@@ -2,12 +2,19 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Fixes
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
## 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)
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682)
- 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.
@@ -17,14 +24,22 @@ Docs: https://docs.openclaw.ai
- 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.
- PDF/tools: use ClawPDF for PDF extraction and surface MCP structured content in agent tool results. (#87670)
### Fixes
- Agents: fall back to local config pruning when the optional `agents delete` Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.
- 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.
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, and sandbox stat fields.
- Providers/agents: preserve seeded Anthropic signatures, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, and recover empty preflight compaction. (#87593)
- 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.
@@ -55,7 +70,7 @@ Docs: https://docs.openclaw.ai
- 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, plugin gauntlet, and beta-smoke runs instead of false-greening.
- 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

View File

@@ -48,6 +48,7 @@ RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
ARG OPENCLAW_EXTENSIONS
# Copy pinned Bun binary from the official image instead of fetching via curl.
COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun
@@ -77,7 +78,12 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
# paths. Matrix's native downloader can hit transient release CDN errors while
# still exiting successfully, so retry the package downloader before failing.
# Skip the entire check when matrix is not a bundled extension (e.g. msteams-only builds).
RUN set -eux; \
if ! printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'matrix'; then \
echo "==> matrix not bundled, skipping matrix-sdk-crypto check"; \
exit 0; \
fi; \
echo "==> Verifying critical native addons..."; \
for attempt in 1 2 3 4 5; do \
if find /app/node_modules -name "matrix-sdk-crypto*.node" 2>/dev/null | grep -q .; then \

View File

@@ -2,6 +2,53 @@
<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>
@@ -490,104 +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>
</channel>
</rss>

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

@@ -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
}
}

View File

@@ -1,36 +0,0 @@
import SwiftUI
private struct StatusGlassCardModifier: ViewModifier {
@Environment(\.colorSchemeContrast) private var contrast
let brighten: Bool
let verticalPadding: CGFloat
let horizontalPadding: CGFloat
func body(content: Content) -> some View {
content
.padding(.vertical, self.verticalPadding)
.padding(.horizontal, self.horizontalPadding)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.contrast == .increased ? 1.0 : 0.5)
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
}
}
extension View {
func statusGlassCard(brighten: Bool, verticalPadding: CGFloat, horizontalPadding: CGFloat = 12) -> some View {
self.modifier(
StatusGlassCardModifier(
brighten: brighten,
verticalPadding: verticalPadding,
horizontalPadding: horizontalPadding))
}
}

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