Compare commits

..

780 Commits

Author SHA1 Message Date
Mason Huang
482b071ebc fix(telegram): avoid rich messages in group chats 2026-06-16 00:17:43 +08:00
xydt-tanshanshan
85a635368e fix(memory): prevent empty expected model in memory index identity
Guard memory index identity resolution against empty or whitespace provider models by falling back to fts-only, and use fts-only as the fallback source model when an adapter fallback cannot resolve a model.

This prevents empty expectedModel mismatch reasons that can leave memory search dirty while preserving registered adapter default-model resolution.

Refs #90787
2026-06-15 23:56:39 +08:00
ly-wang19
0888debcd3 fix(cron): add cron edit --clear-model
Completes the CLI half of #91298.
2026-06-15 23:54:00 +08:00
Rohit
794bd89fa0 fix(cli): allow zero Discord timeout duration
Fixes #93327
2026-06-15 23:42:51 +08:00
openclaw-clownfish[bot]
93b7e3d717 fix(memory-core): safely refresh qmd index during collection repair
Squash merge ProjectClownfish replacement PR #92910.\n\nSource PR credit: thanks @imadal1n for the original index rewrite direction in #68590.
2026-06-15 23:34:01 +08:00
Ayaan Zaidi
da92615816 feat(telegram): send rich messages as rich html (#93286)
* feat(telegram): render rich messages through rich html

* docs(telegram): teach agents rich formatting

* fix(telegram): bound rich draft payloads (#93286)

* fix(telegram): narrow rich draft payload type (#93286)

* fix(telegram): preserve rich table cell formatting (#93286)

* fix(telegram): honor rich table mode config (#93286)

* fix(telegram): default rich markdown tables (#93286)

* fix(telegram): gate rich table block mode (#93286)

* fix(telegram): normalize raw rich html limits (#93286)

* fix(telegram): preserve link preview suppression (#93286)

* fix(telegram): preserve rich markdown headings (#93286)

* fix(telegram): reject unsupported rich media sources (#93286)

* fix(telegram): honor link preview off in rich chunks (#93286)

* fix(telegram): avoid double escaping markdown media (#93286)

* fix(telegram): render markdown media via placeholders (#93286)

* fix(telegram): preserve table text in prompt context (#93286)
2026-06-15 20:38:41 +05:30
Vincent Koc
767e8280ac fix(cli): harden official plugin recovery (#93325)
* fix(cli): harden official plugin recovery

* fix(config): preserve include write context

* fix(config): reject external include mutations

* fix(config): bind snapshots to config paths

* fix(config): preserve write ownership

* fix(cli): preflight plugin config mutations

* chore(plugin-sdk): refresh api baseline

* test(config): prove install env policy mutations

* fix(cli): preflight plugin updates

* fix(cli): preflight non-npm id migrations

* chore(plugin-sdk): refresh api baseline

* fix(cli): satisfy plugin recovery checks
2026-06-15 23:07:29 +08:00
Ayaan Zaidi
c1219d161d fix(android): preserve history context usage (#92837) (thanks @Tosko4) 2026-06-15 19:55:17 +05:30
Ayaan Zaidi
636aab6891 refactor(android): distill session event refresh 2026-06-15 19:55:17 +05:30
Tosko4
8f9493c213 fix(android): clear stale context usage snapshots 2026-06-15 19:55:17 +05:30
Tosko4
826ea2bf85 fix(android): show live chat context usage 2026-06-15 19:55:17 +05:30
Peter Steinberger
0bbac63d00 fix(tasks): migrate legacy agent attribution 2026-06-15 07:09:01 -07:00
Peter Steinberger
1fef20c96b fix(tasks): preserve requester agent attribution 2026-06-15 07:09:01 -07:00
Alix-007
77d6ad6f65 fix(tasks): honor explicit agentId in gateway tasks.list and repair test helper signature
Two code-review findings. (1) gateway taskMatchesAgent fell through to a requester/owner/child session-key scan even when the task had an explicit agentId, so a worker subagent task owned by agent:main:main also matched agentId:main; make explicit task.agentId authoritative and keep the session-key fallback only for legacy records without an agentId, with a gateway tasks.list regression. (2) the cross-agent attribution test passed async (root) to the zero-arg withTaskRegistryTempDir helper (TS2345/TS7006); drop the unused parameter and redundant env assignment.
2026-06-15 07:09:01 -07:00
Alix-007
206ac169e6 fix: attribute spawned task runs to child agent 2026-06-15 07:09:01 -07:00
Peter Steinberger
66079161d7 fix(macos): route Peekaboo through app bridge 2026-06-15 09:14:16 -04:00
Momo
dd7f2ef002 Persist ClawHub skill install provenance (#93283)
Summary:
- The PR adds artifact, installed skill-file, source URL, and verification-envelope fields to ClawHub skill origin/lock metadata while keeping install telemetry restricted to the older version/registry shape.
- PR surface: Source +144, Tests +139. Total +283 across 2 files.
- Reproducibility: not applicable. as a bug reproduction. Source inspection shows current main lacks the richer `.clawhub` provenance fields, and the PR body provides after-patch live output from a ClawHub install.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Persist ClawHub skill install provenance

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

Prepared head SHA: 65774f4f4b
Review: https://github.com/openclaw/openclaw/pull/93283#issuecomment-4707787041

Co-authored-by: momothemage <niuzhengnan@163.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: momothemage
2026-06-15 13:04:08 +00:00
Alix-007
018d279468 fix(memory): clean rollback-journal reindex temp sidecar on NFS stores
cleanupAgedMemoryReindexTempFiles only removed WAL sidecars (-wal/-shm) of orphaned reindex temp DBs. On NFS-backed stores configureMemorySqliteWalMaintenance -> requireRollbackJournalMode forces journal_mode=DELETE, so the reindex temp DB uses a rollback journal; a hard crash leaves an orphaned .tmp-<uuid>-journal that leaked forever (cleanup neither deleted nor even discovered it). Add -journal to both the delete set (memoryIndexFileSuffixes) and the discovery set (reindexTempEntrySuffixes), with regression tests for the temp-plus-journal and stranded-journal cases.
2026-06-15 05:49:12 -07:00
Peter Steinberger
a09f6b1b27 fix(codex): preserve terminal outcome ordering (#93287) 2026-06-15 05:31:46 -07:00
Ayaan Zaidi
d1b33a6040 fix(telegram): recover pid-reused ingress claims 2026-06-15 17:31:03 +05:30
Alex Knight
8682d0701b perf(sessions): share one enumeration across archive retention sweeps (#91957)
Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
2026-06-15 21:11:50 +10:00
Mason Huang
4029fbd2b2 fix(status): avoid stale session context windows (#93220)
Summary:
- The PR filters stale session/live context-token values when rendering `/status`, threads existing per-agent/default context caps into status rendering, and adds regression tests for status message and summary output.
- PR surface: Source +107, Tests +155. Total +262 across 7 files.
- Reproducibility: yes. Source inspection shows current main forwards stale live and persisted context-token v ... atus`, and the PR comments include live gateway output validating the Kimi/DeepSeek mismatch after the fix.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(status): avoid stale session context windows

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

Prepared head SHA: 4a8e9299a3
Review: https://github.com/openclaw/openclaw/pull/93220#issuecomment-4705953238

Co-authored-by: masonxhuang <masonxhuang@tencent.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: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-15 11:08:48 +00:00
Sliverp
e7ee1c55b4 fix(qqbot): keep markdown table chunks valid (#92428)
* fix(qqbot): keep markdown table chunks valid

* fix(qqbot): keep markdown table chunks valid across message boundaries (#92428) (thanks @sliverp)

Co-authored-by: sliverp <870080352@qq.com>
2026-06-15 18:32:25 +08:00
Mason Huang
3ce3ed668d fix(status): correct pinned model clear hint (#93231)
Summary:
- This PR changes pinned-session `/status` guidance, model-selection docs, and status tests to recommend `/model default` instead of `/model <configured>` or `/reset` for clearing a session model pin.
- PR surface: Source 0, Tests 0, Docs +4. Total +4 across 7 files.
- Reproducibility: yes. from source inspection. Current main and v2026.6.6 emit the old `/reset` hint, while `/model default` clears persisted model overrides and `/reset` intentionally preserves user-selected overrides.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs: align model clear hint docs
- PR branch already contained follow-up commit before automerge: fix(status): correct pinned model clear hint

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

Prepared head SHA: 1181624daa
Review: https://github.com/openclaw/openclaw/pull/93231#issuecomment-4706327717

Co-authored-by: masonxhuang <masonxhuang@tencent.com>
Co-authored-by: Mason Huang <masonxhuang@tencent.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: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-15 10:15:54 +00:00
Peter Steinberger
0314819f91 fix(agents): replace prose terminal classifiers (#93228)
* fix(agents): replace prose terminal classifiers

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>

* fix(agents): preserve terminal failure lifecycles

* fix(agents): order parallel terminal summaries

* fix(agents): preserve structured post-tool silence

* fix(agents): preserve structured replay provenance

---------

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-06-15 02:53:14 -07:00
Alex Knight
eab22a911a fix(auto-reply): clear pending-final state before honoring post-send abort (#89115) (#93201)
In dispatchReplyFromConfig the user-message success branch ran
throwIfDispatchOperationAborted() *before* clearPendingFinalDeliveryAfterSuccess().
If stuck-session recovery aborted the run in the window between the final reply
shipping and the clear, the message was delivered but pendingFinalDelivery stayed
true forever — the get-reply redelivery short-circuit then silently blocked every
future inbound and the agent "went silent" (#89115).

Reorder so the durable pending-final bookkeeping is cleared first, then honor the
abort afterwards (preserving abort reporting). Also clear the stranded
pendingFinalDeliveryIntentId field — agent-command.ts already clears it but the
success helper did not.
2026-06-15 19:46:06 +10:00
Jason (Json)
773ffd87a1 fix(tui): keep parent stdin paused after exit (#93159)
Keep the setup TUI parent stdin paused after its inherited-stdio child exits so Docker and PTY setup parents terminate cleanly. Align pre/post setup terminal cleanup with the cleanup-then-exit contract and add lifecycle regression coverage.

Thanks @fuller-stack-dev.
2026-06-15 17:37:43 +08:00
Vincent Koc
3add8af427 docs(skills): post Discord announcements as user 2026-06-15 17:26:30 +08:00
Ben Badejo
3fc850fe86 fix(matrix): replace recovered command progress lines (#89920)
* fix(matrix): replace recovered command progress lines

* fix(matrix): replace recovered command progress lines

* fix(matrix): share command progress identity

* fix(channels): share command progress identity

* fix command progress draft replacement

* fix command progress ids without changing public line ids

* test(telegram): assert command progress preview update

* fix(telegram): keep progress preview test typed

---------

Co-authored-by: Benjamin Badejo <ben@benbadejo.com>
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
2026-06-15 19:14:43 +10:00
zhang-guiping
ba1be23821 fix #69443: [Bug] Subagent RPC callback to WeChat session key routed to main session instead (#90231)
* fix agent session-key callback routing

* fix reset ack session-key delivery

* fix direct agent to session key routing

* fix lint in direct agent session routing

* fix: route subagent RPC callbacks to agent-shaped --to session key instead of main session (#90231) (thanks @zhangguiping-xydt)

---------

Co-authored-by: sliverp <870080352@qq.com>
2026-06-15 17:10:50 +08:00
Sash Zats
233b48daaa refactor: prune unused iOS code (#91996)
Prune unused iOS surfaces and regenerate the Xcode project. Add a scoped Periphery PR gate with hardened artifact handling and stale-status cleanup.

Co-authored-by: Sash Zats <sash@zats.io>
2026-06-15 02:07:15 -07:00
Ayaan Zaidi
501f63443f fix(openai): route spark through codex runtime 2026-06-15 14:17:15 +05:30
Ayaan Zaidi
dae2bcf31b style(openai): trim spark suppression comments 2026-06-15 14:17:15 +05:30
VACInc
aaa5ce6280 fix(openai): preserve catalog spark base urls 2026-06-15 14:17:15 +05:30
VACInc
6e8982f7a0 fix(openai): preserve suppression base urls 2026-06-15 14:17:15 +05:30
VACInc
8ea848acb0 docs(openai): clarify spark oauth support 2026-06-15 14:17:15 +05:30
VACInc
59e6452772 fix(openai): restore spark oauth routing 2026-06-15 14:17:15 +05:30
Vincent Koc
e573b751bf fix(cron): expose safe explicit delivery context 2026-06-15 16:45:58 +08:00
Peter Steinberger
d012d29e6f test(protocol): narrow literal union schemas 2026-06-15 04:26:00 -04:00
Jason O'Neal
44bf1c6d72 fix(voice-call): require realtime websocket path boundary
Tighten voice-call realtime WebSocket upgrade matching so configured stream paths match exactly or as slash-delimited children only.

Rejects same-prefix sibling paths that previously entered the realtime handler via a raw startsWith check. Preserves root stream-path child routing and adds bound VoiceCallWebhookServer regression coverage for valid child, sibling rejection, and root child behavior.

Verification:
- node scripts/run-vitest.mjs run extensions/voice-call/src/webhook.test.ts
- node scripts/run-vitest.mjs run extensions/voice-call/src/webhook.test.ts extensions/voice-call/src/webhook/realtime-handler.test.ts
- node scripts/run-oxlint.mjs --tsconfig config/tsconfig/oxlint.extensions.json extensions/voice-call/src/webhook.ts extensions/voice-call/src/webhook.test.ts
- ./node_modules/.bin/oxfmt --check --threads=1 extensions/voice-call/src/webhook.ts extensions/voice-call/src/webhook.test.ts
- git diff --check
- GitHub PR checks passing
2026-06-15 18:21:22 +10:00
Peter Steinberger
7a7165ad22 fix(protocol): emit Swift enums for literal unions 2026-06-15 03:20:42 -04:00
Vincent Koc
928b5932a3 fix(agents): track normalized message target evidence 2026-06-15 15:10:20 +08:00
Jason (Json)
77a682c5de fix(agents): retry empty post-tool final turns (#93073)
Recover assistant turns that complete tool work without producing a visible final answer, while preserving intentional silent replies.

Use concrete tool-instance replay safety across embedded, Codex, and Copilot runtimes so unknown, mutating, async-started, and durable recall operations fail closed. Preserve genuine empty Codex final items without promoting commentary or tool-progress echoes.

Supersedes #90872. Thanks @fuller-stack-dev.

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-06-15 00:08:57 -07:00
Vincent Koc
efbefceb0e fix(cron): preserve explicit delivery and timeout semantics 2026-06-15 14:58:46 +08:00
Goutam Adwant
1c30bb8ce6 fix(telegram): preserve sticker media paths (#93130)
* fix(telegram): preserve sticker media paths

* fix(telegram): address PR validation failures

* fix(telegram): preserve sticker media context

* test(telegram): fix sticker proof checks

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 14:43:32 +08:00
Vincent Koc
94833b2c90 fix(release): support ClawHub-only runtime builds 2026-06-15 14:23:57 +08:00
Vincent Koc
1057e74438 fix(e2e): resolve macOS Parallels VM
(cherry picked from commit a231ab8acf)
2026-06-15 14:23:57 +08:00
Vincent Koc
55a6d8c57d fix(e2e): resume restored Parallels snapshots
(cherry picked from commit a7e0822a1a)
2026-06-15 14:23:57 +08:00
Vincent Koc
b8967fc877 fix(docker): seed prune store from lockfile
(cherry picked from commit 47ec5be9ef)
2026-06-15 14:23:57 +08:00
Vincent Koc
42759a1b79 fix(telegram): repair rich message typecheck 2026-06-15 14:23:57 +08:00
Vincent Koc
5b18b7560e fix(release): harden plugin package preflight 2026-06-15 14:23:57 +08:00
dongdong
bcb016a528 fix: accept mixed source/dist bundled roots (#93119)
* fix: accept mixed source/dist bundled roots fixes #87730

* fix(plugins): validate mixed bundled roots per plugin

* fix(plugins): preserve active source overlays

---------

Co-authored-by: Jasmine Zhang <jasminezhang@JasminedeMac-mini.local>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 14:00:48 +08:00
Ayaan Zaidi
b3f315461b fix(telegram): preserve rich markdown line breaks 2026-06-15 11:07:15 +05:30
Vincent Koc
5b460c4669 fix(status): preserve rich message line breaks 2026-06-15 11:07:15 +05:30
Dallin Romney
1f8c4d3958 simplify QA evidence profile and mappings/coverage shape (#93153)
* test(qa): simplify evidence coverage shape

* test(qa): collapse evidence scorecard metadata

* test(qa): document evidence schema version
2026-06-14 22:26:58 -07:00
mushuiyu_xydt
04875efd28 fix(memory-core): vary dream diary recall snippets (#91225)
Prevent repeated first-day Dream Diary narratives by prioritizing fresh recall snippets across the bounded short-term store and adding recent diary context to narrative generation. Keep diary reads best-effort and reject symlink/non-file inputs.

Fixes #83830.

Thanks @mushuiyu886.

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>
2026-06-14 22:02:06 -07:00
liuhao1024
7e0128ae65 fix(agents): preserve literal current session resolution (#93138)
* fix(agents): resolve "current" session alias locally without gateway round-trip

The system prompt tells agents to use sessionKey="current" to refer to
their own session.  Previously, resolveSessionReference sent the literal
string "current" to the gateway sessions.resolve action, which rejected
it with INVALID_REQUEST and logged a noisy error line on every tool call.
The wrapper fell back to requesterInternalKey and succeeded, so the tool
worked — but the gateway error was spurious.

Add "current" to the well-known client alias check in
resolveCurrentSessionClientAlias so it is resolved locally to the
requester's session key, matching how TUI/CLI/WebChat client labels are
handled.  This eliminates the unnecessary gateway round-trip and the
error log line.

Fixes #78424

* test: update session_status tests for local current-key resolution

* test: update session_status tests for local current-key resolution

* Revert "test: update session_status tests for local current-key resolution"

This reverts commit d9f6c8b5248921c99f43dc222667ffa429b34401.

* Revert "test: update session_status tests for local current-key resolution"

This reverts commit 40bf77d06711833c1beaeedf562b60a765a559d6.

* Revert "fix(agents): resolve "current" session alias locally without gateway round-trip"

This reverts commit d92bc9b91e0840ea5823cd44223c139e434c5ec4.

* fix(agents): preserve literal current session resolution

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 12:32:15 +08:00
liuhao1024
1db8ab3734 fix(feishu): pass card_msg_content_type to get full card content (fixes #78289) (#93134)
* fix(feishu): pass card_msg_content_type to get full card content

When reading Feishu interactive card messages via getMessageFeishu,
the API returns a degraded structure (title + 'upgrade client' prompt)
unless card_msg_content_type=user_card_content is passed in params.

Fixes #78289

* fix(feishu): request full card content for message reads

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 12:04:55 +08:00
Omar Shahine
cc954798f2 fix(imessage): honor disabled reply actions (#93137)
Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
2026-06-15 11:53:35 +08:00
Mason Huang
4fc805320f fix(cron): require explicit message target proof (#92318)
Summary:
- Merged fix(cron): require explicit message target proof after ClawSweeper review.

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

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

Prepared head SHA: 2aff537f9f
Review: https://github.com/openclaw/openclaw/pull/92318#issuecomment-4704342205

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-15 03:52:05 +00:00
Mason Huang
06431fd99b test: add temp directory helper guidance (#87298)
Summary:
- Merged test: add temp directory helper guidance after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(scripts): honor temp report failure mode
- PR branch already contained follow-up commit before automerge: fix(scripts): reduce temp report noise
- PR branch already contained follow-up commit before automerge: fix(scripts): cover test support temp reports
- PR branch already contained follow-up commit before automerge: fix(scripts): report temp use in test helpers
- PR branch already contained follow-up commit before automerge: fix(scripts): broaden temp report test surface
- PR branch already contained follow-up commit before automerge: fix(scripts): cover nested test temp reports

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

Prepared head SHA: 132f14a381
Review: https://github.com/openclaw/openclaw/pull/87298#issuecomment-4704338581

Co-authored-by: masonxhuang <masonxhuang@tencent.com>
Co-authored-by: Mason Huang <masonxhuang@tencent.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: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-15 03:51:43 +00:00
Dallin Romney
3d38c9a633 test(qa): embed profile scorecard evidence (#93109)
* test(qa): embed profile scorecard evidence

* test(qa): fix profile runner return lint

* test(qa): satisfy suite command lint return
2026-06-14 20:51:38 -07:00
Ayaan Zaidi
663fabbe30 fix(telegram): render progress drafts as rich previews 2026-06-15 08:52:27 +05:30
Peter Lindsey
c847db550f fix(telegram): keep streamed tool-progress lines on separate lines
Telegram's rich-markdown renderer treats a lone "\n" as a soft break
(rendered as a space), so streamed tool-progress draft lines joined by a
single newline collapsed onto one line. Pass "\n\n" as the progress-draft
line separator for Telegram; it renders a blank line as a single break, so
each tool/thinking/commentary line gets its own line again. Other channels
keep the single-newline default, so Discord and the rest are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 08:52:27 +05:30
Galin Iliev
50c82b3020 fix(scripts): add database-first legacy store guard
Adds a required database-first legacy-store guard and regression coverage for legacy runtime state write patterns.

The guard is wired into architecture/preflight/changed checks, narrows the documented guard contract to the implemented filesystem-write scope, and tightens extension migration exemptions to explicit owner APIs. Also includes a small memory-core lint unblocker after current CI flagged an unnecessary non-null assertion.

Verification:
- pnpm check:database-first-legacy-stores
- pnpm lint:scripts
- node scripts/run-vitest.mjs test/scripts/check-database-first-legacy-stores.test.ts -- --reporter=verbose
- node scripts/run-oxlint.mjs extensions/memory-core/src/memory/manager-embedding-ops.ts
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI green for PR head 34dde2c620

Closes #91628.
2026-06-14 20:08:06 -07:00
Vincent Koc
dc46a67e80 fix(memory-core): remove redundant provider key assertion 2026-06-15 11:05:07 +08:00
Marcus Castro
7d8b000bf7 fix(whatsapp): bound socket operations (#93094)
* fix(whatsapp): bound socket operations

* test(whatsapp): type monitor fixture config

* fix(whatsapp): align socket timeout semantics

* test(whatsapp): cover socket timeout edge cases

* test(whatsapp): shrink socket timeout coverage

* refactor(whatsapp): simplify socket timeout boundary

* fix(whatsapp): keep send api socket type structural
2026-06-15 00:04:11 -03:00
clawsweeper[bot]
ac1042b09b fix(voice-call): preserve live Twilio streams in stale reaper (#90812)
Summary:
- The PR updates the voice-call plugin to preserve live `speaking`/`listening` calls without `answeredAt`, backfill max-duration enforcement for live/restored call paths, and add regression tests.
- PR surface: Source +90, Tests +223. Total +313 across 9 files.
- Reproducibility: yes. source-level: current main and v2026.6.6 still reap aged non-terminal calls solely bec ... king` or `listening` without setting it. I did not run a live Twilio carrier call in this read-only review.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: fix(voice-call): preserve live Twilio streams in stale reaper
- Included post-review commit in the final squash: fix(clawsweeper): address review for automerge-openclaw-openclaw-9062…

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

Prepared head SHA: 5fee2ff7a1
Review: https://github.com/openclaw/openclaw/pull/90812#issuecomment-4637047870

Co-authored-by: Sahibzada Allahyar <sahibzada@fastino.ai>
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-06-15 02:39:29 +00:00
Chunyue Wang
fd80e0dd6b fix(agents): do not misclassify client-disconnect abort as run timeout (#90936)
Summary:
- The PR adds an abort-signal-specific timeout classifier, switches two embedded attempt abort handlers to it, and adds focused failover tests.
- PR surface: Source +5, Tests +32. Total +37 across 3 files.
- Reproducibility: yes. from source inspection and a focused Node abort-reason check, but not from a live 180- ... ault AbortController abort reason through the broad timeout classifier used by the embedded abort handlers.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agents): do not misclassify client-disconnect abort as run timeout

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

Prepared head SHA: 2708b0a37d
Review: https://github.com/openclaw/openclaw/pull/90936#issuecomment-4638919394

Co-authored-by: openperf <16864032@qq.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-06-15 02:38:47 +00:00
mushuiyu_xydt
44e6caff54 fix(memory): accept local default model path migration (#92954)
* fix(memory): accept local default model path migration

Treat the official local default embedding model's hf URI and downloaded GGUF path identities as equivalent so upgraded local memory indexes do not pause solely on path-format changes.

* fix(memory): satisfy local identity lint

Avoid filtered array tail access in the local model filename helper while preserving the same compatibility behavior.

* fix(memory): preserve local embedding identity aliases

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 09:29:42 +08:00
Dallin Romney
d1eda0bd6f test(reply): preserve telegram dedupe fallback (#93107) 2026-06-14 18:25:40 -07:00
Dallin Romney
cae64ee360 fix(reply): avoid bundled channel load in dedupe (#93104) 2026-06-14 18:09:26 -07:00
Dallin Romney
e8db9c3bc0 test(qa): add qa run --profile and unified output summary/evidence (#91587)
* test(qa): add mapped qa run profiles

* test(qa): document mapped profile runner

* test(qa): validate run profiles from mapping

* test(qa): preserve root profile parsing

* test(qa): simplify taxonomy profile dispatch

* test(qa): align tool coverage CLI expectation

* test(qa): fix profile dispatch fixture type

* test(qa): share profile runner option types

* test(qa): split shared cli runner options

* test(qa): unify profile suite artifacts

* fix(qa): filter profile scenarios by provider lane

* test(qa): drop native scenario subreports

* fix(qa): keep native log refs repo-relative

* fix(cli): preserve qa run root profile parsing

* fix(qa): avoid qa profile flag collision

* fix(qa): reject profile flags without qa profile
2026-06-14 18:08:42 -07:00
Kevin Lin
e82d19fb06 feat(codex): add auto plugin approvals (#92625)
* feat(codex): add on-request plugin approvals

* feat(codex): rename plugin approval policy to auto

* fix(codex): update binding schema version callers
2026-06-14 18:00:38 -07:00
aliahnaf2013-max
870ec6dee2 fix(codex): close one-shot app server clients
Fix gateway-routed one-shot Codex app-server teardown so owned shared clients are retired after run cleanup. Verified with focused tests, Showboat proof, and green PR CI.
2026-06-14 17:55:31 -07:00
Dallin Romney
fef8394079 Convert QA scenarios to YAML files (#92915)
* refactor: load QA scenarios from YAML

* docs: update personal QA scenario docs

* test: keep QA scenarios YAML-only
2026-06-14 17:31:18 -07:00
Vincent Koc
1ca3d4f586 test(reply): default followup enqueue mock to accepted 2026-06-15 08:21:49 +08:00
Yuval Dinodia
02acb0bd74 fix(gateway): accept file-only input on /v1/responses (parity with image-only) (#93011)
* fix(gateway): accept file-only input on /v1/responses (parity with image-only)

* fix(gateway): preserve file-only model context

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-15 08:01:19 +08:00
zhang-guiping
a1f18ef46e fix #90333: [Bug]: Discord image build aborts at step 66 — openclaw-build-messaging-plugins.py exits 1 (#92869)
* fix(cli): recover versioned Discord plugin installs

* fix(cli): harden plugin install recovery

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-15 07:58:22 +08:00
openclaw-clownfish[bot]
9ba6ed1d5c fix(agents): preserve fresh usage after compaction (#93084)
* fix(compaction): preserve fresh usage after compaction

Co-authored-by: HollyChou <128659251+Hollychou924@users.noreply.github.com>

Co-authored-by: 吴杨帆 <39647285+leno23@users.noreply.github.com>

* fix(compaction): satisfy stale usage timestamp narrowing

* fix(clownfish): address review for ghcrawl-156678-autonomous-smoke (1)

Co-authored-by: HollyChou <128659251+Hollychou924@users.noreply.github.com>

Co-authored-by: de1ty <7804799+de1tydev@users.noreply.github.com>

Co-authored-by: 吴杨帆 <39647285+leno23@users.noreply.github.com>

Co-authored-by: Zhao Shiqi <109639815+425072024@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: 吴杨帆 <39647285+leno23@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: Zhao Shiqi <109639815+425072024@users.noreply.github.com>
2026-06-15 07:33:45 +08:00
Josh Lehman
f1b8827d20 refactor: route bundled plugin session callers through seam (#89129)
Merged via squash.

Prepared head SHA: 7975bc06ac
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-14 12:26:53 -07:00
kumaxs
356385045f fix(opencode-go): warm context metadata from provider catalog (#92913)
Register OpenCode Go's provider-owned static catalog so lifecycle cache warmup supplies the correct context window to memory flush and compaction without persisting catalog rows in user config.

Fixes #92912.

Co-authored-by: kumaxs <45620232+kumaxs@users.noreply.github.com>
2026-06-14 12:08:49 -07:00
Sam
a189baa4b3 fix(agents): tolerate missing attribution baseUrl (#92991)
Avoid assuming every runtime model exposes a string `baseUrl` before provider attribution checks. Preserve OpenRouter and Cloudflare attribution behavior while allowing Bedrock session setup to reach provider routing.

Fixes #92974.

Co-authored-by: Sami Rusani <sr@samirusani>
2026-06-14 11:43:10 -07:00
zengLingbiao
07dfdd4bd0 fix(agents): prevent duplicate before-tool-call hooks (#93009)
Prevent duplicate `before_tool_call` execution when an already wrapped tool passes through schema normalization and coding-tool assembly. Preserve the normalized schema while replacing stale wrapper context with the current agent/session/run context.

Fixes #92973.

Co-authored-by: zengLingbiao <zeng.lingbiao@xydigit.com>
2026-06-14 11:25:43 -07:00
Shakker
96f786d4b1 fix: route oauth fallback env setup 2026-06-14 19:21:40 +01:00
Shakker
0100c27bc0 test: route oauth helper env setup 2026-06-14 19:19:04 +01:00
Shakker
a8ca9619a5 fix: route oauth lock env setup 2026-06-14 19:17:22 +01:00
Shakker
3a8d422000 test: scope tilde home env overrides 2026-06-14 19:15:19 +01:00
Shakker
fc3e00cc11 fix: route acp harness env restore 2026-06-14 19:13:12 +01:00
Shakker
a1b7f3570c test: centralize session file env restore 2026-06-14 19:11:31 +01:00
Shakker
b8ed2c3280 fix: restore acp task env scope 2026-06-14 19:06:25 +01:00
Shakker
8bc728307d test: route wsl env mutations 2026-06-14 19:03:47 +01:00
OfflynAI
81924cfd5e fix(ollama): preserve length-limited responses (#89160)
Preserve Ollama length termination and surface incomplete token-limited embedded-agent turns without discarding durable tool output.

Fixes #89051.

Co-authored-by: joelnishanth <140015627+joelnishanth@users.noreply.github.com>
2026-06-14 10:59:37 -07:00
openclaw-clownfish[bot]
6134c00657 fix(telegram): cool down transient sendChatAction failures (#93020)
* fix(telegram): cool down transient sendChatAction failures

Co-authored-by: Langning Zhang <145515129+Boulea7@users.noreply.github.com>

Co-authored-by: Sumaia Zaman <45918347+sumaiazaman@users.noreply.github.com>

Co-authored-by: pick-cat <266665499+Pick-cat@users.noreply.github.com>

* fix(clownfish): address review for ghcrawl-156876-autonomous-smoke (1)

Co-authored-by: Langning Zhang <145515129+Boulea7@users.noreply.github.com>

Co-authored-by: Sumaia Zaman <45918347+sumaiazaman@users.noreply.github.com>

Co-authored-by: pick-cat <266665499+Pick-cat@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: pick-cat <266665499+Pick-cat@users.noreply.github.com>
2026-06-15 01:07:10 +08:00
openclaw-clownfish[bot]
4c2fef4a3b fix(gateway): repair usage cost aggregation across agents (#93022)
* fix(gateway): aggregate usage cost across agents

* fix(clownfish): address review for ghcrawl-157040-autonomous-smoke (1)

Co-authored-by: luke-skywalker-open-claw <262978557+luke-skywalker-open-claw@users.noreply.github.com>

Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
2026-06-15 01:06:36 +08:00
Vincent Koc
b470316fc0 fix(state): harden sqlite path caching
Resolve explicit relative SQLite DB paths before caching handles and centralize durable SQLite connection pragmas so busy_timeout is applied before WAL/NFS negotiation.
2026-06-15 01:04:35 +08:00
Sally O'Malley
7e12a3326d feat(openrouter): surface Fusion panel config (#93005)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-14 12:44:49 -04:00
ZengWen-DT
a42bda5b37 fix(memory): clean stale reindex temp files (#92891)
* fix(memory): clean stale reindex temp files

* fix(memory): harden stale reindex cleanup

* fix(memory): serialize safe reindex cleanup

* fix(memory): satisfy reindex lock lint

---------

Co-authored-by: zengwen <zeng_wen@foxmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-15 00:40:43 +08:00
NVIDIAN
ecaebfc51b fix(agents): retry thinking-only errored turns (#92191)
Retry replay-safe reasoning-only provider errors before assistant failover while preserving classified fallback and terminal-output ownership. Adds deterministic Anthropic gateway fault-injection coverage and focused regression tests.\n\nCo-authored-by: ai-hpc <mail.speedy.hpc@hotmail.com>
2026-06-14 09:39:27 -07:00
Ciward
364461949d fix: refresh slash command routing config (#39617)
Use the active runtime snapshot for Discord and Slack native command routing and Discord autocomplete after config hot writes.

Fixes #39605

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-14 09:32:18 -07:00
Vincent Koc
10b0dea77a test(gateway): retry chat temp cleanup 2026-06-15 00:28:58 +08:00
Martin Kessler
aefd49909d fix(skills): keep managed prompt paths readable (#92894)
* fix(skills): keep managed prompt paths readable

* fix(skills): preserve only managed skill prompt paths

* chore: refresh proof gate

* fix(skills): refresh managed prompt snapshots

* fix(skills): preserve plugin prompt paths in state roots

* test(skills): use generic state-root path names

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-14 12:19:08 -04:00
sandieman2
c67dc59b02 fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread (#90943)
* fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread

Two core bugs caused composed replies to be silently dropped (no delivery,
no error) when a second message arrived in the same thread mid-run:

1. dispatch-from-config: ensureDispatchReplyOperation only kept the
   dispatch-owned operation authoritative while it had no result. Once
   runReplyAgent completed the operation to drain queued follow-ups, a
   second same-thread inbound could claim the session and the first final
   reply would try to re-acquire the lane instead of finishing delivery,
   deadlocking behind the queued work. Keep the dispatch-owned operation
   authoritative through final delivery.

2. reply-payloads-dedupe: messaging-tool reply dedupe compared only the
   channel target, not the routed thread, so a send in one thread could
   suppress a later reply in a different thread. Thread the routed thread
   id through buildReplyPayloads + follow-up delivery and only fall back to
   channel-only matching for providers without a thread-aware suppression
   matcher when neither side carries thread evidence.

Adds regression tests; existing Telegram topic-suppression behavior is
preserved by gating the thread guard to providers lacking a plugin matcher.

* fix(reply): preserve threaded message delivery evidence

* fix(reply): dedupe final payloads by delivery route

* fix(slack): preserve native send thread evidence

* fix(reply): preserve explicit reply thread evidence

* fix(reply): align explicit reply route dedupe

* fix(reply): preserve delivery lane through final dispatch

* fix(mattermost): preserve threaded tool send routes

* chore(plugin-sdk): refresh API baseline

* fix(reply): align final delivery route dedupe

* fix(reply): gate followups on final delivery

* fix(reply): keep send receipts private

* fix(reply): infer implicit message provider

* fix(reply): align routed threading policy

* fix(reply): preserve queued delivery context

* fix(reply): hydrate queued system event routes

* fix(reply): hydrate queued execution routes

* fix(reply): scope final delivery barriers

* fix(slack): preserve DM target aliases

* fix(reply): mirror resolved source thread routes

* fix(mattermost): retain delayed delivery barrier

* fix(codex): separate message routing from tool policy

* fix(reply): consume normalized Slack DM targets once

* fix(slack): remove stale target alias

* style(reply): satisfy changed lint gates

* fix(mattermost): preserve explicit reply targets

* test: align Slack reply branch checks

* fix(reply): persist overflow summaries to admitted session

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-14 09:11:05 -07:00
Josh Lehman
e1744184b8 refactor: route command session reads through seam (#89122)
Merged via squash.

Prepared head SHA: 73218cc7cd
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-14 09:01:17 -07:00
Vincent Koc
2e240e772b test(gateway): expect trajectory session branch bundle 2026-06-14 23:34:08 +08:00
Vincent Koc
5836982506 test(gateway): allow optional trajectory metadata bundle 2026-06-14 23:24:55 +08:00
Vincent Koc
40093f5a93 fix(gateway): track effective watcher polling mode 2026-06-14 23:19:54 +08:00
zhang-guiping
d0851435e8 fix #86872: Subagent run reports success but fails to write output file (#92642)
* fix(codex): wait for native subagent completion

Codex native subagent lifecycle status is only a progress signal; the task row should not report success until the transcript or native completion result is available.

* fix(codex): preserve later native subagent failures

* test(codex): freeze authoritative subagent results

* fix(codex): preserve remote V1 completion fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 23:18:57 +08:00
Vincent Koc
3826cda4d8 fix(gateway): run export helpers through cli entry 2026-06-14 23:08:19 +08:00
Vortex Openclaw
771881d189 fix(elevenlabs): use current TTS model ids (#92904)
* fix(elevenlabs): use current TTS model ids

* fix(elevenlabs): preserve served legacy model choices

---------

Co-authored-by: Ariel Bravy <ariel@vortexradar.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 23:01:27 +08:00
ZengWen-DT
6db496b04b fix(tui): keep spinner active when toggling tools (#92909)
* fix(tui): keep spinner active when toggling tools

* fix(tui): preserve finishing status when toggling tools

---------

Co-authored-by: zengwen <zeng_wen@foxmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 22:59:29 +08:00
iloveleon19
4892bbc10f fix(mattermost): merge progress preview lines by identity (#91331)
* fix(mattermost): merge progress preview lines by identity

* fix(mattermost): preserve progress across assistant boundaries

* fix(mattermost): compose reasoning with progress previews

* fix(channels): reset reasoning progress at block boundaries

* fix(channels): align tool progress line identities

* fix(channels): keep tool call identity mapping injective

---------

Co-authored-by: leon <leon@gmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 22:58:54 +08:00
Yuval Dinodia
7f6df80537 fix(google): route Gemini CLI OAuth through the env proxy (#46184) (#92815) 2026-06-14 22:52:18 +08:00
이민재
1a2e418500 fix: use passive periodic sqlite wal checkpoints
Use PASSIVE for periodic SQLite WAL checkpoints while keeping explicit checkpoint() and close() on TRUNCATE by default.

Preserve the old interval export as a compatibility alias, add the neutral interval export, and update the task storage docs contract.

Fixes #81715.
2026-06-14 22:46:15 +08:00
liuhao1024
a823cc3b1c docs(browser-control): document OPENCLAW_EAGER_BROWSER_CONTROL_SERVER requirement (#92845)
The standalone loopback HTTP API only starts when
OPENCLAW_EAGER_BROWSER_CONTROL_SERVER=1 is set in the gateway
service environment. Without it, browser control works via CLI and
agent tools but nothing listens on the loopback control port.

Fixes #92841
2026-06-14 22:43:26 +08:00
Colin Johnson
fd855c831f feat(webui): add session workspace rail (#92856)
* feat(webui): add session workspace rail

* fix(webui): address session workspace review

* fix(webui): secure session workspace previews

* fix(webui): handle nested session workspace paths

* fix(webui): update session file protocol models

* fix(webui): clear session rail lint
2026-06-14 22:39:51 +08:00
Vincent Koc
ccf5976d06 fix(gateway): async trajectory export approvals 2026-06-14 22:25:47 +08:00
abel-zer0
b5999bc6a0 fix(agents): drop incomplete reasoning replay turns (#88656)
Drop assistant replay turns that ended at the token limit with only incomplete hidden reasoning while preserving visible text, tool calls, empty turns, and unknown content shapes. Apply the same classification to embedded replay and public transport transforms, with focused regression, live OpenAI/Anthropic provider proof, docs, autoreview, Testbox, and green CI.

Co-authored-by: clawstation <abel@stationzero.ai>
2026-06-14 07:02:03 -07:00
Vincent Koc
fc6d448138 fix(ci): skip session accessor guard for older targets 2026-06-14 21:35:37 +08:00
Vincent Koc
2e745ba225 fix(webchat): route trajectory slash export before agent dispatch 2026-06-14 21:22:40 +08:00
Josh Lehman
ef47dd610c refactor: add session accessor seam with gateway consumer (#90463)
Merged via squash.

Prepared head SHA: 58aa59eaf8
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-14 06:18:47 -07:00
mmyzwl
fbc3fa3876 fix(agents): recover genericized Anthropic thinking errors (#92916)
Recover invalid Anthropic thinking replays when provider details survive genericization in SDK, failover, cause-chain, or terminal stream error fields.

The recovery matcher now uses cycle-safe named error carriers, avoids scanning assistant content and tool arguments, and retains one retry per provider call. Focused regressions cover each carrier, cyclic causes, terminal errors, and false-positive payload text.

Addresses the recovery path in #92201. The separate root cause that creates or persists invalid signatures remains open for investigation.

Co-authored-by: wlzeng0668001202 <ceng.wenlong@xydigit.com>
2026-06-14 06:08:24 -07:00
Vincent Koc
d28691da97 fix(release): preserve child release check refs 2026-06-14 20:55:05 +08:00
Ayaan Zaidi
65e6d9c98c fix(auto-reply): strip delivery hints from leading metadata 2026-06-14 18:18:20 +05:30
Ayaan Zaidi
d498b1cce4 fix(plugin-sdk): expose delivery hints without utility imports 2026-06-14 18:18:20 +05:30
Ayaan Zaidi
210877a73e fix(auto-reply): share message-tool delivery hints 2026-06-14 18:18:20 +05:30
Ayaan Zaidi
d5b1d4529f test(auto-reply): trim duplicate progress assertion 2026-06-14 18:18:20 +05:30
Forge
36f6008842 test(auto-reply): assert allowed suppressed progress gating 2026-06-14 18:18:20 +05:30
Forge
cf88b4c024 fix(auto-reply): align message-tool progress gating 2026-06-14 18:18:20 +05:30
ragesaq
a15427d605 fix(auto-reply): deliver channel message-tool final replies
Clarify that interim assistant text remains visible under message_tool_only delivery while the final answer must use the message tool, and forward progress for channel message-tool turns once the message tool has delivered the final reply.

Co-authored-by: Forge <forge@psiclawops.dev>

Co-authored-by: Chisel <chisel@psiclawops.dev>
2026-06-14 18:18:20 +05:30
David
23d74dad12 fix(lmstudio): honor thinking off for binary reasoning models (#92002)
Scope disabled-thinking payload repair to LM Studio's lightweight provider stream hook. Preserve official OpenAI and Anthropic tool-calling paths.

Co-authored-by: David <32288+nxmxbbd@users.noreply.github.com>
2026-06-14 05:41:49 -07:00
Vincent Koc
ae8da992ce test(gateway): unblock trajectory export live release gate 2026-06-14 20:41:34 +08:00
Stellar鱼
4644e0c102 fix(memory-wiki): tolerate artifacts without agent ids
Fixes #92207.

Normalize public memory artifacts at the memory host boundary so providers that omit agentIds produce an empty list instead of throwing during artifact cloning, sorting, or memory-wiki bridge import. The bridge now renders those artifacts with unknown agents while downstream consumers still receive stable array-shaped metadata.

Verification:
- node scripts/run-vitest.mjs src/plugins/memory-state.test.ts extensions/memory-wiki/src/bridge.test.ts --maxWorkers=1
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Crabbox run_2a30de5d0a00 / cbx_3684cb0b7ea5: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- GitHub PR checks clean on 19678ed60f
2026-06-14 20:19:24 +08:00
JC
fff193402e fix(gateway): build row metadata for single session lists
Refs #92057.

Build the request-scoped row metadata context for every non-empty sessions.list result, including limit=1, so single-row lists use the shared subagent metadata read index instead of direct per-row registry snapshot lookups. This keeps the existing single-row store child-session candidate optimization intact while removing the single-row metadata-cache gap.

Verification:
- node scripts/run-vitest.mjs src/gateway/session-utils.single-row-cache.test.ts --maxWorkers=1
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Crabbox run_f89b56ffea83 / cbx_f1b1f5013225: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- GitHub PR checks clean on 1ba6619f2e
2026-06-14 19:50:30 +08:00
danbao
c78f9376d9 fix(gateway): degrade config watcher to polling
Fixes #92851.

When native filesystem watching exhausts its retry budget, the gateway config reloader now falls back to polling instead of disabling hot reload for the rest of the process. The watcher state tracks the effective Chokidar polling mode, including CHOKIDAR_USEPOLLING overrides, so forced polling avoids a redundant native phase and forced native mode reports an accurate native-mode disable.

Verification:
- node scripts/run-vitest.mjs src/gateway/config-reload.test.ts --maxWorkers=1
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Testbox-through-Crabbox tbx_01kv2xvbqkv4dmvvvsswzm75hz: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- GitHub PR checks clean on c9762c5159
2026-06-14 19:29:29 +08:00
Peter Steinberger
d4a74b4993 fix(subagents): handle configured bare model provenance 2026-06-14 04:29:22 -07:00
Jasmine Zhang
aa04aef629 fix(subagents): preserve config-selected child model overrides 2026-06-14 04:29:22 -07:00
Vincent Koc
0716ebc1e5 test(openai): extend live STT fixture timeout 2026-06-14 19:21:25 +08:00
Chunyue Wang
cda040b4e5 fix(agents): clamp subagent spawn thinking overrides
Fixes #92412.

Subagent spawns that request an unsupported explicit thinking level now clamp through the existing provider/model thinking fallback instead of hard-failing after the orchestrator has already received an accepted ack. The exception is limited to trusted subagent spawn runs by requiring both the subagent lane and a subagent-shaped session key, so interactive and non-subagent explicit `--thinking` validation still fails loudly.

Verification:
- `node scripts/run-vitest.mjs src/agents/agent-command.live-model-switch.test.ts --maxWorkers=1`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- Testbox-through-Crabbox `tbx_01kv2wt0nqavsmnvzzzy2antrc`: `OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed`
- GitHub PR checks clean on `c71186863337d9dfb9a18e5349ebef634a7d5ccd`
2026-06-14 19:11:16 +08:00
Vincent Koc
00479b12d1 test(gateway): authorize trajectory export live command 2026-06-14 18:57:39 +08:00
Peter Steinberger
2a6eeceb40 fix(openai): recover invalid reasoning signatures (#92941) 2026-06-14 03:51:16 -07:00
Vincent Koc
30f5e6f639 test(installer): stabilize npm prefix probe test 2026-06-14 18:48:11 +08:00
Vincent Koc
dae9345407 test(gateway): isolate trajectory export live seed turn 2026-06-14 18:31:48 +08:00
Thomas Krohnfuß
a12942518e fix(openai): omit gpt-5.5 tool reasoning effort (#90574)
Fix GPT-5.5 Chat Completions tool requests by omitting the incompatible reasoning effort only on verified OpenAI and Azure routes. Preserve no-tool requests and nonblank custom OpenAI-compatible providers; add official regional endpoint metadata plus OpenAI and Anthropic live regression proof.

Co-authored-by: Thomas Krohnfuß <thomas.krohnfuss@stud.th-luebeck.de>
2026-06-14 03:28:54 -07:00
ghitafilali
b2c5e790b4 fix: restart gateway after isolated cron setup timeout
Restart the gateway after isolated cron setup timeouts and harden stale cron-task finalization around restart/reload boundaries.

Verification: focused cron/tasks/CLI regression suites, gateway filesystem/session regression suite, test typecheck, core lint shards, git diff --check, autoreview, Blacksmith Testbox changed gate tbx_01kv2srgbex71w9ce5rwv2wtr4, and clean GitHub PR checks on 13d06b5d6f.
2026-06-14 18:26:27 +08:00
Vincent Koc
37e3e895b0 test(release): unblock beta validation checks 2026-06-14 18:02:38 +08:00
Vincent Koc
2f34d06b42 fix(release): harden beta validation gates
(cherry picked from commit 91eeda0d708c2d8dac7c09c259b7cf390193f83f)
2026-06-14 17:33:48 +08:00
liuhao1024
db5e415888 fix(feishu): re-resolve route when dynamic agent binding already exists in runtime config (fixes #42837) (#92814)
* fix(feishu): re-resolve route when dynamic agent binding already exists in runtime config

When dynamicAgentCreation is enabled and a binding was previously written
to the config file (e.g. from a prior message), the in-memory cfg may be
stale and not contain the binding. Previously, maybeCreateDynamicAgent
returned { created: false, updatedCfg: cfg } with the stale cfg, and
bot.ts only re-resolved the route when created === true. This caused
subsequent messages to still route to agent:main.

Fix: check runtime.config.current() for the binding when it is missing
from the in-memory cfg. When found, return the runtime's current config
so the caller can re-resolve the route with up-to-date bindings.

Fixes #42837

* fix(feishu): serialize dynamic agent config updates

* fix(feishu): route with refreshed runtime config

* fix(feishu): use current dynamic-agent policy

* fix(feishu): reauthorize refreshed dynamic routes

* fix(feishu): authorize dynamic agent mutations

* fix(feishu): complete account-scoped dynamic routing

* fix(feishu): revalidate current direct routes

* fix(feishu): isolate named-account dynamic agents

* fix(feishu): bound named dynamic agent ids

* docs(feishu): explain legacy dynamic agent cap

* test(feishu): fix dynamic routing check types

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 17:28:54 +08:00
openclaw-clownfish[bot]
b9e8e6d66e fix(sessions): restore reset archive fallback reads
Fall back to valid reset transcript archives when active async session transcripts are missing, while keeping active transcript priority and choosing the newest valid archive across roots.

Validation:

- node scripts/run-vitest.mjs src/gateway/session-utils.fs.test.ts src/gateway/sessions-history-http.test.ts src/gateway/sessions-history-http.revocation.test.ts src/gateway/session-history-state.test.ts src/gateway/server.chat.gateway-server-chat-b.test.ts src/gateway/managed-image-attachments.test.ts src/agents/tools/embedded-gateway-stub.test.ts src/tui/embedded-backend.test.ts

- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.src.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-src-pr92879.tsbuildinfo

- git diff --check origin/main...HEAD && git diff --check

- autoreview --mode branch --base origin/main: clean

Direct-landed from #92879 because the source branch has maintainer edits disabled and the landed diff needed maintainer repair before merge.

Co-authored-by: Masato Hoshino <246810661+masatohoshino@users.noreply.github.com>

Co-authored-by: Hu Yitao <39733381+CadanHu@users.noreply.github.com>

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 17:24:17 +08:00
Peter Steinberger
a57b4c513f fix(openai): guard post-hook tool payloads (#92928)
Guard OpenAI post-hook tool inspection and code-mode filtering against unreadable accessors and asynchronous payload replacements. Preserve valid official `exec` and `wait` function tools across Responses and Chat Completions paths.

Supersedes #89703.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 02:17:25 -07:00
openclaw-clownfish[bot]
333a93ce44 fix(cli): avoid false downgrade prompt for latest tag
Keep unresolved latest package targets moving while preserving downgrade confirmation for unresolved non-latest dist-tags.

Validation:

- node scripts/run-vitest.mjs src/cli/update-cli.test.ts

- node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.test.src.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/test-src-pr92911.tsbuildinfo

- git diff --check origin/main...HEAD && git diff --check

Direct-landed from #92911 because the source branch has maintainer edits disabled and ClawSweeper requested changelog removal plus behavior proof.

Co-authored-by: Andy Wu <31586206+Andy312432@users.noreply.github.com>

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 17:16:22 +08:00
Bek
46a5a5ee5f fix(media): route OAuth image defaults through Codex (#92824)
Route implicit OpenAI image understanding through the Codex app-server for eligible OpenAI OAuth profiles. Preserve scoped and persisted credential ownership plus the rotating-token refresh lifecycle for isolated clients.

Fixes #87168

Thanks @bek91.
2026-06-14 17:11:18 +08:00
Vincent Koc
af091174db test(gateway): allow trajectory export instruction latency 2026-06-14 17:05:24 +08:00
Dallin Romney
1affe4fcdf Fold Telegram RTT sampling into live QA evidence (#92550)
* refactor(qa): fold telegram rtt into live evidence

* test: default package telegram rtt samples

* refactor(qa-lab): fold telegram rtt into live evidence

* fix(qa-lab): keep package telegram rtt optional for focused runs

* fix(qa-lab): avoid stale rtt evidence on failed samples

* fix(qa-lab): pass telegram live env into credential leasing

* fix(qa-lab): update telegram canary remediation artifacts

* docs(qa): remove stale telegram observed artifact guidance

* fix(qa-lab): clarify telegram empty-reply remediation

* fix(qa-lab): honor telegram rtt timeout

* ci(qa): drop stale telegram capture env

* refactor: align telegram evidence coverage fields

* fix: ignore stale telegram observed artifacts

* fix: preserve telegram rtt coverage mapping

* fix: omit unused telegram rtt catch binding

* docs: document telegram rtt check selector
2026-06-14 17:02:33 +08:00
Peter Steinberger
439a9e97fd fix(openai): quarantine unreadable tool schemas (#92921)
Snapshot unreadable OpenAI tool descriptors and schemas before payload construction, preserve healthy siblings, and reconcile hard tool choices with the surviving function inventory.

Adds live-tested Responses and Chat Completions coverage, including allowed_tools, while keeping Anthropic regressions green.

Related: #89413, #89013, #89016, #89378, #89543, #90200, #90283, #90286, #90397

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 01:45:38 -07:00
Vincent Koc
1c86769cb8 test(gateway): keep trajectory export live proof correlated 2026-06-14 16:38:58 +08:00
Vincent Koc
b1caba5906 test(qa): align tool coverage CLI expectation 2026-06-14 16:11:00 +08:00
Vincent Koc
d2c0d3ac9b test(agents): skip anthropic billing drift in live tool checks 2026-06-14 15:56:29 +08:00
openclaw-clownfish[bot]
e99ab385cf fix(memory): preserve reindex rollback recovery (#92881)
* fix(memory): preserve reindex rollback recovery

Co-authored-by: Shiwen Han <46259514+TSHOGX@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-5644-autonomous-smoke (1)

Co-authored-by: Shiwen Han <46259514+TSHOGX@users.noreply.github.com>

* test: update memory reindex test routing expectation

* chore(memory): remove release changelog entry

* fix(memory): complete reindex retry recovery

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Shiwen Han <46259514+TSHOGX@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 15:31:51 +08:00
Peter Steinberger
19130e0dc2 fix(providers): quarantine unreadable Anthropic payload tools (#92908)
Quarantine unreadable and invalid Anthropic-family tool schemas before OpenAI-compatible serialization, keep tool choices aligned with surviving tools, and preserve provider metadata.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 00:27:48 -07:00
openclaw-clownfish[bot]
8d08b90489 fix(memory-wiki): stop flagging raw source pages as malformed (#92876)
* fix(memory-wiki): stop flagging raw source pages as malformed

* fix(clownfish): address review for gitcrawl-11828-autonomous-smoke (1)

* fix(memory-wiki): skip freshness lint for raw source pages

* fix(memory-wiki): keep raw source ids linted

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 15:18:41 +08:00
Dallin Romney
a3e9dfee0e Simplify QA scorecard mapping shape (#92558)
* test(qa): simplify scorecard mapping shape

* test(qa): use typed scorecard evidence refs

* test(qa): map scorecard categories by coverage id

* feat: align qa coverage with taxonomy features

* refactor: keep qa coverage ids canonical

* refactor: minimize qa coverage id churn

* test: align qa coverage id assertions

* test: update qa evidence coverage expectations

* refactor qa taxonomy coverage ids

* style qa taxonomy coverage ids

* test qa coverage lint fix

* test qa coverage type fix
2026-06-14 00:16:33 -07:00
Vincent Koc
cf4000b47b fix(gateway): preserve slash command media replies 2026-06-14 14:54:18 +08:00
Vincent Koc
85ebbec117 docs(changelog): refresh 2026.6.8 notes
(cherry picked from commit 0b5cb00980c68a39b8fb1d77f6c04e9733bcbb09)
2026-06-14 14:54:18 +08:00
Vincent Koc
f85ef0d114 fix(gateway): deliver command block replies in webchat
(cherry picked from commit 33390ee88f5c8325efc60d1793c532a9489a5a72)
2026-06-14 14:54:18 +08:00
brokemac79
d1299658ac fix(active-memory): preserve verbose recall summaries (#90739)
* fix(active-memory): preserve verbose recall summaries

* fix(active-memory): require recall evidence for recovery

* fix(active-memory): recognize capped recall results

* fix(active-memory): preserve grounded recall state

* refactor(active-memory): limit recovery to completed recalls

* fix(active-memory): ground terminal recall recovery

* fix(active-memory): limit unavailable recovery to completed replies

* fix(active-memory): harden recall evidence recovery

* fix(active-memory): preserve timeout recovery contract

* fix(active-memory): preserve capped failure evidence

* fix(active-memory): reject content-only recall failures

* fix(active-memory): ground completed recall summaries

* fix(active-memory): separate hook and recall timeouts

* fix(active-memory): classify custom tool failures

* fix(active-memory): preserve harness tool evidence

* fix(active-memory): reject explicit empty results

* fix(active-memory): wait for fallback recall evidence

* fix(codex): report dynamic tool results

* fix(active-memory): separate preflight recall deadline

* fix(active-memory): normalize recall tool names

* fix(agents): classify unavailable approvals

* docs(active-memory): clarify hook timeout phases

* test(active-memory): stabilize timeout abort proof

* fix(agents): preserve successful cancellation outcomes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 23:38:58 -07:00
Peter Steinberger
a02813164d fix(anthropic): quarantine invalid direct tool schemas (#92896)
Quarantine unreadable and structurally invalid direct/custom Anthropic tool schemas in both canonical request builders while preserving healthy siblings, forced-choice semantics, OAuth name mapping, and official OpenAI behavior.

Supersedes #89418, #89221, #90228, #89622, #89229, and #90278.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-13 23:28:36 -07:00
Jefsky Wong
462c076a24 fix(anthropic): merge consecutive assistant replay turns (#87346)
Merge adjacent Anthropic assistant turns before dangling tool-use validation so signed tool calls remain immediately paired with their tool results. Preserve contributor credit. Fixes #87329.
2026-06-13 22:34:38 -07:00
Peter Steinberger
d68de3f77d docs(crabbox): warm Testbox in parallel 2026-06-14 01:20:32 -04:00
liuhao1024
2e20dd8dbf fix(anthropic): omit stale thinking from disabled requests (#92373)
Preserve signed thinking for active Anthropic tool-result continuation while omitting native thinking from completed history when the new request disables or omits thinking. Applies the same replay rule to the legacy SDK provider and managed Anthropic transport. Fixes #92360.
2026-06-13 22:17:02 -07:00
elfka toruviel
66880a5d73 fix(agents): preserve valid reasoning replay metadata (#90682)
Preserve validated provider reasoning ciphertext, signatures, and replay identifiers through transcript redaction without exempting malformed, nested, or credential-shaped values.

Fixes #90093.

Co-authored-by: Elfka Toruviel <aeb31988340aa87b@toruviel.online>
2026-06-13 21:47:38 -07:00
Song Luo
231b5a14d5 fix(cli): clarify --tz help text for offset-less --at values
Clarifies cron edit --at help after maintainer rebase and preserves the Gateway-host timezone wording for cron --tz help.

Validation:
- git diff --check
- node scripts/run-vitest.mjs src/cli/cron-cli.test.ts
- local Codex autoreview clean, no actionable findings

Co-authored-by: rrrrrredy <rrrrrredy@users.noreply.github.com>
2026-06-14 12:34:10 +08:00
openclaw-clownfish[bot]
c5db07eddc fix(lobster): surface workflow path errors
Surface missing bare Lobster workflow file paths instead of silently falling through to inline pipeline parsing.

The runner now treats plain workflow file inputs as file paths, keeps inline commands with file-like arguments as pipelines, and preserves existing workflow file paths that contain spaces. Regression coverage covers missing bare workflow paths, inline false positives, and spaced workflow filenames.

Fixes #68101.

Based on and credits #68106 by @vvitovec. This replacement branch carries the focused fix forward because #68106 is dirty against current main and could not be repaired on the fork branch with available bot permissions.

Validation:
- node scripts/run-vitest.mjs extensions/lobster/src/lobster-runner.test.ts
- autoreview clean: no accepted/actionable findings after the spaced-path fix
- GitHub checks: 127 pass, 0 fail, 0 pending

Co-authored-by: Viktor Vítovec <230458341+vvitovec@users.noreply.github.com>
2026-06-14 12:18:33 +08:00
openclaw-clownfish[bot]
808f677ab4 fix(feishu): target typing reaction on inbound message
Target Feishu Typing reactions at the inbound message id while preserving reply and thread routing to the topic root.

This keeps the fallback to replyToMessageId for flows without a separate inbound target, and adds regression coverage for topic/replyInThread behavior and synthetic Feishu turn sources.

Based on and credits #67783 by @huiwen01. This replacement branch carries the same user-visible fix forward because #67783 is dirty against main and earlier automation could not update the fork branch with available permissions. This intentionally does not reuse or expand #73958; root_id routing remains separate.

Validation:
- pnpm check:changed
- pnpm -s vitest run extensions/feishu/src/bot.test.ts extensions/feishu/src/reply-dispatcher.test.ts extensions/feishu/src/monitor.reaction.test.ts
- autoreview clean: no accepted/actionable findings
- GitHub checks: 127 pass, 0 fail, 0 pending

Co-authored-by: huiwen01 <89329207+huiwen01@users.noreply.github.com>
2026-06-14 12:11:49 +08:00
Jason (Json)
7259cb5c77 fix(update): continue after package doctor warnings (#91586)
* fix(update): continue after package doctor warnings

* fix(update): type advisory step rendering

* fix(update): preserve advisory doctor step state

* fix(update): share advisory doctor state

* fix(update): keep timed-out doctor failures blocking

* fix(update): require explicit doctor advisory result

* fix(update): reject malformed doctor advisory results

* fix(update): bound doctor advisory diagnostics

* fix(update): keep doctor advisory restart-neutral

* fix(update): protect doctor advisory IPC

* fix(update): scope doctor advisories to converging updater

* fix(update): scope doctor advisories to deferred repairs

* fix(update): secure doctor advisory IPC

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 21:03:57 -07:00
openclaw-clownfish[bot]
889bc52ba5 fix(ui): repair iOS Safari chat viewport handling
Repairs https://github.com/openclaw/openclaw/pull/63644.

Review proof: local structured autoreview on branch review/pr-92855 against origin/main exited clean with no accepted/actionable findings.

Co-authored-by: Xi Qi <1311124+macdao@users.noreply.github.com>
2026-06-14 11:36:42 +08:00
openclaw-clownfish[bot]
c8cf57a1a5 fix(hooks): reject slug-generator error payloads
Repairs https://github.com/openclaw/openclaw/pull/64181.

Co-authored-by: Cypher <28184436+Cypherm@users.noreply.github.com>
2026-06-14 11:31:01 +08:00
openclaw-clownfish[bot]
9a0bae06b7 fix(acp): accept MCP date protocolVersion in ACP server
Repairs https://github.com/openclaw/openclaw/pull/56176.
Fixes https://github.com/openclaw/openclaw/issues/56102.

Co-authored-by: Saurabh Mishra <2924124+bugkill3r@users.noreply.github.com>
2026-06-14 11:30:48 +08:00
Eduardo Piva
7f49f875de Add diagnostics OTEL capability contract tests (#92045) 2026-06-14 11:26:49 +08:00
openclaw-clownfish[bot]
bd10e1998b fix(tailscale): preserve parse errors for malformed JSON
Accept the fail-closed behavior for malformed `tailscale funnel status --json`: noisy valid JSON should parse, but malformed status output should not silently become “route absent”.

Source PR: https://github.com/openclaw/openclaw/pull/63321

Co-authored-by: Francisco Maestre Torreblanca <2027043+franciscomaestre@users.noreply.github.com>
2026-06-14 11:22:05 +08:00
zhang-guiping
650c5cac33 fix(qqbot): surface failed media sends (#92823)
* fix(qqbot): surface media send failures

* test(qqbot): cover text send failures

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 11:13:56 +08:00
Jason (Json)
965fa05df3 feat: add tool search directory mode
Add an experimental directory mode that keeps large authorized tool schemas deferred while exposing bounded discovery, exact deferred hydration, and normal OpenClaw policy/hook execution. Client tools remain directly visible; ambiguous hidden names fail closed.
2026-06-13 20:08:39 -07:00
openclaw-clownfish[bot]
9f32bea397 fix(feishu): await HTTP server shutdown during monitor cleanup
Source PR: https://github.com/openclaw/openclaw/pull/48588

Co-authored-by: alex sagit <267811734+alex-xuweilong@users.noreply.github.com>
2026-06-14 10:56:15 +08:00
zengLingbiao
7fb0d45b48 fix(agents): add usage guidance to sessions_spawn tool description (fixes #91814) (#91824)
* fix(agents): add usage guidance to sessions_spawn tool description (fixes #91814)

* fix(agents): tighten sessions spawn guidance

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 10:49:26 +08:00
Jason (Json)
8ae1adfdcc ci: gate stable releases on Windows companion assets (#92555)
* ci: gate stable releases on Windows companion assets

* fix(release): reject malformed Windows checksum manifests

* fix(release): make Windows recovery fail closed

* fix(release): tighten Windows asset identity checks

* fix(release): validate prepared candidate tarballs

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 19:33:33 -07:00
LowCode191
4001be54e4 fix(agents): catch malformed image blocks in sanitizeContentBlocksImages (#92792)
* fix(agents): catch malformed image blocks in sanitizeContentBlocksImages

* fix(agents): sanitize malformed-only image results

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 10:24:48 +08:00
Jayesh Betala
e8b35a8280 fix(status): render sub-1000 token counts as plain integers (#89736)
* fix(status): render sub-1000 token counts as plain integers

formatKTokens always divided by 1000 and appended "k", so token counts
below 1000 rendered as misleading fractional k in `openclaw status`
output (e.g. 999 rounded up across the boundary to "1.0k", 420 -> "0.4k",
a 300-token cache write -> "write 0.3k").

Guard value < 1000 to render the plain rounded integer, matching the
canonical formatTokenCount convention (src/utils/usage-format.ts). The
>=1000 "k" behavior is unchanged. Adds focused regression tests for the
0/420/999/1000/12000 boundary and small-session/small-cache status lines.

Fixes #89735

* fix(status): reuse canonical token formatter

* refactor(status): extract lightweight token formatter

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 10:24:42 +08:00
liuhao1024
077d3f1366 fix(stale): exempt ClawSweeper actionable labels from stale lifecycle (#92801)
Add clawsweeper:queueable-fix, clawsweeper:source-repro, and
clawsweeper:fix-shape-clear to exempt-issue-labels in all 4 stale
workflow steps and the backfill-closures script's issueExemptLabels
set.

Previously, issues classified by ClawSweeper as actionable fix
candidates could still be marked stale and auto-closed, creating
a conflict between the two automation systems (e.g. #78640,
#81078, #81122 had both 'stale' and 'clawsweeper:queueable-fix').

Fixes #89564
2026-06-14 10:24:20 +08:00
zhang-guiping
8afbc98018 fix(whatsapp): require durable auth before login success (#92095) 2026-06-14 10:24:14 +08:00
openclaw-clownfish[bot]
f58f8c86b9 fix(discord): raise thread title timeout and tokens
Source PR: https://github.com/openclaw/openclaw/pull/64734

Co-authored-by: Hana Chang <741302+hanamizuki@users.noreply.github.com>
2026-06-14 10:22:02 +08:00
clawsweeper[bot]
24e729fc4e feat(browser): extend --labels overlay to full-page and element captures (#92834)
Summary:
- The replacement PR extends Browser plugin labeled screenshots to honor Playwright full-page/ref/element scope, returns annotation bounding boxes, and updates docs, tests, and skill guidance.
- PR surface: Source +415, Tests +550, Docs +24. Total +989 across 12 files.
- Reproducibility: yes. Current main source shows the labeled Playwright helper ignores fullPage/ref/element and omits annotations, and the source PR supplies live before/after commands for the Browser plugin path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs(browser): correct raw-CDP labels caveat in automation skill
- PR branch already contained follow-up commit before automerge: fix(browser): preserve labelsSkipped semantics for off-viewport refs
- PR branch already contained follow-up commit before automerge: docs(browser): scope labels docs by driver
- PR branch already contained follow-up commit before automerge: docs(browser): fix labels annotation indent and document scope fix
- PR branch already contained follow-up commit before automerge: docs(browser): indent annotations box schema under --labels bullet
- PR branch already contained follow-up commit before automerge: docs(browser): indent labels annotation schema

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

Prepared head SHA: 70aca6c506
Review: https://github.com/openclaw/openclaw/pull/92834#issuecomment-4700431344

Co-authored-by: FMLS <kfliuyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mason Huang <masonxhuang@tencent.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: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-14 02:21:23 +00:00
Peter Steinberger
aef670cf0c fix(copilot): strip replayed thinking blocks
Remove replayed thinking and redacted-thinking blocks from GitHub Copilot Claude history and final Anthropic payloads while preserving visible content, tool turns, and non-empty assistant structure.

Fixes #81520
Supersedes #87060 and #81534

Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
2026-06-13 19:14:16 -07:00
clawsweeper[bot]
399f5bc993 fix: refresh model context metadata safely
Cap configured session context overrides by the selected model's known context window, refresh provider/model metadata consistently, and preserve the fixed Anthropic 1M context contract.

Fixes #39857

Co-authored-by: Kros Dai <7087+xdanger@users.noreply.github.com>
2026-06-13 18:48:43 -07:00
openclaw-clownfish[bot]
34678d8dfa fix(telegram): preserve command callbacks while prefixing generic callback data (#92825)
Fixes #54909.

Repair #54962 by preserving raw slash-command callbacks while routing generic callback data to agents as `callback_data: <value>`.

Validation:
- pnpm check:changed
- pnpm -s vitest run extensions/telegram/src/bot.create-telegram-bot.test.ts
- Codex /review
- Real behavior proof
- GitHub PR checks

Source PR credit: continues @hnshah's work from #54962 and preserves @timt80's report credit from #54909.

Co-authored-by: Hiten Shah <3155200+hnshah@users.noreply.github.com>
2026-06-14 09:47:16 +08:00
Vincent Koc
c60b424124 test(release): accept trajectory command session final
(cherry picked from commit 9458ffa7e8)
2026-06-14 09:42:13 +08:00
Vincent Koc
340c2456bb test(release): harden trajectory export live check
(cherry picked from commit b2d4cb7f86)
2026-06-14 09:25:18 +08:00
openclaw-clownfish[bot]
971542b7f6 UI: localize Logs tab labels (#92820)
Repair #61080 by routing Logs tab user-facing labels through Control UI locale keys while keeping export filename suffixes stable.

Validation:
- pnpm ui:i18n:check
- pnpm check:changed
- ui/src/ui/views/logs.test.ts
- Codex /review
- GitHub PR checks

Source PR credit: continues @rubensfox20's work from https://github.com/openclaw/openclaw/pull/61080.

Co-authored-by: rubensfox20 <111531429+rubensfox20@users.noreply.github.com>
2026-06-14 09:23:00 +08:00
scotthuang
b3dc274034 fix(gateway): preserve active runs during plugin finalization (#92746)
* fix(gateway): preserve active run during plugin finalization

* fix(ui): skip session.message history reload while gateway reports active run

* fix(ui): remove unused eslint-disable directive

* fix(ui): preserve active runs through finalization

---------

Co-authored-by: scotthuang <scotthuang@tencent.com>
Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 08:58:27 +08:00
liuhao1024
1acca038b1 fix(gateway): use resolveNonNegativeNumber for totalTokens to display 0 instead of ? (fixes #43009) (#92795)
* fix(gateway): use resolveNonNegativeNumber for totalTokens to display 0 instead of ?

resolvePositiveNumber requires value > 0, filtering out the valid
totalTokens = 0 case (new session, no usage yet). This caused the TUI
to display 'tokens ?/200k' instead of 'tokens 0/200k (0%)'.

Use resolveNonNegativeNumber (>= 0) for the final totalTokens value
used in session display. The needsTranscriptTotalTokens check at line
2041 still correctly uses resolvePositiveNumber to decide whether to
fetch transcript data.

Fixes #43009

* fix(gateway): preserve fresh zero-token sessions

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 08:56:49 +08:00
liuhao1024
fd4f5b3f59 fix(macos): defer isOverflowing mutation to break SwiftUI render loop (fixes #43480) (#92778)
* fix(macos): defer isOverflowing mutation to break SwiftUI render loop

measuredHeight() mutated model.isOverflowing synchronously during a SwiftUI
view update cycle. The onChange(of: attributed) handler triggered
updateWindowFrame → targetFrame → measuredHeight, which set isOverflowing,
invalidating the view and re-triggering onChange — an infinite render loop
causing 100% CPU pinwheel.

Fix: defer the isOverflowing mutation via DispatchQueue.main.async with an
equality guard to prevent redundant updates. The frame calculation itself
remains synchronous so the window size is correct immediately.

Fixes #43480

* fix(macos): preserve latest overflow measurement

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 08:56:25 +08:00
Vincent Koc
ac3a98e55d test(gateway): capture trajectory export command events
(cherry picked from commit b656a51046)
2026-06-14 08:53:54 +08:00
liuhao1024
57e8c50d19 fix(telegram): skip IPv4 fallback when user explicitly configures non-ipv4first dnsResultOrder (fixes #41671) (#92806)
* fix(telegram): skip IPv4 fallback when user configures non-ipv4first dnsResultOrder

When the user explicitly configures channels.telegram.network.dnsResultOrder
to a non-ipv4first value (e.g. verbatim), the sticky IPv4 fallback dispatcher
should not be armed. Forcing autoSelectFamily=false + dnsResultOrder=ipv4first
overrides the user's explicit IPv6-friendly config, causing media downloads to
fail on hosts where IPv4 is broken but IPv6 works.

Fixes #41671

* fix(telegram): respect explicit DNS fallback policy

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-14 08:51:26 +08:00
liuhao1024
274b7b1d9f fix(doctor): avoid false-positive legacy cron store warning when store was already migrated (fixes #92683) (#92690)
* fix(doctor): avoid false-positive legacy cron store warning when store was already migrated

When rawJobs.length > 0 and other issues exist (notifyCount, dreamingStaleCount)
but legacyStoreDetected is false (file already removed after migration), the doctor
unconditionally printed 'Legacy cron job storage detected at ...' — misleading users
into thinking the migration was incomplete.

Fix: conditionally use 'Cron store issues detected' heading when no legacy store file
exists, reserving 'Legacy cron job storage detected' for actual legacy store presence.

Fixes #92683

* test(doctor): add test for false-positive legacy cron store warning (#92683)
2026-06-14 08:49:07 +08:00
Vincent Koc
efca4b7e64 fix(memory): keep skipped recall diagnostics opt-in
Follow-up to #92745 after maintainer autoreview found that the skipped recall event widened the shipped MemoryHostEvent union and changed limited legacy reads.

Keep readMemoryHostEvents() source-compatible by filtering diagnostic records before applying limits, and expose skipped recall diagnostics through the opt-in MemoryHostEventRecord/readMemoryHostEventRecords path.

Original skipped-recall behavior landed in #92745 by @mushuiyu886.
2026-06-14 08:47:03 +08:00
Jason (Json)
65b460f234 fix(nodes): surface pending reapproval diagnostics (#92547)
* fix(nodes): surface pending reapproval diagnostics

* fix(nodes): harden reapproval diagnostics

* fix(nodes): scope pending diagnostics

* fix(nodes): request pairing diagnostics in cli

* fix(nodes): reuse stored auth for diagnostics

* fix(nodes): preserve selected diagnostics credentials

* fix(nodes): prefer approved diagnostics auth

* fix(nodes): narrow diagnostics fallbacks

* fix(nodes): recover from stale diagnostics auth

* fix(gateway): preserve connect error narrowing

* fix(nodes): isolate privileged diagnostics auth

* fix(nodes): constrain privileged diagnostics auth

* fix(nodes): close diagnostics review gaps

* fix(nodes): guard reapproval cleanup races

* fix(nodes): defer stale pairing cleanup

* fix(nodes): preserve reapproval on hello failure

* test(nodes): await post-handshake reapproval cleanup

* test(nodes): avoid unbound websocket send capture

* fix(nodes): allow local auth-none diagnostics

* fix(nodes): preserve overlapping reapproval

* fix(nodes): preserve pending node metadata

* fix(nodes): keep connection age with status

* fix(nodes): preserve reapproval during reconnect races

* fix(nodes): serialize reapproval cleanup

* fix(nodes): bound reapproval reconnect races

* test(nodes): satisfy cleanup claim lint

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 17:40:18 -07:00
liuhao1024
4c3c0ff5f9 fix(telegram): acknowledge callbacks before sequentialize
Fixes #42156.

Answer Telegram callback queries before per-chat/topic sequentialize can queue the handler behind an active turn, and carry the in-flight answer promise on the grammY context so the normal handler reuses it instead of double-answering.

Proof:
- node scripts/run-vitest.mjs extensions/telegram/src/bot.create-telegram-bot.test.ts -- -t "answers callback queries before same-chat sequentialize delays handlers|sequentializes updates by chat and thread|routes callback_query payloads as messages"
- node scripts/run-vitest.mjs extensions/telegram/src/bot.test.ts extensions/telegram/src/bot-handlers.runtime.test.ts
- node_modules/.bin/oxfmt --check extensions/telegram/src/bot-core.ts extensions/telegram/src/bot-handlers.runtime.ts extensions/telegram/src/callback-query-answer-state.ts extensions/telegram/src/bot.create-telegram-bot.test.ts
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Azure Crabbox cbx_cba20c462ad5 / silver-barnacle: OPENCLAW_TESTBOX=1 node scripts/crabbox-wrapper.mjs run --provider azure --class Standard_D4ads_v6 --idle-timeout 90m --ttl 240m --timing-json -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed

Proof gap: live Telegram Desktop/burner-account proof was not run because openclaw-telegram-user-crabbox-proof is not installed in this shell.
2026-06-14 08:37:24 +08:00
Vincent Koc
9a27af9507 test(gateway): wait for trajectory export guidance
(cherry picked from commit 4675423788)
2026-06-14 08:26:47 +08:00
Peter Steinberger
1d9b9ef48f fix: reject unvalidated voice media streams
Reject voice media stream start frames when no acceptance validator is configured, preventing fail-open STT/TTS session creation. Verified locally, with autoreview, in a remote Linux dev box, and by green CI.
2026-06-13 17:26:19 -07:00
Ashish Patel
8f62ec6177 fix(status): avoid cumulative usage for context percent (#92604)
* fix(status): avoid cumulative usage for context percent

* fix(status): preserve legacy context totals
2026-06-14 08:07:08 +08:00
swide
b72634f56d fix(gateway): forward image-only input on /v1/responses (parity with chat completions) (#92488)
* fix(gateway): accept image-only input on /v1/responses

The OpenResponses endpoint rejected requests whose `input` contained only
an `input_image` (no `input_text`) with `400 Missing user message in
input.`, even though the image was parsed and collected into `images`.
The guard only checked `prompt.message` and ignored `images`, unlike the
equivalent /v1/chat/completions guard which uses
`!prompt.message && images.length === 0`.

Align the OpenResponses guard with Chat Completions so image-only turns
are forwarded to the agent. Empty input (no text and no image) still
returns 400.

Adds regression tests: image-only base64 input -> 200 with image reaching
the agent, and empty content -> 400.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(gateway): pass image-only /v1/responses turns to the agent

The one-line guard alone was insufficient: even after letting image-only
input past the `Missing user message` check, the downstream agent command
(`prepareAgentCommandExecution`) throws `Message (--message) is required`
for an empty message, so image-only `/v1/responses` returned 500.

Mirror the /v1/chat/completions prompt builder: substitute the shared
IMAGE_ONLY_USER_MESSAGE placeholder for the active image-only user turn so
the turn is not dropped and the real image is still attached via `images`.
Promote the placeholder constant to the shared gateway agent-prompt module
so both endpoints stay in sync, and revert the responses guard back to the
original `!prompt.message` check (responses images are not scoped to the
active turn, so the placeholder is the correct, single source of truth).

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retrigger CI (flaky startup-core test timeout, unrelated to change)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: songwendong <songwendong@shuidi-inc.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:06:20 +08:00
mushuiyu_xydt
99e7dad0e4 fix(memory): surface skipped short-term recall hits (#92745)
Record diagnostic events when memory_search returns durable memory hits that are intentionally excluded from short-term promotion, so users can distinguish eligibility decisions from recall tracking failures.
2026-06-14 08:05:52 +08:00
Vincent Koc
d626e99c31 test(gateway): preserve live Codex api-key auth
(cherry picked from commit 4aa50732f6)
2026-06-14 08:00:22 +08:00
openclaw-clownfish[bot]
c2754150c9 fix(heartbeat): route outbound mirror to isolated session key (#92807)
* fix(heartbeat): route outbound mirror to isolated session key

Co-authored-by: Merkava <263752781+agent-merkava@users.noreply.github.com>

* fix(clownfish): address review for ghcrawl-143801-autonomous-smoke (1)

Co-authored-by: Merkava <263752781+agent-merkava@users.noreply.github.com>

* fix(clownfish): address review for ghcrawl-143801-autonomous-smoke (1)

Co-authored-by: Merkava <263752781+agent-merkava@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Merkava <263752781+agent-merkava@users.noreply.github.com>
2026-06-14 07:51:29 +08:00
Chunyue Wang
5b21384ab6 fix(cron): isolate transient auth cooldowns
Keep cron-local transient auth failures from polluting shared cooldowns while preserving real auth/billing/rate-limit propagation. Verified with focused auth/cron tests, type proof, autoreview, and clean CI.
2026-06-14 07:45:20 +08:00
Zhao Shiqi
edd76238fe fix(daemon): keep duplicate Windows gateway tasks visible
Normalize Windows schtasks default gateway names without hiding similarly prefixed duplicate tasks. Verified with focused daemon tests, type proof, autoreview, and clean CI.
2026-06-14 07:45:16 +08:00
Darren Xu
d6b3950734 fix(tavily): keep web search contract executable
Keep the Tavily public artifact lightweight while lazily executing through the provider runtime. Verified with focused Tavily/provider artifact tests and clean CI.
2026-06-14 07:43:17 +08:00
Jayesh Betala
61145dc252 fix(ports): avoid stale cleanup for non-gateway SSH listeners
Limit SSH tunnel classification to actual queried-port forwards and keep SSH-like non-gateway listeners out of stale gateway cleanup. Verified with focused port/restart tests and clean CI.
2026-06-14 07:43:12 +08:00
Anurag Bheemappa Gnanamurthy
382db15e33 fix(configure): mask gateway token prompts
Mask gateway token/password prompts while preserving blank-token generation. Verified with focused configure wizard tests and clean CI.
2026-06-14 07:43:07 +08:00
Colin Johnson
1a8747620e fix: reflow composer beside workspace rail
Reflow the Control UI chat composer into the main chat column when the Workspace Files rail is expanded, so the composer stays beside the rail instead of extending underneath it.

Prepared head SHA: 7108d88cda
Co-authored-by: Colin Johnson <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
2026-06-14 00:40:03 +01:00
Rohit
e55cebf4c2 fix(canvas): validate CLI numeric options
Validate Canvas CLI numeric arguments before node invocation.

- Reject malformed `--invoke-timeout` values through the shared Canvas invoke path before resolving a node.
- Keep snapshot `--quality` bounded to the existing Canvas tool schema range of 0..1.
- Add focused CLI regressions for timeout validation, quality bounds, and accepted boundary values.

Verification:
- `node scripts/run-vitest.mjs extensions/canvas/src/cli.test.ts --maxWorkers=1`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main ...`
- AWS Crabbox `cbx_b7838c58daba`, run `run_bbb58ca536f4`: `check:changed`
- Exact PR head `a970ce594ea6e7284ace519352fc258b1b81cb80`: GitHub checks green
2026-06-14 07:39:17 +08:00
Vincent Koc
2d4a9eb405 test(matrix): avoid racy inbound dedupe TTL
(cherry picked from commit 8ae3662b1c)
2026-06-14 07:35:14 +08:00
Vincent Koc
47759c3506 fix(qa): accept rich Telegram canary presence
(cherry picked from commit e86eb7567a)
2026-06-14 07:20:16 +08:00
Rohit
3429e33feb fix(matrix): validate CLI numeric option ranges
Validates Matrix CLI numeric option ranges before invoking setup or verification side effects.

`--initial-sync-limit` must now be non-negative, and `--timeout-ms` must now be positive.

Original PR by @rohitjavvadi.

Verification:
- `node scripts/run-vitest.mjs extensions/matrix/src/cli.test.ts --maxWorkers=1`
- autoreview clean
- Crabbox AWS `cbx_5c32f138ab3a` / `swift-lobster`, run `run_6e133b8b82e7`: `check:changed` passed
- exact PR head CI green: `d75f118299029b0516311646276cd2d6582379c5`
2026-06-14 07:16:39 +08:00
Vincent Koc
894f521aa5 test(macos): avoid real approvals migration in tests 2026-06-14 07:08:26 +08:00
Vincent Koc
97c5e6c235 test(macos): isolate exec approvals env 2026-06-14 07:04:53 +08:00
Stellar鱼
9974641d1e fix(agents): preserve compatible CLI session runtime pins
Preserves provider-compatible CLI runtime session pins across reply execution, follow-up execution, dispatch visibility, preflight compaction, and memory flush.

This keeps sessions pinned to compatible CLI runtimes such as `claude-cli` from leaking into embedded OpenClaw maintenance paths while still rejecting cross-provider runtime pins.

Original PR by @yu-xin-c; includes maintainer follow-up for the sibling memory paths.

Verification:
- `node scripts/run-vitest.mjs src/auto-reply/reply/agent-runner-execution.test.ts src/auto-reply/reply/agent-runner-memory.test.ts src/agents/model-runtime-aliases.test.ts --maxWorkers=1`
- autoreview clean
- Crabbox AWS `cbx_44400b494e97` / `coral-prawn`, run `run_69dd43475e39`: `check:changed` passed
- exact PR head CI green: `303b2f794f6c01fcf21b62b27c536b5f6eceb421`
2026-06-14 06:54:51 +08:00
Vincent Koc
924f4c1964 fix(qa): read rich Telegram replies in live checks
(cherry picked from commit 9c8b880353)
2026-06-14 06:35:59 +08:00
dependabot[bot]
2f57352eaa chore(deps): bump macOS Swift dependencies
Updates the macOS Swift package resolution for patch releases of Peekaboo, Sparkle, and swift-log.

Verification:
- `swift package describe --type json`
- `swift build --target OpenClawIPC`
- `swift build --target OpenClawDiscovery`
- upstream tag/revision checks for Peekaboo 3.4.1, Sparkle 2.9.3, and swift-log 1.13.2
- autoreview clean
- exact PR head CI green for macOS, dependency, and security checks
2026-06-14 06:26:41 +08:00
Vincent Koc
c11fcbcb6a fix(release): repair beta validation fixtures 2026-06-14 06:19:23 +08:00
lizeyu-xydt
5b6810211c fix(docker): remove stale nested openclaw package
Remove the stale nested openclaw package, its .bin shim, and the pnpm virtual-store copy from the runtime Docker image before final runtime assets are copied.

Run the package dist import-closure check after the cleanup so the check validates the final runtime-assets tree that the image ships.

Compatibility note: private Docker paths under /app/node_modules/openclaw and /app/node_modules/.bin/openclaw are removed; downstream images should use the documented /usr/local/bin/openclaw launcher or /app/openclaw.mjs.

Fixes #92551.
Thanks @lzyyzznl for the fix and @fxstein for the report.
2026-06-14 06:17:55 +08:00
Vincent Koc
e4313bac97 fix(ui): localize workspace file rail labels 2026-06-14 06:01:30 +08:00
Vincent Koc
6ebb303ef0 docs(changelog): refresh 2026.6.8 notes 2026-06-14 05:43:48 +08:00
Yuval Dinodia
ae68006a8f fix(sessions): enforce channel send policy for account-scoped DMs
Derive the channel from canonical account-scoped DM session keys when resolving session.sendPolicy, so channel-scoped allow/deny rules apply to per-account-channel-peer sessions.

Keep derivation limited to canonical channel peer key shapes and add malformed-key regressions so incomplete or non-channel keys do not accidentally match channel rules.

Compatibility note: existing channel-scoped send-policy rules can now block account-scoped DM sends that were previously allowed by this bug.

Thanks @yetval for the fix.
2026-06-14 05:38:42 +08:00
Peter Steinberger
735f59af73 feat(providers): add GLM-5.2 support (#92796)
* feat(providers): add GLM-5.2 support

* ci(live): add GLM-5.2 provider shard
2026-06-13 14:33:28 -07:00
mushuiyu_xydt
47112fc423 fix(memory): split header-too-large embedding batches
Route OpenAI/OpenAI-compatible request_headers_too_large embedding failures into the existing memory-core batch splitter instead of aborting bulk memory indexing.

Tighten the classifier to require header-too-large wording rather than a bare 431 status token, so unrelated provider errors do not fan out into recursive requests.

Fixes #92465.
Thanks @mushuiyu886 for the fix and @BrettHamlin for the report and proof.
2026-06-14 05:20:25 +08:00
Andy Ye
8549a203d4 Honor WhatsApp configured ACP bindings (#92513)
Merged via squash.

Prepared head SHA: 665080f482
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-06-13 18:09:22 -03:00
liuhao1024
d912909230 docs(nodes): add node config example
Add a Nodes overview `openclaw.json` example for node pairing, command allow/deny policy, node exec routing, and per-agent node pinning.

Also clarifies exact `denyCommands` matching and links readers to the config reference for pairing and command-policy field details.

Fixes #92662.
Thanks @liuhao1024 for the fix and @ZengWen-DT for the parallel docs wording on exact node command policy.
2026-06-14 04:58:06 +08:00
Sally O'Malley
e6ffcf7362 docs: clarify before_install hook scope (#92766)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-13 16:54:04 -04:00
Ariel Smoliar
8047350445 docs(config): correct agent defaults concurrency comments
Correct the exported agent defaults type comments for `maxConcurrent` and `subagents.maxConcurrent` so they match the runtime defaults of 4 and 8.

No runtime behavior changes.
Thanks @ArielSmoliar for the fix.
2026-06-14 04:44:03 +08:00
Terrance Chen
15e4fbf593 fix(markdown-core): treat infinity chunk limit as unbounded
Fix render-aware markdown chunking so `Number.POSITIVE_INFINITY` is treated as an explicit unbounded chunk limit instead of falling back to `1`.

This preserves full Signal media captions and disabled Signal text chunking while keeping invalid non-finite limits on the existing fallback path.

Fixes #92734.
Thanks @yhterrance for the report and fix.
2026-06-14 04:29:12 +08:00
KRATOS
4e4ea1c16b fix(diagnostics): keep recovery scheduling out of the stuck-session warning backoff (#92752)
Summary:
- The branch changes diagnostic stuck/long-running warning backoff so recovery-eligible classifications are still returned during throttled warning ticks and updates the diagnostic tests.
- PR surface: Source +17, Tests +48. Total +65 across 2 files.
- Reproducibility: yes. Current main source shows logSessionAttention can return undefined during stuck or lon ... g backoff before the heartbeat reaches requestStuckSessionRecovery; I did not run a live QQ gateway replay.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(diagnostics): keep recovery scheduling out of the stuck-session w…

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

Prepared head SHA: f61ec3a33f
Review: https://github.com/openclaw/openclaw/pull/92752#issuecomment-4699298908

Co-authored-by: Gnanam <gnanasekaran.sekareee@gmail.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-06-13 20:05:33 +00:00
Narahari Raghava
b2da129e51 fix(tui): show resolved model ref in confirmation
Uses the canonical model ref returned by `sessions.patch` for the TUI `/model` confirmation so alias inputs report the model that was actually applied. The fallback still shows the raw input when a backend does not return `resolved`, and the display path uses `modelKey` so nested model ids keep the provider prefix without double-prefixing self-prefixed ids.

Proof: local focused TUI Vitest/format/lint; autoreview clean; Crabbox AWS focused TUI test run `run_7d7cc5b040e8`; exact-head GitHub CI green on `6db4acfb08f9d477ee1bdab429bd7189b78ffc92`.
2026-06-14 03:45:59 +08:00
Yzx
5b21a0337b fix(state): avoid sqlite wal on nfs state volumes
Detects NFS-backed SQLite database paths in the shared WAL helper and uses rollback journaling for those paths while preserving WAL/checkpoint maintenance on local filesystems. The NFS path now verifies SQLite's effective journal mode before disabling WAL maintenance, and core/memory/proxy-capture callers pass database path context into the centralized helper.

Fixes #90491.

Proof: local focused Vitest/format/lint; autoreview clean after fixing the journal-mode verification finding; Crabbox AWS focused test run `run_2ea7014350da`; Crabbox AWS changed gate `run_c828bbfe7d23`; exact-head GitHub CI green on `59674305ecd863d4815eec6098ccd3daab79ca4f`.
2026-06-14 03:32:13 +08:00
Shakker
dbf24fe35a fix: start workspace files collapsed 2026-06-13 20:19:34 +01:00
sallyom
d03932af18 feat: make workspace files panel collapsible
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-13 20:19:34 +01:00
Zee Zheng
13a079b3f8 fix(telegram): expose thread create CLI remap
Exposes Telegram's thread-create CLI remap through the exported Telegram channel action adapter, preserving the existing plugin-owned mapping to topic-create before gateway dispatch.\n\nFixes #81581.\n\nProof: local focused format/lint/Vitest and dry-run; autoreview clean; Crabbox AWS run_07b98c939fce focused tests; Crabbox AWS run_1b7b35ce1de1 check:changed; exact-head GitHub CI green on 16f6afbdd7.
2026-06-14 02:37:31 +08:00
Colin Johnson
e58310b000 fix(ios): force stale foreground gateway reconnects (#92552) 2026-06-13 20:33:39 +02:00
狼哥
a1814586c6 fix(ui): preserve dashboard session parent lineage
Preserves the selected Control UI session as the parent when creating dashboard child sessions even if the session list is stale or filtered, while avoiding the synthetic unknown session as a parent.\n\nFixes #90623.\n\nProof: local focused format/lint/Vitest/browser test; autoreview clean; Crabbox AWS run_a2bfdcd2315a UI proof; Crabbox AWS run_ce60fdc546ff check:changed; exact-head GitHub CI green on 03d1c6f646.
2026-06-14 02:14:28 +08:00
Matt H
ca2410ab07 fix(parallel): send User-Agent on free MCP requests
Adds the OpenClaw Parallel User-Agent to free Parallel Search MCP requests so the zero-config web_search path is identifiable at the HTTP layer, matching the paid REST transport.\n\nProof: local focused format/lint/Vitest; live anonymous Parallel MCP handshake; autoreview clean; Crabbox AWS run_bf41ce86e862 focused regression; Crabbox AWS run_ee9b8954b081 check:changed; exact-head GitHub CI green on b7e45e3bfc.
2026-06-14 01:52:59 +08:00
ooiuuii
d20fdf3b38 fix(gateway): mark active main sessions before restart shutdown aborts (#91357)
* Mark active main sessions during restart shutdown

* Type restart marker mock in close tests

* fix(gateway): preserve active run ownership across restart

* fix(gateway): preserve active runs across restart

* fix(gateway): close restart recovery edge cases

* fix(cron): preserve lifecycle ownership across restart

* fix(gateway): release rejected run contexts

* fix(gateway): preserve restart lifecycle ownership

* fix(cron): retain overlapping run ownership

* fix(agents): preserve restart terminal precedence

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 10:49:17 -07:00
Josh Avant
689ebc815b feat: support /btw in CLI-backed sessions (#92669)
* feat: support CLI btw side questions

* test: fix CLI prepare test fixture types

* fix: lazy load local btw runner
2026-06-13 19:36:53 +02:00
bymle
22069bcc56 fix(google): strip provider prefix from Vertex model path
Summary:
- Strip the redundant `google/` provider prefix before embedding Google Vertex model ids under `/publishers/google/models/`.
- Keep bare Vertex model ids unchanged.
- Add regression coverage for the provider-qualified Vertex path.

Verification:
- `node_modules/.bin/oxfmt --check --threads=1 extensions/google/transport-stream.ts extensions/google/transport-stream.test.ts`
- `node scripts/run-oxlint.mjs extensions/google/transport-stream.ts extensions/google/transport-stream.test.ts`
- `node scripts/run-vitest.mjs extensions/google/transport-stream.test.ts --maxWorkers=1 -t 'strips redundant google provider prefixes from Google Vertex model paths'`
- Autoreview clean
- AWS Crabbox `run_649b209478d2` focused Node 24 regression proof
- AWS Crabbox `run_e193db2707ad` remote `check:changed`
- Exact-head CI green for `23aca6f46f596e220df37d939317b433f7044ec6`
- Contributor live Google Vertex proof recorded in the PR body
2026-06-14 01:13:43 +08:00
NianJiu
b01a54de6f fix(ui): restore sidebar session picker interactivity above desktop workbench (#92705)
* fix(ui): restore sidebar session picker interactivity above desktop workbench

The collapsed sidebar session picker was covered by the chat content
area when the workspace rail was visible at wider viewports. Two
issues caused this:

1. .sidebar-session-select--collapsed .chat-session-picker used
   var(--z-dropdown) which was never defined, creating an invalid
   z-index declaration (falls back to auto).

2. .shell-nav and .content--chat are grid siblings with equal
   z-index (auto), and .content--chat (later DOM) paints above
   .shell-nav, covering the session picker that extends from the
   nav column into the content column.

Fix: add position:relative + z-index:10 to .shell-nav so it stacks
above .content--chat; change overflow from hidden to visible so
the session picker extends beyond the nav rail; replace undefined
var(--z-dropdown) with z-index:100.

* fix(ui): keep sidebar picker z-index tokenized

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-14 01:13:29 +08:00
Ayaan Zaidi
45e36a241a fix(telegram): pass rich text prompts to cli backends 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
5cb6f8aa9f fix(telegram): show rich text prompt for final replies 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
b9ad8649d0 fix(telegram): allow rich tables in group prompts 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
4e8a527542 test(telegram): align message flow fixture with rich drafts 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
0eb92fa79c fix(telegram): clean rich message CI gates 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
f1e303404c feat(telegram): nudge agents toward rich text 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
80d2b40fac fix(telegram): keep rich text media-free 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
a3bc0097c8 fix(telegram): migrate retired native draft config 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
93318050e1 test(telegram): cover rich list and table limits 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
18fbcef496 fix(chunking): preserve surrogate pairs 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
e8b142feb1 refactor(telegram): remove native draft previews 2026-06-13 21:45:22 +05:30
Ayaan Zaidi
547cc0f109 feat(telegram): send text as rich messages 2026-06-13 21:45:22 +05:30
zhouhe-xydt
bb71f46251 fix(ui): preserve reset soft command args
Fixes #91316

Summary:
- Preserve `/reset soft ...` arguments when Control UI dispatches the local reset command.
- Reuse parsed slash-command semantics for reset confirmation detection.
- Keep non-soft reset tails on the destructive confirmation path across whitespace and colon separators.

Verification:
- `node_modules/.bin/oxfmt --check --threads=1 ui/src/ui/app-chat.ts ui/src/ui/app-chat.test.ts`
- `node scripts/run-oxlint.mjs ui/src/ui/app-chat.ts ui/src/ui/app-chat.test.ts`
- `node scripts/run-vitest.mjs ui/src/ui/app-chat.test.ts --maxWorkers=1 -t 'reset soft|reset softish|typed /reset command dispatch'`
- Autoreview clean
- AWS Crabbox `run_fbaf31b3fff8` focused Node 24 regression proof
- AWS Crabbox `run_eb3af5b92e42` remote `check:changed`
- Exact-head CI green for `5dee6f488fd393cb2127fe152f0d3fd53ccc13d2`
2026-06-13 23:55:59 +08:00
Vincent Koc
a6aa84f2d0 test(plugins): avoid brittle provider ref error text 2026-06-13 23:30:38 +08:00
Andy Ye
3b94949437 fix(agents): deliver generated media completions in webchat
Fixes #91003

Add explicit generated-media directives to completion handoff prompts and treat real attachment payloads as visible session-only delivery evidence for dashboard/webchat completions. Hardened maintainer follow-up keeps malformed attachment arrays from masking failed delivery and keeps generated MEDIA directive values single-line sanitized.

Proof: focused local format/lint/Vitest, clean final autoreview, Crabbox AWS focused proof run_32499eb46b33, Crabbox AWS check:changed run_af46879ffbd1, and exact-head GitHub CI green for f8e6f4a04e.
2026-06-13 23:21:08 +08:00
Vincent Koc
45056a463a fix(test): extend watchdog for gateway core shard 2026-06-13 23:01:11 +08:00
ZengWen-DT
c773d8cd8e fix(cron): de-duplicate main-session heartbeat events
Fixes #44922

Preserve heartbeat-owned cron reminders as a single model input during heartbeat runs while keeping normal-turn fallback delivery when a heartbeat is skipped.

Proof: focused local Vitest/oxlint/format, clean autoreview, Crabbox AWS run_67abc286250a, Crabbox AWS check:changed run_bddebf014d58, and exact-head GitHub CI green for 341e807d7a.
2026-06-13 22:49:48 +08:00
Vincent Koc
eb1b640854 test(config): contain shell env fallback in config write tests 2026-06-13 22:22:01 +08:00
Andy Ye
ddacb7ba39 fix(memory): keep memory_search in transient qmd mode (#92639)
Summary:
- Merged fix(memory): keep memory_search in transient qmd mode after ClawSweeper review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(memory): close transient search managers
- PR branch already contained follow-up commit before automerge: fix(memory): preserve default search managers
- PR branch already contained follow-up commit before automerge: fix(memory): preserve qmd cli boot freshness

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

Prepared head SHA: 64fe82c24c
Review: https://github.com/openclaw/openclaw/pull/92639#issuecomment-4698763950

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@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-06-13 14:14:54 +00:00
Song Zhenlin
762d8d8e64 fix(feishu): clear client cache on test SDK swap
Clear cached Feishu clients when the test runtime replaces the SDK, preventing stale clients from leaking across test setup. Adds regression coverage for the SDK swap path. Fixes #83911.
2026-06-13 22:01:15 +08:00
Anson_H
205ab8d4bd perf(terminal): reuse ANSI truncation scanner
Reuse one module-level ANSI/OSC scanner during visible-width truncation and reset scanner state between calls. Keeps styled, plain, and OSC-8 truncation behavior covered by regression tests.
2026-06-13 21:54:53 +08:00
Ayaan Zaidi
7994880864 fix(usage): suppress unknown total-only cost 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
afe75b3387 fix(usage): warn on broken footer templates 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
84cbaf1832 fix(usage): preserve partial footer counts 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
5892dc8522 docs(usage): avoid unsupported duration template path 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
a55accb4b6 fix(usage): reject empty footer templates 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
cdd71103c9 test(usage): align full footer contract 2026-06-13 19:16:02 +05:30
Ayaan Zaidi
7328caba82 fix(usage): simplify default full footer 2026-06-13 19:16:02 +05:30
Peter Lindsey
3ec16bbad3 feat(usage): merge user footer templates over the default + ship full scale palette + docs
- messages.usageTemplate now layers OVER the built-in default (objects
  merge by key, arrays/scalars replace), like other openclaw config
  objects, so a user template only needs the delta it adds/changes.
- Default ships the full scale palette (braille/block/shade/moon/level/
  weather/plants/moons6); users add more by name.
- Document the template format end to end (the "default" sentinel, merge
  behavior, the contract paths, verb table, piece forms, a worked example)
  in docs/concepts/usage-tracking.md — previously unauthorable from docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:16:02 +05:30
Peter Lindsey
cc831f8684 feat(usage): built-in "default" footer template (hidden)
Set messages.usageTemplate to the sentinel "default" to render a
good-looking built-in /usage full footer without supplying a template.
Intentionally undocumented in the config schema/help for now; a path or
inline object still overrides, and unset keeps the built-in line.

The default lives in source (default-template.ts) rather than a shipped
JSON so it stays in lockstep with the renderer. It keeps the 📚
context-window bar; it does not render limits/reset windows (the merged
PluginHookReplyUsageState carries no limits data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:16:02 +05:30
Jayesh Betala
89cc175b2e fix(disk-space): promote rounded GiB boundary
Round MiB before selecting the display unit so low-disk warnings do not render boundary values as 1024 MiB. Adds regression coverage for the GiB boundary. Fixes #90245.
2026-06-13 21:45:51 +08:00
Ayaan Zaidi
3c02c239b4 test(openai): type storeless responses replay cases 2026-06-13 19:13:38 +05:30
Ayaan Zaidi
7359206b76 refactor(openai): simplify storeless replay gating 2026-06-13 19:13:38 +05:30
snowzlm
37d6fd2e81 test(OpenAI Responses): cover storeless replay compatibility 2026-06-13 19:13:38 +05:30
snowzlm
8ecf55b36a fix(OpenAI Responses): gate replay when store is stripped 2026-06-13 19:13:38 +05:30
Song Zhenlin
2e8a2d617d fix(browser): remove dead requireRef navigation import
Remove the unused requireRef import and void anchor from Browser navigation command registration while keeping navigate/resize registration covered by regression tests. Fixes #83878.
2026-06-13 21:39:51 +08:00
Vincent Koc
27e24ca683 fix(test): extend watchdog for slow vitest shards 2026-06-13 21:37:57 +08:00
huangjianxiong
68e234f9e2 fix(cli): preserve usage-error exits for lazy reparses
Reparse nested lazy commands from the Commander root so unknown options keep the original argv and exit non-zero. Adds nested lazy-command coverage for the root rawArgs path. Fixes #92069.
2026-06-13 21:33:30 +08:00
clawsweeper[bot]
5854e0c8f6 fix: split image setup and request timeout semantics (#92673)
Summary:
- The PR separates image media-understanding setup and provider request timeout handling, adds focused timeout regression tests, and updates gateway/Codex docs for the existing image timeout setting.
- PR surface: Source +39, Tests +67, Docs +8. Total +114 across 5 files.
- Reproducibility: yes. Source inspection shows current main subtracts setup elapsed time from the provider request timeout, and the PR adds a slow-setup regression test that exercises the failure path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs: clarify image timeout phase semantics
- PR branch already contained follow-up commit before automerge: fix: bound image setup timeout separately
- PR branch already contained follow-up commit before automerge: Revert "fix: bound image setup timeout separately"
- PR branch already contained follow-up commit before automerge: fix: split image setup and request timeout semantics

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

Prepared head SHA: 001dee3fb0
Review: https://github.com/openclaw/openclaw/pull/92673#issuecomment-4698582136

Co-authored-by: Mason Huang <masonxhuang@tencent.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: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-13 13:29:45 +00:00
Mason Huang
eaeedbf1f9 fix(docs): finalize i18n postprocess before skip (#92668)
Summary:
- Merged fix(docs): finalize i18n postprocess before skip after ClawSweeper review.

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

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

Prepared head SHA: ad79445835
Review: https://github.com/openclaw/openclaw/pull/92668#issuecomment-4698629026

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-13 13:17:03 +00:00
Rishi Tamrakar
dc493bc9a2 fix(slack): emit message_sent on outbound replies (#89943)
Emit terminal Slack message_sent and message:sent hooks across normal, streaming, preview, fallback, slash, failure, and TTS reply paths with canonical session/target correlation and one outcome per logical payload.

Fixes #89942

Co-authored-by: Rishi Tamrakar <rishi.ktamrakar@gmail.com>
2026-06-13 06:10:51 -07:00
zhang-guiping
78c66742ab fix(agents): expose session identity in runtime prompts
Expose session key and stable session id in Runtime prompt metadata for embedded, CLI-backed, and command-generated agent prompts so agents do not infer session identity. Fixes #92453.
2026-06-13 21:03:38 +08:00
zhang-guiping
4c23d1d597 fix(agents): narrow sessions_send active-run fallback
Limit sessions_send active-run queue delivery to run-scoped targets, keep stranded cron-run fallback for valid cron run keys, and report unsafe queue rejections without rerouting through durable sessions. Fixes #91420.
2026-06-13 20:31:50 +08:00
zhang-guiping
8eb1fa09c6 fix(gateway): reject unknown OpenAI agent selectors
Reject explicit unknown or malformed OpenAI-compatible agent selectors before creating gateway request/session state. Default aliases remain compatible; chat completions, Responses, and embeddings now return structured invalid_request_error responses for bad explicit selectors. Fixes #92504.
2026-06-13 20:16:13 +08:00
Vincent Koc
2d2c1e63f0 test(test): align source full-suite sharding assertion 2026-06-13 19:57:09 +08:00
mushuiyu_xydt
6cdbccaa9e fix #73713: surface nested embedding fetch failures (#92628)
* fix(cli): surface nested fetch failure details

* fix(cli): preserve error prefixes in runtime output
2026-06-13 19:55:37 +08:00
Kailigithub
9f522ee7df fix(copilot): disable eager tool streaming for Claude 4.5 (#75393)
Disable Anthropic eager tool streaming only for GitHub Copilot Claude 4.5 proxy routes, preserving newer Claude behavior.

Fixes #75348

Co-authored-by: Kailigithub <Kailigithub@users.noreply.github.com>
2026-06-13 04:52:02 -07:00
lundog
7404b2b5b4 fix(channels): keep contributed message-tool schema properties optional (#91137)
Co-authored-by: Lundog <jeremy.lundy@lifebeacon.com>
2026-06-13 19:39:01 +08:00
Vincent Koc
73aabcceda fix(test): split local full-suite shards when throttled 2026-06-13 19:36:35 +08:00
dongdong
b1fc8673df fix(anthropic): add Claude Haiku 4.5 catalog entries (#90116)
Add both official Claude Haiku 4.5 API identifiers to the Anthropic static catalog and cover their metadata with a focused regression test.

Fixes #90088

Co-authored-by: Jasmine Zhang <jasminezhang@JasminedeMac-mini.local>
2026-06-13 04:31:57 -07:00
Vincent Koc
4cf4e54179 feat(memory-wiki): import OKF bundles 2026-06-13 19:27:52 +08:00
WhatsSkiLL
84519f7e3c fix(cron): preserve yielded media completions (#92146)
* fix(agents): preserve sessions_yield media pauses

* fix(agents): resume cron media completions

* fix(agents): restore cron media wait predicate import

---------

Co-authored-by: WhatsSkiLL <284122573+IWhatsskill@users.noreply.github.com>
2026-06-13 19:23:25 +08:00
liuhao1024
6314c377bb fix(openrouter): normalize provider-qualified model IDs (#92627)
Normalize provider-qualified OpenRouter model IDs before capability lookup and transport while preserving native OpenRouter namespace IDs.

Fixes #92611.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-13 12:05:28 +01:00
Andy Ye
d3e7e03669 fix(ui): preserve WebChat backscroll during streaming (#92622)
* fix(ui): preserve webchat backscroll during streaming

* fix(ui): keep forced chat scroll above follow lock

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-13 18:40:31 +08:00
Vincent Koc
64f9f3c278 fix(models): stabilize vertex marker regression tests 2026-06-13 18:28:50 +08:00
Chunyue Wang
cd3eb438f0 fix(agents): pause yielded subagent runs whose terminal also signals abort (#92631)
* fix(agents): pause yielded subagent runs whose terminal also signals abort (#92448)

* fix(agents): clear yielded subagent grace timers

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-13 18:23:10 +08:00
Alix-007
26281a8a11 fix(slack): diagnose invalid channel map keys (#89438)
Diagnose Slack channel-map keys that cannot route as configured, including account inheritance, open-policy overrides, malformed room identifiers, and DM identifiers.

Fixes #81665

Co-authored-by: Alix-007 <li.long15@xydigit.com>
2026-06-13 03:13:14 -07:00
Vincent Koc
4208c89ec4 test(qa-lab): align bootstrap selection assertion 2026-06-13 18:11:46 +08:00
Peter Steinberger
c9c19a1106 test(models): stabilize plugin auth marker fixtures (#92652)
Stabilizes model auth marker tests against the current manifest-metadata discovery seam and isolated Vitest environment.

Runtime behavior is unchanged; provider-owned non-secret markers remain declared in plugin manifests.
2026-06-13 02:58:02 -07:00
Peter Steinberger
f78d7b52d8 fix: require admin for HTTP session kills (#92651)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 02:57:15 -07:00
Ayaan Zaidi
ff6940036b fix(usage): reload missing usage templates (#89835) (thanks @Marvinthebored) 2026-06-13 15:25:14 +05:30
Ayaan Zaidi
b477bfe84b fix(usage): tighten usage footer template handling 2026-06-13 15:25:14 +05:30
Peter Lindsey
d4237cb14d test(vitest): align scoped-config expectation with usage-bar shard include
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
20bc546d94 test(usage): register usage-bar tests in auto-reply-core shard; fix unused-var lint
The usage-bar subdirectory was not covered by any full-suite shard glob
(template.test.ts is disqualified from unit-fast by filesystem-state),
failing the shard coverage check. Extend autoReplyCoreTestInclude to the
subdir; unit-fast-eligible files are auto-excluded by the scoped config.

Also create the temp dir explicitly in the missing-file test instead of
binding an unused tmpFile result, which tripped oxlint no-unused-vars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
069cb8d636 perf(usage): load the footer template off the reply-delivery path
loadUsageBarTemplate ran a synchronous statSync on every reply (and readFileSync
on mtime change) to check template freshness — synchronous filesystem I/O in the
latency-sensitive reply path, which on a slow / networked / blocked filesystem
can stall /usage full delivery (and the single-threaded event loop with it).

Read the template once into memory and keep it fresh with a filesystem watcher
(persistent: false), so the per-reply path is filesystem-free. A missing file is
not cached (a later-created template is still picked up on a subsequent call); a
watch failure leaves the one-time load in place. Adds template.test.ts covering
inline / file / invalid-JSON / missing-then-created and the FS-free hot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
8cc5d2d85c feat(usage): render real context occupancy + last-call usage atoms
Consume the usageState contract's contextUsedTokens (populated by #89629) so
the context gauge reflects real end-of-turn window occupancy instead of the
multi-call turn aggregate, which overstates it (often past 100%) and pins the
meter full while /status shows the true figure. Fall back to the aggregate
when contextUsedTokens is absent (single-call turns, where they coincide).

Also expose the final model call's usage as usage.last.* (input/output/cache +
cache_hit_pct) so a template can render the last exchange vs the turn
aggregate.

Adds the consumed fields (contextUsedTokens, lastUsage) to
PluginHookReplyUsageState as the renderer's type dependency; their population
lands in #89629.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
690f27749c feat(usage): add fixed:N decimal-format verb to usage-bar translator
num truncates sub-1000 values to integers, so monetary fields like
cost.turn_usd rendered as 0 (or with full float noise when piped raw).
Add a fixed:N verb that formats a number to N decimal places (default 2),
returning empty string for non-numeric input — matching the other
formatters' guard style.

  {cost.turn_usd|fixed:4}   0.03771985 -> 0.0377

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
7af8153388 fix(usage): clear type-aware oxlint in usage-bar translator
- no-misused-spread: use Array.from for code-point glyph split (same
  behavior, astral glyphs intact) instead of string spread.
- no-base-to-string: return the case glyph only when it is a string.
- no-unnecessary-type-assertion: drop redundant cast already narrowed
  by isObject.

No behavior change (render output byte-identical; tsgo clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
a66a065ffb style(usage): satisfy oxfmt + oxlint for native renderer
Formatting (import order, indentation) and bracket-notation access for
reserved template keys (_aliases/_default) to satisfy no-underscore-dangle.
No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Peter Lindsey
64d0fc8336 feat(usage): native templated /usage full renderer (retires the footer plugin)
Render the per-reply /usage full footer in core from a declarative template
(the openclaw.usageBar.v1 format) when messages.usageTemplate is set; fall back
to the built-in line otherwise. Ports the reference usage_bar.py engine to TS so
no external process is involved (the external surface is just template data).

- usage-bar/translator.ts: engine (verbs num/dur/pct/inv/alias/meter, segment
  forms text/when/map/each, output.surfaces, item_scales). Codepoint-correct
  glyph indexing; fail-open (empty render -> boring fallback).
- usage-bar/contract.ts: buildUsageContract (snapshot -> openclaw.usageLine.v1).
- usage-bar/template.ts: resolve from a path (mtime-cached) or inline object.
- agent-runner: capture the per-turn usage snapshot and render the template at
  the /usage full branch in place of the built-in line when configured.
- messages.usageTemplate config (string path | inline object) + strict schema.
- translator.test.ts: verb parity, segment forms, astral glyphs, e2e render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:25:14 +05:30
Rohit
f3df863aff fix(gateway): honor profile auth for SecretRef model entries (#90686)
Fixes #90685 by allowing models.list availability to use matching auth-profile credentials when provider config contains a non-env SecretRef, while preserving unavailable results for unresolved SecretRef-only providers.

Adds isolated regression coverage for file SecretRefs and secretref-managed provider markers.

Co-authored-by: Rohit <rohitjavvadi2@gmail.com>
2026-06-13 02:39:06 -07:00
Peter Steinberger
26b9736922 fix: require admin for HTTP model overrides
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 02:08:20 -07:00
samson910022
44f45d8729 fix(models): bound model browse discovery (#92247)
Bounds default model browsing to configured/read-only discovery while preserving explicit full-catalog browsing. Reuses prepared plugin metadata and auth state without triggering external CLI discovery on the picker hot path, while retaining provider normalization and canonical runtime aliases.

Verified with focused model tests, official OpenAI and Anthropic transport suites, fresh live tool calls for both providers, a full build, AWS check:changed, remote Docker OpenAI tools E2E, and green PR CI.

Fixes #91809.

Co-authored-by: samson1357924 <98934496+samson1357924@users.noreply.github.com>
2026-06-13 02:04:55 -07:00
Ayaan Zaidi
1c655008cd fix(hooks): tighten reply usage state correlation 2026-06-13 14:25:24 +05:30
Peter Lindsey
618d78144e fix(usage): don't resolve subscription windows for an absent auth signal
A missing per-turn authMode was mapped to "oauth", so an OpenAI api-key turn
that arrived without an explicit auth mechanism could resolve and display
ChatGPT subscription windows that aren't its own — served straight from the 60s
limits cache, which the "re-checked at fetch time" guard does not cover.

Treat a genuinely absent signal as non-eligible (same as api-key): no usage
provider resolves and the footer omits limit windows. Present mechanisms are
unchanged — oauth/auth-profile/token stay eligible, and only OpenAI is gated on
the credential type so other providers are unaffected. A real oauth/profile turn
always carries its mechanism; one arriving blank is an upstream tagging bug to
fix at the source.

Inverts the now-incorrect "absent => oauth-eligible" test into regression
coverage for the absent/api-key case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
Peter Lindsey
56f2102c28 fix(usage): thread real context occupancy + last-call usage into usageState
context.used_tokens / pct_used were derived from the snapshot's aggregate
prompt total (cacheRead+cacheWrite+input). Over a multi-call tool-loop turn
that is the run AGGREGATE, overstating window occupancy (often past 100%) so a
footer's context gauge pins full while /status shows the true ~7%.

Add two optional fields to PluginHookReplyUsageState and populate them in the
reply path:
- contextUsedTokens: the final call's prompt size (agentMeta.promptTokens) =
  real end-of-turn occupancy, a point-in-time state, not the aggregate.
- lastUsage: the final model call's usage only (vs `usage`, the turn
  aggregate), so a footer can render the last exchange's i/o + cache.

Both optional and additive; consumers fall back to the aggregate when absent
(correct for single-call turns). Renderer consumption lands separately (#89835).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
Peter Lindsey
99db98a7ce fix(usage): map auth mechanism to usage-credential type for provider limits
requestShaping.authMode is the auth *mechanism* (e.g. "auth-profile" for a
configured auth profile), not the credential *type* resolveUsageProviderId
expects. Gating limits on it === "oauth"/"token" dropped 📊 for legit OAuth
(profile-based) turns. Map it: api-key/aws-sdk -> no usage provider (cannot
borrow cached oauth windows); oauth/token/auth-profile/absent -> usage-eligible,
with the real credential re-checked at fetch time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
Peter Lindsey
0dbfa1f6be fix(hooks): make usageState limits credential-aware + document best-effort delivery
Addresses review feedback on #89629.

1) Provider-limit resolution no longer defaults to OAuth. getProviderUsageLimits
   and getProviderUsageLimitsCached resolved with `credentialType ?? "oauth"`, and
   the agent-runner snapshot call passed no credential type, so an api-key OpenAI
   turn could borrow cached OAuth/ChatGPT usage windows. Drop the "oauth" default
   (missing credential type => no OpenAI usage provider) and thread the turn's
   authMode through at the call site. Adds provider-usage.limits.test.ts covering
   api-key/no-credential (no fetch), oauth/token (resolves), and non-OpenAI.

2) usageState is documented as best-effort, present only on live dispatcher
   delivery. Routed durable and recovered queue replays re-run this hook as a
   stateless transform over the original payload (see QueuedDeliveryPayload); a
   point-in-time usage snapshot is not stateless and would replay stale after a
   restart, so it is intentionally omitted there. Consumers must treat the field
   as optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
Peter Lindsey
f06f2f17c2 feat(hooks): per-turn usageState (with provider limits + rich atoms) on reply_payload_sending
Attach a per-turn execution snapshot to the reply_payload_sending hook as
`usageState`, so a plugin (or the future in-core /usage renderer) can render a
per-response usage readout as a pure consumer of the contract — no side calls.

Recorded in agent-runner, consumed in dispatch. Fields: provider, model,
resolvedRef/requested, reasoningEffort, fastMode, fallbackUsed, is_override
(overrideSource), authMode, compactionCount, contextTokenBudget, token usage,
turn cost (USD), duration, owning agentId/sessionId, chatType, the agent
identity (name/emoji), and the active provider's subscription `limits` windows.

reply_payload_sending is the one reply hook universal across every surface
(incl. the Codex app-server, which emits no llm_output/agent_end), so it is the
correct harness-agnostic place for per-turn usage. Limits are resolved by a
core-internal non-blocking SWR helper (src/infra/provider-usage.limits.ts) and
attached to the snapshot — no new plugin-SDK accessor. All fields optional/additive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:25:24 +05:30
BunsDev
7bd533a80e Revert "chore(maint): make PR changelog edits release-only (#92607)"
This reverts commit 4640baa299.
2026-06-13 03:10:10 -05:00
Dallin Romney
561b293c7a Run Vitest and Playwright scenarios from qa suite (#92606)
* test(qa): run vitest and playwright scenarios from qa suite

* fix(qa): harden scenario suite dispatch

* refactor(qa): share scenario path utilities

* refactor(qa): share test file scenario runner

* refactor(qa): route test file scenarios through suite runtime

* refactor(qa): use explicit suite runtime result kind

* test(qa): write suite evidence artifact

* refactor(qa): clarify suite execution dispatch

* fix(qa): keep test-file scenarios out of flow-only runners

* refactor(qa): export mixed scenario suite runner
2026-06-13 01:06:10 -07:00
zhangqueping
21aa8faf8a #92589: fix(internal-runtime-context): wrap prompt-preface runtime context body in delimiters (#92593)
* fix(internal-runtime-context): wrap prompt-preface runtime context body in delimiters

When buildRuntimeContextMessageContent constructs the hidden
runtime context prompt block, the body (which may contain
sensitive metadata like relevant-memories, sender info, and
conversation metadata) was not wrapped in the standard
INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters.  If the model
echoed this context back in its reply, stripInternalRuntimeContext
could only remove the header and notice lines — the sensitive
body leaked through to user-visible surfaces like Feishu
streaming cards.

Wrap the runtime context body in BEGIN/END delimiters so the
existing stripInternalRuntimeContext (which handles delimited
blocks first) can fully remove the entire block.

Closes #92589

* chore: retrigger CI for proof check

* chore: retrigger CI with corrected proof format

* chore: retrigger CI with corrected proof field format
2026-06-13 02:59:28 -05:00
lizeyu-xydt
b0bd9c8ed8 fix(docs): pin Windows Hub download links to v2026.6.5 (#92605)
The Windows Hub companion installers are promoted to the main OpenClaw
release via a manual workflow_dispatch, not every release includes them.
The /releases/latest/download/ links resolved to v2026.6.6 which does not
have the OpenClawCompanion assets, causing 404 errors.

Pin the links to v2026.6.5 (the latest release that has the assets) and
add a fallback note directing users to the releases page when a release
is missing the companion installers.

Fixes #92470
2026-06-13 02:59:22 -05:00
liuhao1024
c5d599c8c4 docs(gateway): add uptime monitoring guidance to health check docs (fixes #55768) (#92608) 2026-06-13 02:59:18 -05:00
mushuiyu_xydt
3a1a5c0dac fix(memory): preserve qmd startup errors (#92618) 2026-06-13 02:59:14 -05:00
Val Alexander
0849cac106 fix(a11y): B-1+B-2+B-3 — contrast, focus states, minimum font sizes (#89822)
* fix(a11y): B-1 — raise muted text contrast to ≥4.8:1 WCAG AA

* fix(ui): C-1 — ChatSidePanel joins glass surface language

* feat(mobile): D-1 — hamburger overlay nav below 900px

- Esc key now closes nav drawer (globalKeydownHandler)
- Nav item tap targets bumped to min-height: 44px + padding: 10px 16px
  in the ≤1100px drawer breakpoint (was 40px / 0 12px)
- Hamburger toggle + overlay drawer were already wired in app-render.ts;
  this completes close-on-Esc and ensures accessible tap targets

* fix(a11y): B-2 — consistent focus-visible states distinct from hover

* fix(a11y): B-3 — lift all sub-12px text to 12px minimum

* fix(a11y): narrow focus and CSS scope

* fix(a11y): finish focus-visible selectors
2026-06-13 02:57:17 -05:00
Val Alexander
32ce06daf8 feat(ui): hide empty workboard columns (#89615) 2026-06-13 02:57:14 -05:00
Val Alexander
751d3db1cc docs: UX-013 — design system documentation (#89827)
* docs: UX-013 — shared design system documentation

* docs: align design system docs with source tokens
2026-06-13 02:57:11 -05:00
Val Alexander
4640baa299 chore(maint): make PR changelog edits release-only (#92607) 2026-06-13 02:57:08 -05:00
tangtaizong666
c9f0bfd476 fix(agents): isolate invalid plugin model catalogs (#92564)
Keep valid root and plugin models available when one generated plugin catalog is invalid, while retaining and logging the catalog error.

Fixes #92553.

Co-authored-by: tangtaizong666 <tangtaizhong792@gmail.com>
2026-06-13 00:52:52 -07:00
Hansraj Singh Thakur
ab559a7257 fix(channels): report progress draft startup failures (#92083)
Report timer-fired channel progress-draft startup failures at the shared gate boundary instead of silently dropping them. Explicitly awaited starts still reject, and failed timer starts remain retryable.

Refs #92031.

Co-authored-by: Hansraj Singh Thakur <hansraj136@gmail.com>
2026-06-13 00:42:13 -07:00
Andy Ye
7c08804541 fix(slack): persist delivered replies in transcripts (#92498)
Persist successful same-channel Slack and CLI assistant replies exactly once in the owning transcript. Preserve delivery-hook output, routed/runtime ownership, custom stores, and authoritative reset/session rotation bindings.

Fixes #92489

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-06-13 00:17:16 -07:00
Vincent Koc
991471b8ec fix(mistral): skip unreadable tool schemas (#90242)
Skip unreadable Mistral tool schemas while preserving valid sibling tools. Omit empty tool payloads, reject forced choices for removed tools, and snapshot pinned tool names before validation and emission.

Live Mistral E2E: run_9b34d30e9f5b
CI: https://github.com/openclaw/openclaw/actions/runs/27459679633

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-13 00:00:52 -07:00
brokemac79
7190fc4de8 fix(doctor): diagnose blocked external channel plugins (#86629)
Diagnose configured channel plugins whose installed owners are blocked by trust or activation policy, while preserving multi-owner fallback and actionable channel safety warnings.

Closes #83212.

Co-authored-by: luyifan <al3060388206@gmail.com>
2026-06-12 23:24:19 -07:00
Evgeni Obuchowski
9d9389bc6b fix(fireworks): resolve catalog model params from manifest (#90326)
Resolve bundled Fireworks manifest models through core's static catalog so Kimi K2.6 keeps its 262,144-token context limit and nested model compatibility metadata.

Keep the existing dynamic fallback for uncataloged Fireworks IDs and align bundled Kimi reasoning metadata with existing runtime behavior.

Verified with focused tests, extension/core type checks, lint/format, full build, fresh autoreview, required CI, and a live Fireworks Kimi K2.6 embedded run using a real key.

Co-authored-by: Evgeni Obuchowski <evgeni@obukhovski.com>
2026-06-12 22:48:46 -07:00
Jason (Json)
6c88811b4b Expose paged channel action results (#88993)
* fix(discord): expose paged thread list results

* fix(discord): keep thread pagination local

* fix(discord): use archive timestamps for thread cursors

* test(discord): cover thread pagination results

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-12 21:29:43 -07:00
Rain
4a6666796f fix(moonshot): rewrite duplicate native Kimi tool call ids
Preserve the first native Kimi tool-call ID while rewriting repeated replay occurrences to deterministic OpenAI-style IDs and keeping paired tool results aligned. Moonshot responses-family behavior and providers that do not opt in remain unchanged.

Closes #51593

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
2026-06-12 21:14:03 -07:00
Peter Steinberger
68222ba5e3 fix: harden websocket payload handling 2026-06-12 21:03:10 -07:00
ragesaq
1bd783045b fix(gateway): mirror commentary-phase assistant events to session message subscribers
Non-control-UI-visible runs previously dropped assistant commentary on the
floor for session message subscribers. Mirror those events to exact session
subscribers, gated strictly on phase === "commentary" so untagged text or
delta frames and final-answer streaming never dual-lane into channel
surfaces. Dialects that emit commentary as untagged deltas should tag the
phase at provider normalization instead.

Co-authored-by: Forge <forge@psiclawops.dev>
Co-authored-by: Chisel <chisel@psiclawops.dev>
2026-06-13 09:31:53 +05:30
Patrick Erichsen
6cf06e8e7e ci: split plugin ClawHub publishing paths
* feat: partition clawhub plugin release candidates

* fix: read clawhub trusted publisher config endpoint

* feat: split clawhub plugin bootstrap workflow

* ci: split plugin clawhub publish paths

* ci: pin clawhub package publish workflow

* ci: keep clawhub bootstrap token out of builds

* ci: fix clawhub release dry-run gating

* ci: align clawhub oidc publish refs

* ci: make clawhub bootstrap recovery idempotent

* ci: route clawhub repair candidates through bootstrap

* ci: preserve tideclaw alpha clawhub guards

* ci: simplify clawhub release ref handling

* ci: extract clawhub release routing plan

* ci: extract clawhub release runtime state

* test: guard clawhub release helper executability

* ci: pin ClawHub CLI for plugin publishing

* ci: allow historical ClawHub dry-run validation

* ci: fix ClawHub bootstrap token handoff
2026-06-12 20:16:06 -07:00
Dallin Romney
ded3a93058 fix(e2e): keep lifecycle timeout cleanup alive (#92566) 2026-06-12 18:52:34 -07:00
Peter Lee
0063f3076c fix(moonshot): backfill reasoning_content on assistant tool-call replay messages (#92396)
Moonshot/Kimi requires reasoning_content on all assistant tool-call messages
when thinking is enabled. After LCM compaction, cross-model fallback, or
session repair, the replayed history may be missing this field, causing a
400 error from the Moonshot API.

Backfill an empty string to satisfy the API schema contract without
fabricating semantic reasoning content. Follows the same provider-owned
backfill pattern already used by Kimi Coding (extensions/kimi-coding/stream.ts)
and DeepSeek V4 (provider-stream-shared.ts).

Fixes #71491

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 17:58:43 -07:00
Peter Steinberger
8c7e5c6918 feat(moonshot): add Kimi K2.7 Code support (#92554)
* feat(moonshot): add Kimi K2.7 Code support

* test(moonshot): surface K2.7 live provider errors

* ci(live): accept Kimi key for Moonshot sweeps

* test(moonshot): verify K2.7 across API regions
2026-06-12 17:37:28 -07:00
Shakker
e338037034 test: cover telegram expandable blockquotes 2026-06-13 01:29:27 +01:00
Jamil Zakirov
05796759ad fix(telegram): allow expandable blockquotes 2026-06-13 01:29:27 +01:00
Dallin Romney
d8b3e523ff Add QA scorecard taxonomy validation (#91500)
Merged via squash.

Prepared head SHA: a9aec907d4
Co-authored-by: RomneyDa <6581799+RomneyDa@users.noreply.github.com>
Co-authored-by: RomneyDa <6581799+RomneyDa@users.noreply.github.com>
Reviewed-by: @RomneyDa
2026-06-12 17:07:51 -07:00
Dallin Romney
4809ac70fa Add QA evidence artifact output (#91484)
* feat: add qa evidence summary normalization

* chore: rename qa evidence target environment

* chore: align qa evidence profile terminology

* chore: align qa evidence summary fields

* chore: add qa evidence taxonomy ref

* test: remove stale multipass evidence example

* test(qa): normalize vitest and playwright evidence

* test(qa): slim evidence summary metadata

* test(qa): clarify evidence summary inputs

* test(qa): rename scenario specs in evidence flow

* test(qa): treat evidence profiles as mapping strings

* test(qa): use neutral evidence test identity

* test(qa): nest evidence summary joins

* refactor(qa): normalize live evidence summaries

* fix(qa): accept normalized telegram rtt summaries

* fix(qa): normalize evidence lane summaries

* fix(qa): align evidence summaries with requirements

* refactor(qa): tighten evidence summary builders

* refactor(qa): restore standard evidence ids

* fix(qa): keep legacy summaries out of rtt evidence

* refactor(qa): make package evidence provenance explicit

* test(qa): keep script tests out of qa lab internals

* refactor(qa): rename scenario evidence definitions

* refactor(qa): clean evidence summary wording

* test(qa): fix evidence summary test inputs

* refactor(qa): simplify evidence identity fields

* refactor(qa): tighten evidence summary inputs

* refactor(qa): rename evidence artifact
2026-06-12 16:12:58 -07:00
Dallin Romney
777edadb36 fix: update esbuild audit pin (#92540) 2026-06-12 15:36:49 -07:00
brokemac79
8d9ce35b92 fix(sandbox): render cli skill prompts from materialized paths (#92508) 2026-06-12 16:59:32 -04:00
xydigit-sj
69bf333dde fix(outbound): honor top-level image param as send media source (#92407) (#92416)
When a message send action included an `image` media-source param, the shared outbound runner recognized it for sandbox validation and media-access hints but then omitted it from the generic send payload, causing text-only delivery with a silent ok:true result.

Add `image` to the mediaHint resolution chain in buildSendPayloadParts so it is treated as a first-class media source for send only, preserving action-specific image semantics for non-send actions. Add regression coverage.

Fixes #92407.
2026-06-12 16:09:45 -04:00
Shakker
e3a6da0f51 test: tighten doctor update progress coverage 2026-06-12 17:58:28 +01:00
Amer Sheeny
8ec1c0676b fix(doctor): drop redundant Boolean conversion flagged by oxlint 2026-06-12 17:58:28 +01:00
Amer Sheeny
e4b6b9ea66 test(doctor): cover update progress wiring and spinner cleanup 2026-06-12 17:58:28 +01:00
Amer Sheeny
aba3751ad7 fix(doctor): show per-step progress spinners during update 2026-06-12 17:58:28 +01:00
Josh Avant
9921825e17 Fix Telegram spooled buffered replay (#92281)
* fix telegram spooled buffered replay

* fix telegram replay type checks

* fix telegram replay lint

* test telegram replay visible output retry guard

* fix telegram rollback failure retry
2026-06-12 11:51:46 -05:00
Josh Avant
652e616a29 fix: repair rejected Anthropic thinking replay (#92286)
* fix: repair rejected Anthropic thinking replay

* fix: narrow recovered retry result

* test: satisfy thinking recovery lint

* test: prove thinking retry preserves fresh reasoning

* test: type narrow thinking retry proof
2026-06-12 11:48:19 -05:00
Josh Avant
f385491c23 fix: clarify gateway SecretRef auth diagnostics (#92290)
* fix gateway secretref health diagnostics

* fix gateway health result type narrowing
2026-06-12 11:18:22 -05:00
Ruben Cuevas Menendez
7387083a95 fix(codex): preserve memory prompt registration (#92350)
* fix(codex): restore memory recall guidance

* fix(codex): add memory recall fallback

* fix(codex): preserve memory prompt registration

* test(codex): expect memory slot in scoped harness load

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-12 12:17:02 -04:00
Josh Avant
462092936a fix(agent): continue after source message tool replies (#92343) 2026-06-12 11:14:57 -05:00
Josh Avant
da4671ebcc fix provider static model fallback (#92293) 2026-06-12 11:14:00 -05:00
Josh Avant
9386d6214f fix: resolve managed secretref provider auth (#92235) 2026-06-12 10:59:04 -05:00
Josh Avant
8673c65c6b fix(update): hand off Linux service auto-updates (#92282) 2026-06-12 10:55:21 -05:00
Josh Avant
f3eb8e9714 Fix OTLP log trace correlation (#92276)
* fix diagnostics otel log trace correlation

* test diagnostics trace provenance contract
2026-06-12 10:54:21 -05:00
Josh Avant
f80f472190 fix(agents): classify structured unsupported model errors (#92280)
* fix(agents): classify structured unsupported model errors

* test(agents): update embedded harness helper mock
2026-06-12 10:35:39 -05:00
Josh Avant
3643de4ba7 fix heartbeat suppressed commitment delivery (#92231) 2026-06-12 10:13:13 -05:00
Josh Avant
41a9277844 fix: fail closed for cli-backed btw fallback (#92226) 2026-06-12 10:11:35 -05:00
Josh Avant
79901fb4ba fix: inherit static transport for configured DeepSeek models (#92265) 2026-06-12 10:09:53 -05:00
Josh Avant
e728957989 Fix disabled heartbeat one-shot cron retries (#92225)
* fix: retry disabled cron wake one-shots

* fix: satisfy cron retry CI checks
2026-06-12 09:54:33 -05:00
Josh Avant
d9124c9700 fix doctor channel SecretRef preview (#92229) 2026-06-12 09:50:31 -05:00
Shakker
81c553e2fb fix: stop docker build commands by pid and group 2026-06-12 15:16:00 +01:00
Chunyue Wang
0fc5a57a34 fix(anthropic-vertex): stop re-marking cache_control on transport-budgeted payloads (#92387)
Summary:
- The PR removes the Anthropic Vertex adapter’s redundant cache-control payload-policy pass, forwards caller payload hooks unchanged, and adds regressions for preserving transport-budgeted payloads.
- PR surface: Source -35, Tests -11. Total -46 across 2 files.
- Reproducibility: yes. at source level. Current main reapplies cache policy to a finalized, fully budgeted pa ... ion logs show the corresponding five-marker rejection; this review did not run a live post-fix GCP request.

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

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

Prepared head SHA: 6ef19602bf
Review: https://github.com/openclaw/openclaw/pull/92387#issuecomment-4688955121

Co-authored-by: openperf <16864032@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-06-12 12:59:02 +00:00
Shakker
1bd04ac983 fix: route respawn hint env clears 2026-06-12 12:29:00 +01:00
Shakker
294779e5d6 test: scope plugin dispatch state env 2026-06-12 12:26:41 +01:00
Shakker
888835cfe6 fix: route live env restore deletes 2026-06-12 12:24:55 +01:00
Shakker
a716950a3c test: route state dir env helper 2026-06-12 12:22:12 +01:00
Shakker
667bc2c4ca fix: scope commitment heartbeat state env 2026-06-12 12:20:15 +01:00
Shakker
01b004c594 test: scope commitment full chain state env 2026-06-12 12:18:01 +01:00
Shakker
9cf1ef1d90 fix: restore commitment extraction state env 2026-06-12 12:15:07 +01:00
Shakker
1c5099803f test: restore commitment runtime state env 2026-06-12 12:12:56 +01:00
Shakker
0d4968d466 fix: restore commitment store state env 2026-06-12 12:10:46 +01:00
Shakker
0efe5857bc test: scope doctor missing state env 2026-06-12 12:08:44 +01:00
Shakker
fed2c36611 fix: add test env delete helper 2026-06-12 12:06:12 +01:00
Shakker
bcc1105b30 test: scope reply session state env 2026-06-12 12:03:40 +01:00
Shakker
b750d314b7 fix: scope image inbound state env 2026-06-12 12:00:59 +01:00
Ayaan Zaidi
d4819948f3 fix(telegram): restart isolated polling on getUpdates conflict and surface it in status 2026-06-12 10:29:56 +05:30
Ayaan Zaidi
4a3d06ee37 fix(telegram): carry bot api error codes across the ingress worker boundary 2026-06-12 10:29:56 +05:30
Ayaan Zaidi
ff04e24ead fix(telegram): retry transient draft preview failures instead of killing the stream 2026-06-12 10:18:11 +05:30
Ayaan Zaidi
a956ab8481 refactor(telegram): centralize edit error classification in network-errors 2026-06-12 10:18:11 +05:30
Vincent Koc
3b78d41a9e fix(release): use trusted publishing for plugin npm 2026-06-12 12:07:32 +08:00
Vincent Koc
3c9c4aa428 fix(docs): remove stale ClawHub nav page 2026-06-12 12:07:32 +08:00
Jesse Merhi
6223a538bc fix(docker): bundle QA Lab runtime in the image (#92087)
* fix(docker): split qa lab runtime fixes

* fix(docker): remove store platform selector

* test(docker): assert qa lab ui copy is gated
2026-06-12 14:02:32 +10:00
liuhao1024
8be3beec74 fix(cron): preserve timezone on expression edits
Fixes #92291.

Preserves the existing cron timezone when `cron edit <id> --cron ...` replaces only the expression, while avoiding stale stagger writes and preserving API/UI omitted-timezone clearing semantics.

Proof:
- node scripts/run-vitest.mjs src/cli/cron-cli/register.cron-edit.test.ts src/cli/cron-cli.test.ts src/cron/service.jobs.test.ts src/cron/normalize.test.ts
- node scripts/run-oxlint.mjs src/cli/cron-cli/register.cron-edit.ts src/cli/cron-cli/register.cron-edit.test.ts src/cli/cron-cli.test.ts src/cron/service/jobs.ts src/cron/service.jobs.test.ts
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- Azure Crabbox cbx_3ce6a4e0d945 / silver-lobster: OPENCLAW_TESTBOX=1 node scripts/crabbox-wrapper.mjs run --provider azure --class Standard_D4ads_v6 --idle-timeout 90m --ttl 240m --timing-json -- env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
2026-06-12 11:33:52 +08:00
mushuiyu_xydt
6a2ec62865 fix(daemon): keep unsupported service status readable
Fixes #25621.\n\nKeep gateway status readable on unsupported service-manager platforms by returning a conservative read-only service adapter, while lifecycle mutations still reject clearly. Includes regression coverage for resolver, status, summary, and lifecycle behavior.\n\nVerified with focused Vitest/oxlint/diff checks, autoreview, and Azure Crabbox check:changed on lanes core/coreTests.
2026-06-12 12:05:22 +09:00
Galin Iliev
301213a05f test(sqlite): add state perf query plan harness
Adds a SQLite state query-plan regression test and smoke benchmark, wires the smoke artifact into source performance evidence, validates SQLite smoke output in the performance summary, and removes a retired ClawHub nav entry that broke docs link checks.

Fixes #91616
2026-06-11 14:49:26 -07:00
Patrick Erichsen
9827490f5f fix: rely on ClawHub plugin publish checks 2026-06-11 11:51:57 -07:00
Shakker
575cae59d4 fix: preserve utils exports in doctor health tests 2026-06-11 17:32:25 +01:00
Shakker
4e4dc10db0 fix: avoid support write shadowed variable 2026-06-11 17:32:25 +01:00
Shakker
34a1102506 fix: preflight skill writes before rollback metadata 2026-06-11 17:32:25 +01:00
Shakker
1156ab637c refactor: move workspace skill writes to lifecycle 2026-06-11 17:32:25 +01:00
Shakker
0fc2faa0f4 test: narrow whatsapp mention fixture 2026-06-11 17:29:24 +01:00
Shakker
fdf3667e09 test: clean whatsapp alias assertions 2026-06-11 17:29:24 +01:00
Shakker
de9260f813 fix: keep whatsapp inbound aliases live 2026-06-11 17:29:24 +01:00
Marcus Castro
4d45884419 docs(plugins): record whatsapp inbound compatibility 2026-06-11 17:29:24 +01:00
Marcus Castro
1bea7d8ef3 test(whatsapp): cover inbound context compatibility 2026-06-11 17:29:24 +01:00
Marcus Castro
008d785a80 test(whatsapp): update auto reply inbound fixtures 2026-06-11 17:29:24 +01:00
Marcus Castro
eebcb100b8 refactor(whatsapp): read inbound contexts in auto reply 2026-06-11 17:29:24 +01:00
Marcus Castro
b5295a6a34 refactor(whatsapp): introduce inbound message contexts 2026-06-11 17:29:24 +01:00
Shakker
3d6252a517 test: harden stalled websocket cleanup 2026-06-11 15:32:58 +01:00
Shakker
c4d3f0545c fix: validate workshop support symlink writes 2026-06-11 15:20:38 +01:00
Shakker
6b0525f237 fix: gate Skill Workshop symlink writes 2026-06-11 15:20:38 +01:00
abnershang
287b10a895 feat(skills): allow trusted workshop symlink targets 2026-06-11 15:20:38 +01:00
Sally O'Malley
ea813a2476 fix: handle explicit silent assistant replies (#92073)
Signed-off-by: sallyom <somalley@redhat.com>
2026-06-11 10:20:08 -04:00
Matt H
9be1699074 fix(wizard): report keyless web search providers as ready
Onboarding finalize now treats configured web search providers with requiresCredential: false as ready instead of warning that an API key is missing. This covers keyless providers such as Parallel Search (Free), DuckDuckGo, and Ollama while preserving credential-required warnings for providers that need keys.\n\nProof: focused wizard/search tests; oxlint on changed files; git diff --check; autoreview clean; Azure Crabbox check:changed cbx_b92ef084c21c passed; GitHub checks green.
2026-06-11 23:18:49 +09:00
mushuiyu_xydt
777f7409d8 fix(installer): stop after failed Node package installs
Linux Node package-manager setup/install failures now fail the installer immediately instead of falling through to a misleading success path. Adds regression coverage for NodeSource setup and apt nodejs install failures under conditional shell invocation.\n\nFixes #73837\n\nProof: bash -n scripts/install.sh; node scripts/run-vitest.mjs test/scripts/install-sh.test.ts; node scripts/run-oxlint.mjs test/scripts/install-sh.test.ts; git diff --check origin/main...HEAD; autoreview clean; Azure Crabbox check:changed cbx_6286dc1e287b passed.
2026-06-11 22:58:43 +09:00
clawsweeper[bot]
2bec2caf0c fix(channel): harden local setup trust (#92175)
Summary:
- The PR extends channel setup trust enforcement and trusted catalog fallback from workspace-origin plugins to ... nfigured load paths into catalog discovery, and adds focused regression plus Docker/package proof coverage.
- PR surface: Source +190, Tests +892, Other +324. Total +1406 across 13 files.
- Reproducibility: yes. The source PR provides a concrete clean-main Docker/package path where an explicitly t ... ns unresolved, while the patched package resolves it and still blocks untrusted module and setup execution.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(channel): stabilize trusted catalog dts typing
- PR branch already contained follow-up commit before automerge: fix(channel): repair trusted catalog exclusions typing
- PR branch already contained follow-up commit before automerge: test(channel): cover local channel plugin trust
- PR branch already contained follow-up commit before automerge: chore(deps): refresh plugin shrinkwraps
- PR branch already contained follow-up commit before automerge: test(channel): route trust regression in command shard
- PR branch already contained follow-up commit before automerge: test(channel): remove e2e-named trust regression

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

Prepared head SHA: eabee04d54
Review: https://github.com/openclaw/openclaw/pull/92175#issuecomment-4680798117

Co-authored-by: Mason Huang <masonxhuang@tencent.com>
Co-authored-by: Copilot <223556219+Copilot@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: hxy91819
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
2026-06-11 13:48:41 +00:00
liuhao1024
047785eb30 fix(cron): report SQLite storage path in cron.status instead of legacy jobs.json (#92144)
* fix(cron): report SQLite storage path in cron.status instead of legacy jobs.json

The `cron.status` gateway response returned `storePath` pointing to the
legacy `jobs.json` path, but cron jobs are actually stored in the shared
SQLite state database. This misled operators and agents into looking for
a JSON file that no longer exists.

- Add `storage: "sqlite"` and `sqlitePath` fields to CronStatusSummary
- Mark legacy `storePath` as @deprecated (kept for backward compat)
- Update CLI warning to prefer sqlitePath over storePath
- Add regression assertions in read-ops test

Fixes #91766

* fix(macos): prefer sqlitePath in cron status display

* fix(macos): add sqlitePath to CronSchedulerStatus type
2026-06-11 22:39:42 +09:00
Shakker
f7ee25291a chore: remove redundant proof scripts 2026-06-11 14:29:12 +01:00
Vincent Koc
79d7defd0b test(ci): relax docker signal wait 2026-06-11 21:52:19 +09:00
Vincent Koc
68ec783e74 fix(agents): project thinking catalog compat 2026-06-11 21:14:50 +09:00
兰之
9a6c71a47d fix(agents): retry same model across short rate-limit windows (#91911)
Bound same-model rate-limit retries to explicit short-window signals or parsed short Retry-After values, honor Retry-After in the retry sleep, preserve zero-rotation fallback behavior, and record same-model rate-limit retries separately from profile rotations.

Verification:
- node scripts/run-vitest.mjs src/agents/embedded-agent-runner/run/assistant-failover.test.ts src/agents/embedded-agent-runner/run/helpers.test.ts
- Azure Crabbox cbx_bdb5a7807a1f / coral-shrimp: OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-06-11 21:00:49 +09:00
openclaw-clownfish[bot]
99d0bdc23a fix(cli): validate gateway RPC timeout inputs
Reject malformed or explicit empty Gateway RPC timeout values before opening Gateway calls, align the shared Gateway RPC omitted-timeout fallback with the 30000 ms CLI default, and validate explicit `cron add --timeout-seconds` values at the CLI boundary.

Carries forward the useful source work from #54646 and the earlier timeout-validation context from #40953. #60661 remains separate accepted-run timeout semantics work and is intentionally not folded into this change.

Validation:
- `npm run review-results -- /tmp/clownfish-check-27341769444`
- `git diff --check`
- OpenClaw PR checks on `ce7bd8b9388a5689b14ddc2b3a984f7b4647e5ca`: 132 pass, 0 pending, 0 failing
- ClawSweeper re-review: https://github.com/openclaw/clawsweeper/actions/runs/27344244608

Co-authored-by: RayRuan <43744645+ruanrrn@users.noreply.github.com>
Co-authored-by: Homeran <11574611+comeran@users.noreply.github.com>
2026-06-11 20:52:07 +09:00
Vincent Koc
6fb0c940fa fix(release): gate beta publish on plugin verification
Delay public GitHub release publication until postpublish verification, dependency evidence upload, proof append, and required plugin publish gates pass.

Also updates release-maintainer instructions so newly publishable plugins are minted/prepublished through an owner-approved path without consuming the next auto-bumped beta version unless that path is the actual release publish.
2026-06-11 20:42:58 +09:00
Ben Newell
cdb55b3edb fix(memory-core): retry narrative message reads (#89091)
* fix(memory-core): retry narrative message reads

* fix(memory-core): wait longer for narrative text
2026-06-11 20:36:18 +09:00
Dream Hunter
8d72cb9401 fix(memory): abort orphaned embedding work when memory_search times out (#91742)
* fix(memory): abort orphaned embedding work when memory_search times out

memory_search raced its 15s deadline with Promise.race and returned a clean
timeout to the agent, but the underlying embedQueryWithRetry loop kept
retrying (3 attempts x 60s) against the embedding backend with no consumer.
Thread the tool-owned AbortSignal through manager.search ->
embedQueryWithRetry -> runEmbeddingOperationWithTimeout so the deadline
cancels in-flight embedding work, stops the retry loop, and skips
fallback-provider activation for an absent caller.

Fixes #91718

* fix(memory): let the deadline result win before aborting the search

Abort listeners dispatch synchronously, so an abort-aware search could
reject the raced task before the timeout promise resolved and replace the
stable 'memory_search timed out after 15s' result with a provider-wrapped
abort error. Resolve the timeout first, then abort.

* fix(memory): scope deadline abort to builtin embeddings

* fix(memory): preserve deadline signal across fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:36:11 +09:00
Chunyue Wang
4f3c2cd2df fix(ollama): use provider thinking default in SDK session factory (#91657)
* fix(ollama): use provider thinking default in SDK session factory

* fix(agents): preserve model metadata for thinking defaults

* fix(agents): resolve custom Ollama thinking policy

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:36:05 +09:00
Evgeni Obuchowski
16ea3f272f fix(plugins): stop derived metadata snapshot rescan storm in /models (regression shipped since v2026.5.18) (#92127)
Since 5734193fdf ("fix(plugins): keep metadata snapshot memo fresh",
first shipped in v2026.5.18), the in-process plugin metadata snapshot
memo stores derived-registry results under a key recomputed from the
freshly built snapshot.index, while lookups key off the persisted-index
registry state. On installs where the registry resolves as "derived"
(persisted index absent or not covering the running checkout), the two
keys never match. Worse, the lookup-side adoption loop returns the most
recently stored registryState for the context, so two alternating call
shapes (e.g. the model-catalog build mixing workspace-scoped and global
lookups) each adopt the other shape's state, compute a key that was
never stored, and re-run the full plugin manifest scan - on every call,
forever. Chat /models (and each subsequent provider/model pick) pays
multiple full manifest scans plus all downstream snapshot-identity cache
invalidation per step, pinning a CPU core for seconds on every
interaction, in every chat channel.

Fix: store the memo under the exact memoKey/registryState the call
looked up by, instead of re-deriving a second key from snapshot.index.
Freshness is unchanged - the lookup context hash and the plugin metadata
lifecycle clears (install/reload/doctor) still own invalidation. The
now-unused index parameter of resolvePersistedRegistryMemoState is
removed.

Measured on the author's VPS (real plugin discovery, identical catalog
output of 263 entries on both sides): the full-discovery model catalog
build behind chat /models dropped from ~6.3s to ~0.3s (~21x), with
repeat snapshot lookups going from full rescans to memo hits.

Regression test: alternating derived call shapes must not re-scan
(red on main: 4 scans; green with this fix: 2).
2026-06-11 20:35:59 +09:00
Yuval Dinodia
83705fba04 fix(config): stop config.patch replacePaths index suffix from widening array consent (#91966)
* fix(config): stop config.patch replacePaths index suffix from widening array consent

normalizeConfigPatchReplacePath stripped a trailing array bracket, so an entry/index-scoped token like bindings[0] or bindings[] collapsed onto the bare whole-array token (bindings). That bare token is both the merge replaceArrayPaths key and the destructive-array gate's exact-path token, so an index-scoped consent silently authorized a full-array replacement and dropped unrelated base entries on the gateway config.patch path, and the same collapse let the agent self-edit tool truncate id-keyed arrays whenever no protected path happened to be involved.

Keep the interior index normalization (agents.list[0].skills -> agents.list[].skills) but no longer collapse a trailing bracket, so a bracket/index-suffixed token never matches the bare whole-array token and the destructive-array gate stays fail-closed unless the documented exact path is passed. Update the agent-tool test whose expectation depended on the old collapse: agents.list[0] now does a non-destructive id-keyed merge that only changes model and is correctly allowed.

* fix(config): distinguish indexed and array replace consent

* test(config): cover replace consent syntax

* fix(config): make replace path normalization idempotent

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:35:52 +09:00
Vincent Koc
3883d7365e fix(qqbot): guard silent-final tool flushing 2026-06-11 20:09:55 +09:00
吴杨帆
71d3d8bc74 fix(doctor): warn on unsupported hook entry loaders (#89319)
* fix(doctor): warn on unsupported hook entry loaders

* fix(doctor): guard null hook entry configs

* fix(doctor): guard null hook entry configs

* fix(doctor): repair misplaced hook loader paths

* fix(doctor): clarify unsupported hook entry repair

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 20:09:51 +09:00
Alix-007
0b80732137 fix(cron): reject durations that overflow to a non-finite value (#89448)
* fix(cron): reject durations that overflow to a non-finite value

parseDurationMs guarded the parsed mantissa but returned Math.floor(n * factor)
with no finite check on the product. A finite mantissa times a large unit factor
(e.g. "1e302d", factor 86_400_000) overflows to Infinity, which was returned as
the millisecond value. Reject a non-finite result instead, matching the existing
contract that already rejects non-finite / non-positive mantissas.

Fixes #83906.

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

* ci: rerun flaky runner checks

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:09:45 +09:00
Syu
038da08073 fix(discord): clean migrated thread binding state (#89552)
* fix(discord): clean migrated thread binding state

* fix(discord): omit undefined thread binding fields

---------

Co-authored-by: Hex <hex@openclaw.ai>
2026-06-11 20:09:39 +09:00
Yuval Dinodia
4b4211e6c7 fix(agents): keep migrated session entry ids unique on v1 upgrade (#89085)
migrateV1ToV2 assigned each entry id via generateId(ids) but never added the
result back into ids, so the collision-check set stayed empty for the whole
migration and generateId's check was a no-op. A v1 to v2 upgrade could then mint
two entries with the same 8-hex id, and because the migration rebuilds the
parent/child tree from those ids it would parent the second entry to itself,
corrupting the branch. Add ids.add(entry.id) so the generator sees prior ids and
retries on collision.

Adds a regression test that drives the real SessionManager.open migration path
with a seeded id collision.
2026-06-11 20:09:33 +09:00
cornna
1532b46e2a fix(models): clarify provider model registration hint (#89508)
Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com>
2026-06-11 20:09:27 +09:00
Vincent Koc
60e818f563 fix(agents): forward channel identity to CLI hooks 2026-06-11 19:33:00 +09:00
Sliverp
7e88c287a1 fix(qqbot): flush tool output before silent non-streaming final (#92074)
* fix(qqbot): flush tool output before silent non-streaming final

* fix qqbot silent final delivery

* chore: drop local plugin runtime helper

* fix: suppress stale qqbot tool flush

* fix(qqbot): flush tool output before silent non-streaming final (#92074) (thanks @sliverp)

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-06-11 18:28:32 +08:00
Leon GyeongMan Baek
96e1b8c3ba Google: show detailed Gemini CLI OAuth extraction failures (#41991)
* Google: preserve Gemini CLI OAuth failure context

Port the diagnostics-only fix to the bundled Google OAuth implementation so user-facing setup errors explain why automatic Gemini CLI client-config discovery failed.

Constraint: #54289 may remove or gate automatic Gemini CLI credential extraction
Rejected: Change extraction consent behavior here | security/product decision belongs in #54289
Confidence: medium
Scope-risk: narrow
Tested: pnpm test -- extensions/google/oauth.test.ts
Tested: pnpm check
Tested: pnpm format:check extensions/google/oauth.credentials.ts extensions/google/oauth.test.ts
Not-tested: full pnpm test suite

* Google: clarify Gemini bundle fallback diagnostic comment

Keep the follow-up limited to the explanatory comment so it matches the diagnostic error preservation added around bundle traversal failures.

Constraint: comment-only cleanup after diagnostics port
Confidence: high
Scope-risk: narrow
Tested: pnpm format:check extensions/google/oauth.credentials.ts
Not-tested: tests not run; comment-only change

* fix: resolve OAuth test rebase conflict
2026-06-11 19:24:52 +09:00
Lu Wang
43b4e27699 fix(thinking): apply Claude profile to anthropic-messages catalog rows (#92053)
* fix(thinking): apply Claude profile to anthropic-messages catalog rows

When a custom provider (e.g. `jdcloud-anthropic`) fronted Claude Opus over
the native anthropic-messages adapter, `--thinking xhigh` was silently
clamped to `off`. The thinking-profile dispatcher resolves bundled plugin
policy surfaces by exact provider id, so a renamed Anthropic-compatible
provider never reached the anthropic plugin's policy and `xhigh` was not
in the resulting profile.

`auto-reply/thinking.ts` already had a fallback keyed on
`context.api === "anthropic-messages"` that attached
`CLAUDE_FABLE_5_THINKING_PROFILE` for Fable models. Generalize it to use
`resolveClaudeThinkingProfile(modelId, params)` instead — the same
canonical helper the anthropic plugin uses — which still returns the Fable
profile for Fable models and now returns the correct Opus 4.7/4.8 profile
(with `xhigh`/`adaptive`/`max`) for Claude Opus regardless of provider id.

Non-Claude models on anthropic-messages routes still get the base
profile, and a Claude id on a non-Anthropic transport (e.g. an
openai-completions catalog row) is unaffected.

Fixes #91975

* fix(thinking): match native Anthropic includeNativeMax in fallback

Address ClawSweeper P2 review on #92053. The anthropic-messages fallback
in `resolveThinkingProfile` calls `resolveClaudeThinkingProfile` but
omits the `{ includeNativeMax: true }` option that the bundled anthropic
plugin uses (extensions/anthropic/provider-policy-api.ts:38,45).

For native-xhigh Claude families (Opus 4.7/4.8) this had no effect since
the native-xhigh branch already exposes `max`. But adaptive Claude
families that take the adaptive-default branch (e.g. claude-sonnet-4-6,
claude-opus-4-6) silently lost `max` parity on custom anthropic-messages
providers compared to native Anthropic policy.

Also add a regression test on `claude-sonnet-4-6` that verifies the
adaptive-branch path keeps `max` for custom providers.

* docs(thinking): document deliberate compat.xhigh bypass on anthropic-messages

Self-review surfaced a subtle behavior change worth documenting: when the
anthropic-messages fallback was generalized, non-Claude models on this
transport stop honoring catalog `compat.supportedReasoningEfforts: ["xhigh"]`
because they take the Claude base profile instead of falling through to the
later `catalogSupportsXHigh` upgrade path.

This is intentional — anthropic-messages does not carry a generic xhigh
contract; xhigh on this protocol is a Claude-family capability. Add an
inline comment at the resolver site and a regression test that locks the
suppression so the next reader (or a future patch) doesn't accidentally
restore the upgrade path.

* fix(thinking): extract Claude profile to leaf to break import cycle

The previous commits added a `resolveClaudeThinkingProfile` import from
`auto-reply/thinking.ts` to `plugin-sdk/provider-model-shared.ts`. The
shared barrel re-exports `provider-replay-helpers` and `plugins/types`,
which transitively reach back into `auto-reply` via the gateway server
methods chain — creating the madge cycle reported by
`check:madge-import-cycles`:

    auto-reply/thinking.ts
      -> ... -> plugin-sdk/provider-model-shared.ts
      -> plugins/{config-schema, host-hooks, ...} -> plugins/types.ts

Move `BASE_CLAUDE_THINKING_LEVELS`, `isClaudeAdaptiveThinkingDefaultModelId`,
and `resolveClaudeThinkingProfile` to a new leaf module
`src/plugins/provider-claude-thinking.ts` whose only imports are
`@openclaw/llm-core` and the existing leaf `provider-thinking.types`.

`provider-model-shared.ts` continues to re-export both helpers so existing
consumers (`extensions/anthropic/*`, the public test surface) are
unaffected. `auto-reply/thinking.ts` now imports the leaf directly,
breaking the cycle.

* test(thinking): add live proof harness for #91975 anthropic-messages clamp

---------

Co-authored-by: wanglu241 <wanglu241@jd.com>
2026-06-11 19:24:46 +09:00
Lu Wang
43b1088962 fix(cli-runner): scope claude-cli queue to live-session owner identity (#91946) (#91974)
* fix(cli-runner): scope claude-cli queue to live-session owner identity

Fresh claude-cli runs without a stored cliSessionId previously collapsed
onto a single workspace-scoped queue key, serializing all fan-out within
one workspace regardless of subagent lane configuration.

Replace the workspace fallback with the same owner identity that
claude-live-session.ts already uses for its live-session map
(agentAccountId + agentId + authProfileId + sessionId + sessionKey),
keeping per-session resume safety while letting independent OpenClaw
sessions in the same workspace run concurrently.

Refactor buildClaudeLiveKey() to share the new buildClaudeOwnerKey()
helper so the queue key and the live-session key cannot drift.

Refs: #91946

* test(cli-runner): pin owner-key hash + document buildClaudeOwnerKey contract

Add a golden-hash regression test for buildClaudeOwnerKey using the
exact legacy fixture, so a future refactor that reorders fields or
flips the JSON encoding can't silently orphan every deployed Claude
live session at upgrade. Hash verified empirically against the prior
inline sha256(JSON.stringify(...)) in buildClaudeLiveKey.

Add a JSDoc on buildClaudeOwnerKey explaining the cross-module contract
between the CLI run queue and the live-session map.

Refs: #91946

* docs(cli-runner): tighten buildClaudeOwnerKey contract comment

The previous comment claimed an encoding mismatch would orphan deployed
live sessions across upgrades. The Claude live-session registry is
process-local, so any restart already discards every entry — the real
invariant is that the queue path and live-session path produce
byte-identical owner keys *within a single process*, so a fresh queued
turn picks up the same live session the registry already holds. Update
the helper docstring and the golden-hash test description accordingly;
the pinned hash and behavior are unchanged.

* test(cli-runner): add owner-key concurrency demo script

A pure-Node, no-test-runner demo that reproduces the PR-head queue
behavior end-to-end: BEFORE-PR collapse (workspace lane), distinct-owner
overlap, and identical-owner serialization, all in one run with
millisecond-stamped event ordering. Useful as a low-overhead regression
check for the owner-key contract and as a maintainer-runnable proof
artifact for #91946.

* test(cli-runner): satisfy oxlint curly + no-promise-executor-return

Wrap single-statement if/for-of bodies in braces and rewrite the
sleep helper so its Promise executor is a void block instead of an
arrow with an implicit return. No behavior change; demo output and
the byte-equivalent slice fingerprints are unchanged.

---------

Co-authored-by: wanglu241 <wanglu241@jd.com>
2026-06-11 19:24:39 +09:00
Sunjae Kim
5d42ad6654 Stabilize A2A prompt cache metadata (#90173)
A2A session routing identifiers are needed for delivery provenance, but concrete session keys in extraSystemPrompt make the agent system prompt vary between otherwise identical handoffs. Keep the model-facing system context stable by describing high-cardinality session slots with placeholders while retaining concrete values in inputProvenance. Channel names stay concrete: they are low-cardinality (discord/slack/webchat/...), so they do not meaningfully fragment the cache, and they inform reply formatting on the receiving agent.

Constraint: OpenClaw contributor PRs require focused behavior proof and tests for prompt/cache-facing changes.

Rejected: Removing routing metadata entirely | would weaken model context for requester/target roles.

Rejected: Placeholdering channel values too | drops model-visible formatting context for negligible cache benefit (reviewer feedback).

Confidence: medium

Scope-risk: narrow

Directive: Keep concrete session identifiers out of extraSystemPrompt; preserve them in structured provenance or payload fields. Low-cardinality channel labels may stay model-visible.

Tested: node scripts/run-vitest.mjs src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: corepack pnpm exec oxfmt --check src/agents/tools/sessions-send-helpers.ts src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: node scripts/run-oxlint.mjs src/agents/tools/sessions-send-helpers.ts src/agents/tools/sessions-send-helpers.test.ts src/agents/openclaw-tools.sessions.test.ts

Tested: git diff --check

Tested: live before/after provider cache trace (isolated local gateway, two A2A sends from distinct requester sessions; see PR Real behavior proof)

Co-authored-by: Sunjae Kim <sunjaekim@bigvalue.co.kr>
2026-06-11 19:24:32 +09:00
xydt-tanshanshan
865fdab075 fix(memory): preserve live SQLite index during swaps
Fixes #91216.

Preserve the live memory SQLite index during atomic reindex swaps by publishing the POSIX main DB with an overwrite rename, keeping target WAL/SHM sidecars rollbackable until publish succeeds, and refusing no-create post-swap reopens that would otherwise auto-create an empty DB.

Verification:
- node scripts/run-vitest.mjs extensions/memory-core/src/memory/manager.atomic-reindex.test.ts extensions/memory-core/src/memory/manager-db-probe.test.ts extensions/memory-core/src/memory/manager.self-heal-missing-identity.test.ts extensions/memory-core/src/memory/manager-reindex-state.test.ts extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts extensions/memory-core/src/memory/manager.readonly-recovery.test.ts
- git diff --check origin/main...HEAD
- node scripts/run-oxlint.mjs extensions/memory-core/src/memory/manager-atomic-reindex.ts extensions/memory-core/src/memory/manager.atomic-reindex.test.ts extensions/memory-core/src/memory/manager-db.ts extensions/memory-core/src/memory/manager-db-probe.test.ts extensions/memory-core/src/memory/manager-sync-ops.ts
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- GitHub CI on 60df2b4178
2026-06-11 19:15:20 +09:00
openclaw-clownfish[bot]
c692fabeba fix(feishu): reply inside P2P direct-message threads (#92136)
* fix(feishu): keep P2P replies inside direct-message threads

* fix(clownfish): address review for ghcrawl-165996-agentic-merge (1)

* fix(clownfish): address review for ghcrawl-165996-agentic-merge (1)

Co-authored-by: LiaoyuanNing@TTC <259494737+LiaoyuanNing@users.noreply.github.com>

---------

Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: LiaoyuanNing@TTC <259494737+LiaoyuanNing@users.noreply.github.com>
2026-06-11 18:57:51 +09:00
lizeyu-xydt
1e878dde7c #92109: [Bug]: EmbeddedAttemptSessionTakeoverError caused by Btrfs ctimeNs instability (#92123)
* fix(session-lock): remove ctimeNs from session file fingerprint comparison

Btrfs background maintenance (snapshots, scrub, quota) updates ctime
without any file content change. Including ctimeNs in the fingerprint
causes false-positive EmbeddedAttemptSessionTakeoverError on all Btrfs
filesystems, breaking cron jobs and subagent spawns with 100% failure.

dev + ino + size + mtimeNs are sufficient to detect external writes —
any content change will also update mtimeNs and/or size. ctimeNs only
tracks metadata changes and adds no meaningful protection.

Closes #92109

* test(session-lock): cover ctime-only fence drift

* fix(session-lock): narrow ctime drift acceptance

* fix(session-lock): trust verified ctime drift

* fix(session-lock): bound ctime drift digest

* fix(session-lock): skip absent ctime digest

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 18:40:38 +09:00
Vincent Koc
dbf1b742be test(ci): align Anthropic agent expectations 2026-06-11 18:35:11 +09:00
Vincent Koc
7ae90845ab Merge branch 'perf/main-ci-tail' of https://github.com/openclaw/openclaw into perf/main-ci-tail
* 'perf/main-ci-tail' of https://github.com/openclaw/openclaw:
  perf(ci): isolate Docker tooling tests
2026-06-11 18:15:53 +09:00
Vincent Koc
f1401b2cac perf(ci): isolate Docker tooling tests 2026-06-11 18:13:36 +09:00
Vincent Koc
4430f89697 perf(ci): isolate Docker tooling tests 2026-06-11 17:40:04 +09:00
Ayaan Zaidi
0c4fc0a2e3 refactor(agents): derive CLI commentary classification from consumer presence 2026-06-11 13:57:39 +05:30
ragesaq
6396221858 fix(auto-reply): stop dropping claude-cli narration when commentary lane is off
After #91976, the claude-cli JSONL parser reclassifies assistant text that
precedes a tool_use block as commentary. The classification gate
(commentaryProgressEnabled !== undefined) was looser than the delivery gate
(commentaryProgressEnabled === true && onItemEvent), so any channel that
defined the flag as false engaged classification with no consumer wired:
flushPendingClaudeCommentaryText() called an undefined onCommentaryText and
silently discarded the text. On Discord with verbose off this dropped all
inter-tool narration and the pre-final-answer preamble text.

Two-layer fix:
- Align the classify gate with the delivery gate in both CLI dispatch sites
  (agent-runner-execution, followup-runner) so classification only engages
  when a commentary consumer exists.
- Defense in depth: flushPendingClaudeCommentaryText() now falls back to the
  assistant text lane instead of discarding when no consumer is wired, so no
  future gate mismatch can silently eat model output.

Reported on Discord: claude-cli backend lost interleaved narration and the
regular-text reasoning preamble with or without /verbose on.
2026-06-11 13:57:39 +05:30
Peter Steinberger
418d7e1e83 fix(clickclack): allow explicit enable through plugin allowlist (#92084)
Allow an explicit canonical ClickClack enable/setup selection to record ClickClack in a nonempty plugin allowlist, while preserving unrelated allowlist rejection, denylist authority, and global plugin disablement.

Validated at source head 24af9d8e75 with focused regressions, built-CLI disposable-config E2E, security checks, and autoreview. Merged under owner authorization despite the two documented untouched-main agent-core baseline failures.
2026-06-10 21:47:20 -07:00
Marvinthebored
8e81bf774e fix(sessions): preserve user model override across daily/idle rollover (#90128)
User-driven /model (and sessions.patch) overrides were dropped when a
session rolled over at the daily/idle reset boundary, reverting to the
configured default on the next turn despite the 'Model set to ... for
this session' ack. The override-preservation carryover in
initSessionState was gated on resetTriggered, so implicit stale
rollovers (the common case for always-on channel sessions) skipped it.

Run resolveResetPreservedSelection for any rollover that mints a new
session from an existing entry (explicit /new + /reset AND implicit
stale daily/idle). resolveResetPreservedSelection already preserves only
user-driven overrides and clears auto-fallback pins, so resets still
return to the default.

Adds regression tests in session.test.ts covering both cases.

Fixes #90119

Filed with AI assistance (OpenClaw agent); reviewed by @Peetiegonzalez.

Co-authored-by: Marvinthebored <marvinthebored@users.noreply.github.com>
2026-06-10 23:31:49 -04:00
Ayaan Zaidi
bd96e4d22d refactor(auto-reply): distill verbose commentary lane wiring 2026-06-11 08:18:04 +05:30
Ayaan Zaidi
317ba47543 test(discord): add onVerboseProgressVisibility to dispatch params type 2026-06-11 08:18:04 +05:30
Cameron Beeley
b25b8f396c fix(discord): yield the commentary draft while durable verbose progress is active
Discord consumes the dispatch verbose-progress visibility getter the same way
Telegram does: while the durable lane is delivering commentary as standalone
messages, the ephemeral progress draft skips its preamble lines so commentary
renders exactly once. Covered by an active/inactive regression pair.
2026-06-11 08:18:04 +05:30
Cameron Beeley
4ce1d7843a feat(auto-reply): emit durable tool summaries from CLI runner tool results
The CLI parser already emits tool result events (name, toolCallId, isError,
sanitized result), but the runner bridge dropped them, so CLI-backed runs had
no durable tool record under verbose while embedded runs did. The bridge now
forwards result events, and both runners feed a summary tracker that renders
the same formatToolAggregate line the embedded runner emits (meta captured
from the start event args), plus the tool output block when full verbose
output is enabled. Delivery rides each runner's existing tool-result route, so
verbose gating, ordering ahead of the final answer, and the Telegram durable
routing all apply unchanged.
2026-06-11 08:18:04 +05:30
Cameron Beeley
dc55a5b112 feat(telegram): route verbose progress payloads durably instead of into the streaming draft
With streaming on, the dispatcher diverted tool-kind payloads (including the
new durable commentary messages) into the ephemeral progress draft, where they
were discarded when the final answer arrived - so verbose runs lost their
progress record whenever streaming was enabled. While the durable verbose lane
is active (per the dispatch visibility getter), tool payloads are now sent as
real standalone messages and the draft yields its commentary lines; tool/plan
draft lines keep the draft since they have no durable counterpart. Reasoning
lane and tool status reactions are unaffected.
2026-06-11 08:18:04 +05:30
Cameron Beeley
a397fcabd9 feat(auto-reply): deliver inter-tool commentary as standalone verbose progress messages
When verbose progress is enabled, preamble item events now flush as durable
standalone progress messages through the same delivery path as tool summaries,
instead of living only in ephemeral channel streaming drafts. The latest text
per item id is buffered so snapshot-style producers send one message per item;
the buffer flushes when the producer moves on (next item, tool event, block
reply, or final reply) and drains before the final answer.

Verbose runs also force commentary classification on (commentaryProgressEnabled),
so inter-tool text routes to the commentary lane rather than being folded into
the final answer text.

Dispatch additionally exposes a live verbose-progress visibility getter via the
new onVerboseProgressVisibility reply option, so draft-rendering channels can
route progress to the durable lane while it is active.
2026-06-11 08:18:04 +05:30
Shubhankar Tripathy
0bcabea9cc fix(discord): scope command-deploy cache by application id (#77367)
* fix(discord): scope command-deploy cache by application id

Multi-bot Discord setups share a single command-deploy-cache.json under the
state dir. Cache keys were unscoped (`global:reconcile`, `guild:<id>`), so a
later account whose command set hashed identically to an earlier account would
hit the shared hash and skip its own application's command reconcile entirely
— Discord's Integration panel showed 'This application has no commands' for
the secondary bot even though gateway connect, application id, and token were
all valid.

Scope every cache key with `app:<clientId>:` so each Discord application
reconciles independently. Add regression tests covering: two applications with
identical command sets each call REST against their own application; a single
application with the same command set still hits the persisted cache; the
on-disk cache JSON contains application-scoped keys.

Fixes #77359.

* fix(discord): merge on-disk hashes inside persistHashes to survive concurrent writes

Codex follow-up on #77359 noted that server-channels.ts can start multiple
Discord deployers concurrently, so two deployers that both load the cache
file before either persists end up with the second writer overwriting the
first writer's app-scoped key — defeating the rate-limit cache that the
file exists to provide.

Inside persistHashes, re-read the on-disk cache and merge it with our
in-memory entries before the rename. Our in-memory entries always win on
key collisions (we just produced them); on-disk entries we don't have in
memory are preserved. Refresh in-memory state after the write so future
writes from the same deployer also keep entries other deployers added.

This is the lighter of the two repairs the codex review suggested
(re-read/merge vs serialize writes); it covers the realistic case where
one deployer writes before the other persists. Add a regression test that
exercises the load-then-other-deployer-writes-then-persist sequence.

* fix(discord): serialize command-deploy cache persists via in-process mutex

Codex follow-up on #77367 noted: re-read-before-write inside persistHashes
isn't enough — two deployers running persistHashes in true parallel can
both read the same snapshot before either writes, and the later rename
overwrites the earlier writer's app-scoped entries.

Add a module-level Map<storePath, Promise<void>> mutex and wrap the
read-merge-write cycle in withCachePersistLock so concurrent persists for
the same on-disk path serialize. In-process is sufficient because Discord
deployers only run inside the gateway process.

New regression test fires three deployers via Promise.all on the same
tick and asserts all three application-scoped entries survive — pre-fix
this race lost at least one entry.

* fix(discord): add override modifier on StaticCommand.description to satisfy strict TS

Current main enables noImplicitOverride; the StaticCommand test helper
re-declares the concrete BaseCommand.description property, which now
requires an explicit 'override' modifier (TS4114).

* test(discord): suppress typescript/unbound-method on vitest mock refs

The createRest() helper returns vi.fn() handlers cast as RequestClient,
so expect(rest.get).toHaveBeenCalledTimes(...) triggers
typescript/unbound-method 12 times. File-level disable: these are
vitest mock identities, not unbound class methods.

* fix(discord): clean up command cache lock

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-10 22:45:54 -04:00
Vincent Koc
d4fcc38696 fix(anthropic): repair Mythos contract types 2026-06-11 11:15:47 +09:00
Vincent Koc
25ca39e876 fix(tts): preserve async model discovery 2026-06-11 11:11:23 +09:00
Vincent Koc
7acedeaf11 fix(anthropic): require Mythos adaptive thinking 2026-06-11 11:11:23 +09:00
Vincent Koc
4a06e4c773 test(tts): update prepared completion contract 2026-06-11 11:11:23 +09:00
Vincent Koc
33f44d0f27 fix(tts): use prepared completion auth 2026-06-11 11:11:23 +09:00
Vincent Koc
f76bdffd06 fix(bedrock): normalize Mythos minimal effort 2026-06-11 11:11:23 +09:00
Vincent Koc
aa5b1bd697 fix(auth): apply runtime auth to labels 2026-06-11 11:11:22 +09:00
Vincent Koc
abec88cfab fix(auth): apply runtime request overrides everywhere 2026-06-11 11:11:22 +09:00
Vincent Koc
25bea06596 fix(foundry): type runtime auth result 2026-06-11 11:11:22 +09:00
Vincent Koc
08655fb02b fix(foundry): bind auth and thinking contracts 2026-06-11 11:11:22 +09:00
Vincent Koc
76ce9d6d22 fix(foundry): expose Claude 4.6 max effort 2026-06-11 11:11:22 +09:00
Vincent Koc
cc344fee65 fix(foundry): make API labels exhaustive 2026-06-11 11:11:22 +09:00
Vincent Koc
78d621855d fix(foundry): label Anthropic Messages onboarding 2026-06-11 11:11:22 +09:00
Vincent Koc
f0bbe4d95e fix(bedrock): guard Mantle thinking options 2026-06-11 11:11:22 +09:00
Vincent Koc
fdf1ec9f5c fix(bedrock): route Mythos through Mantle 2026-06-11 11:11:22 +09:00
Vincent Koc
d6081f99ff fix(bedrock): keep optional thinking opt-in 2026-06-11 11:11:22 +09:00
Vincent Koc
819fc0037a fix(foundry): align Claude thinking contracts 2026-06-11 11:11:22 +09:00
Vincent Koc
bebe96b402 test(foundry): type auth lookup 2026-06-11 11:11:22 +09:00
Vincent Koc
818c3c276e fix(foundry): preserve canonical thinking identity 2026-06-11 11:11:22 +09:00
Vincent Koc
7d681ab603 fix(vertex): default Mythos to adaptive thinking 2026-06-11 11:11:22 +09:00
Vincent Koc
e3b619505c fix(foundry): use bearer auth in native transport 2026-06-11 11:11:21 +09:00
Vincent Koc
72bfc5e9bf fix(foundry): preserve bearer auth intent 2026-06-11 11:11:21 +09:00
Vincent Koc
6b0f718b9a fix(providers): encode Mythos adaptive requests 2026-06-11 11:11:21 +09:00
Vincent Koc
9b57db3b60 fix(foundry): route bearer auth from headers 2026-06-11 11:11:21 +09:00
Vincent Koc
5e4a84e04f fix(vertex): preserve Mythos thinking policy 2026-06-11 11:11:21 +09:00
Vincent Koc
9ffb5407f7 fix(providers): reconcile Claude profile routing 2026-06-11 11:11:21 +09:00
Vincent Koc
4457ae3e35 fix(providers): preserve Claude output limits 2026-06-11 11:11:21 +09:00
Vincent Koc
a33e58cfbb test(anthropic): type Foundry auth fixtures 2026-06-11 11:11:21 +09:00
Vincent Koc
aabb8498da fix(providers): bound Claude model matching 2026-06-11 11:11:21 +09:00
Vincent Koc
55384bc236 fix(foundry): clamp Mythos Preview thinking 2026-06-11 11:11:21 +09:00
Vincent Koc
e7136005eb fix(types): narrow provider auth metadata 2026-06-11 11:11:21 +09:00
Vincent Koc
9db8601235 fix(foundry): avoid stale setup metadata 2026-06-11 11:11:21 +09:00
Vincent Koc
2031603542 fix(providers): preserve Mythos max routing 2026-06-11 11:11:21 +09:00
Vincent Koc
b4aa520b07 fix(bedrock): preserve Mythos Preview thinking policy 2026-06-11 11:11:21 +09:00
Vincent Koc
4afe616f22 fix(providers): apply auth patch deletions 2026-06-11 11:11:21 +09:00
Vincent Koc
06312ad805 fix(foundry): preserve Sonnet output limits 2026-06-11 11:11:20 +09:00
Vincent Koc
c4493943e1 fix(foundry): clear API key auth on Entra setup 2026-06-11 11:11:20 +09:00
Vincent Koc
15806717d9 fix(foundry): infer selected Claude routes 2026-06-11 11:11:20 +09:00
Vincent Koc
0a8dfc21be fix(foundry): align Claude onboarding contracts 2026-06-11 11:11:20 +09:00
Vincent Koc
378ae44aad fix(foundry): keep API-key Claude auth 2026-06-11 11:11:20 +09:00
Vincent Koc
b66f0b6ca3 fix(foundry): use bearer auth for Claude 2026-06-11 11:11:20 +09:00
Vincent Koc
2fa9a5eaa0 fix(foundry): scope Entra tokens by API 2026-06-11 11:11:20 +09:00
Vincent Koc
3ba617681f fix(providers): preserve adaptive Claude routing 2026-06-11 11:11:20 +09:00
Vincent Koc
7b0b6290b5 test: stabilize full Mac regression suite 2026-06-11 11:10:00 +09:00
Shakker
dac468c731 fix: repair update restart type checks 2026-06-11 02:59:39 +01:00
Vincent Koc
c78d27e4b3 fix(test): bound gateway harness teardown 2026-06-11 10:52:59 +09:00
Vincent Koc
16382e4066 perf(ci): move tooling off artifact critical path 2026-06-11 10:52:59 +09:00
Shakker
36b01d9534 test: update memory helper routing expectation 2026-06-11 02:30:35 +01:00
zengLingbiao
46a3442251 fix(web_fetch): sanitize URL whitespace from LLM tool call arguments (fixes #91651) (#91950)
* fix(web_fetch): sanitize URL whitespace from LLM tool call arguments (fixes #91651)

* fix(web_fetch): narrow URL repair to only strip scheme-authority whitespace

Constrain normalization to the reported malformed-scheme pattern
(whitespace between :// and authority) instead of deleting all
whitespace globally. Path and query spaces are intentionally
preserved — the WHATWG URL parser percent-encodes those correctly.

Add focused regression tests proving path/query/pct-encoded spaces
survive sanitization. (fixes #91651)

* fix(web-fetch): preserve scheme-like path text

* fix(web-fetch): preserve unicode URL text

* fix(web-fetch): retain raw URL argument text

* fix(web-fetch): trim leading unicode URL whitespace

* fix(web-fetch): normalize bare authority whitespace

* fix(web-fetch): satisfy URL sanitizer checks

* fix(web-fetch): pass URL sanitizer lint

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:21:07 +09:00
Shakker
400b3e04fb fix: stop unknown-root git services 2026-06-11 02:18:47 +01:00
Shakker
535b1f53ac fix: keep update status polling read only 2026-06-11 02:18:47 +01:00
Shakker
082a4fa6a5 fix: preserve consumed update restart status 2026-06-11 02:18:47 +01:00
Shakker
df52f8ff64 fix: fail stopped service update restart errors 2026-06-11 02:18:47 +01:00
Shakker
2e9c93cb3a fix: isolate update tests from supervisor env 2026-06-11 02:18:47 +01:00
Shakker
80fc9b112b fix: avoid stale cleanup for reachable gateway probes 2026-06-11 02:18:47 +01:00
Shakker
6d32e42a99 fix: continue dev preflight after manager failures 2026-06-11 02:18:47 +01:00
Shakker
9db41b3876 fix: verify managed git restart service 2026-06-11 02:18:47 +01:00
Shakker
dee334f17a fix: constrain dev remote fallback 2026-06-11 02:18:47 +01:00
Shakker
05996096cc fix: clean up failed dev branch fallback 2026-06-11 02:18:47 +01:00
Shakker
54040c6c0e fix: roll back failed dev upstream setup 2026-06-11 02:18:47 +01:00
Shakker
34f2446601 fix: preserve dev branch upstream after fallback 2026-06-11 02:18:47 +01:00
Shakker
4ef71795bc fix: honor selected dev preflight fallback 2026-06-11 02:18:47 +01:00
Shakker
ba2782e64c fix: stop package service during dev switch 2026-06-11 02:18:47 +01:00
Shakker
cf6572dea4 fix: preserve git mode for supervised handoff 2026-06-11 02:18:47 +01:00
Shakker
a7b58f21c5 fix: resolve dev preflight manager from candidate 2026-06-11 02:18:47 +01:00
Shakker
6b3bcc986f fix: require service identity for update handoffs 2026-06-11 02:18:47 +01:00
Shakker
124625ec4d fix: stop git service when switching install roots 2026-06-11 02:18:47 +01:00
Shakker
e44835ae29 fix: resolve dev update preflight from remote main 2026-06-11 02:18:47 +01:00
Shakker
906158f572 fix: extend managed update handoff polling 2026-06-11 02:18:47 +01:00
Shakker
e1c4c3151c fix: keep gateway stopped after plugin update failure 2026-06-11 02:18:47 +01:00
Shakker
6ea8ac7ea3 fix: defer git update service stop until mutation 2026-06-11 02:18:47 +01:00
Shakker
f385aaab8a fix: protect managed git update service ownership 2026-06-11 02:18:47 +01:00
Shakker
3394a4ad2c fix: refresh update status sentinel 2026-06-11 02:18:47 +01:00
Shakker
862790fbd7 fix: verify supervised update handoff in UI 2026-06-11 02:18:47 +01:00
abnershang
a98c158414 docs: align supervised git update handoff docs 2026-06-11 02:18:47 +01:00
abnershang
48ed8f3e81 fix(update): hand off supervised git updates 2026-06-11 02:18:47 +01:00
abnershang
3a2adf856b fix: hand off supervised git updates 2026-06-11 02:18:47 +01:00
Vincent Koc
f03e3372b3 test(memory): type self-heal identity assertions 2026-06-11 10:10:30 +09:00
NVIDIAN
0328ca53cd fix(agents): honor configured CLI resume timeouts (#90912)
* fix(agents): honor configured CLI resume timeouts

* fix(agents): pass explicit CLI timeout overrides

* fix(agents): preserve timeout provenance

* test(agents): cover configured timeout provenance

* fix(agents): resolve CLI timeout provenance centrally

* fix(agents): carry reply timeout provenance

* fix(agents): forward CLI run lane

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:05:43 +09:00
Peter Steinberger
88038db3e5 fix(agent): dampen Discord stale thread replies (#91962)
* fix(agent): dampen Discord stale thread replies

* fix(agent): scope Discord quiet prompt by chat type

* fix(agent): forward queued chat type

---------

Co-authored-by: Dallin Romney <dallinromney@gmail.com>
2026-06-10 18:02:58 -07:00
Sergio Cadavid
610691c796 fix(media): resolve state-relative inbound attachments (#92055)
* fix(media): resolve state-relative inbound attachments

* fix(media): preserve cwd attachment precedence

* test(media): cover relative path collisions

* fix(media): skip blocked cwd collisions

* fix(media): validate relative candidates before selection

* fix(media): canonicalize candidate roots

* fix(media): preserve lexical candidate handoff

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 10:00:02 +09:00
Hansraj Singh Thakur
eb673e5c3d fix(cron): structural top-of-hour match in stagger heuristic (#92030)
* fix(cron): structural top-of-hour match in stagger heuristic

Top-of-hour detection used includes('*') on the hour field, accepting
malformed tokens like '5*'. Match only '*' or '*/N' structurally.

* fix(cron): preserve wildcard hour lists

* fix(cron): support question-mark wildcard

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 09:59:57 +09:00
Chunyue Wang
f194be19e2 fix(diagnostics): release wedged session lane when stuck recovery aborts with queued session work (#91802) 2026-06-11 09:59:22 +09:00
xydt-tanshanshan
1a2eb74b9d fix(memory): self-heal missing index identity by initializing provider during sync (#91897)
* [AI] fix(memory): self-heal missing index identity by initializing provider during sync

When a gateway's memory index identity becomes "missing" with chunks
already indexed, canRebuildMissingIdentity stays false because
this.provider is null (async provider init hasn completed yet), so
needsMissingIdentityReindex is false, and the sync loop bails out
with dirty=true forever — the gateway never self-heals.

Now, when indexIdentity.status is "missing" and a provider is
configured but this.provider is null, runSync() calls
ensureProviderInitialized() first, then re-evaluates the identity
state. If the provider becomes available,
canRebuildMissingIdentity flips to true, unlocking the self-heal
reindex path.

Refs #91167

* [AI] fix(memory): allow FTS-only self-heal when chunks are all FTS-only and provider unavailable

When a gateway's memory index identity is 'missing' with chunks already
indexed, canRebuildMissingIdentity stays false if the embedding provider
is unavailable, causing the sync loop to bail out with dirty=true forever.

The previous approach (calling ensureProviderInitialized inside runSync)
was redundant because the public sync() method already initializes the
provider before runSyncWithReadonlyRecovery.

The real fix: when every existing chunk has model='fts-only', rebuilding
the index as FTS-only is safe — no semantic data is lost. So
canRebuildMissingIdentity should also be true when hasOnlyFtsChunks,
even if the provider is unavailable.

Also adds hasSemanticChunks() helper to detect whether any chunks have
a non-fts-only model.

Non-forced test: seeds FTS-only chunks with no meta, syncs without
force, verifies identity transitions from 'missing' to 'valid'.

Refs #91167

* [AI] fix(memory): gate hasSemanticChunks scan to missing-identity path only

Only compute hasOnlyFtsChunks when identity is missing, chunks exist,
and the provider is unavailable. This avoids scanning the chunks table
for model classification on every ordinary sync.

* test(memory): protect semantic index self-heal

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 09:53:01 +09:00
Vincent Koc
adad27d744 fix(exec): honor state dir approvals (#92056) 2026-06-11 09:30:56 +09:00
Vincent Koc
d559dfecfa test(models): complete blank-base-url provider fixture 2026-06-11 08:53:41 +09:00
Alix-007
f5dd33c975 fix(control-ui): make Control UI bootstrap config endpoint base-path-relative (#66946) (#91305)
* fix(control-ui): make bootstrap config endpoint base-path-relative (#66946)

CONTROL_UI_BOOTSTRAP_CONFIG_PATH embedded a hard-coded /__openclaw prefix
instead of being base-path-relative. When the Control UI is served under
/__openclaw__/, both the gateway and the browser loader compose
${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}, producing the doubled
/__openclaw__/__openclaw/control-ui-config.json URL that 404s.

Make the constant base-path-relative (/control-ui-config.json) so the
composed URL is correct under any base path, align the Vite dev stub and
the docs, and add gateway.controlUi.basePath "/__openclaw__" coverage.

* fix(control-ui): serve bootstrap config at default __openclaw__ entry (#66946)

The reporter runs the default deployment (no gateway.controlUi.basePath),
so the Control UI SPA is mounted under the default /__openclaw__/ namespace.
A browser opening that entry infers basePath="/__openclaw__" from the URL
(inferBasePathFromPathname) and fetches /__openclaw__/control-ui-config.json,
but an empty-base-path gateway only served the bare /control-ui-config.json,
so the default-entry bootstrap request 404'd and chat never finished loading.

Make handleControlUiHttpRequest also accept the default-namespace alias
/__openclaw__/control-ui-config.json when no base path is configured. The
alias is derived from the existing CONTROL_UI_NAMESPACE_PREFIX mount constant
and is purely additive: the bare /control-ui-config.json endpoint and the
configured-base-path endpoint are both preserved (no route removed).

Add gateway HTTP coverage for the real default-entry scenario (empty base
path + /__openclaw__/... request) that fails without the alias, alongside the
configured-base-path, bare-path compatibility, and doubled-path 404 cases.

* fix(control-ui): preserve legacy bootstrap endpoint as compat alias (#66946)

Current main and v2026.6.1 serve and document the single-underscore
/__openclaw/control-ui-config.json bootstrap endpoint under an empty
base path (that literal was CONTROL_UI_BOOTSTRAP_CONFIG_PATH before the
path was made base-path-relative). Making the constant relative dropped
that match, so older bundles and clients hitting the documented endpoint
would 404 after upgrading.

Accept the legacy single-underscore path as an empty-base-path
compatibility alias in matchesControlUiBootstrapConfigPath, derived from
the legacy /__openclaw namespace joined with the canonical config
constant (so it tracks any filename rename) and named
LEGACY_BOOTSTRAP_CONFIG_PATH with a comment. The canonical
/control-ui-config.json and the default-namespace
/__openclaw__/control-ui-config.json aliases are unchanged; only this
path is added. The doubled /__openclaw__/__openclaw/... path still 404s.

Add a focused regression that the legacy endpoint returns config under an
empty base path; it 404s without the alias (verified non-vacuous).

* fix(control-ui): preserve legacy bootstrap route under configured base path (#66946)

The previous revision preserved the single-underscore
/__openclaw/control-ui-config.json bootstrap endpoint only under an empty
base path. A deployment with a configured gateway.controlUi.basePath
(e.g. /x) served and documented that endpoint at
${basePath}/__openclaw/control-ui-config.json before this PR made the
config path base-path-relative, so configured-base-path users, older
bundles, and clients that still request it would 404 after upgrading.

Extend matchesControlUiBootstrapConfigPath so the legacy single-underscore
suffix is accepted under every base path, not just the empty one. The
matcher now checks the canonical and legacy suffixes uniformly as
${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH} and
${basePath}${LEGACY_BOOTSTRAP_CONFIG_PATH} for both the empty and
configured cases, reusing the existing LEGACY_BOOTSTRAP_CONFIG_PATH
constant (no new hard-coded literal). The default-namespace
/__openclaw__/control-ui-config.json alias stays empty-base-path-only
(it is the path the inferred default entry requests when no base path is
configured). All three empty-base-path behaviors are unchanged; the
doubled /__openclaw__/__openclaw/... path still 404s under both an empty
and a configured base path.

Add a focused regression that the configured-base-path legacy endpoint
returns the bootstrap config; it 404s without the alias (verified
non-vacuous). No CHANGELOG.md change.

* fix(ui): mount config stub under vite base

* fix(ui): preserve default config stub route

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:49:09 +09:00
Vincent Koc
439d2a9404 fix(ui): narrow submitted progress status 2026-06-11 08:43:19 +09:00
Ahmed Tarek
c66fd30595 🐛 fix(openai): remove chatgpt-responses transport override from gpt-5.3-codex catalog entry (#91720)
* fix(openai): remove chatgpt-responses transport override from gpt-5.3-codex catalog entry

The static catalog entry for gpt-5.3-codex hardcoded
api: "openai-chatgpt-responses" and baseUrl: "https://chatgpt.com/backend-api",
forcing all users through the ChatGPT backend — which requires OAuth, not
a standard API key. This broke gpt-5.3-codex for every API-key user after
v2026.6.1.

Remove the transport overrides so the model inherits the provider defaults
(openai-responses + api.openai.com/v1), restoring v2026.5.18 behavior.
OAuth/Codex users are unaffected — dynamic model resolution in
shouldResolveDynamicModelThroughCodex handles the ChatGPT routing based
on provider config, not the static catalog entry.

* ci: retrigger opengrep scan (transient install failure)
2026-06-11 08:42:46 +09:00
Yuval Dinodia
98e239d012 fix(models): keep bundled provider catalog when configured base URL is blank (#91270) (#91292)
* fix(models): keep bundled provider catalog when configured base URL is blank

A models.providers.<id> entry with a blank baseUrl ("") erased the
bundled provider catalog from the generated model registry. The empty
base URL flowed into provider discovery and the catalog merge, where it
overrode the bundled transport URL; the resulting provider then failed
isWritableProviderConfig and was dropped from models.json entirely.

For Google this meant gemini-2.5-flash and gemini-flash-latest stopped
resolving on the embedded runtime with model_not_found, even though the
bundled Google catalog and forward-compat resolver know those ids. This
hit users in merge mode whose config was partially written by a plugin.

Strip blank provider base URLs before discovery and merge so a blank
value means "use the provider default" instead of clobbering it.

Fixes #91270

* test(models): cover blank provider base url

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:40:46 +09:00
Ahmed Tarek
69f31ebb0c 🐛 fix(agents): classify harness provider mismatch as format error (#91710) (#91711)
* fix(agents): classify harness provider mismatch as format error (#91710)

When an agent harness rejects a model because the provider id is not in
its supported set, the error message was unclassified — falling through
to reason="unknown" in the model fallback notice. This made harness
provider mismatches (e.g. stale codex plugin rejecting openai/gpt-5.3-codex)
invisible to the user.

Add a format error pattern for the harness rejection message so the
fallback notice reports "format" instead of "unknown".

* ci: retrigger with real behavior proof in PR body
2026-06-11 08:40:41 +09:00
FMLS
84241461fd feat(cron): add readable ISO time fields to cron runs JSON output (#91471)
Adds `tsIso`, `runAtIso`, and `nextRunAtIso` to the JSON output of the `cron runs` command. This enhancement derives local-offset ISO 8601 strings from the existing numeric timestamps purely at the display layer, matching the diagnostic log format. The underlying SQLite storage, protocol schema, and raw numeric fields remain completely unchanged to ensure strict backward compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 08:37:28 +09:00
Hansraj Singh Thakur
8ff77c8168 fix(gateway): recover config hot-reload after watcher errors (#92027)
A chokidar watcher 'error' permanently disabled config hot-reload with a
single warn. Re-create the watcher with bounded backoff (500ms/2s/5s, 3
retries); on exhausted budget escalate to log.error and flip a persistent
hotReloadStatus to disabled. stop() clears any pending re-create timer.
2026-06-11 08:36:23 +09:00
Yzx
ecf29d74ec fix(opencode-go): add qwen plus tiered pricing (#91351)
* fix(opencode-go): add qwen plus tiered pricing

* fix(usage): tier pricing by total prompt tokens

* fix(pricing): select qwen tiers by prompt tokens

* fix(pricing): preserve tier basis across catalogs

* fix(opencode-go): keep qwen pricing representable

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:36:14 +09:00
Hansraj Singh Thakur
003b3ca6b2 perf(agents): sanitize compaction messages once for token estimation (#92026)
splitMessagesByTokenShare wrapped each message in a 1-element array and
double-cloned it per message. Sanitize the full array once and pass a
precomputed per-message token count array; totals unchanged, allocations
reduced.
2026-06-11 08:30:47 +09:00
Hansraj Singh Thakur
91cc69d70e perf(agents): memoize XML attribute regex in DSML stream parser (#92034)
parseXmlAttribute compiled a fresh RegExp on every call in the DeepSeek DSML
streaming parser. Memoize by attribute name via a module-level Map and escape
the name; behavior unchanged (non-global regex).
2026-06-11 08:30:27 +09:00
Hansraj Singh Thakur
201b5f312f fix(tools): surface unsupported-signal in anyOf availability (#92029)
An anyOf availability group swallowed a nested unsupported-signal authoring
error when any sibling branch parsed as available. Propagate unsupported-signal
diagnostics regardless of sibling availability.
2026-06-11 08:30:04 +09:00
zhang-guiping
feb030f2c6 fix(ui): show prompt progress while sending (#91215)
* fix(ui): show prompt progress while sending

* fix(ui): scope prompt progress to queued send state

* fix(ui): prioritize newer submitted progress

* fix(ui): keep submitted progress authoritative

* fix(ui): preserve current-run terminal status

* test(ui): keep terminal fixture current

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 08:29:44 +09:00
AMARA
f049477dd4 fix(memory): write dream fallback without subagent runtime (#90121) 2026-06-11 08:29:22 +09:00
Ruben Cuevas Menendez
2b89623c50 fix(xai): clarify x_search query guidance (#91163) 2026-06-11 08:29:07 +09:00
Hansraj Singh Thakur
a4a4c76617 fix(sessions): derive channel from direct-chat session keys in send-policy (#92022)
deriveChannelFromKey returned undefined for direct/dm keys, so channel-scoped
send rules never fired for direct chats. Treat parts[0] as the channel for
direct/dm keys too, matching deriveChatTypeFromKey.
2026-06-11 08:28:21 +09:00
Hansraj Singh Thakur
b9e1099f5e fix(gateway): log swallowed background-task finalization errors (#92033)
Background-task finalization swallowed all errors. Log via formatForLog at
warn so non-transient failures are observable, staying non-blocking.
2026-06-11 08:28:03 +09:00
Hansraj Singh Thakur
5d6899c731 fix(mcp): always log channel-bridge notification failures (#92032)
MCP channel-bridge notification failures were only logged when verbose,
otherwise swallowed. Emit one low-noise diagnostic always; gate only the
error detail behind verbose.
2026-06-11 08:27:45 +09:00
zengLingbiao
15498f88fb fix(memory-core): check SQLite plugin state for dreaming ingestion audit after JSON migration (fixes #92017) (#92020)
* fix(memory-core): check SQLite plugin state for dreaming ingestion audit after JSON migration (fixes #92017)

* fix: add SQLite-only regression tests for dreaming ingestion audit (fixes #92017)
2026-06-11 08:26:50 +09:00
Vincent Koc
3659ff8bbf fix(agents): prefer explicit sessions_send keys (#92047)
Honor caller-provided sessionKey values when stale label metadata is also present, and keep denied session-id sends from echoing the resolved canonical session key.

Supersedes openclaw/openclaw#74009 and fixes openclaw/openclaw#64699.

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-11 08:26:25 +09:00
Harjoth Khara
f995f9f411 fix(fal): parse raw completed queue results (#92051) 2026-06-11 08:25:25 +09:00
Truffle
13dce48321 fix(state): tolerate chmod-less state volumes
Keep state database startup working on filesystems without POSIX chmod support while failing closed for ordinary EPERM ownership failures. EPERM is tolerated only for already-private targets or when a disposable same-directory probe confirms chmod is unsupported.\n\nFixes #91919
2026-06-11 08:20:42 +09:00
Seth Moffett
1b23e73830 Issue 89661: add unit test 2026-06-11 00:13:44 +01:00
Seth Moffett
54db3f9df5 Issue 89661: Skill toggle bug fix 2026-06-11 00:13:44 +01:00
Vincent Koc
cae66a7d5b test(ci): restore upgrade survivor session fixture (#92049) 2026-06-11 08:06:19 +09:00
Dallin Romney
2db65e5778 fix(macos): clarify unsupported voice wake UI (#91754) 2026-06-10 15:59:03 -07:00
Dallin Romney
c7ed990769 fix: preserve non-oneOf schema array order (#91891) 2026-06-10 15:58:30 -07:00
Shakker
a450ff036a fix: repair origin main CI failures 2026-06-10 23:49:41 +01:00
Vincent Koc
a7b0d325af fix(sessions): repair shipped stale transcript paths 2026-06-11 07:30:34 +09:00
Vincent Koc
0923ee251e fix(sessions): rewrite migrated transcript paths 2026-06-11 06:41:37 +09:00
Agustin Rivera
e15b646f18 fix(security): block build tool env overrides (#92007) 2026-06-10 14:41:25 -07:00
Alex Knight
3faf669801 Redact tool output secrets (#85196)
* redact tool output secrets

* Expand tool-output secret redaction

* fix(security): keep redaction prefilter in sync with expanded defaults

- build DEFAULT_REDACT_PREFILTER_RE from sources covering every default
  pattern family: new vendor prefixes, webhook hosts, bare query/form keys,
  userinfo/connection-string passwords, and percent/plus/invisible
  obfuscated keys (including trailing separator splices)
- run default-pattern redaction tests through the default options path and
  redact the vendor corpus per token so prefilter gaps fail tests
- fix quoted standalone assignment values containing the other quote char
  or an unterminated quote; never re-mask *** placeholders
- align net-policy URL query-name separator stripping with logging key
  normalization (Hangul fillers)

* fix(security): keep base64-prefix redaction out of media payloads

- pure-base64-alphabet token prefixes (gAAAA, AKIA, ASIA, dapi,
  ATCTT3xFfG, ATATT, ATBB) now require a non-alphanumeric left boundary,
  skip explicit ;base64, payload spans, and run unchunked so chunk starts
  cannot fake the boundary or hide the container from the lookbehind
- tokens after URL/path delimiters or assignments still mask; data-URL
  media survives redaction byte-identical (fixes chat media mirror CI)
- regression tests: tiny-PNG data URL, in-blob plus boundary,
  chunk-aligned large data URL, reset-path Fernet token, path AWS key

---------

Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com>
2026-06-11 07:34:50 +10:00
Vincent Koc
2d404f1b86 fix(release): align survivor session migration assertion 2026-06-11 05:57:55 +09:00
Vincent Koc
8042ec4cb8 fix(qa): scope runtime parity mock requests 2026-06-11 05:19:10 +09:00
anagnorisis2peripeteia
f1f00cbf1d fix: capture cron wake origin session
Capture the originating sessionKey and agentId for cron wake tool calls so non-main session and multi-agent wakes return to the conversation lane that requested them.

Carry stored delivery context through queued wake events so topic/thread replies route correctly, while preserving the default no-origin wake behavior and explicit target:none opt-out.

Refs #46886.
Refs #64556.
Thanks @anagnorisis2peripeteia.

Co-authored-by: Cameron Beeley <cameron.beeley@gmail.com>
2026-06-10 20:52:40 +01:00
Vincent Koc
e6b0a22f36 test(update): align corrupt plugin repair guidance 2026-06-11 04:33:25 +09:00
Colin
2129d5b3ab fix(control-ui): refine mobile chat layout 2026-06-10 20:03:13 +01:00
Vincent Koc
797aab5ab7 test(memory): create rem preview source fixture 2026-06-11 03:52:40 +09:00
Shakker
d7f211b2b4 test: clarify usage cache refresh proof 2026-06-10 19:25:35 +01:00
Edward Abrams
e6e27c354c perf(usage-cost-cache): throttle full-cache rewrites during refresh
refreshCostUsageCache rewrote the entire .usage-cost-cache.json after every
single scanned session. With ~8190 stale session files and a 108MB cache, that
was O(N * cacheSize) work — sustained CPU burn from repeated 100MB+
JSON.stringify + atomic-replace cycles every refresh.

Checkpoint policy now batches durable writes:
  - At most one rewrite per 256 scanned files
  - Or one rewrite per 5s of wall time
  - Final write only when something actually changed (no-op refresh on a
    fully-fresh cache no longer rewrites the file)

Crash safety is preserved: an interrupted refresh still has a recent
checkpoint on disk, and the next run rescans only the unfinished tail
(file size + mtime + pricingFingerprint match).

Validation:
  - pnpm vitest run src/infra/session-cost-usage.test.ts (39/39 pass)
  - New test 'throttles cache writes during a large stale refresh' confirms
    cache renames stay below sessionCount/4 (was ~sessionCount+1) and that
    a no-op refresh issues zero cache writes.
  - pnpm check:changed (clean)

Beads: openclaw-0zr
2026-06-10 19:25:35 +01:00
Shakker
a69dd43de5 docs: document command timeout result code 2026-06-10 19:21:35 +01:00
NVIDIAN
781e03a179 fix(process): return timeout code for killed commands 2026-06-10 19:21:35 +01:00
Vincent Koc
fbb1eab4c7 fix(release): raise package scan headroom 2026-06-11 03:07:13 +09:00
Vincent Koc
5d2eb3c3e1 fix(release): stabilize candidate validation 2026-06-11 03:04:02 +09:00
Vincent Koc
aeb537c286 test(reply): type direct delivery payloads 2026-06-11 02:59:32 +09:00
Vincent Koc
caa83b0c42 fix(reply): retain direct fragment boundaries 2026-06-11 02:59:32 +09:00
Vincent Koc
c944986c7b fix(reply): preserve meaningful final whitespace 2026-06-11 02:59:32 +09:00
Vincent Koc
a427aff304 test(agents): expose assistant message index 2026-06-11 02:59:32 +09:00
Vincent Koc
32f8411256 test(agents): update embedded subscription fixture 2026-06-11 02:59:32 +09:00
Vincent Koc
40da4a31d2 fix(reply): preserve direct block emission order 2026-06-11 02:59:32 +09:00
Vincent Koc
ea7e5ee436 fix(reply): preserve direct block emission order 2026-06-11 02:59:32 +09:00
Vincent Koc
756e9d16a0 fix(reply): carry final assistant message identity 2026-06-11 02:59:32 +09:00
Vincent Koc
80dc6cf6ab fix(reply): use latest direct assistant group 2026-06-11 02:59:31 +09:00
Vincent Koc
8f82f5809f fix(reply): match direct fragments to final messages 2026-06-11 02:59:31 +09:00
Vincent Koc
389c0aba98 fix(reply): group direct block delivery evidence 2026-06-11 02:59:31 +09:00
Vincent Koc
41000143a1 fix(reply): dedupe directly sent block media 2026-06-11 02:59:31 +09:00
Vincent Koc
a1546fae80 fix(reply): preserve repeated direct block fragments 2026-06-11 02:59:31 +09:00
Vincent Koc
551664c3c5 fix(reply): dedupe combined direct block text 2026-06-11 02:59:31 +09:00
Vincent Koc
7a4f678a29 test(reply): assert unmatched final text 2026-06-11 02:59:31 +09:00
Vincent Koc
c7c695c208 fix(reply): preserve unmatched final text 2026-06-11 02:59:31 +09:00
Vincent Koc
9ef34da5a9 fix(reply): cover direct streaming block media 2026-06-11 02:59:31 +09:00
Vincent Koc
9922da3965 fix(reply): dedupe media finals after direct blocks 2026-06-11 02:59:31 +09:00
Ted Li
50c2068268 fix(cron): use final-call usage for session token totals (#91737)
* fix: use final-call usage for cron session totals

* fix(cron): preserve usable token snapshots

* fix(cron): derive token snapshots with fallback

* fix(cron): separate session and run token totals

* fix(cron): derive cumulative telemetry totals

* fix(cron): include cached usage in run totals

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 02:39:06 +09:00
Yuval Dinodia
b0520fa320 fix(cron): reject cron expressions that have no reachable run time (#91688)
* fix(cron): reject cron expressions with no reachable run time

A structurally valid but never-matching cron expression (for example
"0 0 30 2 *", Feb 30) was accepted at registration and then silently never
fired: the job showed as enabled but never ran, never appeared in the run
log, and was never auto-disabled.

croner constructs these expressions without error and nextRun() returns null,
so computeNextRunAtMs returns undefined with no throw, and the scheduler
treated it as "no work to do".

Add a satisfiability assertion at the shared service validation boundary
(createJob and applyJobPatch) so every create/update path and every caller
(gateway RPC, CLI, and the in-process host-hook and dreaming callers that
bypass the RPC layer) rejects an expression with no upcoming run time.
applyJobPatch only asserts when the patch changes the schedule, so a job that
predates this change can still be disabled or repaired. The thrown message is
classified by the gateway as INVALID_REQUEST.

* fix(cron): validate enabled schedules with service clock

* fix(cron): keep schedule validation service-owned

* fix(cron): validate staggered schedule reachability

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 02:39:00 +09:00
Vincent Koc
cdd923c286 docs(changelog): refresh 2026.6.6 notes 2026-06-11 02:34:35 +09:00
Vincent Koc
09356fb985 docs(release): align remaining patch placeholders 2026-06-11 02:33:19 +09:00
Vincent Koc
fb9dc867b1 docs(release): fix sequential patch numbering 2026-06-11 02:31:50 +09:00
Hemant Sudarshan
2e4bf410a2 fix(browser): honor cdp url for default session profile (#80143) 2026-06-10 12:36:11 -04:00
Andy Ye
33a3e05683 fix(webchat): finalize provider failure lifecycle (#91895)
* fix(webchat): finalize provider failure lifecycle

* chore: narrow fallback failure lifecycle marker

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-10 12:33:21 -04:00
Devin Robison
49737a50af Fail closed on exec approval timeout (#89938)
* Fail closed on exec approval timeout

* Align exec approval fallback default docs
2026-06-10 10:32:54 -06:00
kun9053
78a5e3ede7 fix(agents): preserve reasoning_content replay for Gemma 4 openai-completions models (#91645) (#91696)
Gemma 4 models via openai-completions (vLLM, OpenRouter, etc.) were
silently losing in-turn reasoning_content during multi-turn tool replay.
Unlike DeepSeek/Xiaomi which 400 on the missing field, these providers
accept the request but produce degraded tool-call quality — arguments
collapse to {} and identical tool calls repeat.

Root cause: two places explicitly excluded Gemma 4 from reasoning replay:

1. shouldTrustReasoningContentReplayMetadata() in openai-transport-stream
   skipped Gemma 4 by model ID, causing sanitizeCompletionsReasoningReplayFields
   to strip reasoning_content before sending.

2. buildOpenAICompatibleReplayPolicy() in provider-replay-helpers forced
   dropReasoningFromHistory=true for Gemma 4, discarding reasoning blocks
   during history sanitization before they reached the transport layer.

Remove both exclusions. Gemma 4 on non-OpenAI providers now preserves
in-turn reasoning through the existing provider/metadata checks that
already gate this correctly for other reasoning models.

Co-authored-by: wangyk <prowangyankun@foxmail.com>
2026-06-11 01:30:59 +09:00
shushushu
7926acee98 fix(talk): show OpenAI Realtime WebRTC assistant transcripts (#90426)
* fix(talk): show OpenAI Realtime WebRTC assistant transcripts

Handle current OpenAI Realtime assistant transcript events on the direct
WebRTC transport (response.output_audio_transcript.delta/done,
response.output_text.*), keep legacy response.audio_transcript.*
compatibility, and dedup duplicate assistant bubbles in the shared
conversation aggregation so both webrtc and gateway-relay benefit.

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

* fix(talk): drop unsafe transcript rewrite heuristic

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 01:16:21 +09:00
Vincent Koc
17be26bc4f fix(gateway): arm qmd startup maintenance
Fix Gateway QMD startup so interval and embedding maintenance are armed when configured, even when the immediate on-boot update is disabled.
2026-06-11 01:13:41 +09:00
weiqinl
7a34986ef0 fix(mcp): repair OAuth redirect, errors, and unicode schema patterns (#91451)
* fix(mcp): repair OAuth redirect, errors, and unicode schema patterns

Fixes #91433

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): address PR review on redirect compat and OAuth errors

Preserve 127.0.0.1 default, retry DCR with localhost on rejection,
fix lint no-new probes, and test body-less OAuth error responses.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): preserve OAuth redirect and error body text

Persist localhost redirect after DCR fallback so --code exchange matches,
and read body-less foreign response text before SDK error parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): harden OAuth and schema normalization

* fix(mcp): persist only successful OAuth fallback

* fix(mcp): preserve OAuth retry state

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-11 01:10:20 +09:00
Jason (Json)
e24c3df27d fix(auth): verify SQLite auth migration before cleanup
Verify imported auth profiles and auth runtime state can be read back from the canonical SQLite store before doctor removes legacy JSON auth files.

The migration now keeps legacy JSON in place with a warning when SQLite verification misses expected imported data, avoiding source cleanup before durable readback succeeds.

Verification:
- `scripts/pr review-tests 91740 src/commands/doctor-auth-flat-profiles.test.ts`
- `./node_modules/.bin/oxfmt --check src/commands/doctor-auth-flat-profiles.ts src/commands/doctor-auth-flat-profiles.test.ts`
- `./node_modules/.bin/oxlint src/commands/doctor-auth-flat-profiles.ts src/commands/doctor-auth-flat-profiles.test.ts`
- `scripts/pr-prepare gates 91740` passed `pnpm build` and `pnpm check`; broad local `pnpm test` failed in unrelated baseline shards and was explicitly waived by maintainer.
- Real behavior proof: https://github.com/openclaw/openclaw/actions/runs/27285927515/job/80593035530

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: Radek Sienkiewicz <velvet-shark@users.noreply.github.com>
2026-06-10 17:18:18 +02:00
Vincent Koc
dbcae5b78d fix(memory): keep ignored-name QMD roots watchable
Fix QMD watcher ignore handling for explicitly configured roots whose directory names are normally ignored, and prefer the most-specific configured watch root for overlapping collections.

Validated with focused QMD/tooling tests, full core support boundary tests, green CI, and ClawSweeper re-review.
2026-06-11 00:12:15 +09:00
Peter Steinberger
0e7b5c3429 feat(anthropic): support Claude Fable 5 adaptive thinking (#91882)
* feat(anthropic): support Claude Fable 5

* test(anthropic): tighten Fable stream fixtures

* fix(anthropic): preserve Vertex input types

* test(anthropic): use provider-ready Vertex effort

* fix(anthropic): support Fable deployment aliases

* fix(anthropic): discard incomplete Fable output

* feat(anthropic): support Fable on Bedrock

* fix(anthropic): preserve Fable reasoning contracts

* refactor(anthropic): unify canonical Claude model policy

* fix(anthropic): satisfy extension thinking types

* test(anthropic): complete canonical alias fixture

* fix(bedrock): scope thinking case declarations
2026-06-10 08:08:35 -07:00
Agustin Rivera
ade5ac0350 fix(browser): validate discovered CDP websocket URLs (#91747)
* fix(browser): validate discovered cdp websocket urls

* fix(browser): validate cdp tab creation websockets

* fix(browser): guard termination cdp websocket

* fix(browser): use .toString() instead of String() to satisfy oxlint no-base-to-string

* fix(browser): avoid cdp termination assertion stringification

* fix(browser): preserve cdp ssrf policy
2026-06-10 07:59:29 -07:00
lifuyue
ac21e89c13 Support existing-session browser CDP endpoints (#91736)
* Support existing-session browser CDP endpoints

* Fix browser existing-session test fixture type

---------

Co-authored-by: OpenAI Codex <codex@openai.com>
2026-06-10 10:44:35 -04:00
brokemac79
b71d8e1c32 fix(sandbox): use materialized skill paths in startup prompts (#91791)
* fix(sandbox): use materialized skill paths in command prompts

* fix(sandbox): resolve backend prompt workdirs

* fix(sandbox): preserve custom backend prompt fallback

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-06-10 23:35:34 +09:00
Vincent Koc
4ecec2f9e2 test(tooling): isolate pnpm fallback path 2026-06-10 23:11:21 +09:00
wangmiao0668000666
bb6e47729c fix(compaction): lower default timeout from 900s to 180s, preserve explicit config (#91361)
Merged via squash.

Prepared head SHA: ac545967f9
Co-authored-by: wangmiao0668000666 <290215524+wangmiao0668000666@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-06-10 14:51:54 +02:00
Ayaan Zaidi
050c0813b3 docs(streaming): document Telegram block-mode preview chunk sizing 2026-06-10 16:30:25 +05:30
Ayaan Zaidi
7200c316e5 refactor(telegram): drop NO_REPLY precount debug logging 2026-06-10 16:30:25 +05:30
Ayaan Zaidi
3078a8335d refactor(telegram): trust grammy api contract in handler seams 2026-06-10 16:30:25 +05:30
Ayaan Zaidi
709855d623 fix(telegram): delete retired dispatch dedupe buckets after doctor import 2026-06-10 16:30:25 +05:30
Ayaan Zaidi
272b599369 feat(telegram): size block-mode preview chunks from streaming.preview.chunk 2026-06-10 16:30:25 +05:30
Sally O'Malley
05a3b44c93 fix(telegram): block unauthorized DM text from cache and prompt context (#91478)
Move Telegram inbound authorization into a single pre-cache gate so unauthorized DM text is never recorded into the reply-chain cache or dispatch-dedupe state before allowlist/pairing checks run. The gate covers fresh messages, edited messages, group edits, and edited channel posts; edits are authorized silently and never trigger pairing challenges, and requireTopic root DMs are dropped before pairing challenges.

Fixes #91209.

Thanks @sallyom!
2026-06-10 15:50:32 +05:30
Ayaan Zaidi
c84e521920 refactor(discord): distill reply hydration tests 2026-06-10 14:54:32 +05:30
FullerStackDev
86215c9634 test(discord): avoid unbound hydration mock 2026-06-10 14:54:32 +05:30
FullerStackDev
514ac29d03 test(discord): cover reply hydration fetch failures 2026-06-10 14:54:32 +05:30
FullerStackDev
b08eef0508 fix(discord): hydrate reply context metadata 2026-06-10 14:54:32 +05:30
Ayaan Zaidi
9c6186de43 fix(telegram): bound dispatch dedupe state 2026-06-10 14:52:22 +05:30
Ayaan Zaidi
0d41316bcc fix(state): preserve ttl for plugin-state imports 2026-06-10 14:52:22 +05:30
Ayaan Zaidi
9297c20a85 fix(telegram): use SDK dispatch replay dedupe 2026-06-10 14:52:22 +05:30
Ayaan Zaidi
b2dec9cd50 feat(plugin-sdk): expose persistent dedupe import helpers 2026-06-10 14:52:22 +05:30
Ayaan Zaidi
a283df4bfe refactor(update): simplify refresh failure restart path 2026-06-10 14:48:30 +05:30
FullerStackDev
cab62324f4 fix(update): recover package gateway restart after refresh failure 2026-06-10 14:48:30 +05:30
Ayaan Zaidi
1ec04c6baa refactor(update): unify finalization command registration 2026-06-10 14:43:46 +05:30
FullerStackDev
f0206d935a fix(update): avoid repair command lint shadow 2026-06-10 14:43:46 +05:30
FullerStackDev
800f4dcb62 fix(update): expose plugin convergence repair 2026-06-10 14:43:46 +05:30
Vincent Koc
43bbde4830 fix(build): respect PATH-less pnpm environments 2026-06-10 18:06:31 +09:00
Vincent Koc
7d3e8dc963 test(qa): restore memory fallback config safely 2026-06-10 18:03:15 +09:00
Vincent Koc
3b13d6ae38 fix(build): fall back to Corepack for pnpm 2026-06-10 18:03:15 +09:00
Vincent Koc
69aca06e02 test(ci): assert larger QA runners 2026-06-10 18:02:10 +09:00
Vincent Koc
0766a5c2ec fix(qa): corrupt Matrix idb state in sqlite 2026-06-10 17:49:59 +09:00
Vincent Koc
2c146261a2 fix(qa): accept completed mock image turns 2026-06-10 17:48:33 +09:00
Vincent Koc
87abb8defb fix(qa): preserve Matrix recovery state in sqlite 2026-06-10 17:35:41 +09:00
Vincent Koc
3b180d5d99 fix(qa): wait for restart wake before capability check 2026-06-10 17:09:18 +09:00
Ayaan Zaidi
b9095bf70d refactor(channel): share draft chunking resolver 2026-06-10 13:35:19 +05:30
Ayaan Zaidi
049c3c4877 test(telegram): cover callback API metadata 2026-06-10 13:29:11 +05:30
Ayaan Zaidi
1265da2a5c fix(telegram): use concrete callback API calls 2026-06-10 13:29:11 +05:30
Josh Avant
cfdabfbaab Fix stale visible reply recovery (#91840)
* fix visible reply stale recovery

* fix visible recovery lint loop

* fix visible reply registry recovery

* test: cover failed visible recovery admission
2026-06-10 02:56:15 -05:00
Vincent Koc
e3fe6715af test(sessions): allow canonical recovery path aliases 2026-06-10 16:46:06 +09:00
Vincent Koc
0948bd648a test(e2e): widen kitchen sink RPC coverage 2026-06-10 16:42:47 +09:00
Vincent Koc
d07cd4c968 fix(ci): give QA builds larger runners 2026-06-10 16:42:24 +09:00
openclaw-clownfish[bot]
db5b883a9c fix(ci): include ACPX in shared live-test image
* fix(ci): include ACPX in shared live-test image

* fix(clownfish): address review for clawsweeper-commit-openclaw-openclaw-806a0119f3cd (1)

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-10 16:40:25 +09:00
Vincent Koc
fd7e181500 fix(ci): raise QA build heap limit 2026-06-10 16:35:34 +09:00
Ayaan Zaidi
3407402b2c test(plugins): guard dedicated channel sidecars 2026-06-10 13:05:24 +05:30
Ayaan Zaidi
05a0dfdd08 refactor(extensions): split channel contract sidecars 2026-06-10 13:05:24 +05:30
Ayaan Zaidi
7f9f687d82 refactor(channels): remove bundled contract fallbacks 2026-06-10 13:05:24 +05:30
Vincent Koc
7f1d82ab25 revert(sessions): defer session metadata sqlite
Reverts 538d36eaaa while preserving subsequent main changes. The beta-only SQLite downgrade rescue and reverse migration remain excluded.
2026-06-10 16:34:06 +09:00
Andy Ye
9408380ae7 fix(memory-core): keep qmd json search one-shot (#91837) 2026-06-10 16:30:15 +09:00
Vincent Koc
0a3aa5f278 fix(test): add session ids to Slack fixtures 2026-06-10 16:18:43 +09:00
Vincent Koc
a3d5e5bc72 fix(test): support macOS Bash 3 script suites 2026-06-10 15:37:15 +09:00
Vincent Koc
7cb2571a99 test(sessions): migrate fixtures to sqlite store 2026-06-10 15:35:10 +09:00
Vincent Koc
7b7e8f6e88 test(memory): stop asserting private sync return 2026-06-10 15:23:58 +09:00
Vincent Koc
60459d4061 test(chutes): normalize discovery request headers 2026-06-10 15:22:43 +09:00
Vincent Koc
54288a1e7c test(imessage): align echo cache assertion 2026-06-10 15:20:05 +09:00
brokemac79
de4b8d8ebf feat(plugins): allow installed trusted policy contracts
Allow explicitly enabled installed plugins to register declared trusted tool policies and agent tool result middleware, with trusted policy ids scoped by plugin owner.\n\nVerification covered targeted plugin/agent tests, typecheck, build, lint, local autoreview, and a Blacksmith Testbox runtime proof (tbx_01ktr1nq0rhq47fjkwrepm7fd3).
2026-06-10 16:18:23 +10:00
Vincent Koc
52bc2a12bc fix(ci): disable memory slot in release smoke config 2026-06-10 14:56:21 +09:00
Vincent Koc
ca4e4d93d2 fix(ci): use cursor pagination for closed issues 2026-06-10 14:53:40 +09:00
Vincent Koc
92418fc9da fix(memory-core): filter stale recall entries in REM harness preview 2026-06-10 14:35:44 +09:00
Ayaan Zaidi
c7b4c6bfc5 fix(gateway): handle missing launchd gui domains 2026-06-10 10:51:19 +05:30
FullerStackDev
c39dea917a fix(gateway): route headless doctor hints 2026-06-10 10:51:19 +05:30
FullerStackDev
40aad24e8a fix(gateway): surface headless LaunchAgent state 2026-06-10 10:51:19 +05:30
Vincent Koc
69a73b6278 chore(codex): bump app-server to 0.139.0 2026-06-10 14:14:53 +09:00
openclaw-clownfish[bot]
54c400a975 fix(plugin-sdk): refresh API baseline hash 2026-06-10 14:12:38 +09:00
Patrick Erichsen
e9671ed603 feat: feature openrouter in onboarding provider picker 2026-06-09 21:59:56 -07:00
kenny
b9280d5863 feat: add OpenRouter OAuth login
(cherry picked from commit dccfb60656)
2026-06-09 21:59:56 -07:00
Vincent Koc
b4cdd92119 fix(codex): avoid guardian review for local models (#88630)
* fix(codex): avoid guardian review for local models

* fix(codex): route app-server auto exec review

* fix(codex): make guardian requirements provider-aware

* fix(codex): block unrouted bound approvals

* fix(channels): satisfy ingress queue lint

* fix(codex): use local-model policy for side forks

* fix(extensions): satisfy ingress lint

* fix(codex): require trusted exec reviewer model

* fix(exec): share control command approval guards

* fix(codex): fail closed for unknown guardian model provider

* fix(codex): reject custom exec reviewer endpoints

* fix(codex): preserve bound providers on app-server reuse

* fix(codex): prefer qualified app-server model providers

* fix(codex): preserve guardian on model control switches

* fix(codex): retain local providers across model switches

* fix(codex): distrust aliased reviewer model refs

* fix(codex): preserve providers after thread rotation

* fix(codex): clear stale providers on qualified model switches

* fix(codex): prefer qualified models over legacy providers

* fix(codex): validate reviewer trust before auto approvals

* fix(codex): recompute reviewer policy after binding rotation

* fix(codex): normalize reviewer aliases before trust checks

* fix(codex): retain bound providers for slashed local models

* fix(codex): normalize provider trust checks for exec review

* fix(codex): ignore stale bindings for explicit providers

* fix(codex): share trusted reviewer endpoint policy

* fix(codex): keep network approvals on plugin path

* fix(codex): route provider-qualified model refs

* fix(codex): reject blank masked OpenAI base overrides

* fix(codex): scope exec reviewer alias trust

* fix(codex): distrust exec reviewer transport overrides
2026-06-09 21:38:22 -07:00
Patrick Erichsen
5a0b95269d docs: add plugin validation fixes guide (#91819) 2026-06-09 21:14:17 -07:00
dependabot[bot]
69b95c3447 chore(deps): bump useblacksmith/setup-docker-builder (#91666)
Bumps the actions group with 1 update: [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder).


Updates `useblacksmith/setup-docker-builder` from 1.8.0 to 1.9.0
- [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases)
- [Commits](722e97d12b...ab5c1da94f)

---
updated-dependencies:
- dependency-name: useblacksmith/setup-docker-builder
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 20:57:06 -07:00
Colin Johnson
bf89552e67 Improve iPad and iPhone control surfaces (#91557)
* feat(ios): expand iPad layout support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat: improve iPad and iPhone control surfaces

* fix: preserve workboard dispatch compatibility

* fix: keep Talk reachable on iPad

* fix: add universal iPad app icons

* fix: address ready-review iOS feedback

* fix: avoid workboard board id shadowing

* fix ios sidebar separators

---------

Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-06-09 21:46:02 -05:00
Josh Avant
a8d33f23a0 Fix context-engine compaction ownership for Codex sessions (#91590)
* fix(agents): keep context-engine compaction primary

* fix(codex): request native compaction after context engines

* test: cover 90496 compaction and reset edge cases

* fix(codex): guard secondary native compaction binding

* fix(codex): keep native compaction hint internal

* fix(codex): wait for active native turns before resume
2026-06-09 21:33:00 -05:00
Omar Shahine
6c045c5ca3 fix(imessage): surface inbound startup diagnostics (#91785)
Merged via squash.

Prepared head SHA: 597684c365

Proof:
- Focused tests, lint/type/diff checks, and autoreview passed before merge.
- ClawSweeper re-review marked proof and patch quality platinum after lobster live monitor proof.
- Maintainer accepted the diagnostics-only default-log privacy/noise tradeoff.

Lobster proof id: openclaw-lobster-live-monitor-proof-ada22165-6306-46b6-8ed0-6c94fcab6bbc

Reviewed-by: @omarshahine
2026-06-09 19:10:09 -07:00
Omar Shahine
bfccbc3fee fix(imessage): harden outbound send transport (#91783)
Merged via squash.

Prepared head SHA: 39ea25767b

Proof:
- Focused tests, docs/config generation, lint/type/doc checks passed before merge.
- ClawSweeper re-review marked proof and patch quality platinum after lobster live send proof.
- Maintainer accepted the `channels.imessage.sendTransport` config surface and compatibility-risk tradeoff.

Lobster proof id: openclaw-lobster-live-proof-c74895c2-b629-4bb0-abcb-e6521069b3d8

Reviewed-by: @omarshahine
2026-06-09 19:09:15 -07:00
Vincent Koc
9a1f2022b1 fix(security): avoid crypto hash for oauth lock names 2026-06-10 09:54:38 +09:00
Vincent Koc
5967ae61bd fix(security): audit oauth lock hash 2026-06-10 09:32:32 +09:00
Vincent Koc
48ec58a584 fix(security): remediate openclaw alerts 2026-06-10 09:02:00 +09:00
openclaw-clownfish[bot]
c0a4a7890d fix(doctor): keep TTS legacy migration on supported paths (#91787)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-10 08:54:36 +09:00
Vincent Koc
0a6a10193d fix(release): guard Parallels skip-restore lanes 2026-06-10 08:27:59 +09:00
Vincent Koc
c350c35fad fix(release): allow QA capability restore patch
(cherry picked from commit db711701d2)
2026-06-10 08:27:59 +09:00
Vincent Koc
56dc53f6d2 fix(release): harden Parallels smoke validation
(cherry picked from commit 810a821c65)
2026-06-10 08:27:59 +09:00
Dallin Romney
ec0f311f7f fix(config): clarify retired skill workshop plugin warning (#91757) 2026-06-09 16:26:02 -07:00
Agustin Rivera
f0d8048aa3 fix(search): enforce native web search tool policy (#91750)
* fix(search): enforce native web search tool policy

* fix(search): apply session policy to native web search

* fix(search): gate direct OpenAI native search

* fix(search): redact native web search provider context
2026-06-09 16:25:15 -07:00
openclaw-clownfish[bot]
54415d322f fix(ui): drain restored chat queue after session switch (#91780)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com>
2026-06-10 08:20:12 +09:00
colmbrogan
3a9ea1d85b fix(imessage): skip idle approval discovery scans (#88530)
* fix(imessage): bound idle approval discovery scans

* fix(imessage): complete bounded approval discovery

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
2026-06-09 16:03:48 -07:00
Michael Appel
a90eb93452 Harden sandbox bind source validation (#91741) 2026-06-09 15:59:03 -07:00
clawsweeper[bot]
468db12c21 fix(mcp): lowercase SSE event-source header keys to prevent duplicate Authorization (401) (#91773)
Summary:
- The branch lowercases SSE EventSource SDK and operator header keys before merging and adds a regression test for duplicate case-variant Authorization headers.
- PR surface: Source 0, Tests +59. Total +59 across 2 files.
- Reproducibility: yes. Source inspection shows current main can preserve both lowercase `authorization` from  ... K EventSource hook and configured `Authorization`, and the PR adds a focused regression test for that path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(mcp): lowercase SSE event-source header keys to prevent duplicate…

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

Prepared head SHA: c8f7a7940e
Review: https://github.com/openclaw/openclaw/pull/91773#issuecomment-4664644390

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-06-09 22:52:29 +00:00
Patrick Erichsen
4ca6ac326e docs: remove superpowers spec draft 2026-06-09 15:51:37 -07:00
Andy Ye
9833f3ea9b fix(ui): require user intent for chat sessions (#91480)
Summary:
- The PR adds an explicit user-intent argument to `createChatSession`, updates the New Chat and `/new` action callers to pass it, adds helper regression coverage, and carries minor gateway formatting/import ordering churn.
- PR surface: Source +8, Tests +9. Total +17 across 8 files.
- Reproducibility: yes. at source level: current main lets `createChatSession(state)` reach `sessions.create`  ... ct flow, so the exact user-path reproduction remains integration-level rather than locally reproduced here.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(tasks): restore timers before maintenance apply
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'origin/main' into HEAD

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

Prepared head SHA: e7cd79006b
Review: https://github.com/openclaw/openclaw/pull/91480#issuecomment-4651778423

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@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-06-09 22:40:07 +00:00
Jacob Tomlinson
e9bd90d209 docs(security): clarify env var report scope (#91765) 2026-06-10 06:14:24 +09:00
Agustin Rivera
314de694c4 fix(mcp): harden stdio env filtering (#91751) 2026-06-09 14:08:47 -07:00
Shakker
4648701fc1 chore: fix ACP guard lint issues 2026-06-09 22:07:05 +01:00
Shakker
3b7631e50d perf: bound ACP metadata key repair lookup 2026-06-09 22:07:05 +01:00
Shakker
21104cd52e test: use valid deleted ACP bridge fixture 2026-06-09 22:07:05 +01:00
Shakker
6ab084e89d test: use valid configured ACP bridge fixture 2026-06-09 22:07:05 +01:00
Shakker
e16193bad5 fix: skip ACP metadata probe for configured agents 2026-06-09 22:07:05 +01:00
Shakker
31d49c59d7 test: require ACP metadata in resolve unit fixture 2026-06-09 22:07:05 +01:00
Shakker
ef2ca9e50e fix: repair ACP metadata in deleted-agent guard 2026-06-09 22:07:05 +01:00
Shakker
09854d9de7 fix: make ACP metadata key repair idempotent 2026-06-09 22:07:05 +01:00
Shakker
d31d26ef42 fix: validate migrated ACP metadata at canonical key 2026-06-09 22:07:05 +01:00
Shakker
440284f879 fix: rekey ACP metadata during session key migration 2026-06-09 22:07:05 +01:00
Shakker
784e86433c fix: preserve ACP metadata key during deleted-agent checks 2026-06-09 22:07:05 +01:00
Shakker
a82abc771a fix: align session resolve deleted-agent entry type 2026-06-09 22:07:05 +01:00
Shakker
a93bc61a84 fix: read canonical ACP metadata for deleted-agent guard 2026-06-09 22:07:05 +01:00
Shakker
b502a92bf1 fix: require ACP metadata for deleted-agent bypass 2026-06-09 22:07:05 +01:00
Agustin Rivera
21410d1c32 fix(codex): guard sandbox http requests (#91752)
* fix(codex): guard sandbox http requests

* fix(codex): align sandbox http policy
2026-06-09 13:54:24 -07:00
Agustin Rivera
a4e02cd1dd fix(elevated): reject group ids as senders (#91748)
* fix(elevated): reject group ids as senders

* fix(elevated): keep channel parsing out of core
2026-06-09 13:20:36 -07:00
Agustin Rivera
b6a3f2988c fix(gateway): restrict non-owner loopback tools (#91749)
* fix(gateway): restrict non-owner loopback tools

* fix(gateway): split loopback owner cache key
2026-06-09 13:15:48 -07:00
Alex Knight
bf95883812 feat(diagnostics-otel): capture tool input/output content via trusted channel (#91256)
diagnostics.otel.captureContent.{toolInputs,toolOutputs} were documented
and config-wired but never produced any span content. Emit tool args and
results over the trusted private-data diagnostic channel (mirroring the
model-content path), and have the OTel exporter bound/redact/truncate them
before span export. Raw tool content never rides the public event bus.

Scope: core embedded-runner tool path (canonical producer). Codex
(async-batched) and Claude CLI remain follow-ups tracked by the issue.

Refs #77391
2026-06-10 05:52:52 +10:00
Agustin Rivera
d2ddc26e89 fix(msteams): require admin for group actions (#91746) 2026-06-09 12:52:24 -07:00
Niels Kaspers
96a49caffa docs: clarify trusted-proxy websocket scopes (#85950) 2026-06-09 12:40:12 -07:00
Agustin Rivera
2649064548 fix(discord): require sender for moderation actions (#91745) 2026-06-09 12:33:38 -07:00
Dallin Romney
370cef2e3b docs: align Feishu DM policy defaults (#91755) 2026-06-09 12:31:47 -07:00
Dallin Romney
a2dd821908 docs: clarify matrix plugin upgrade repair (#91753) 2026-06-09 12:22:59 -07:00
Shubhankar Tripathy
443115c632 fix(config): warn for retired skill-workshop plugin entry instead of failing validation (#90244) (#90838) 2026-06-09 12:20:34 -07:00
openclaw-release-bot
5b9cb3bd3a chore(release): update appcast for 2026.6.5 2026-06-09 19:13:54 +00:00
Dallin Romney
8b84e951e5 perf(tui): prewarm runtime plugins before first send (#90782)
* perf: prewarm TUI runtime plugins before first send

* fix: satisfy TUI prewarm lint

* fix(tui): clarify runtime warmup submit block

* refactor(tui): warm embedded runtime during history load

* fix(tui): align runtime prewarm workspace
2026-06-09 11:30:28 -07:00
scotthuang
52154eda0d fix: preserve configured ACP deleted-agent guard 2026-06-09 18:31:40 +01:00
scotthuang
3853eb15af test(gateway): add store integration proof for ACP deleted-agent guard 2026-06-09 18:31:40 +01:00
scotthuang
696c1ecd20 fix(gateway): skip deleted-agent guard for ACP harness session keys
ACP session keys use agent:<harnessId>:acp:<uuid>, so sessions_send and
sessions.resolve must not treat harness ids as agents.list owners.
2026-06-09 18:31:40 +01:00
NVIDIAN
2da7dc9f2c test: cover auto-enable cache freshness 2026-06-09 18:08:28 +01:00
NVIDIAN
439b0582e2 perf(config): avoid stale implicit auto-enable cache 2026-06-09 18:08:28 +01:00
NVIDIAN
f13b6ea151 perf(config): dedupe plugin auto-enable fanout work 2026-06-09 18:08:28 +01:00
Shakker
56d201fa67 fix: retry workflow sanity checkout fetches 2026-06-09 17:39:01 +01:00
Shakker
9bb68b55dd fix: avoid gateway restart for tui footer config 2026-06-09 17:35:03 +01:00
Shakker
d48778994f fix: gate tui host footer behind config 2026-06-09 17:35:03 +01:00
WB
479e2aaae3 fix(tui): show connection host in footer 2026-06-09 17:35:03 +01:00
brokemac79
1893a0727a fix(status): restore Codex synthetic usage line
Restores the Codex/OpenAI usage line in status by routing Codex-harness usage through the Codex app-server provider hook. Preserves configured app-server startup options, selected OpenAI/Codex auth profiles, weekly-window cadence, and Codex credit wording while skipping unsupported API-key usage probes. Fixes #91694.
2026-06-10 01:32:33 +09:00
Shakker
61e93a800d fix: bound model catalog state cache 2026-06-09 17:27:33 +01:00
Shakker
da7f9c51df fix: isolate model catalog cache contexts 2026-06-09 17:27:33 +01:00
Shakker
55156a1241 fix: keep model catalog cache keys current 2026-06-09 17:27:33 +01:00
ai-hpc
cc856cde1b fix(models): refresh persisted catalog cache keys 2026-06-09 17:27:33 +01:00
ai-hpc
4106f446bd fix(models): persist agent catalog cache 2026-06-09 17:27:33 +01:00
Jacob Tomlinson
8c3ba33463 fix(mattermost): keep default replies in existing threads
Restores the documented Mattermost default where replyToMode="off" does not start new threads for top-level messages, but still preserves replies that arrive inside an existing Mattermost thread.

Manual Mattermost proof and focused monitor tests cover threaded channel replies, top-level off-mode messages, and direct messages.
2026-06-09 17:08:51 +01:00
Pavan Kumar Gondhi
86bab9699d fix: block git protocol env controls [AI] (#91619)
* fix: block git protocol env controls

* fix: preserve restrictive git protocol env

* fix: preserve restrictive git allowlists

* fix: filter inherited git protocol allowlists

* test: cover restrictive git allowlists

* test: avoid opengrep fixture false positives

* test: type env fixture helper narrowly

* fix: preserve zero git protocol booleans

* fix: preserve invalid git protocol booleans

* fix: force git protocol from user off

* fix: share git inherited env sanitization
2026-06-09 21:09:14 +05:30
Shakker
d2a6529f04 fix: avoid mcp shutdown response snapshot allocation 2026-06-09 16:05:14 +01:00
Shakker
2dcfd9f218 fix: close mcp loopback streams on shutdown 2026-06-09 16:05:14 +01:00
Shakker
d86069ded0 test: strengthen mcp loopback transport coverage 2026-06-09 16:05:14 +01:00
Cameron Beeley
7269b26926 fix(gateway): validate Origin before auth on GET/DELETE; merge-safe test token
Addresses review:
- Reorder the new GET and DELETE branches so rejectsBrowserLoopbackRequest()
  runs BEFORE bearer auth, matching the POST path — a browser-Origin loopback
  request is now rejected (403) before auth, preserving the loopback Origin
  boundary even for unauthenticated browser requests. Added focused tests:
  browser-Origin GET and DELETE with no bearer return 403 (before auth).
- The new transport tests now read the loopback owner token from
  getActiveMcpLoopbackRuntime().ownerToken instead of resolveMcpLoopbackBearerToken,
  so they don't depend on that helper's import (which current main's test file
  no longer carries).
2026-06-09 16:05:14 +01:00
Cameron Beeley
224ea76d29 fix(gateway): keep MCP loopback stateless; add DELETE no-op + transport tests
Addresses review feedback on the Streamable HTTP transport:

- Keep the loopback server stateless: drop the advertised Mcp-Session-Id header
  (the server owns no session lifecycle, so advertising a session id clients
  would echo back was misleading). Resolves the stateless-vs-sessionful concern.
- Add DELETE /mcp as an auth-gated 200 no-op (Streamable HTTP teardown), so
  clients that send DELETE on close get a clean 200 instead of 405; Allow now
  advertises GET, POST, DELETE.
- Keep the GET/SSE notification channel (the actual fix for the 'still
  connecting' hang) auth-gated and browser-origin-rejected.
- Add focused gateway tests: GET 200 + text/event-stream, GET 401 (no auth),
  GET 403 (browser origin), DELETE 200, DELETE 401, unsupported 405 with the
  correct Allow, and POST stays stateless (no Mcp-Session-Id).
2026-06-09 16:05:14 +01:00
Cameron Beeley
cc0a18da4f fix: remove DELETE handler — loopback MCP is stateless, no sessions to terminate
The DELETE path acknowledged Mcp-Session-Id without validating,
terminating, or expiring anything. Since the loopback server is
stateless (session ID is cosmetic for spec compliance), return 405
instead of pretending to support session teardown.
2026-06-09 16:05:14 +01:00
Cameron Beeley
48b3cb69b7 fix: route GET SSE through browser-origin gate 2026-06-09 16:05:14 +01:00
Cameron Beeley
cfe0bac99a fix: flush SSE response and gate DELETE behind auth + browser-origin check
- flushHeaders() + initial SSE comment so clients don't hang
- DELETE requires bearer auth (matching GET/POST gates)
- DELETE checks browser-origin rejection (matching POST gate)
2026-06-09 16:05:14 +01:00
Cameron Beeley
7f69fe009a fix(gateway): support Streamable HTTP MCP transport (GET/SSE + DELETE)
The MCP loopback server generates config with `"type": "http"` for
Claude Code, but only handled POST requests. Claude Code's Streamable
HTTP client sends GET to open an SSE notification channel before
completing initialization. The 405 rejection on GET caused Claude Code
to hang at "still connecting" indefinitely.

- Accept GET /mcp with bearer auth, return text/event-stream (idle SSE)
- Accept DELETE /mcp for session termination (spec compliance)
- Add Mcp-Session-Id header to POST responses (spec requirement)
- Update 405 Allow header to reflect supported methods
2026-06-09 16:05:14 +01:00
Pavan Kumar Gondhi
7cdec28706 fix: block rustup toolchain env overrides [AI] (#91615)
* fix: block rustup toolchain env overrides [AI]

* test: cover inherited rustup env stripping [AI]

* fix: preserve inherited rustup env [AI]

* fix: filter ignored opengrep changed paths [AI]

* fix: honor opengrep ignored directory globs [AI]

* fix: match ignored opengrep descendants [AI]

* fix: cover rustup mirror overrides [AI]

* fix: preserve opengrep directory-only ignores [AI]

* chore: drop opengrep cleanup from rustup fix [AI]
2026-06-09 20:03:32 +05:30
Pavan Kumar Gondhi
9f413acc18 fix: expand unsafe host env denylist (#91618)
* fix: expand unsafe host env denylist

* test: annotate host env security fixtures

* test: align opengrep fixture suppressions

* test: keep opengrep suppressions inline

* test: avoid opengrep fixture call patterns
2026-06-09 19:44:54 +05:30
Ayaan Zaidi
98d5c46530 fix(agents): keep compaction notices additive 2026-06-09 18:34:37 +05:30
Vincent Koc
65848d0b45 fix(discord): restore runtime timeout compatibility exports (#91686)
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 20:43:04 +09:00
Vincent Koc
9ef67ea405 test(memory): drop backend config bridge test 2026-06-09 20:12:25 +09:00
Vincent Koc
66749a3713 test: consolidate wrapper facade coverage 2026-06-09 20:12:25 +09:00
Vincent Koc
73ce4fdcbb fix(plugin-sdk): align Discord component edit facade types (#91679)
* fix(plugin-sdk): align Discord component edit facade types

* test(plugin-sdk): satisfy Discord facade type lint

* test(upgrade): seed migrated survivor sessions

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-09 19:58:10 +09:00
mushuiyu_xydt
a36e05050a fix #88009: [Feature]: batched memory embedding should batch over files (#89138)
Merged via squash.

Prepared head SHA: 66d362a56d
Co-authored-by: mushuiyu886 <266724580+mushuiyu886@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-06-09 06:38:30 -04:00
2826 changed files with 228126 additions and 46767 deletions

View File

@@ -54,6 +54,13 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
Testbox policy applies.
- Cold Testbox acquisition and hydration often take tens of seconds. When broad
remote proof is likely, immediately start
`node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --keep --timing-json`
in a background command session while inspecting, editing, and running
focused local tests. Poll later, reuse the returned `tbx_...` with
`--provider blacksmith-testbox --id <tbx_id>`, and stop it before handoff.
Do not warm speculatively when remote proof is unlikely.
- Always report the actual provider and id. `cbx_...` means AWS Crabbox;
`tbx_...` means Blacksmith Testbox through Crabbox. If the output only says
`blacksmith testbox list`, use `blacksmith testbox list --all` before

View File

@@ -0,0 +1,51 @@
---
name: discord-user-post
description: Post an approved message as the logged-in Discord user through the Discord desktop app. Use for release announcements or other direct user-authored Discord posts; not for OpenClaw channel sends, bots, webhooks, relays, agent sessions, or archive search.
---
# Discord User Post
Use `$computer-use` to operate `/Applications/Discord.app` in the user's
existing logged-in session. This workflow represents the user directly.
## Prepare
1. Draft the complete final message outside Discord.
2. Confirm the intended server and channel with the user when either is
ambiguous.
3. Open Discord and navigate to the exact destination without entering the
message.
4. Verify the visible server name, channel header, and logged-in account.
Do not infer the target from unrelated Discord content. Stop if Discord is not
logged in, the account is wrong, or the exact destination cannot be verified.
## Confirm and Post
Posting is representational communication. Follow the `$computer-use`
confirmation policy even when the user previously asked for an announcement:
1. Show the user the exact final body and verified destination.
2. Request action-time confirmation before typing into Discord.
3. After confirmation, enter the approved body unchanged.
4. Visually inspect the composed message and destination again.
5. Send once.
If the body or destination changes after confirmation, request confirmation
again before sending.
## Verify
- Confirm the message appears once, from the user's account, in the intended
channel.
- Report the server, channel, and visible send result.
- Do not edit, delete, react, or send a follow-up without the corresponding
user instruction and confirmation.
## Guardrails
- Never use `openclaw message`, an OpenClaw agent, a Discord bot, webhook, relay,
or token for this workflow.
- Never expose private Discord content or account details in public output.
- Never send a draft, partial message, duplicate, or unreviewed attachment.
- For Discord archive/history/search, use `$discrawl` instead.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Discord User Post"
short_description: "Post approved messages through the logged-in Discord app"
default_prompt: "Post this approved message as me through the logged-in Discord desktop app."

View File

@@ -19,7 +19,7 @@ attribution.
## Inputs
- Target base version: `YYYY.M.D`, without beta suffix.
- Target base version: `YYYY.M.PATCH`, without beta suffix.
- Base tag: last reachable shipped release tag, usually the previous stable or
the previous beta train requested by the operator.
- Target ref: exact branch/SHA being released.
@@ -37,7 +37,7 @@ attribution.
3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter;
infer notes from subject, body, touched files, tests, and nearby commits.
4. Rewrite one stable-base section only:
- use `## YYYY.M.D`
- use `## YYYY.M.PATCH`
- do not create beta-specific headings
- do not leave a stale `## Unreleased` section above the target release
- if `Unreleased` contains release-bound notes, fold them into the target
@@ -93,7 +93,7 @@ attribution.
10. Validate and ship:
- `git diff --check`
- for docs/changelog-only changes, no broad tests are required
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.D notes" CHANGELOG.md`
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`
- push, pull/rebase if needed, then branch/rebase release from latest `main`
## Quota / API Outage Rule

View File

@@ -13,7 +13,7 @@ Use this skill for `qa-lab` / `qa-channel` work. Repo-local QA only.
- `docs/help/testing.md`
- `docs/channels/qa-channel.md`
- `qa/README.md`
- `qa/scenarios/index.md`
- `qa/scenarios/index.yaml`
- `extensions/qa-lab/src/suite.ts`
- `extensions/qa-lab/src/character-eval.ts`
@@ -198,7 +198,9 @@ pnpm openclaw qa character-eval \
- Judges default to `openai/gpt-5.4,thinking=xhigh,fast` and `anthropic/claude-opus-4-6,thinking=high`.
- Report includes judge ranking, run stats, durations, and full transcripts; do not include raw judge replies. Duration is benchmark context, not a grading signal.
- Candidate and judge concurrency default to 16. Use `--concurrency <n>` and `--judge-concurrency <n>` to override when local gateways or provider limits need a gentler lane.
- Scenario source should stay markdown-driven under `qa/scenarios/`.
- Scenario source is YAML-only under `qa/scenarios/`: use `index.yaml` and
per-scenario `*.yaml` files with top-level `title`, `scenario`, and optional
`flow`. Never add fenced `qa-scenario` / `qa-flow` Markdown files.
- For isolated character/persona evals, write the persona into `SOUL.md` and blank `IDENTITY.md` in the scenario flow. Use `SOUL.md + IDENTITY.md` only when intentionally testing how the normal OpenClaw identity combines with the character.
- Keep prompts natural and task-shaped. The candidate model should receive character setup through `SOUL.md`, then normal user turns such as chat, workspace help, and small file tasks; do not ask "how would you react?" or tell the model it is in an eval.
- Prefer at least one real task, such as creating or editing a tiny workspace artifact, so the transcript captures character under normal tool use instead of pure roleplay.
@@ -234,7 +236,8 @@ pnpm openclaw qa manual \
## Repo facts
- Seed scenarios live in `qa/`.
- Seed scenarios live in `qa/scenarios/index.yaml` and
`qa/scenarios/<theme>/*.yaml`.
- Main live runner: `extensions/qa-lab/src/suite.ts`
- QA lab server: `extensions/qa-lab/src/lab-server.ts`
- Child gateway harness: `extensions/qa-lab/src/gateway-child.ts`
@@ -262,8 +265,9 @@ pnpm openclaw qa manual \
## When adding scenarios
- Add or update scenario markdown under `qa/scenarios/`
- Keep kickoff expectations in `qa/scenarios/index.md` aligned
- Add or update scenario YAML under `qa/scenarios/`; do not add `.md` scenario
files or fenced YAML blocks.
- Keep kickoff expectations in `qa/scenarios/index.yaml` aligned
- Add executable coverage in `extensions/qa-lab/src/suite.ts`
- Prefer end-to-end assertions over mock-only checks
- Save outputs under `.artifacts/qa-e2e/`

View File

@@ -6,7 +6,8 @@ description: "Draft or post OpenClaw beta/stable Discord release announcements f
# 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.
Use with `$discord-user-post` when actually posting to Discord as the logged-in
user.
## Evidence First
@@ -80,6 +81,7 @@ Fresh installs still point to `https://openclaw.ai`.
## 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.
When asked to post, use `$discord-user-post` to operate the logged-in Discord
desktop app as the user. Resolve and visibly verify the exact server/channel,
inspect the final body, and request action-time confirmation before entering or
sending it. Never use OpenClaw channel sends, bots, webhooks, relays, or tokens.

View File

@@ -36,8 +36,8 @@ Do not update these from mixed sources. All three ASC fields must come from the
## Workflow Shape
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
@@ -56,37 +56,37 @@ Private preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D \
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH \
-f preflight_only=true \
-f smoke_test_only=false \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
-f public_release_branch=release/YYYY.M.PATCH
```
Private validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D
-f tag=vYYYY.M.PATCH \
-f source_ref=release/YYYY.M.PATCH
```
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f tag=vYYYY.M.PATCH \
-f preflight_only=false \
-f smoke_test_only=false \
-f preflight_run_id=<successful-preflight-run> \
-f validate_run_id=<successful-validation-run> \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
-f public_release_branch=release/YYYY.M.PATCH
```
## Verify
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.PATCH.zip`.
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.

View File

@@ -10,12 +10,15 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
## Respect release guardrails
- Do not change version numbers without explicit operator approval.
- Versions use `YYYY.M.PATCH`, where `PATCH` is the sequential release-train number within the month, not the calendar day.
- Choose a new beta train from stable and beta releases only. Alpha-only tags do not consume or advance the beta/stable patch number. Continue the highest existing unpublished/published beta train with the next `beta.N` when appropriate; otherwise increment the highest stable/beta patch by one and start at `beta.1`.
- Example: after stable `2026.6.5`, the next new beta train is `2026.6.6-beta.1`, even if automated alpha-only tags such as `2026.6.10-alpha.1` exist.
- Ask permission before any npm publish or release step.
- This skill should be sufficient to drive the normal release flow end-to-end.
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
- Normal release work happens on a branch cut from `main`, not directly on
`main`. Use `release/YYYY.M.D` for the branch name.
`main`. Use `release/YYYY.M.PATCH` for the branch name.
- If the operator asks for a release without saying stable/full, default to
beta only. Continue from beta to stable only when the operator explicitly asks
for the full release or an automated beta-and-stable train.
@@ -92,7 +95,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
## Keep release channel naming aligned
- `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
- `beta`: prerelease tags like `vYYYY.M.PATCH-beta.N`, with npm dist-tag `beta`
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
- `dev`: moving head on `main`
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
@@ -108,7 +111,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- `docs/install/updating.md`
- Peekaboo Xcode project and plist version fields
- Before creating a release tag, make every version location above match the version encoded by that tag.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version locations still stay at `YYYY.M.PATCH`.
- “Bump version everywhere” means all version locations above except `appcast.xml`.
- Release signing and notary credentials live outside the repo in the private maintainer docs.
- Every stable OpenClaw release ships the npm package, macOS app, and signed
@@ -129,29 +132,41 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
tagged commit when the delta is mac packaging, signing, workflow, or
validation-only release machinery. If mac packaging needs release-branch-only
fixes after the stable npm package or GitHub tag is already published, do not
create a `vYYYY.M.D-N` correction tag just to change the workflow source.
Dispatch the private mac workflows for the original `tag=vYYYY.M.D` with
`source_ref=release/YYYY.M.D` and `public_release_branch=release/YYYY.M.D`;
create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source.
Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with
`source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`;
provenance checks must prove the source SHA descends from the tag and
validation/preflight use the same source. Reserve `vYYYY.M.D-N` correction
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
tags for emergency hotfixes that must publish a new npm package/release
identity, not for ordinary mac-only packaging recovery.
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
- That shared production Sparkle feed is stable-only. Beta mac releases may
upload assets to the GitHub prerelease, but they must not replace the shared
`appcast.xml` unless a separate beta feed exists.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version still stays
at `YYYY.M.PATCH`, but the mac release must use a strictly higher numeric
`APP_BUILD` / Sparkle build than the original release so existing installs
see it as newer.
- Stable Windows Hub release closeout requires the signed
`OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and
`OpenClawCompanion-SHA256SUMS.txt` assets on the canonical
`openclaw/openclaw` GitHub Release. Use the public `Windows Node Release`
workflow after the matching `openclaw/openclaw-windows-node` release exists;
it verifies Authenticode signatures on Windows before uploading assets.
`openclaw/openclaw` GitHub Release. Pass the exact signed
`openclaw/openclaw-windows-node` release tag as `windows_node_tag` to
`OpenClaw Release Publish`, together with the candidate-approved
`windows_node_installer_digests` map; it prevalidates the published source
release and required installers against that map before any publish child,
dispatches the public `Windows Node Release` workflow while the OpenClaw
release is still a draft, carries those pinned source asset digests
unchanged, verifies the expected OpenClaw Foundation Authenticode signer on
Windows, re-downloads and checksum-verifies the promoted asset contract, and
blocks publication until the canonical asset contract is present. Use direct
`Windows Node Release` dispatch only for recovery, always with an exact tag,
never `latest`, and the explicit `expected_installer_digests` JSON map from
the approved source release. Recovery rejects unexpected
`OpenClawCompanion-*` target asset names, then replaces the expected contract
assets with the pinned source bytes.
- Website Windows Hub download links should target exact canonical
`openclaw/openclaw/releases/download/vYYYY.M.D/...` assets for the current
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
stable release, or `releases/latest/download/...` only after verifying the
redirect resolves to that same tag, so the installable signed Windows artifact
is visible from both the GitHub release page and openclaw.ai.
@@ -165,7 +180,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
beta release tag as the base, then inspect every commit through the target
release SHA.
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
rebase or backport must refresh the same stable-base `## YYYY.M.D` section
rebase or backport must refresh the same stable-base `## YYYY.M.PATCH` section
before the new version/tag commit.
- Include both merged PR commits and direct commits on `main`. Direct commits
matter: infer notes from their subject, body, touched files, linked issues,
@@ -188,11 +203,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
- Changelog entries should be user-facing, not internal release-process notes.
- GitHub release and prerelease bodies must use the full matching
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
or editing a release, extract from `## YYYY.M.D` through the line before the
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
next level-2 heading and use that complete block as the release notes.
- To update an existing GitHub Release body, resolve the numeric release id and
patch that resource with the notes file as the `body` field:
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.D --jq .id`, then
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then
`gh api -X PATCH repos/openclaw/openclaw/releases/<id> -F body=@/tmp/notes.md`.
Do not trust `gh release edit --notes-file` or `--input` JSON if verification
disagrees; verify with `gh api repos/openclaw/openclaw/releases/<id>` because
@@ -205,10 +220,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
record's `docsPath` or `/plugins/compatibility` when no more specific
deprecation page exists.
- When cutting a mac release with a beta GitHub prerelease:
- tag `vYYYY.M.D-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
- tag `vYYYY.M.PATCH-beta.N` from the release commit
- create a prerelease titled `openclaw YYYY.M.PATCH-beta.N`
- use release notes from the stable base `CHANGELOG.md` version section
(`## YYYY.M.D`), not a beta-specific heading
(`## YYYY.M.PATCH`), not a beta-specific heading
- attach at least the zip and dSYM zip, plus dmg if available
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
- `### Changes` first
@@ -218,10 +233,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
Use the OpenClaw account's existing release-post style:
- Format: `OpenClaw YYYY.M.D 🦞` or `🦞 OpenClaw YYYY.M.D is live`, blank line,
- Format: `OpenClaw YYYY.M.PATCH 🦞` or `🦞 OpenClaw YYYY.M.PATCH is live`, blank line,
then 3-4 emoji-led bullets, blank line, one short punchline, then the release
link.
- For beta: say `OpenClaw YYYY.M.D-beta.N 🦞` or `OpenClaw YYYY.M.D beta N is
- For beta: say `OpenClaw YYYY.M.PATCH-beta.N 🦞` or `OpenClaw YYYY.M.PATCH beta N is
live`; keep it clearly beta and avoid implying stable promotion.
- Lead with user-visible capabilities, then important integrations, then
reliability/security/install fixes. Compress "lots of fixes" into one
@@ -306,6 +321,7 @@ Upgrade with the beta channel.
Before tagging or publishing, run:
```bash
pnpm release:fast-pretag-check
pnpm check:architecture
pnpm build
pnpm ui:build
@@ -314,6 +330,38 @@ pnpm release:check
pnpm test:install:smoke
```
- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every
publishable plugin must have a non-empty package-root `README.md`, build its
package-local runtime, and pass the npm and ClawHub release metadata checks
before a tag or publish workflow can start. Do not defer README, entrypoint,
or packed-artifact failures to postpublish verification.
- Before tagging, require green CI for the exact release-candidate SHA, not an
earlier branch SHA. Heal every related red CI, release-check, packaging, or
root-Dockerfile lane on the release branch, forward-port the fix to `main`,
and rerun the affected exact-SHA gates. Never waive a red Docker lane because
npm preflight passed.
- Root Dockerfile proof is mandatory before every beta and stable tag. Run the
release `install-smoke` group or equivalent root Dockerfile build for the
exact candidate SHA and require it to pass. The tag-triggered Docker Release
workflow is post-tag publishing, not the first valid proof that the root
Dockerfile can build.
- Before tagging, diff publishable plugin package manifests against the last
reachable stable/beta release tag. For every newly publishable package
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
package name did not exist in the base tag, verify the target registry package
already exists in npm/ClawHub or stop and help the owner mint/prepublish the
package first. Do not hide or disable release surfaces just to unblock a
train unless the owner explicitly decides the plugin should not ship in that
release; first-package registry ownership is release prep, not product
rollback. The mint/prepublish path must either be the real release publish
path for the auto-bumped beta version, or a deliberately non-consuming
registry-prep step that cannot occupy the next beta version/tag. Confirm
registry owner, npm scope/package-creation permission, provenance path, and
first-package publish plan before the full release publish continues. Useful
npm probe:
`npm view <package-name> version dist-tags --json --prefer-online`; a 404 for
a package newly added to the release is a release-prep blocker, not something
to discover from the publish job.
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
`otel-trace-smoke`, and checks span names plus content/identifier redaction
@@ -332,8 +380,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
```
- This verifies the published registry install path in a fresh temp prefix.
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
- For stable correction releases like `YYYY.M.PATCH-N`, it also verifies the
upgrade path from `YYYY.M.PATCH` to `YYYY.M.PATCH-N` so a correction publish cannot
silently leave existing global installs on the old base stable payload.
- Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke`
now fails the candidate update tarball when npm reports an oversized
@@ -480,7 +528,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
`npm login --auth-type=legacy`, then confirm `npm whoami` reports
`steipete`.
- Promote with a fresh OTP:
`npm dist-tag add openclaw@YYYY.M.D latest --otp "$OTP"`.
`npm dist-tag add openclaw@YYYY.M.PATCH latest --otp "$OTP"`.
- Verify with a cache-bypassed registry read, for example:
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
@@ -506,7 +554,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
the npm version is already published.
- npm validation-only preflight may still be dispatched from ordinary branches
when testing workflow changes before merge. Release checks and real publish
use only `main` or `release/YYYY.M.D`.
use only `main` or `release/YYYY.M.PATCH`.
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
public validation-only handoff. It validates the tag/release state and points
operators to the private repo. It still rebuilds the JS outputs needed for
@@ -531,7 +579,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
waives the full gate; mac beta validation is still only required when
requested.
- Real publish runs may be dispatched from `main` or from a
`release/YYYY.M.D` branch. For release-branch runs, the tag must be contained
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
in that release branch, and the real publish must reuse a successful preflight
from the same branch.
- The release workflows stay tag-based; rely on the documented release sequence
@@ -559,7 +607,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm
does not support trusted publishing for `npm dist-tag add`.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
- Publishable plugins that are new to npm require owner-led first-package
minting before the full release publish. Do not consume the next beta version
with an ad-hoc manual package publish; use the release-owned auto-bumped
version path, or a non-consuming registry setup/preflight step. Bundled
disk-tree-only plugins stay unpublished.
## Fallback local mac publish
@@ -599,8 +651,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
4. Pull latest `main` and confirm current `main` CI is green.
5. Run `/changelog` for the stable base target version on `main`, commit the
changelog rewrite immediately, push, and pull/rebase. For beta releases,
keep the changelog heading as `## YYYY.M.D`, not `## YYYY.M.D-beta.N`.
6. Create `release/YYYY.M.D` from that post-changelog `main` commit.
keep the changelog heading as `## YYYY.M.PATCH`, not `## YYYY.M.PATCH-beta.N`.
6. Create `release/YYYY.M.PATCH` from that post-changelog `main` commit.
7. Make every repo version location match the beta tag before creating it.
8. Commit release preparation changes on the release branch and push the branch.
9. Immediately dispatch Actions > `OpenClaw Performance` from `main` with
@@ -608,15 +660,18 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
off, live OpenAI off, and regression failure off. Let it run in parallel
with preflight and validation work.
10. Run the fast local beta preflight from the release branch before any npm
preflight or publish. Keep expensive Docker, Parallels, and published-package
install/update lanes for after the beta is live unless the operator asks to
run them before beta publication.
preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke
to be green before tagging. Keep the remaining expensive Docker, Parallels,
and published-package install/update lanes for after the beta is live unless
the operator asks to run them before beta publication.
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
release blocker specifically requires it. For stable releases, include the
mac app, signing, notarization, and appcast path.
12. Confirm the target npm version is not already published.
13. Create and push the git tag from the release branch.
14. Create or refresh the matching GitHub release.
14. Do not create or publish the matching GitHub release page yet. The real
publish workflow creates or undrafts it only after postpublish verification
and release evidence upload pass.
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
for the mock parity, live Matrix, and live Telegram credentialed-channel
lanes to pass.
@@ -639,20 +694,33 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes. For pushed or
published beta tags, do not delete/recreate; increment to the next beta tag.
For preflight-only failures where npm did not publish the beta version,
delete/recreate the same beta tag and prerelease at the fixed commit instead
of skipping a prerelease number.
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
scratch before continuing. Never reuse old preflight results after the
commit changes. Once the npm version exists, do not rerun the publish
workflow for that same version; finalize the existing draft/evidence state
manually or cut a correction tag. For pushed or published beta tags, do not
delete/recreate; increment to the next beta tag. For preflight-only failures
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
22. Start `.github/workflows/openclaw-release-publish.yml` from the same branch with
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
`preflight_run_id`.
`preflight_run_id` plus the successful `full_release_validation_run_id`.
For stable publish, also pass the exact non-prerelease
`openclaw/openclaw-windows-node` tag as `windows_node_tag` and its
candidate-approved installer digest map as `windows_node_installer_digests`.
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
24. Run postpublish verification:
24. Wait for the real publish workflow to run postpublish verification,
create or update the GitHub release as a draft, upload dependency evidence,
promote and verify the required Windows Hub assets for stable releases,
append release verification proof, and only then undraft/publish it. If a
waited plugin publish or Windows Hub promotion fails after OpenClaw npm
succeeds, the workflow keeps the release draft with OpenClaw npm evidence
and exits red; do not undraft until the gap is repaired. The standalone
verifier command remains the recovery probe:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only

View File

@@ -37,9 +37,11 @@ This is good for auditability if commits are clearly machine-authored and gated
- Branch name: `tideclaw/alpha/YYYY-MM-DD-HHMMZ`
- Base: current `origin/main` SHA at trigger time.
- State file: resolve from `$release-private` on the Tideclaw host.
- Release tag: `vYYYY.M.D-alpha.N`
- Release tag: `vYYYY.M.PATCH-alpha.N`
- npm dist-tag: `alpha`
`PATCH` is a sequential monthly release-train number, never the calendar day. Determine the alpha train from stable and beta releases; ignore alpha-only patch numbers when choosing the next train. Use one greater than the highest stable/beta patch for the month, then increment only `alpha.N` for repeated nightlies on that train. If a beta exists on that next patch, move alpha to the following train. Legacy alpha-only tags with inflated patch numbers do not advance beta/stable numbering.
Do not reuse old alpha branches for a new run. If rerunning the same base SHA, create a new timestamped branch and record why.
## Start
@@ -98,7 +100,7 @@ Tideclaw may run beta releases from `#releases` or mentioned `#maintainers` comm
Accepted shapes:
```text
@Tideclaw beta release from vYYYY.M.D-alpha.N
@Tideclaw beta release from vYYYY.M.PATCH-alpha.N
@Tideclaw beta release from tideclaw/alpha/YYYY-MM-DD-HHMMZ
@Tideclaw beta release from latest proven alpha
```
@@ -110,7 +112,7 @@ Rules:
3. Verify the source alpha first: GitHub release, npm `alpha` package, release CI, recorded state file, and branch/tag SHA.
4. Create a fresh beta branch `tideclaw/beta/YYYY-MM-DD-HHMMZ` from the proven alpha source, not directly from a moving `main`.
5. Reuse/squash only stabilization fixes already proven on alpha. Do not import unrelated alpha release mechanics unless the beta release docs require them.
6. Compute beta as `vYYYY.M.D-beta.N`, matching npm `--tag beta`.
6. Compute beta as `vYYYY.M.PATCH-beta.N`, matching npm `--tag beta`. Ignore alpha-only patch numbers when selecting the beta train.
7. Run beta release validation/preflight/full release CI and fix failures on the beta branch.
8. Publish beta only after green beta gates. Use GitHub Actions/OIDC, never direct npm publish from the host.
9. Final Discord summary must include source alpha, beta tag/version, branch, fix commits, workflow run IDs, npm/GitHub proof, and any skipped/blocked reason.
@@ -165,7 +167,7 @@ git push -u origin "$BRANCH"
After local proof:
1. Compute the next `vYYYY.M.D-alpha.N` from existing git tags, npm versions, and GitHub releases.
1. Compute the next `vYYYY.M.PATCH-alpha.N` from existing git tags, npm versions, and GitHub releases. Select `PATCH` from stable/beta trains, not the date or the highest alpha-only patch. Reuse the same alpha train and increment `alpha.N` until that patch has a beta; after a beta exists, use the following patch for new alpha builds.
2. Make the alpha branch package version and release metadata match that tag, commit it, and push the branch.
3. Run release validation from the alpha branch, using GitHub CLI, not browser/fetch tools. On the Tideclaw host, bare `gh` is a read-only Codex sandbox wrapper; use `/usr/local/bin/gh-tideclaw-write` for write-capable commands such as `workflow run`, `run cancel`, and publish dispatch:

View File

@@ -1288,6 +1288,7 @@ jobs:
env:
OPENCLAW_LOCAL_CHECK: "0"
TASK: ${{ matrix.task }}
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
shell: bash
run: |
set -euo pipefail
@@ -1297,6 +1298,10 @@ jobs:
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
if [ -n "$PR_BASE_SHA" ]; then
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base"
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base
fi
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
@@ -1358,6 +1363,8 @@ jobs:
- check_name: check-additional-boundaries-bcd
group: boundaries
boundary_shard: 2/4,3/4,4/4
- check_name: check-session-accessor-boundary
group: session-accessor-boundary
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
@@ -1504,6 +1511,15 @@ jobs:
boundaries)
node scripts/run-additional-boundary-checks.mjs
;;
session-accessor-boundary)
if [ ! -f scripts/check-session-accessor-boundary.mjs ]; then
echo "[skip] session accessor boundary check is not present in this checkout"
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then
echo "[skip] session accessor boundary script is not present in package.json"
else
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
fi
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
;;

View File

@@ -783,7 +783,7 @@ jobs:
fi
args=(
-f ref="$TARGET_SHA"
-f ref="$TARGET_REF"
-f expected_sha="$TARGET_SHA"
-f provider="$PROVIDER"
-f mode="$MODE"

View File

@@ -112,7 +112,7 @@ jobs:
persist-credentials: false
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -245,7 +245,7 @@ jobs:
- name: Set up Blacksmith Docker Builder
if: steps.existing.outputs.exists != 'true'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -429,7 +429,7 @@ jobs:
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -542,7 +542,7 @@ jobs:
persist-credentials: false
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000

View File

@@ -0,0 +1,447 @@
name: iOS Periphery Dead Code Comment
on:
workflow_run: # zizmor: ignore[dangerous-triggers] trusted PR commenter; job gates repository, source event, workflow name, live open PR, and exact current head before reading artifacts or writing comments
workflows: ["iOS Periphery Dead Code"]
types: [completed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
issues: write
pull-requests: read
jobs:
comment:
name: Comment on PR
runs-on: ubuntu-24.04
if: >
github.repository == 'openclaw/openclaw' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.name == 'iOS Periphery Dead Code'
steps:
- name: Upsert Periphery PR comment
uses: actions/github-script@v9
with:
script: |
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const childProcess = require("node:child_process");
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
const run = context.payload.workflow_run;
const pr = run.pull_requests?.[0];
if (!pr) {
core.info("No pull request attached to workflow_run.");
return;
}
const { owner, repo } = context.repo;
const repository = `${owner}/${repo}`;
if (run.repository?.full_name !== repository) {
core.info(`Skipping workflow_run from ${run.repository?.full_name ?? "unknown repository"}.`);
return;
}
if (run.event !== "pull_request") {
core.info(`Skipping workflow_run for ${run.event ?? "unknown"} event.`);
return;
}
if (run.name !== "iOS Periphery Dead Code") {
core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`);
return;
}
const livePull = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (livePull.data.state !== "open") {
core.info(`Skipping closed PR #${pr.number}.`);
return;
}
if (livePull.data.base?.repo?.full_name !== repository) {
core.info(`Skipping PR #${pr.number} targeting ${livePull.data.base?.repo?.full_name ?? "unknown repository"}.`);
return;
}
if (livePull.data.head?.sha !== run.head_sha) {
core.info(`Skipping stale run ${run.id}; PR #${pr.number} is now at ${livePull.data.head?.sha}.`);
return;
}
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
filter: "latest",
per_page: 100,
});
const scopeJob = jobs.find((job) => job.name === "Detect iOS scan scope");
const scanJob = jobs.find((job) => job.name === "Scan iOS dead code");
const scanSkipped =
scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped";
if (scanSkipped) {
core.info(`Skipping intentionally omitted Periphery scan for PR #${pr.number}.`);
}
const artifacts = scanSkipped
? []
: await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: run.id,
per_page: 100,
});
const readReport = async () => {
if (scanSkipped) {
return;
}
const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`;
const artifact = artifacts.find((item) => item.name === artifactName);
if (!artifact) {
core.warning(`No ${artifactName} artifact found.`);
return;
}
if (artifact.expired) {
core.warning(`${artifactName} artifact expired.`);
return;
}
const maxArchiveBytes = 1024 * 1024;
const archiveSize = Number(artifact.size_in_bytes);
if (!Number.isSafeInteger(archiveSize) || archiveSize < 0 || archiveSize > maxArchiveBytes) {
core.warning(`Skipping ${artifactName}; compressed artifact size ${artifact.size_in_bytes ?? "unknown"} exceeds the ${maxArchiveBytes} byte limit.`);
return;
}
const archive = await github.rest.actions.downloadArtifact({
owner,
repo,
artifact_id: artifact.id,
archive_format: "zip",
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
const archivePath = path.join(dir, "artifact.zip");
const archiveBuffer = Buffer.from(archive.data);
fs.writeFileSync(archivePath, archiveBuffer);
const allowedArtifactFiles = new Set([
"periphery.json",
"periphery.status",
"periphery.stderr.log",
"periphery.stdout.json",
"should-fail.txt",
]);
const maxEntries = allowedArtifactFiles.size;
const maxEntryBytes = 2 * 1024 * 1024;
const maxTotalBytes = 4 * 1024 * 1024;
const readUInt16 = (offset) => archiveBuffer.readUInt16LE(offset);
const readUInt32 = (offset) => archiveBuffer.readUInt32LE(offset);
const findEndOfCentralDirectoryOffset = () => {
const minimumOffset = Math.max(0, archiveBuffer.length - 0xffff - 22);
for (let offset = archiveBuffer.length - 22; offset >= minimumOffset; offset -= 1) {
if (readUInt32(offset) === 0x06054b50) {
return offset;
}
}
return -1;
};
const endOfCentralDirectoryOffset = findEndOfCentralDirectoryOffset();
if (endOfCentralDirectoryOffset < 0) {
core.warning(`Skipping ${artifactName}; ZIP end-of-central-directory record was not found.`);
return;
}
const entryCount = readUInt16(endOfCentralDirectoryOffset + 10);
const centralDirectorySize = readUInt32(endOfCentralDirectoryOffset + 12);
const centralDirectoryOffset = readUInt32(endOfCentralDirectoryOffset + 16);
if (entryCount < 1 || entryCount > maxEntries) {
core.warning(`Skipping ${artifactName}; artifact has ${entryCount} entries.`);
return;
}
if (
centralDirectoryOffset + centralDirectorySize > archiveBuffer.length ||
readUInt32(centralDirectoryOffset) !== 0x02014b50
) {
core.warning(`Skipping ${artifactName}; invalid ZIP central directory.`);
return;
}
const entries = new Map();
let totalUncompressedSize = 0;
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {
if (offset + 46 > archiveBuffer.length || readUInt32(offset) !== 0x02014b50) {
core.warning(`Skipping ${artifactName}; invalid central directory entry.`);
return;
}
const compressionMethod = readUInt16(offset + 10);
const generalPurposeBitFlag = readUInt16(offset + 8);
const compressedSize = readUInt32(offset + 20);
const uncompressedSize = readUInt32(offset + 24);
const fileNameLength = readUInt16(offset + 28);
const extraLength = readUInt16(offset + 30);
const commentLength = readUInt16(offset + 32);
const externalAttributes = readUInt32(offset + 38);
const nameStart = offset + 46;
const nameEnd = nameStart + fileNameLength;
const nextOffset = nameEnd + extraLength + commentLength;
if (nextOffset > archiveBuffer.length) {
core.warning(`Skipping ${artifactName}; central directory entry exceeds archive bounds.`);
return;
}
const name = archiveBuffer.toString("utf8", nameStart, nameEnd);
const mode = externalAttributes >>> 16;
const fileType = mode & 0o170000;
const isRegularFile = fileType === 0 || fileType === 0o100000;
const invalidName =
!allowedArtifactFiles.has(name) ||
name.includes("/") ||
name.includes("\\") ||
name.includes("..") ||
path.isAbsolute(name);
if (invalidName) {
core.warning(`Skipping ${artifactName}; unexpected artifact entry ${name}.`);
return;
}
if (!isRegularFile || name.endsWith("/")) {
core.warning(`Skipping ${artifactName}; ${name} is not a regular file.`);
return;
}
if (entries.has(name)) {
core.warning(`Skipping ${artifactName}; duplicate artifact entry ${name}.`);
return;
}
if (![0, 8].includes(compressionMethod)) {
core.warning(`Skipping ${artifactName}; ${name} uses unsupported ZIP compression method ${compressionMethod}.`);
return;
}
if ((generalPurposeBitFlag & 0x1) !== 0) {
core.warning(`Skipping ${artifactName}; ${name} is encrypted.`);
return;
}
if (compressedSize > maxEntryBytes || uncompressedSize > maxEntryBytes) {
core.warning(`Skipping ${artifactName}; ${name} exceeds the per-file size limit.`);
return;
}
totalUncompressedSize += uncompressedSize;
if (totalUncompressedSize > maxTotalBytes) {
core.warning(`Skipping ${artifactName}; artifact exceeds the aggregate size limit.`);
return;
}
entries.set(name, { uncompressedSize });
offset = nextOffset;
}
const files = new Map();
for (const [name, entry] of entries) {
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
encoding: "utf8",
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
timeout: 5000,
});
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
return;
}
files.set(name, contents);
}
const read = (name) => {
return files.get(name) ?? "";
};
const status = Number(read("periphery.status").trim() || "1");
let findings = null;
for (const name of ["periphery.json", "periphery.stdout.json"]) {
try {
const parsed = JSON.parse(read(name));
const validFindings =
Array.isArray(parsed) &&
parsed.every(
(finding) =>
finding !== null &&
typeof finding === "object" &&
!Array.isArray(finding),
);
if (validFindings) {
findings = parsed;
break;
}
} catch {}
}
return { findings, status };
};
const report = await readReport();
const status = report?.status ?? 1;
const findings = report?.findings ?? null;
const sanitizeCell = (value) => {
const normalized = String(value ?? "")
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
.replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/gu, "")
.replace(/\s+/gu, " ")
.trim();
const maxEncodedLength = 180;
let escaped = "";
for (const character of normalized) {
const encoded =
character === "`"
? "'"
: character === "|"
? "\\|"
: character;
if (escaped.length + encoded.length > maxEncodedLength) {
break;
}
escaped += encoded;
}
return `\`${escaped || "-"}\``;
};
const rows = (findings ?? []).map((finding) => {
const location = String(finding.location ?? "");
const [file, line] = location.split(":");
return {
file: file ? `apps/ios/${file}` : "",
line: line || "",
kind: String(finding.kind ?? ""),
name: String(finding.name ?? ""),
};
});
let mode = "failure";
let body = `${marker}\n`;
if (scanSkipped) {
mode = "skipped";
body += [
"### iOS Periphery",
"",
"Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.",
].join("\n");
} else if (findings === null) {
body += [
"### iOS Periphery",
"",
"Periphery did not complete or its report could not be safely read. Check the workflow run for details.",
].join("\n");
} else if (rows.length === 0 && status === 0) {
mode = "success";
body += [
"### iOS Periphery",
"",
"No dead Swift code found.",
].join("\n");
} else if (rows.length > 0) {
const shown = rows.slice(0, 50);
body += [
"### iOS Periphery",
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. Remove the code or add a narrow Periphery exemption with a comment explaining why it must stay.`,
"",
"| File | Line | Kind | Name |",
"| --- | ---: | --- | --- |",
...shown.map((row) => `| ${sanitizeCell(row.file)} | ${sanitizeCell(row.line)} | ${sanitizeCell(row.kind)} | ${sanitizeCell(row.name)} |`),
rows.length > shown.length ? "" : null,
rows.length > shown.length ? `Showing first ${shown.length}; full JSON is in the workflow artifact.` : null,
].filter(Boolean).join("\n");
} else {
body += [
"### iOS Periphery",
"",
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
].join("\n");
}
body += "\n";
const maxCommentChars = 60_000;
if (body.length > maxCommentChars) {
body = [
marker,
"### iOS Periphery",
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. The rendered report exceeded the safe comment limit; use the workflow artifact for details.`,
"",
].join("\n");
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: livePull.data.number,
per_page: 100,
});
const existing = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" &&
comment.body?.includes(marker),
);
if (!existing && ["skipped", "success"].includes(mode)) {
core.info(`No existing Periphery comment and scan ${mode}; skipping comment.`);
return;
}
const currentPull = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (
currentPull.data.state !== "open" ||
currentPull.data.base?.repo?.full_name !== repository ||
currentPull.data.head?.sha !== run.head_sha
) {
core.info(`Skipping stale run ${run.id}; PR #${pr.number} changed before comment update.`);
return;
}
const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: run.workflow_id,
event: "pull_request",
head_sha: run.head_sha,
per_page: 100,
});
const supersedingRun = workflowRuns.find(
(candidate) =>
(candidate.id === run.id ||
candidate.pull_requests?.some(
(candidatePull) => candidatePull.number === pr.number,
)) &&
(candidate.run_number > run.run_number ||
(candidate.run_number === run.run_number &&
candidate.run_attempt > run.run_attempt)),
);
if (supersedingRun) {
core.info(`Skipping superseded run ${run.id} attempt ${run.run_attempt}; run ${supersedingRun.id} attempt ${supersedingRun.run_attempt} is newer.`);
return;
}
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
return;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: livePull.data.number,
body,
});

229
.github/workflows/ios-periphery.yml vendored Normal file
View File

@@ -0,0 +1,229 @@
name: iOS Periphery Dead Code
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
workflow_dispatch:
concurrency:
group: ios-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
contents: read
pull-requests: read
jobs:
scope:
name: Detect iOS scan scope
runs-on: ubuntu-24.04
outputs:
should-scan: ${{ steps.scope.outputs.should-scan }}
steps:
- name: Detect changed paths
id: scope
uses: actions/github-script@v9
with:
script: |
if (context.eventName === "workflow_dispatch") {
core.setOutput("should-scan", "true");
return;
}
if (context.payload.pull_request?.draft) {
core.setOutput("should-scan", "false");
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const isScanPath = (filename) =>
typeof filename === "string" && (
filename.startsWith("apps/ios/") ||
filename === ".github/workflows/ios-periphery.yml" ||
filename === ".github/workflows/ios-periphery-comment.yml" ||
filename === "config/swiftformat" ||
filename === "config/swiftlint.yml"
);
const shouldScan = files.some(
({ filename, previous_filename: previousFilename }) =>
isScanPath(filename) || isScanPath(previousFilename)
);
core.setOutput("should-scan", String(shouldScan));
scan:
name: Scan iOS dead code
needs: scope
if: ${{ needs.scope.outputs.should-scan == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Verify Xcode
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Install iOS Swift tooling
run: brew install xcodegen swiftformat swiftlint periphery
- name: Generate iOS project
run: |
set -euo pipefail
./scripts/ios-configure-signing.sh
./scripts/ios-write-version-xcconfig.sh
cd apps/ios
xcodegen generate
- name: Run Periphery
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/ios-periphery"
mkdir -p "$output_dir"
cd apps/ios
set +e
periphery scan \
--config .periphery.yml \
--strict \
--format json \
--write-results "$output_dir/periphery.json" \
>"$output_dir/periphery.stdout.json" \
2>"$output_dir/periphery.stderr.log"
periphery_status="$?"
set -e
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
- name: Build Periphery report
run: |
set -euo pipefail
node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const outputDir = path.join(process.env.RUNNER_TEMP, "ios-periphery");
const read = (name) => {
const file = path.join(outputDir, name);
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
};
const status = Number(read("periphery.status").trim() || "1");
let findings = null;
for (const name of ["periphery.json", "periphery.stdout.json"]) {
try {
const parsed = JSON.parse(read(name));
if (Array.isArray(parsed)) {
findings = parsed;
break;
}
} catch {}
}
const escapeCommandData = (value) =>
String(value ?? "")
.replaceAll("%", "%25")
.replaceAll("\r", "%0D")
.replaceAll("\n", "%0A");
const escapeCommandProperty = (value) =>
escapeCommandData(value)
.replaceAll(":", "%3A")
.replaceAll(",", "%2C");
const rows = (findings ?? []).map((finding) => {
const location = String(finding.location ?? "");
const [file, line] = location.split(":");
const repoFile = file ? `apps/ios/${file}` : "";
return {
file: repoFile,
line: line || "",
kind: String(finding.kind ?? ""),
name: String(finding.name ?? ""),
};
});
for (const row of rows) {
if (!row.file) continue;
const line = row.line ? `,line=${escapeCommandProperty(row.line)}` : "";
const title = `${row.kind || "Unused code"} ${row.name}`.trim();
console.log(`::error file=${escapeCommandProperty(row.file)}${line},title=Dead Swift code::${escapeCommandData(title)}`);
}
let shouldFail = "1";
let summary = "";
if (findings === null) {
summary = [
"### iOS Periphery",
"",
"Periphery did not complete. Check the workflow artifact for stdout/stderr.",
].join("\n");
} else if (rows.length === 0 && status === 0) {
shouldFail = "0";
summary = [
"### iOS Periphery",
"",
"No dead Swift code found.",
].join("\n");
} else if (rows.length > 0) {
summary = [
"### iOS Periphery",
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. See the PR comment or workflow artifact for details.`,
].join("\n");
} else {
summary = [
"### iOS Periphery",
"",
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
].join("\n");
}
fs.writeFileSync(path.join(outputDir, "should-fail.txt"), `${shouldFail}\n`);
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.trim()}\n`);
NODE
- name: Upload Periphery report
if: always()
uses: actions/upload-artifact@v7
with:
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ios-periphery
if-no-files-found: warn
retention-days: 14
- name: Fail on dead code
run: |
set -euo pipefail
test "$(cat "$RUNNER_TEMP/ios-periphery/should-fail.txt")" = "0"

View File

@@ -36,7 +36,7 @@ jobs:
password: ${{ github.token }}
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000

View File

@@ -437,8 +437,17 @@ jobs:
echo "::warning::Could not generate motion-trimmed desktop previews; continuing with screenshots and full MP4 links."
fi
baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")"
read_discord_status_reaction_status() {
local lane="$1"
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
jq -r '.entries[0].result.status' "$root/$lane/qa-evidence.json"
return
fi
jq -r '.scenarios[0].status' "$root/$lane/discord-qa-summary.json"
}
baseline_status="$(read_discord_status_reaction_status baseline)"
candidate_status="$(read_discord_status_reaction_status candidate)"
jq -n \
--arg baseline_status "$baseline_status" \

View File

@@ -451,8 +451,17 @@ jobs:
capture_candidate_discord_web
baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")"
candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")"
read_discord_thread_attachment_status() {
local lane="$1"
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json"
return
fi
jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json"
}
baseline_status="$(read_discord_thread_attachment_status baseline)"
candidate_status="$(read_discord_thread_attachment_status candidate)"
comparison_status="fail"
if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then
comparison_status="pass"

View File

@@ -379,7 +379,6 @@ jobs:
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 }}
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
@@ -445,8 +444,8 @@ jobs:
telegram_exit=$?
set -e
if [[ ! -f "$root/telegram-qa-summary.json" ]]; then
echo "Telegram live QA did not produce a summary." >&2
if [[ ! -f "$root/qa-evidence.json" && ! -f "$root/telegram-qa-summary.json" ]]; then
echo "Telegram live QA did not produce an evidence summary." >&2
exit "$telegram_exit"
fi
echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT"

View File

@@ -126,7 +126,7 @@ jobs:
fetch-depth: 1
- name: Set up Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -220,7 +220,6 @@ jobs:
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 }}
PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || '' }}
run: |

View File

@@ -420,6 +420,7 @@ jobs:
add_suite live-cache
add_profile_suite native-live-src-agents "stable full"
add_profile_suite native-live-src-agents-zai-coding "stable full"
add_profile_suite native-live-src-gateway-core "beta minimum stable full"
add_profile_suite native-live-src-gateway-profiles-anthropic "stable full"
add_profile_suite native-live-src-gateway-profiles-anthropic-smoke "stable"
@@ -1497,7 +1498,7 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.needs_build == '1'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -1593,8 +1594,11 @@ jobs:
run: |
set -euo pipefail
repository="${GITHUB_REPOSITORY,,}"
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}"
live_image_extensions="matrix,acpx"
live_image_tag_suffix="${live_image_extensions//,/-}"
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}"
echo "live_image=${live_image}" >> "$GITHUB_OUTPUT"
echo "live_image_extensions=${live_image_extensions}" >> "$GITHUB_OUTPUT"
echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Log in to GHCR
@@ -1617,7 +1621,7 @@ jobs:
- name: Setup Docker builder
if: steps.image_exists.outputs.exists != '1'
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
with:
max-cache-size-mb: 800000
@@ -1629,7 +1633,7 @@ jobs:
file: ./Dockerfile
target: build
build-args: |
OPENCLAW_EXTENSIONS=matrix
OPENCLAW_EXTENSIONS=${{ steps.image.outputs.live_image_extensions }}
platforms: linux/amd64
tags: ${{ steps.image.outputs.live_image }}
sbom: true
@@ -1745,6 +1749,7 @@ jobs:
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 ;;
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
@@ -1833,7 +1838,7 @@ jobs:
run: |
set -euo pipefail
all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks)
all_providers=(anthropic google minimax moonshot openai opencode-go openrouter xai zai fireworks)
normalize_provider() {
local value="${1,,}"
@@ -1919,6 +1924,7 @@ jobs:
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 ;;
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
openai) require_any OpenAI OPENAI_API_KEY ;;
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
@@ -1951,6 +1957,12 @@ jobs:
timeout_minutes: 60
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-agents-zai-coding
label: Native live Z.AI Coding Plan
command: ZAI_CODING_LIVE_TEST=1 node .release-harness/scripts/test-live-shard.mjs native-live-src-agents-zai-coding
timeout_minutes: 15
profile_env_only: false
profiles: stable full
- suite_id: native-live-src-gateway-core
label: Native live gateway core
command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core

View File

@@ -527,6 +527,13 @@ jobs:
cleanup_gateway
trap - EXIT
if node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:sqlite:perf:smoke'] && fs.existsSync('scripts/bench-sqlite-state.ts') ? 0 : 1)"; then
pnpm test:sqlite:perf:smoke
cp .artifacts/sqlite-perf/smoke.json "$SOURCE_PERF_DIR/sqlite-perf-smoke.json"
else
echo "SQLite state smoke probe is not available in ${TESTED_REF}; continuing with the remaining source probes." >> "$GITHUB_STEP_SUMMARY"
fi
summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
--source-dir "$SOURCE_PERF_DIR" \
--output "$SOURCE_PERF_DIR/index.md")
@@ -604,7 +611,7 @@ jobs:
## Source probes
Additional gateway boot, memory, plugin pressure, mock hello-loop, and CLI startup numbers are in [source/index.md](source/index.md).
Additional gateway boot, memory, plugin pressure, mock hello-loop, CLI startup, and SQLite state smoke numbers are in [source/index.md](source/index.md).
EOF
fi
fi

View File

@@ -1181,7 +1181,7 @@ jobs:
runtime_tool_coverage_release_checks:
name: Enforce QA Lab runtime tool coverage
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
@@ -1204,13 +1204,35 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Download runtime parity status
uses: actions/download-artifact@v8
with:
name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/
- name: Verify runtime parity producer status
id: verify_runtime_parity_status
shell: bash
run: |
set -euo pipefail
status_path=".artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env"
status="$(sed -n 's/^status=//p' "$status_path" | tail -n 1)"
if [[ "$status" != "success" ]]; then
echo "Runtime parity producer status is ${status:-missing}; skipping coverage artifact consumer."
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- name: Download runtime parity artifacts
if: steps.verify_runtime_parity_status.outputs.ready == 'true'
uses: actions/download-artifact@v8
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
- name: Enforce standard runtime tool coverage
if: steps.verify_runtime_parity_status.outputs.ready == 'true'
run: |
set -euo pipefail
pnpm openclaw qa coverage \
@@ -1412,7 +1434,6 @@ jobs:
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: |
set -euo pipefail

View File

@@ -15,6 +15,14 @@ on:
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
required: false
type: string
windows_node_tag:
description: Exact openclaw-windows-node release tag, required for stable OpenClaw publish
required: false
type: string
windows_node_installer_digests:
description: Candidate-approved compact JSON map of Windows installer names to pinned sha256 digests
required: false
type: string
npm_telegram_run_id:
description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence
required: false
@@ -81,12 +89,15 @@ jobs:
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}
steps:
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
@@ -115,6 +126,22 @@ jobs:
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
exit 1
fi
stable_release=true
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
stable_release=false
fi
if [[ -n "${WINDOWS_NODE_TAG}" && ! "${WINDOWS_NODE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then
echo "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: ${WINDOWS_NODE_TAG}" >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_TAG}" ]]; then
echo "Stable OpenClaw publish requires an explicit windows_node_tag." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${stable_release}" == "true" && -z "${WINDOWS_NODE_INSTALLER_DIGESTS}" ]]; then
echo "Stable OpenClaw publish requires candidate-approved windows_node_installer_digests." >&2
exit 1
fi
tideclaw_alpha_publish=false
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_publish=true
@@ -143,6 +170,73 @@ jobs:
;;
esac
- name: Validate stable Windows source release
id: windows_source
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
APPROVED_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
run: |
set -euo pipefail
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
exit 0
fi
source_json="$(gh release view "${WINDOWS_NODE_TAG}" \
--repo openclaw/openclaw-windows-node \
--json tagName,isDraft,isPrerelease,assets,url)"
if [[ "$(printf '%s' "${source_json}" | jq -r '.tagName')" != "${WINDOWS_NODE_TAG}" ]]; then
echo "Windows source release tag does not match ${WINDOWS_NODE_TAG}." >&2
exit 1
fi
if [[ "$(printf '%s' "${source_json}" | jq -r '.isDraft')" == "true" ]]; then
echo "Stable OpenClaw publish requires a published Windows source release." >&2
exit 1
fi
if [[ "$(printf '%s' "${source_json}" | jq -r '.isPrerelease')" == "true" ]]; then
echo "Stable OpenClaw publish requires a non-prerelease Windows source release." >&2
exit 1
fi
required_assets=(
"OpenClawCompanion-Setup-x64.exe"
"OpenClawCompanion-Setup-arm64.exe"
)
required_assets_json="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc .)"
if ! approved_installer_digests="$(printf '%s' "${APPROVED_INSTALLER_DIGESTS}" | jq -ce --argjson names "${required_assets_json}" '
if type == "object" and
(keys | sort) == ($names | sort) and
all(.[]; type == "string" and test("^sha256:[a-f0-9]{64}$"))
then .
else error("invalid candidate-approved Windows installer digest map")
end
')"; then
echo "windows_node_installer_digests must contain exactly the candidate-approved current installer asset contract." >&2
exit 1
fi
for asset_name in "${required_assets[@]}"; do
asset_matches="$(printf '%s' "${source_json}" | jq -c --arg name "${asset_name}" '[.assets[]? | select(.name == $name)]')"
asset_match_count="$(printf '%s' "${asset_matches}" | jq 'length')"
if [[ "${asset_match_count}" != "1" ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} must contain exactly one required asset ${asset_name}; found ${asset_match_count}." >&2
exit 1
fi
asset_digest="$(printf '%s' "${asset_matches}" | jq -r '.[0].digest // empty')"
if [[ ! "${asset_digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} is missing its immutable SHA-256 digest." >&2
exit 1
fi
approved_digest="$(printf '%s' "${approved_installer_digests}" | jq -r --arg name "${asset_name}" '.[$name]')"
if [[ "${asset_digest}" != "${approved_digest}" ]]; then
echo "Windows source release ${WINDOWS_NODE_TAG} asset ${asset_name} no longer matches its candidate-approved digest." >&2
exit 1
fi
done
echo "installer_digests=${approved_installer_digests}" >> "$GITHUB_OUTPUT"
echo "- Windows Node source release: prevalidated \`${WINDOWS_NODE_TAG}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Download OpenClaw npm preflight manifest
id: preflight_artifact
if: ${{ inputs.publish_openclaw_npm }}
@@ -337,6 +431,7 @@ jobs:
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
run: |
{
echo "### Release target"
@@ -347,13 +442,16 @@ jobs:
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ID// }" ]]; then
echo "- Full release validation: \`${FULL_RELEASE_VALIDATION_RUN_ID}\`"
fi
if [[ -n "${WINDOWS_NODE_TAG// }" ]]; then
echo "- Windows Node source release: \`${WINDOWS_NODE_TAG}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
publish:
name: Publish plugins, then OpenClaw
needs: [resolve_release_target]
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 120
environment: npm-release
steps:
- name: Checkout release SHA
@@ -383,11 +481,19 @@ jobs:
WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }}
PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }}
NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }}
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
run: |
set -euo pipefail
dispatch_workflow() {
is_stable_release() {
[[ "${RELEASE_TAG}" != *"-alpha."* && "${RELEASE_TAG}" != *"-beta."* ]]
}
dispatch_workflow_at_ref() {
local workflow_ref="$1"
shift
local workflow="$1"
shift
@@ -397,7 +503,7 @@ jobs:
-F per_page=100 \
--jq '[.workflow_runs[].id]')"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
printf '%s\n' "$dispatch_output" >&2
run_id="$(
printf '%s\n' "$dispatch_output" |
@@ -432,6 +538,10 @@ jobs:
printf '%s\n' "${run_id}"
}
dispatch_workflow() {
dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@"
}
print_pending_deployments() {
local workflow="$1"
local run_id="$2"
@@ -653,6 +763,128 @@ jobs:
done
}
guard_existing_public_release() {
local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then
return 0
fi
is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')"
if [[ "${is_draft}" == "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
asset_name="openclaw-${release_version}-dependency-evidence.zip"
has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')"
has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')"
has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')"
release_url="$(printf '%s' "${release_json}" | jq -r '.url')"
if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then
return 0
fi
{
echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}."
echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}"
echo "Create a new beta tag or delete/draft the incomplete public release before retrying."
} >&2
exit 1
}
guard_openclaw_npm_not_already_published() {
local release_version release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then
return 0
fi
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}"
{
echo "openclaw@${release_version} is already published on npm."
echo "Refusing to dispatch publish child workflows for an already-published version."
echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version."
echo "Release page, if present: ${release_url}"
} >&2
exit 1
}
resolve_clawhub_release_plan() {
local -a plan_args
clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
plan_args=(
--release-tag "${RELEASE_TAG}"
--release-publish-branch "${CHILD_WORKFLOW_REF}"
--release-publish-run-id "${GITHUB_RUN_ID}"
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
)
if [[ -n "${PLUGINS// }" ]]; then
plan_args+=(--plugins "${PLUGINS}")
fi
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}"
echo "Resolved OpenClaw release ClawHub dispatch plan:"
cat "${clawhub_plan_path}"
clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")"
normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")"
bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")"
missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")"
normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")"
bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")"
missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")"
{
echo "### ClawHub release plan"
echo
echo "- Normal OIDC candidates: \`${normal_plugin_count}\`"
echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`"
echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`"
if [[ -n "${normal_plugins}" ]]; then
echo "- Normal plugins: \`${normal_plugins}\`"
fi
if [[ -n "${bootstrap_plugins}" ]]; then
echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`"
fi
if [[ -n "${missing_trusted_plugins}" ]]; then
echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
}
append_clawhub_dispatch_args() {
local target="$1"
while IFS=$'\t' read -r key value; do
clawhub_dispatch_args+=(-f "${key}=${value}")
done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}")
}
write_clawhub_runtime_state() {
local force_skip_clawhub="$1"
local output_path="$2"
node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \
--repository "${GITHUB_REPOSITORY}" \
--wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \
--force-skip-clawhub "${force_skip_clawhub}" \
--normal-run-id "${plugin_clawhub_run_id:-}" \
--bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \
--bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}"
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -698,14 +930,115 @@ jobs:
else
gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--title "${title}" \
--notes-file "${notes_file}" \
"${prerelease_args[@]}" \
"${latest_arg}"
fi
echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
publish_github_release() {
if is_stable_release; then
verify_windows_release_asset_contract
fi
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
verify_windows_release_asset_contract() {
local actual_companion_assets actual_digest asset_name expected_companion_assets expected_digest expected_hash expected_installer_names manifest_dir manifest_json manifest_path release_json
# Add future promoted installer names, such as MSIX x64/ARM64, here.
local -a installer_assets=(
"OpenClawCompanion-Setup-x64.exe"
"OpenClawCompanion-Setup-arm64.exe"
)
local -a required_assets=(
"${installer_assets[@]}"
"OpenClawCompanion-SHA256SUMS.txt"
)
release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json assets,url)"
expected_companion_assets="$(printf '%s\n' "${required_assets[@]}" | jq -R . | jq -sc 'sort')"
actual_companion_assets="$(printf '%s' "${release_json}" | jq -c '
[.assets[]? | select(.name | startswith("OpenClawCompanion-")) | .name] | sort
')"
if [[ "${actual_companion_assets}" != "${expected_companion_assets}" ]]; then
echo "Stable release OpenClawCompanion asset names do not exactly match the current contract." >&2
return 1
fi
for asset_name in "${required_assets[@]}"; do
if ! printf '%s' "${release_json}" | jq -e --arg name "${asset_name}" 'any(.assets[]?; .name == $name)' >/dev/null; then
echo "Stable release is missing required Windows asset ${asset_name}." >&2
return 1
fi
done
manifest_dir="${RUNNER_TEMP}/openclaw-windows-release-contract"
manifest_path="${manifest_dir}/OpenClawCompanion-SHA256SUMS.txt"
rm -rf "${manifest_dir}"
mkdir -p "${manifest_dir}"
gh release download "${RELEASE_TAG}" \
--repo "$GITHUB_REPOSITORY" \
--pattern "OpenClawCompanion-SHA256SUMS.txt" \
--dir "${manifest_dir}"
if ! manifest_json="$(jq -Rsc '
split("\n") as $lines |
(if $lines[-1] == "" then $lines[0:-1] else $lines end) |
map(sub("\r$"; "")) |
if all(.[]; test("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
then map(capture("^(?<hash>[a-f0-9]{64}) (?<name>[^/\\\\]+)$"))
else error("malformed Windows checksum manifest entry")
end
' "${manifest_path}")"; then
echo "Stable release Windows checksum manifest contains malformed entries." >&2
return 1
fi
expected_installer_names="$(printf '%s\n' "${installer_assets[@]}" | jq -R . | jq -sc 'sort')"
if ! printf '%s' "${manifest_json}" | jq -e --argjson expected "${expected_installer_names}" '
length == ($expected | length) and
([.[].name] | sort) == $expected and
([.[].name] | unique | length) == length
' >/dev/null; then
echo "Stable release Windows checksum manifest does not exactly match the installer asset contract." >&2
return 1
fi
for asset_name in "${installer_assets[@]}"; do
expected_digest="$(printf '%s' "${WINDOWS_NODE_INSTALLER_DIGESTS}" | jq -r --arg name "${asset_name}" '.[$name] // empty')"
actual_digest="$(printf '%s' "${release_json}" | jq -r --arg name "${asset_name}" '.assets[]? | select(.name == $name) | .digest // empty')"
if [[ -z "${expected_digest}" || "${actual_digest}" != "${expected_digest}" ]]; then
echo "Stable release Windows asset ${asset_name} does not match its pinned digest." >&2
return 1
fi
expected_hash="${expected_digest#sha256:}"
if ! printf '%s' "${manifest_json}" | jq -e --arg name "${asset_name}" --arg hash "${expected_hash}" '
any(.[]; .name == $name and .hash == $hash)
' >/dev/null; then
echo "Stable release Windows checksum manifest does not match pinned digest for ${asset_name}." >&2
return 1
fi
done
echo "- Windows Hub asset contract: verified" >> "$GITHUB_STEP_SUMMARY"
}
promote_windows_release_assets() {
if ! is_stable_release; then
return 0
fi
if [[ -z "${WINDOWS_NODE_INSTALLER_DIGESTS// }" ]]; then
echo "Stable release is missing prevalidated Windows installer digests." >&2
return 1
fi
windows_node_run_id="$(dispatch_workflow windows-node-release.yml \
-f tag="${RELEASE_TAG}" \
-f windows_node_tag="${WINDOWS_NODE_TAG}" \
-f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}")"
echo "- Windows Node release run ID: \`${windows_node_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
wait_for_run windows-node-release.yml "${windows_node_run_id}"
}
upload_dependency_evidence_release_asset() {
local release_version download_dir asset_path asset_name artifact_name
release_version="${RELEASE_TAG#v}"
@@ -735,9 +1068,11 @@ jobs:
}
verify_published_release() {
local release_version evidence_path
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
local -a verify_args
skip_clawhub="${1:-false}"
release_version="${RELEASE_TAG#v}"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
@@ -750,16 +1085,18 @@ jobs:
--dist-tag "${RELEASE_NPM_DIST_TAG}"
--repo "${GITHUB_REPOSITORY}"
--workflow-ref "${CHILD_WORKFLOW_REF}"
--clawhub-workflow-ref "${clawhub_workflow_ref}"
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
--plugin-npm-run "${plugin_npm_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
--skip-github-release
)
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
else
verify_args+=(--skip-clawhub)
fi
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json"
write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}"
while IFS= read -r arg; do
verify_args+=("${arg}")
done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}")
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
fi
@@ -775,7 +1112,7 @@ jobs:
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path windows_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
@@ -789,16 +1126,20 @@ jobs:
else
telegram_line="- npm Telegram beta E2E: not supplied"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
else
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json"
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
windows_line=""
if [[ -n "${windows_node_run_id// }" ]]; then
windows_line="- Windows Hub promotion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${windows_node_run_id} from openclaw/openclaw-windows-node@${WINDOWS_NODE_TAG}"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_SHA="${TARGET_SHA}" \
RELEASE_REPO="${GITHUB_REPOSITORY}" \
RELEASE_TARBALL="${tarball}" \
RELEASE_INTEGRITY="${integrity}" \
@@ -808,7 +1149,9 @@ jobs:
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
CLAWHUB_LINE="${clawhub_line}" \
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
TELEGRAM_LINE="${telegram_line}" \
WINDOWS_LINE="${windows_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
@@ -825,14 +1168,17 @@ 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}\``,
`- release SHA: \`${process.env.RELEASE_SHA}\``,
`- 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}`,
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
process.env.CLAWHUB_LINE,
process.env.CLAWHUB_BOOTSTRAP_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
...(process.env.WINDOWS_LINE ? [process.env.WINDOWS_LINE] : []),
].join("\n");
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
@@ -847,6 +1193,7 @@ jobs:
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
@@ -856,6 +1203,9 @@ jobs:
else
echo "- OpenClaw npm publish: skipped by input"
fi
if is_stable_release && [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- Windows Hub promotion: required before the GitHub release can be published"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "- Workflow completion waits for ClawHub"
else
@@ -863,26 +1213,68 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
guard_existing_public_release
guard_openclaw_npm_not_already_published
resolve_clawhub_release_plan
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
clawhub_args+=(-f plugins="${PLUGINS}")
fi
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
plugin_clawhub_run_id=""
if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args normal
plugin_clawhub_run_id="$(dispatch_workflow_at_ref \
"$(jq -r '.normal.ref' "${clawhub_plan_path}")" \
"$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
else
echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY"
fi
plugin_clawhub_bootstrap_run_id=""
plugin_clawhub_bootstrap_completed="false"
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
clawhub_dispatch_args=()
append_clawhub_dispatch_args bootstrap
plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \
"$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \
"$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \
"${clawhub_dispatch_args[@]}")"
else
echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY"
fi
{
echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`"
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`"
echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`"
} >> "$GITHUB_STEP_SUMMARY"
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2
if [[ -n "${plugin_clawhub_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true
fi
exit 1
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish."
if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
plugin_clawhub_bootstrap_completed="true"
else
if [[ -n "${plugin_clawhub_run_id}" ]]; then
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
fi
exit 1
fi
fi
openclaw_npm_run_id=""
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
@@ -899,19 +1291,52 @@ jobs:
clawhub_result=""
clawhub_pid=""
clawhub_bootstrap_result=""
clawhub_bootstrap_pid=""
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
else
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
if [[ -n "${plugin_clawhub_run_id}" ]]; then
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}"
clawhub_bootstrap_pid="${wait_run_pid}"
fi
fi
else
if [[ -n "${plugin_clawhub_run_id}" ]]; then
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
else
echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
else
wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
:
else
echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
openclaw_result=""
@@ -925,6 +1350,7 @@ jobs:
failed=0
openclaw_failed=0
windows_node_run_id=""
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
openclaw_failed=1
@@ -934,21 +1360,36 @@ jobs:
openclaw_failed=1
fi
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
create_or_update_github_release
upload_dependency_evidence_release_asset
fi
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
failed=1
fi
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
failed=1
fi
if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then
failed=1
fi
if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then
failed=1
fi
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
if [[ "${failed}" == "0" ]]; then
verify_published_release
else
verify_published_release true
fi
create_or_update_github_release
upload_dependency_evidence_release_asset
if ! promote_windows_release_assets; then
failed=1
fi
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release
else
echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY"
fi
fi
if [[ "${failed}" != "0" ]]; then
exit 1

504
.github/workflows/plugin-clawhub-new.yml vendored Normal file
View File

@@ -0,0 +1,504 @@
name: Plugin ClawHub New
on:
workflow_dispatch:
inputs:
plugins:
description: Comma-separated plugin package names to bootstrap on ClawHub
required: true
type: string
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
dry_run:
description: Validate the token-gated ClawHub bootstrap handoff without publishing.
required: false
default: false
type: boolean
concurrency:
group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
jobs:
resolve_bootstrap_plan:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Resolve checked-out ref
id: ref
env:
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
if [[ -n "${TARGET_REF}" ]]; then
if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "${TARGET_REF}^{commit}")"
elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then
target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")"
else
echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2
exit 1
fi
git checkout --detach "${target_sha}"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on a trusted publish branch
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
while IFS= read -r release_ref; do
if git merge-base --is-ancestor HEAD "${release_ref}"; then
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
fi
fi
echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Validate publishable plugin metadata
env:
RELEASE_PLUGINS: ${{ inputs.plugins }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PLUGINS// }" ]]; then
echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2
exit 1
fi
pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}"
- name: Resolve plugin bootstrap plan
id: plan
env:
RELEASE_PLUGINS: ${{ inputs.plugins }}
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: |
set -euo pipefail
mkdir -p .local
node --import tsx scripts/plugin-clawhub-release-plan.ts \
--selection-mode selected \
--plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json
cat .local/plugin-clawhub-release-plan.json
bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)"
selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)"
matrix_json="$(
jq -c '
[
.bootstrapCandidates[]? + {
bootstrapMode: "publish",
requiresManualOverride: false
},
.missingTrustedPublisher[]? + {
bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end),
requiresManualOverride: true
}
]
' .local/plugin-clawhub-release-plan.json
)"
has_bootstrap_candidates="false"
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
has_bootstrap_candidates="true"
fi
invalid_scope="$(
jq -r '
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
| select(.packageName | startswith("@openclaw/") | not)
| "- \(.packageName)@\(.version)"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid_scope}" ]]; then
echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2
printf '%s\n' "${invalid_scope}" >&2
exit 1
fi
not_bootstrap="$(
jq -r '
(.bootstrapCandidates | map(.packageName)) as $bootstrapNames
| (.missingTrustedPublisher | map(.packageName)) as $repairNames
| .all[]?
| select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not))
| "- \(.packageName)@\(.version)"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${not_bootstrap}" ]]; then
echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2
printf '%s\n' "${not_bootstrap}" >&2
exit 1
fi
if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then
echo "No selected packages require ClawHub bootstrap." >&2
exit 1
fi
{
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
echo "matrix=${matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "ClawHub bootstrap candidates:"
jq -r '
.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"
' .local/plugin-clawhub-release-plan.json
echo "ClawHub trusted-publisher repair candidates:"
jq -r '
.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)"
' .local/plugin-clawhub-release-plan.json
- name: Validate Tideclaw alpha plugin channels
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
exit 0
fi
invalid="$(
jq -r '
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
| select(.publishTag != "alpha" or .channel != "alpha")
| "- \(.packageName)@\(.version) [\(.publishTag)]"
' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid}" ]]; then
echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2
printf '%s\n' "${invalid}" >&2
exit 1
fi
validate_release_publish_approval:
name: Validate release publish approval
needs: resolve_bootstrap_plan
if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
direct_recovery=true
echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
validate_bootstrap_trusted_publisher_cli:
needs: [resolve_bootstrap_plan, validate_release_publish_approval]
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate pinned ClawHub trusted publisher CLI support
env:
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
run: |
set -euo pipefail
help_output="$(
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
clawhub package trusted-publisher set --help 2>&1 || true
)"
printf '%s\n' "${help_output}"
if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row."
exit 1
fi
for required_flag in --repository --workflow-filename; do
if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}."
exit 1
fi
done
publish_bootstrap_plugins:
needs:
[
resolve_bootstrap_plan,
validate_release_publish_approval,
validate_bootstrap_trusted_publisher_cli,
]
if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success')
runs-on: ubuntu-latest
environment: clawhub-plugin-bootstrap
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 8
matrix:
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "true"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Install pinned ClawHub CLI wrapper
run: |
set -euo pipefail
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
EOF
chmod +x "${RUNNER_TEMP}/clawhub"
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
- name: Write ClawHub token config
if: inputs.dry_run != true
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
run: |
set -euo pipefail
config_path="${RUNNER_TEMP}/clawhub-config.json"
CONFIG_PATH="${config_path}" node --input-type=module <<'NODE'
import { writeFileSync } from "node:fs";
const registry = process.env.CLAWHUB_REGISTRY?.trim();
const token = process.env.CLAWHUB_TOKEN?.trim();
const configPath = process.env.CONFIG_PATH;
if (!registry) {
throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.");
}
if (!token) {
throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.");
}
if (!configPath) {
throw new Error("CONFIG_PATH is required.");
}
writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
NODE
echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}"
- name: Publish ClawHub bootstrap package
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}
REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"
run: |
set -euo pipefail
if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then
echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only."
elif [[ "${DRY_RUN}" == "true" ]]; then
bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
else
if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then
export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"
fi
bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
fi
- name: Configure trusted publisher for normal OIDC releases
if: inputs.dry_run != true
env:
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
run: |
set -euo pipefail
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
clawhub package trusted-publisher set "${PACKAGE_NAME}" \
--repository openclaw/openclaw \
--workflow-filename plugin-clawhub-release.yml
verify_bootstrap_clawhub_package:
needs: [resolve_bootstrap_plan, publish_bootstrap_plugins]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 8
matrix:
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
steps:
- name: Verify bootstrap ClawHub package and trusted publisher
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
run: |
set -euo pipefail
node --input-type=module <<'EOF'
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
const packageName = process.env.PACKAGE_NAME;
const packageVersion = process.env.PACKAGE_VERSION;
const packageTag = process.env.PACKAGE_TAG;
if (!packageName || !packageVersion || !packageTag) {
throw new Error("Missing ClawHub bootstrap verification env.");
}
const encodedName = encodeURIComponent(packageName);
const encodedVersion = encodeURIComponent(packageVersion);
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
const trustedPublisherUrl = `${detailUrl}/trusted-publisher`;
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
const artifactUrl = `${versionUrl}/artifact/download`;
async function fetchWithRetry(url, options = {}) {
let lastStatus = "unknown";
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
} catch (error) {
lastStatus = error instanceof Error ? error.message : String(error);
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
}
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
}
const detailResponse = await fetchWithRetry(detailUrl, {
headers: { accept: "application/json" },
});
if (!detailResponse.ok) {
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
}
const detail = await detailResponse.json();
const tags = detail?.package?.tags ?? {};
if (tags[packageTag] !== packageVersion) {
throw new Error(
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
);
}
const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, {
headers: { accept: "application/json" },
});
if (!trustedPublisherResponse.ok) {
throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`);
}
const trustedPublisherDetail = await trustedPublisherResponse.json();
const trustedPublisher = trustedPublisherDetail?.trustedPublisher;
if (
trustedPublisher?.repository !== "openclaw/openclaw" ||
trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" ||
trustedPublisher?.environment != null
) {
throw new Error(
`${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`,
);
}
const versionResponse = await fetchWithRetry(versionUrl);
if (!versionResponse.ok) {
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
}
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
}
console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`);
EOF

View File

@@ -16,7 +16,7 @@ on:
required: false
type: string
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref
required: false
default: ""
type: string
@@ -24,6 +24,10 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
release_publish_branch:
description: Branch name of the approving OpenClaw Release Publish workflow run
required: false
type: string
dry_run:
description: Validate the full ClawHub artifact handoff without publishing.
required: false
@@ -38,9 +42,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.15.0"
CLAWHUB_REGISTRY: "https://clawhub.ai"
CLAWHUB_REPOSITORY: "openclaw/clawhub"
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
jobs:
preview_plugins_clawhub:
@@ -50,9 +52,15 @@ jobs:
outputs:
ref_revision: ${{ steps.ref.outputs.sha }}
has_candidates: ${{ steps.plan.outputs.has_candidates }}
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }}
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
matrix: ${{ steps.plan.outputs.matrix }}
bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }}
missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -83,9 +91,27 @@ jobs:
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate OIDC source matches workflow ref
env:
TARGET_SHA: ${{ steps.ref.outputs.sha }}
WORKFLOW_SHA: ${{ github.sha }}
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
run: |
set -euo pipefail
if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then
if [[ "${DRY_RUN}" == "true" ]]; then
echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch."
exit 0
fi
echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2
echo "The ref input is only supported for dry_run=true." >&2
echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2
exit 1
fi
- name: Validate ref is on a trusted publish branch
env:
WORKFLOW_REF: ${{ github.ref }}
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
@@ -96,8 +122,8 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${WORKFLOW_REF#refs/heads/}"
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
@@ -158,36 +184,78 @@ jobs:
cat .local/plugin-clawhub-release-plan.json
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)"
missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)"
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
has_candidates="false"
if [[ "${candidate_count}" != "0" ]]; then
has_candidates="true"
fi
has_bootstrap_candidates="false"
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
has_bootstrap_candidates="true"
fi
has_missing_trusted_publisher="false"
if [[ "${missing_trusted_publisher_count}" != "0" ]]; then
has_missing_trusted_publisher="true"
fi
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)"
missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)"
{
echo "candidate_count=${candidate_count}"
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}"
echo "skipped_published_count=${skipped_published_count}"
echo "has_candidates=${has_candidates}"
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}"
echo "matrix=${matrix_json}"
echo "bootstrap_matrix=${bootstrap_matrix_json}"
echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "Plugin release candidates:"
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Bootstrap candidates requiring token bootstrap:"
jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Missing trusted publisher candidates:"
jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
echo "Already published / skipped:"
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
- name: Fail when trusted publisher is missing
if: steps.plan.outputs.missing_trusted_publisher_count != '0'
run: |
echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow."
jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json
exit 1
- name: Fail normal publish when bootstrap is required
if: steps.plan.outputs.bootstrap_candidate_count != '0'
run: |
echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run."
jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json
exit 1
- name: Fail manual publish when target versions already exist
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
run: |
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
exit 1
- name: Validate Tideclaw alpha plugin channels
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
env:
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
exit 0
fi
invalid="$(
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
)"
@@ -197,12 +265,6 @@ jobs:
exit 1
fi
- name: Verify OpenClaw ClawHub package ownership
if: steps.plan.outputs.has_candidates == 'true'
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_clawhub
@@ -221,7 +283,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
@@ -240,99 +302,8 @@ jobs:
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 12
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ github.ref }}
fetch-depth: 0
- name: Checkout target revision
env:
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
run: |
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
git checkout --detach "${TARGET_SHA}"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
install-deps: "true"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: main
path: clawhub-source
fetch-depth: 0
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Preview publish command
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
SOURCE_REF: ${{ github.ref }}
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
pack_plugins_clawhub_artifacts:
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
needs: [preview_plugins_clawhub, validate_release_publish_approval]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
@@ -367,47 +338,19 @@ jobs:
install-bun: "true"
install-deps: "true"
- name: Checkout ClawHub CLI source
uses: actions/checkout@v6
with:
persist-credentials: false
repository: ${{ env.CLAWHUB_REPOSITORY }}
ref: main
path: clawhub-source
fetch-depth: 0
- name: Verify package-local runtime build
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
- name: Checkout pinned ClawHub CLI revision
working-directory: clawhub-source
env:
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
run: git checkout --detach "${CLAWHUB_REF}"
- name: Install ClawHub CLI dependencies
working-directory: clawhub-source
- name: Install pinned ClawHub CLI wrapper
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
exit 0
fi
status="$?"
if [[ "${attempt}" == "3" ]]; then
exit "${status}"
fi
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
sleep $((attempt * 15))
done
- name: Bootstrap ClawHub CLI
run: |
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
EOF
chmod +x "$RUNNER_TEMP/clawhub"
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
chmod +x "${RUNNER_TEMP}/clawhub"
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
- name: Pack ClawHub package artifact
env:
@@ -428,19 +371,23 @@ jobs:
if-no-files-found: error
retention-days: 7
approve_plugin_clawhub_release:
approve_plugins_clawhub_release:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions: {}
permissions:
contents: read
steps:
- name: Approve ClawHub package publish
run: echo "ClawHub package publish approved."
- name: Approve Plugin ClawHub release publish
run: |
echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate."
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
needs:
[preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release]
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success')
uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721
permissions:
actions: read
contents: read
@@ -450,19 +397,18 @@ jobs:
max-parallel: 32
matrix:
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
with:
dry_run: ${{ inputs.dry_run }}
json: true
package_artifact_name: ${{ matrix.plugin.artifactName }}
dry_run: ${{ inputs.dry_run }}
registry: https://clawhub.ai
site: https://clawhub.ai
tags: ${{ matrix.plugin.publishTag }}
source_repo: ${{ github.repository }}
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
source_ref: ${{ github.ref }}
tags: ${{ matrix.plugin.publishTag }}
secrets:
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
source_path: ${{ matrix.plugin.packageDir }}
inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector
publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json
verify_published_clawhub_package:
needs: [preview_plugins_clawhub, publish_plugins_clawhub]

View File

@@ -288,6 +288,7 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
- name: Verify published runtime

View File

@@ -159,7 +159,7 @@ jobs:
run_mock_parity:
name: Run QA Lab mock parity lane
needs: [validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 30
env:
QA_PARITY_CONCURRENCY: "1"
@@ -186,7 +186,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run OpenAI candidate lane
@@ -232,7 +232,7 @@ jobs:
name: Run live runtime token-efficiency lane
needs: [authorize_actor, validate_selected_ref]
if: github.event_name == 'schedule'
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 45
environment: qa-live-shared
env:
@@ -267,7 +267,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run live runtime parity lane
@@ -321,7 +321,7 @@ jobs:
name: Run Matrix live QA lane
needs: [authorize_actor, validate_selected_ref]
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all') }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
@@ -352,7 +352,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Matrix live lane
@@ -397,7 +397,7 @@ jobs:
name: Run Matrix live QA lane (${{ matrix.profile }})
needs: [authorize_actor, validate_selected_ref]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
strategy:
@@ -437,7 +437,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Matrix live lane shard
@@ -480,7 +480,7 @@ jobs:
run_live_telegram:
name: Run Telegram live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
@@ -520,7 +520,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Telegram live lane
@@ -532,7 +532,6 @@ jobs:
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 || '' }}
run: |
set -euo pipefail
@@ -575,7 +574,7 @@ jobs:
run_live_discord:
name: Run Discord live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
@@ -615,7 +614,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Discord live lane
@@ -669,7 +668,7 @@ jobs:
run_live_whatsapp:
name: Run WhatsApp live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
concurrency:
group: qa-live-whatsapp-shared
@@ -712,7 +711,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run WhatsApp live lane
@@ -766,7 +765,7 @@ jobs:
run_live_slack:
name: Run Slack live QA lane with Convex leases
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 60
environment: qa-live-shared
steps:
@@ -806,7 +805,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=12288
run: pnpm build
- name: Run Slack live lane

View File

@@ -68,7 +68,7 @@ jobs:
days-before-pr-close: 7
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
@@ -100,7 +100,7 @@ jobs:
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
operations-per-run: 2000
ascending: true
include-only-assigned: true
@@ -172,7 +172,7 @@ jobs:
days-before-pr-close: 7
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
exempt-pr-labels: maintainer,no-stale,bad-barnacle
operations-per-run: 2000
ascending: true
@@ -203,7 +203,7 @@ jobs:
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: stale
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle,clawsweeper:queueable-fix,clawsweeper:source-repro,clawsweeper:fix-shape-clear
operations-per-run: 2000
ascending: true
include-only-assigned: true
@@ -277,6 +277,9 @@ jobs:
"security",
"no-stale",
"bad-barnacle",
"clawsweeper:queueable-fix",
"clawsweeper:source-repro",
"clawsweeper:fix-shape-clear",
]);
const prExemptLabels = new Set(["maintainer", "no-stale", "bad-barnacle"]);
const maintainerAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
@@ -509,60 +512,62 @@ jobs:
let locked = 0;
let inspected = 0;
let cursor = null;
let page = 1;
while (true) {
const { data: issues } = await github.rest.issues.listForRepo({
owner,
repo,
state: "closed",
sort: "updated",
direction: "desc",
per_page: perPage,
page,
});
const result = await github.graphql(
`query ClosedIssuesForLocking(
$owner: String!
$repo: String!
$cursor: String
$perPage: Int!
) {
repository(owner: $owner, name: $repo) {
issues(
first: $perPage
after: $cursor
states: CLOSED
orderBy: { field: CREATED_AT, direction: ASC }
) {
nodes {
number
locked
closedAt
comments(last: 1) {
nodes {
createdAt
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}`,
{
owner,
repo,
cursor,
perPage,
},
);
const issues = result.repository.issues;
if (issues.length === 0) {
break;
}
for (const issue of issues) {
if (issue.pull_request) {
continue;
}
if (issue.locked) {
continue;
}
if (!issue.closed_at) {
for (const issue of issues.nodes) {
if (issue.locked || !issue.closedAt) {
continue;
}
inspected += 1;
const closedAtMs = Date.parse(issue.closed_at);
if (!Number.isFinite(closedAtMs)) {
continue;
}
if (closedAtMs > cutoffMs) {
const closedAtMs = Date.parse(issue.closedAt);
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
continue;
}
let lastCommentMs = 0;
if (issue.comments > 0) {
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 1,
page: 1,
sort: "created",
direction: "desc",
});
if (comments.length > 0) {
lastCommentMs = Date.parse(comments[0].created_at);
}
}
const lastComment = issue.comments.nodes[0];
const lastCommentMs = lastComment ? Date.parse(lastComment.createdAt) : 0;
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
if (lastActivityMs > cutoffMs) {
continue;
@@ -578,7 +583,10 @@ jobs:
locked += 1;
}
page += 1;
if (!issues.pageInfo.hasNextPage || !issues.pageInfo.endCursor) {
break;
}
cursor = issues.pageInfo.endCursor;
}
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);

View File

@@ -8,9 +8,12 @@ on:
required: true
type: string
windows_node_tag:
description: openclaw-windows-node release tag to promote, or latest
description: Exact openclaw-windows-node release tag to promote, for example v0.6.3
required: true
type: string
expected_installer_digests:
description: Compact JSON map of installer asset names to pinned source sha256 digests
required: true
default: latest
type: string
permissions:
@@ -31,46 +34,129 @@ jobs:
env:
RELEASE_TAG: ${{ inputs.tag }}
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
GH_TOKEN: ${{ github.token }}
run: |
if ($env:RELEASE_TAG -notmatch '^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$') {
throw "Invalid OpenClaw release tag: $env:RELEASE_TAG"
}
if ($env:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') {
throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG"
$stableRelease = -not (
$env:RELEASE_TAG.Contains("-alpha.") -or
$env:RELEASE_TAG.Contains("-beta.")
)
if ($env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$') {
throw "windows_node_tag must be an explicit openclaw-windows-node release tag, not latest: $env:WINDOWS_NODE_TAG"
}
try {
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
} catch {
throw "expected_installer_digests must be a JSON object: $_"
}
# Add future signed installer names, such as MSIX x64/ARM64, here.
$requiredInstallerNames = @(
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe"
)
$allowedTargetCompanionAssetNames = @(
$requiredInstallerNames
"OpenClawCompanion-SHA256SUMS.txt"
)
if ($expectedDigests.Count -ne $requiredInstallerNames.Count) {
throw "expected_installer_digests must contain exactly the current installer asset contract."
}
foreach ($name in $requiredInstallerNames) {
$digest = [string]$expectedDigests[$name]
if ($digest -notmatch '^sha256:[A-Fa-f0-9]{64}$') {
throw "expected_installer_digests is missing a valid pinned digest for $name."
}
}
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
if ($targetRelease.tagName -ne $env:RELEASE_TAG) {
throw "OpenClaw release tag mismatch: expected $env:RELEASE_TAG, got $($targetRelease.tagName)"
}
$unexpectedTargetCompanionAssets = @(
$targetRelease.assets |
Where-Object {
$_.name.StartsWith("OpenClawCompanion-") -and
$_.name -notin $allowedTargetCompanionAssetNames
} |
ForEach-Object name |
Sort-Object
)
if ($unexpectedTargetCompanionAssets.Count -ne 0) {
throw "Target OpenClaw release contains unexpected OpenClawCompanion assets before upload: $($unexpectedTargetCompanionAssets -join ', ')"
}
$sourceRelease = gh release view $env:WINDOWS_NODE_TAG --repo openclaw/openclaw-windows-node --json tagName,isDraft,isPrerelease,assets,url | ConvertFrom-Json
if ($sourceRelease.tagName -ne $env:WINDOWS_NODE_TAG) {
throw "Windows source release tag mismatch: expected $env:WINDOWS_NODE_TAG, got $($sourceRelease.tagName)"
}
if ($sourceRelease.isDraft) {
throw "Windows source release must be published: $($sourceRelease.url)"
}
if ($stableRelease -and $sourceRelease.isPrerelease) {
throw "Stable OpenClaw releases require a non-prerelease Windows source release: $($sourceRelease.url)"
}
foreach ($name in $requiredInstallerNames) {
$sourceAssets = @($sourceRelease.assets | Where-Object name -eq $name)
if ($sourceAssets.Count -ne 1) {
throw "Windows source release must contain exactly one required asset $name; found $($sourceAssets.Count)."
}
if ([string]$sourceAssets[0].digest -ne [string]$expectedDigests[$name]) {
throw "Windows source release asset digest does not match the pinned digest: $name"
}
}
gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null
- name: Download Windows Hub release installers
shell: pwsh
env:
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
EXPECTED_INSTALLER_DIGESTS: ${{ inputs.expected_installer_digests }}
GH_TOKEN: ${{ github.token }}
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
$tagArgs = @()
if ($env:WINDOWS_NODE_TAG -ne "latest") {
$tagArgs += $env:WINDOWS_NODE_TAG
}
gh release download @tagArgs `
--repo openclaw/openclaw-windows-node `
--pattern "OpenClawCompanion-Setup-*.exe" `
--dir dist
$expected = @(
"dist/OpenClawCompanion-Setup-x64.exe",
"dist/OpenClawCompanion-Setup-arm64.exe"
# Add future signed installer patterns, such as MSIX x64/ARM64, here.
# Every matched installer is signature-checked, checksummed, and promoted.
$installerPatterns = @(
"OpenClawCompanion-Setup-x64.exe",
"OpenClawCompanion-Setup-arm64.exe"
)
foreach ($file in $expected) {
if (-not (Test-Path -LiteralPath $file)) {
throw "Missing expected Windows installer: $file"
$downloadArgs = @(
$env:WINDOWS_NODE_TAG,
"--repo", "openclaw/openclaw-windows-node",
"--dir", "dist"
)
foreach ($pattern in $installerPatterns) {
$downloadArgs += @("--pattern", $pattern)
}
gh release download @downloadArgs
if ($LASTEXITCODE -ne 0) {
throw "Failed to download Windows release assets from $env:WINDOWS_NODE_TAG."
}
foreach ($pattern in $installerPatterns) {
$patternMatches = @(Get-ChildItem -LiteralPath dist -File | Where-Object Name -Like $pattern)
if ($patternMatches.Count -ne 1) {
throw "Expected exactly one Windows installer matching '$pattern', found $($patternMatches.Count)."
}
}
$expectedDigests = $env:EXPECTED_INSTALLER_DIGESTS | ConvertFrom-Json -AsHashtable
foreach ($file in Get-ChildItem -LiteralPath dist -File) {
$expectedHash = ([string]$expectedDigests[$file.Name]) -replace '^sha256:', ''
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
if ($actualHash -ne $expectedHash) {
throw "Downloaded Windows source asset does not match pinned digest: $($file.Name)"
}
}
- name: Verify Authenticode signatures
shell: pwsh
run: |
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object {
$expectedSignerSubject = "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US"
Get-ChildItem -LiteralPath dist -File | ForEach-Object {
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
if ($signature.Status -ne "Valid") {
throw "$($_.Name) Authenticode signature was $($signature.Status)."
@@ -78,6 +164,9 @@ jobs:
if (-not $signature.SignerCertificate) {
throw "$($_.Name) has no signer certificate."
}
if ($signature.SignerCertificate.Subject -ne $expectedSignerSubject) {
throw "$($_.Name) has unexpected signer subject $($signature.SignerCertificate.Subject)."
}
[pscustomobject]@{
File = $_.Name
Signer = $signature.SignerCertificate.Subject
@@ -88,7 +177,7 @@ jobs:
- name: Write SHA-256 manifest
shell: pwsh
run: |
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" |
Get-ChildItem -LiteralPath dist -File |
Sort-Object Name |
ForEach-Object {
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
@@ -101,12 +190,81 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
gh release upload $env:RELEASE_TAG `
dist/OpenClawCompanion-Setup-x64.exe `
dist/OpenClawCompanion-Setup-arm64.exe `
dist/OpenClawCompanion-SHA256SUMS.txt `
--repo $env:GITHUB_REPOSITORY `
--clobber
$releaseAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name | ForEach-Object FullName)
gh release upload $env:RELEASE_TAG @releaseAssets --repo $env:GITHUB_REPOSITORY --clobber
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload Windows release assets to $env:RELEASE_TAG."
}
- name: Verify promoted release asset contract
shell: pwsh
env:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
New-Item -ItemType Directory -Force -Path verified | Out-Null
$expectedAssets = @(Get-ChildItem -LiteralPath dist -File | Sort-Object Name)
$expectedCompanionAssetNames = @($expectedAssets | ForEach-Object Name | Sort-Object)
$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets | ConvertFrom-Json
$actualCompanionAssetNames = @(
$targetRelease.assets |
Where-Object { $_.name.StartsWith("OpenClawCompanion-") } |
ForEach-Object name |
Sort-Object
)
$assetContractDiff = @(
Compare-Object `
-ReferenceObject $expectedCompanionAssetNames `
-DifferenceObject $actualCompanionAssetNames
)
if (
$actualCompanionAssetNames.Count -ne $expectedCompanionAssetNames.Count -or
$assetContractDiff.Count -ne 0
) {
throw "Promoted OpenClawCompanion asset names do not exactly match the current contract."
}
foreach ($asset in $expectedAssets) {
gh release download $env:RELEASE_TAG `
--repo $env:GITHUB_REPOSITORY `
--pattern $asset.Name `
--dir verified
if ($LASTEXITCODE -ne 0) {
throw "Failed to download promoted Windows release asset $($asset.Name)."
}
}
$manifestPath = "verified/OpenClawCompanion-SHA256SUMS.txt"
$manifestEntries = @(Get-Content -LiteralPath $manifestPath | ForEach-Object {
if ($_ -notmatch '^([A-Fa-f0-9]{64}) ([^\\/]+)$') {
throw "Invalid Windows SHA-256 manifest entry: $_"
}
[PSCustomObject]@{
Hash = $Matches[1]
Name = $Matches[2]
}
})
$expectedInstallerNames = @(
$expectedAssets |
Where-Object Name -ne "OpenClawCompanion-SHA256SUMS.txt" |
ForEach-Object Name
)
$manifestInstallerNames = @($manifestEntries | ForEach-Object Name | Sort-Object)
$contractDiff = @(
Compare-Object `
-ReferenceObject $expectedInstallerNames `
-DifferenceObject $manifestInstallerNames
)
if ($contractDiff.Count -ne 0) {
throw "Promoted Windows SHA-256 manifest does not match the installer asset contract."
}
foreach ($entry in $manifestEntries) {
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath "verified/$($entry.Name)").Hash
if ($hash -ne $entry.Hash) {
throw "Promoted Windows release asset checksum mismatch: $($entry.Name)"
}
}
- name: Summary
shell: pwsh
@@ -119,8 +277,9 @@ jobs:
OpenClaw release: $env:RELEASE_TAG
Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt
"@ >> $env:GITHUB_STEP_SUMMARY
Get-ChildItem -LiteralPath dist -File |
Sort-Object Name |
ForEach-Object {
"- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/$($_.Name)"
} >> $env:GITHUB_STEP_SUMMARY

View File

@@ -34,10 +34,25 @@ jobs:
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
fetch_checkout_ref() {
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
sleep 5
done
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Fail on tabs in workflow files
@@ -78,10 +93,25 @@ jobs:
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
fetch_checkout_ref() {
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
sleep 5
done
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Python
@@ -190,10 +220,25 @@ jobs:
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
fetch_checkout_ref() {
local fetch_status
for attempt in 1 2 3; do
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
fetch_status="$?"
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
return "$fetch_status"
fi
if [ "$attempt" = "3" ]; then
return "$fetch_status"
fi
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
sleep 5
done
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Setup Node environment

2
.gitignore vendored
View File

@@ -127,6 +127,8 @@ mantis/
!.agents/skills/clawdtributor/**
!.agents/skills/control-ui-e2e/
!.agents/skills/control-ui-e2e/**
!.agents/skills/discord-user-post/
!.agents/skills/discord-user-post/**
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/technical-documentation/

View File

@@ -214,6 +214,7 @@ Skills own workflows; root owns hard policy and routing.
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.5`; test GPT with 5.5 preferred, 5.4 ok; no GPT-4.x agent-smoke defaults.
- Prefer behavior tests over workflow/docs string greps. Put operator policy reminders in AGENTS/docs.
- QA scenario sources are YAML only: `qa/scenarios/index.yaml` and `qa/scenarios/<theme>/*.yaml`. Do not add fenced `qa-scenario`/`qa-flow` Markdown files under `qa/scenarios/`.
- Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe.
- Prefer injection and narrow `*.runtime.ts` mocks over broad barrels or `openclaw/plugin-sdk/*`.
- Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.
@@ -251,9 +252,11 @@ Skills own workflows; root owns hard policy and routing.
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
- Release versions use `YYYY.M.PATCH`, where `PATCH` is a sequential monthly release-train number, never the calendar day. Stable and beta tags determine the current train; alpha-only tags do not consume or advance the beta/stable patch number. After `2026.6.5`, the next beta train is `2026.6.6-beta.1` even if higher alpha-only tags exist.
- Alpha/nightly versions use the next unreleased train plus an incrementing prerelease number. Repeated nightlies for the same train increment only `alpha.N`; they must not mint a new patch number from the date.
- Backport means apply to newest open `release/` branch unless user names another target.
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.
- Beta tag/version match: `vYYYY.M.PATCH-beta.N` -> npm `YYYY.M.PATCH-beta.N --tag beta`.
## Platform / Ops

View File

@@ -2,6 +2,71 @@
Docs: https://docs.openclaw.ai
## 2026.6.8
### Highlights
- Telegram and WhatsApp channel delivery are richer and less brittle: Telegram can send structured rich text with tables, lists, expandable blockquotes, prompt-preserving CLI backend delivery, retired native draft migration, and safer rich-media boundaries, while WhatsApp now honors configured ACP bindings. (#92679, #84082, #89421, #92513) Thanks @obviyus, @jzakirov, @spacegeologist, and @TurboTheTurtle.
- Agent and Gateway recovery is sharper across account-scoped DM sends, generated media completions, restart shutdown aborts, yielded subagent pauses, yielded cron media, heartbeat dedupe, session identity prompts, and unknown OpenAI agent selector rejection. (#92788, #91246, #91357, #92631, #92146, #91287, #92468, #92510) Thanks @yetval, @TurboTheTurtle, @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, and @zhangguiping-xydt.
- Provider/model handling expands and tightens with GLM-5.2, Claude Haiku 4.5 catalog rows, OpenRouter and Google Vertex provider-prefix normalization, managed SecretRef auth, bounded model browse discovery, storeless OpenAI Responses replay gating, and Claude 4.5 Copilot tool-streaming safety. (#92796, #90116, #92627, #91218, #90686, #92247, #90706, #75393) Thanks @arkyu2077, @liuhao1024, @bymle, @rohitjavvadi, @samson910022, @snowzlm, and @Kailigithub.
- `/usage` and reply payload hooks now have a native full footer renderer, default template, fixed-decimal formatting, credential-aware limits, better partial-count handling, and warnings for broken templates instead of silent bad output. (#92657, #89835, #89629) Thanks @Marvinthebored.
- UI and mobile flows are steadier: workspace files can collapse and start collapsed, WebChat backscroll survives streaming, the sidebar session picker remains interactive above the desktop workbench, reset soft args survive UI dispatch, stale dashboard session parent lineage is preserved, and iOS reconnects stale foreground gateways. (#92779, #92622, #92705, #91353, #90658, #92552) Thanks @shakkernerd, @TurboTheTurtle, @NianJiuZst, @zhouhe-xydt, @luoyanglang, and @Solvely-Colin.
- Memory, state, and diagnostics recover cleaner: oversized OpenAI embedding batches split before 431s, QMD memory search stays available in transient mode, SQLite avoids WAL on NFS state volumes, stuck-session recovery scheduling no longer resets warning backoff, and Infinity chunk limits stay genuinely unbounded. (#92650, #92618, #92639, #91247, #92752, #92735) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, @gnanam1990, and @yhterrance.
### Changes
- Providers/models: add GLM-5.2 support and Claude Haiku 4.5 catalog entries while keeping provider-qualified model IDs normalized across OpenRouter and Google Vertex paths. (#92796, #90116, #92627, #91218) Thanks @arkyu2077, @liuhao1024, and @bymle.
- Channel plugins: ship Telegram rich-message delivery and WhatsApp ACP binding support, including rich prompt handoff to CLI backends and transport fixtures for richer drafts. (#92679, #92513) Thanks @obviyus and @TurboTheTurtle.
- Agent commands: support `/btw` in CLI-backed sessions and keep CLI usage-error exits classified as usage failures instead of successful runs. (#92669, #92162) Thanks @joshavant and @Pandah97.
- Usage hooks: add built-in full footer rendering, default footer templates, per-turn usage state, credential-aware limits, and fixed-decimal formatting for usage-bar templates. (#92657, #89835, #89629) Thanks @Marvinthebored.
- Docs and operator guidance: document node config examples, clarify before-install hook scope, correct agent default concurrency comments, refresh ZAI provider docs, and update channel/group docs for current Telegram and WhatsApp behavior. (#92677, #92766, #92695) Thanks @liuhao1024, @sallyom, and @ArielSmoliar.
### Fixes
- Channels and delivery: preserve account-scoped DM channel send policy, rich Telegram final replies, rich Telegram tables and lists, Telegram thread-create CLI remapping, Slack outbound `message_sent` hooks, contributed message-tool schema optionality, same-channel generated media completions, and channel chunking around surrogate pairs and Infinity limits. (#92788, #92679, #89421, #89943, #91137, #91246, #92735) Thanks @yetval, @obviyus, @spacegeologist, @rishitamrakar, @lundog, @TurboTheTurtle, and @yhterrance.
- Discord: give generated auto-thread titles a 60-second timeout and 4,096-token reasoning-model output budget, clamped to the selected model output cap. (#64734) Thanks @hanamizuki.
- Agent, cron, and Gateway runtime: mark active main sessions before restart shutdown aborts, pause yielded subagent runs whose terminal also signals abort, preserve yielded media completions, de-duplicate main-session heartbeat events, expose session identity in runtime prompts, reject unknown OpenAI agent selectors, keep generated media completions and slash-command block replies in WebChat, preserve fresh post-compaction usage while clearing stale usage snapshots, and require admin privileges for HTTP session/model override surfaces. (#91357, #92631, #92146, #91287, #92468, #92510, #91246, #50795, #50845, #82874, #92651, #92646) Thanks @ooiuuii, @openperf, @IWhatsskill, @ZengWen-DT, @zhangguiping-xydt, @Hollychou924, @leno23, and @TurboTheTurtle.
- Providers and model replay: preserve storeless OpenAI Responses replay compatibility, avoid eager tool streaming for Claude 4.5 in Copilot, honor profile auth for SecretRef model entries, bound model browsing, strip provider prefixes where runtimes need bare IDs, and surface nested embedding fetch failures. (#90706, #75393, #90686, #92247, #92627, #91218, #92628) Thanks @snowzlm, @Kailigithub, @rohitjavvadi, @samson910022, @liuhao1024, @bymle, and @mushuiyu886.
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp.
## 2026.6.6
### Highlights
- Security boundaries are substantially tighter across transcripts, sandbox binds, host environment inheritance, MCP stdio, Codex HTTP access, native search policy, elevated sender checks, deleted-agent ACP bypasses, loopback tools, Discord moderation, and Teams group actions; exec approvals now fail closed on timeout. (#91529, #91618, #91615, #91619, #91741, #91745, #91746, #91748, #91749, #91750, #91751, #91752, #91763, #89938) Thanks @joshavant, @pgondhi987, @mmaps, @eleqtrizit, @shakkernerd, and @drobison00.
- Telegram delivery is safer and more coherent: account-scoped topics route to the right agent, streamed text survives tool calls, `/compact` works on generic ingress, callback handling uses concrete APIs, draft chunking is shared, durable dispatch dedupe moved into the SDK, and unauthorized DM text stays out of cache and prompt context. (#91189, #88682, #89588, #90212, #91876, #91874, #91904, #91478, #91915) Thanks @codysai001, @alexzhu0, @joelnishanth, @snowzlm, @obviyus, and @sallyom.
- iMessage recovery and delivery now cover always-on inbound restart, durable echo markers, block streaming, idle approval discovery, hardened outbound transport, and actionable inbound startup diagnostics. (#91335, #91449, #88969, #88530, #91783, #91785) Thanks @omarshahine, @jmissig, and @colmbrogan.
- Browser and MCP connectivity gained existing-session CDP support, discovered WebSocket validation, default-profile `cdpUrl` handling, safer browser-output boundaries, Streamable HTTP loopback transport, corrected OAuth/SSE authorization handling, and broader schema compatibility. (#91422, #89851, #91736, #91747, #91451, #80143) Thanks @pgondhi987, @anagnorisis2peripeteia, @lifuyue, @eleqtrizit, @LiuwqGit, and @HemantSudarshan.
- Control UI startup and first-reply latency are lower through cached model metadata, removal of the startup catalog wait, lazy slash-command loading, and first-event tracing with slow-reply diagnostics. (#91531, #91538, #91568, #91583, #91598)
- Provider support expands with OpenRouter OAuth onboarding and Claude Fable 5 adaptive thinking, while Codex sessions keep correct compaction ownership, local models skip guardian review, dynamic tool progress normalizes cleanly, and Gemma 4 reasoning replay is preserved. (#91830, #91882, #91590, #88630, #88768, #91696) Thanks @Patrick-Erichsen, @joshavant, @bdjben, and @Coder-Wangyankun.
### Changes
- CLI progress: emit Claude CLI commentary progress events and bridge inter-tool commentary into channel progress without exposing internal protocol scaffolding. (#89834, #90883) Thanks @anagnorisis2peripeteia.
- Observability: allow trusted diagnostics channels to capture tool input/output content, add first-assistant-event traces, and warn on slow initial replies. (#91256, #91568, #91583) Thanks @amknight.
- Plugins/ClawHub: dogfood reusable package publishing, let dry runs skip publish approval, allow declared installed trusted hooks, report managed plugin version drift, and warn instead of failing on retired Skill Workshop configuration. (#91574, #91591, #90004, #90927, #90838) Thanks @Patrick-Erichsen, @brokemac79, and @lonexreb.
- Memory/providers: move the local llama.cpp runtime into its provider plugin, batch embeddings across files, persist the agent model catalog cache, and keep QMD JSON search one-shot while filtering stale REM recall previews. (#91324, #89138, #90457, #91837, #91851) Thanks @osolmaz, @mushuiyu886, @ai-hpc, and @TurboTheTurtle.
- Channels/mobile: add the QQBot group mention toggle, improve iPad and iPhone control surfaces, and expose the active connection host in the TUI footer. (#91423, #91557, #89909) Thanks @cxyhhhhh, @Solvely-Colin, and @baskduf.
- Performance: prewarm TUI runtime plugins, deduplicate plugin auto-enable fanout, trim dense text-delta snapshots, and reuse prepared startup model metadata. (#90782, #89978, #91580, #91531) Thanks @RomneyDa and @ai-hpc.
### Fixes
- Agent/session recovery: drop stale approval follow-ups after session rebind, remove drained reply-queue items by identity, recover stale main and visible replies, preserve Codex context-engine compaction ownership, lower the default compaction timeout to 180 seconds while respecting explicit configuration, and keep provider-failure terminal lifecycle state correct. (#85679, #91450, #91566, #91840, #91590, #91361, #91895) Thanks @openperf, @yetval, @joshavant, @wangmiao0668000666, and @TurboTheTurtle.
- User-visible content boundaries: suppress Codex/Harmony protocol artifacts, neutralize browser and LanceDB memory media directives, redact transcript images, and preserve native `/compact` replies through source suppression. (#89151, #91422, #91425, #91529, #90212) Thanks @joelnishanth, @pgondhi987, @joshavant, and @snowzlm.
- Channel delivery: keep WhatsApp captured replies attached to the successor controller after restart, retry Feishu rate limits, preserve Mattermost thread replies, canonicalize LINE webhook paths, restore Discord reply hydration and runtime timeout exports, and show OpenAI Realtime WebRTC assistant transcripts. (#85823, #89659, #91684, #91649, #90263, #91686, #90426) Thanks @itsuzef, @ladygege, @jacobtomlinson, @fuller-stack-dev, and @shushushv.
- Cron: cancel active task runs cleanly, preserve terminal timeout/cancel state, and recover no-deliver tool warnings instead of silently losing the outcome. (#90666, #90678) Thanks @ai-hpc.
- Gateway/config/auth: share the approval runtime socket token, replace arrays explicitly in `config.patch`, skip the deleted-agent guard only for valid ACP harness sessions, surface headless LaunchAgent state, verify SQLite auth migration before cleanup, and arm QMD startup maintenance. (#87105, #91551, #91219, #91614, #91740, #91978) Thanks @fuller-stack-dev and @scotthuang.
- Providers/Codex: clarify quota errors, restore the Codex synthetic usage line, canonicalize Codex protocol assets, require API-key auth for realtime voice, normalize ACP model refs, preserve Gemma 4 `reasoning_content`, and avoid guardian review for local models. (#91390, #91709, #91507, #91567, #88630, #91696) Thanks @hxy91819, @brokemac79, @RomneyDa, @joshavant, and @Coder-Wangyankun.
- Updates/builds: recover package Gateway restarts after refresh failure, expose plugin convergence repair, fall back to Corepack in PATH-less pnpm environments, seed the correct Docker store packages, and keep ClawHub dry-run and publish paths reusable. (#91581, #91599, #91547, #91591) Thanks @fuller-stack-dev, @sallyom, and @Patrick-Erichsen.
- UI: require explicit user intent before opening chat sessions and drain restored chat queues after session switches. (#91480) Thanks @TurboTheTurtle.
- Android: avoid the `dataSync` foreground-service type for persistent nodes. (#80082) Thanks @davelutztx.
- Native hooks: bound relay lifetimes so abandoned native hook connections cannot linger indefinitely. (#91550) Thanks @joshavant.
## 2026.6.5
### Highlights
@@ -31,6 +96,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: `sessions_send` now honors an explicit `sessionKey` when stale label metadata is also present, and denied session-id sends no longer echo the resolved canonical session key. Fixes #64699; refs #74009 and #41199. Thanks @Mintalix, @RevisitMoon, and @Mocha-s.
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.
@@ -45,6 +111,7 @@ Docs: https://docs.openclaw.ai
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
- Memory: keep doctor REM harness previews aligned with live REM by dropping short-term recall snippets whose source files disappeared before rendering preview output. Thanks @samzong and @frankekn.
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)

View File

@@ -116,11 +116,19 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
fi && \
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
mkdir -p dist/extensions/qa-lab/web && \
rm -rf dist/extensions/qa-lab/web/dist && \
cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \
fi
# Prune dev dependencies, omitted plugin runtime packages, and build-only
# metadata before copying runtime assets into the final image.
@@ -130,7 +138,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
# installed prod graph in the same step that runs offline prune.
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
CI=true pnpm prune --prod \
--config.offline=true \
--config.supportedArchitectures.os=linux \
@@ -139,6 +147,10 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
node scripts/postinstall-bundled-plugins.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
rm -rf \
/app/node_modules/openclaw \
/app/node_modules/.bin/openclaw \
/app/node_modules/.pnpm/openclaw@*/node_modules/openclaw && \
node scripts/check-package-dist-imports.mjs /app
# ── Runtime base image ──────────────────────────────────────────

View File

@@ -48,6 +48,7 @@ These patterns are usually not vulnerabilities by themselves:
- Prompt injection without a policy, auth, approval, sandbox, or tool-boundary bypass.
- A trusted operator using an intentional local feature, such as local shell access or browser/script execution.
- A report whose only primitive is changing the process or child-process environment before running OpenClaw or an executable OpenClaw invokes.
- A malicious plugin after a trusted operator installs or enables it.
- Multiple adversarial users sharing one Gateway host/config and expecting per-user isolation.
- Scanner-only, dependency-only, or stale-path reports without a working repro and demonstrated OpenClaw impact.
@@ -103,6 +104,7 @@ These are frequently reported but are typically closed with no code change:
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
- Reports that depend on attacker-controlled environment variables changing executable behavior, including variables that redirect lookup paths, preload code, select wrappers/interpreters, alter package-manager or runtime hooks, or make one executable call another executable. Control of the process or child-process environment is trusted host/operator control in OpenClaw's model; these reports need a separate OpenClaw boundary bypass that lets untrusted input set or mutate that environment.
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
- Missing HSTS findings on default local/loopback deployments.
- Reports against test-only harnesses, QA Lab, QE Lab, E2E fixtures, benchmark rigs, or maintainer-only debugging tools when the vulnerable code is not shipped as a supported production surface.
@@ -161,6 +163,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
- Reports whose only claim is environment-variable-driven executable behavior change, including path lookup changes, preload hooks, wrapper/interpreter selection, package-manager/runtime hooks, or variables that make an executable invoke another executable, unless a separate OpenClaw boundary bypass lets untrusted input set or mutate that environment.
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
- Reports whose only claim is use of an explicit trusted-operator control surface (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution) without demonstrating an auth, policy, allowlist, approval, or sandbox bypass.
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
@@ -181,6 +184,7 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
OpenClaw security guidance assumes:
- The host where OpenClaw runs is within a trusted OS/admin boundary.
- Anyone who can set or mutate the OpenClaw process environment, launcher environment, or child-process environment is inside that trusted host/operator boundary.
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.

View File

@@ -2,6 +2,86 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.6.5</title>
<pubDate>Tue, 09 Jun 2026 19:06:49 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2606000590</sparkle:version>
<sparkle:shortVersionString>2026.6.5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.6.5</h2>
<h3>Highlights</h3>
<ul>
<li>QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw <code><thinking></code> content from leaking into channel replies. (#89913, #90132) Thanks @openperf.</li>
<li>MCP tool results now coerce <code>resource_link</code>, <code>resource</code>, <code>audio</code>, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
<li>Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for <code>message_start</code>, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.</li>
<li>Parallel is now a bundled <code>web_search</code> provider with <code>PARALLEL_API_KEY</code> discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.</li>
<li>Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.</li>
<li>Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)</li>
<li>Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)</li>
<li>Agent, tool, and provider loops are stricter around MCP lease timestamps, prompt-cache tool names, local tool catalogs, unreadable dynamic tools, owner-only HTTP tools, and provider catalog metadata, reducing hidden retries and unsafe exposure. (#91124, #91233, #90022, #90261)</li>
<li>macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.</li>
<li>Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded <code>api.parallel.ai/v1/search</code> support. (#85158) Thanks @NormallyGaussian.</li>
<li>Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)</li>
<li>Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.</li>
<li>Skills/ClawHub: avoid one filesystem watcher per skill file during refresh, keeping large skill trees from exhausting watcher limits.</li>
<li>Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.</li>
<li>Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, Android adds theme mode selection, and iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, fallback copy, and unavailable Talk controls reachable. (#90752, #91201)</li>
<li>Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)</li>
<li>Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward <code>web_fetch</code>, clarify legacy <code>openai-codex</code> auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.</li>
<li>Release/process: switch release trains to <code>YYYY.M.PATCH</code> monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at <code>2026.6.5</code>.</li>
<li>Release/process: defer the session-metadata SQLite migration from the <code>2026.6.5</code> beta train so this release keeps the existing JSON-backed session metadata path while the migration risk is worked on <code>main</code>.</li>
<li>Release metadata: align OpenClaw, publishable plugin manifests, generated shrinkwraps, app version metadata, iOS release notes, Matrix plugin changelog, and generated release baselines with the <code>2026.6.5</code> release train.</li>
<li>Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.</li>
<li>Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
<li>Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until <code>message_start</code>, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.</li>
<li>Agents/Codex/tools: MCP lease release no longer refreshes <code>lastUsedAt</code>, prompt-cache tool names are guarded, lean local tool catalogs stay compact, unreadable dynamic tools are quarantined, orphan tool errors still surface, native subagent completion results survive app-server monitoring, and background-session name derivation avoids regex backtracking risk. (#91124, #90612, #90022, #91235, #91233)</li>
<li>Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.</li>
<li>Provider/model resolution: live provider model catalogs keep helper coverage, Ollama catalog metadata is preserved, Google provider prefixes are stripped from Gemini paths, Foundry Responses reasoning replay ids survive, MiniMax M3 thinking stays enabled, Vertex multi-region calls use the right regional host, and OpenRouter streamed generation cost is reconciled. (#91125)</li>
<li>Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.</li>
<li>Gateway/security/config: owner-only HTTP tools are gated, sandbox skills remain readable in writable sandboxes, legacy agent registry and Codex model metadata migrate safely, and stalled MCP response bodies time out instead of tying up Gateway workers. (#90261)</li>
<li>Gateway/config: <code>config.patch</code> now preserves explicit array replacement semantics for arrays without merge keys, so replacement patches do not accidentally merge stale entries. (#91551)</li>
<li>SDK: event pump failures now surface to clients instead of being swallowed behind a quiet iterator shutdown.</li>
<li>Agents/transcripts: inline image payload redaction now catches data URLs and repaired transcript images before they can leak raw image bytes into stored or exported transcripts. (#91529)</li>
<li>Plugins/Gateway: legacy flat Control UI descriptors from shipped JavaScript plugins now normalize <code>name</code> and missing surface fields into session descriptors, restoring Kitchen Sink RPC descriptor proof for package-backed plugin validation.</li>
<li>TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.</li>
<li>Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, isolated agent turn payload messages preserve timeout context, service env planning skips unresolved placeholders that would mask state-dir <code>.env</code> values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #91230, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.</li>
<li>State/storage: Matrix sync and crypto sidecars, memory-wiki import/source-sync state, sandbox registry state, ACPX process state, device-pair notify state, Zalo hosted media, and plugin SDK dedupe state now use SQLite-owned storage instead of ad hoc runtime files. (#91100, #91108, #91056)</li>
<li>Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)</li>
<li>Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on <code>globalThis</code> and default replies stay inside existing Mattermost threads instead of starting new ones; Feishu streaming cards preserve full merged content; iMessage private-API failures and send timeouts explain themselves while split-send coalescing honors balloon metadata; voice-call tracks Twilio streams after connect; ClickClack reply tools respect <code>toolsAllow</code>; Discord runtime adapters stay resolvable; and outbound delivery retries survive budget deferrals. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500, #91041, #90858, #91119, #91241) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, @sahibzada-allahyar, and @jacobtomlinson.</li>
<li>Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.</li>
<li>WhatsApp: captured replies after restart now route through the successor controller instead of the stale pre-restart controller. (#85823)</li>
<li>Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.</li>
<li>Release/CI/E2E: installed-package root dist verification now allows the current package's JavaScript file count while keeping dependency, per-file-size, and scan-bound checks active.</li>
<li>Release/CI/E2E: Chutes OAuth model-discovery proof now accepts standard <code>Headers</code> requests, and QR package install smoke caps Docker CPU requests to the hosted runner capacity so beta validation fails on real package regressions.</li>
<li>Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.</li>
<li>Release/CI/E2E: Docker E2E CPU limits now cap to the runner capacity, keeping package Telegram acceptance on hosted 8-vCPU runners focused on package regressions instead of impossible Docker resource requests.</li>
<li>Release/CI/E2E: task maintenance release checks now reset pinned config around isolated temp state dirs, keeping normal CI focused on the active session-store fixture instead of stale process snapshots.</li>
<li>Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.</li>
<li>Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.</li>
<li>Release/CI/E2E: browser snapshot, release-scenario, release-user-journey, Telegram desktop/RTT/package, web-search, Parallels update, plugin update, doctor switch, and upgrade-survivor diagnostics now stream or bound log/artifact reads so failed proof stays inspectable without unbounded output.</li>
<li>Release/CI/E2E: Parallels smoke validation now runs without requiring <code>pnpm</code> on the host, supports already-started Windows/Linux guests without snapshots, reports empty snapshot metadata clearly, and finds portable user-local Node on Windows.</li>
<li>Release/CI/E2E: ClawHub publish jobs prepare dependencies after checking out the target ref, and Docker store seed package discovery now targets the intended production packages. (#91547)</li>
<li>Release/CI/E2E: QA Lab capability-flip release validation now marks intentional <code>tools.deny</code> restores as array replacements, so beta validation fails only on real capability regressions.</li>
<li>Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.</li>
<li>Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.</li>
<li>Tests/state isolation: QA Lab runtime parity now treats matching controlled tool errors as equivalent and falls back to transcript tool results when mock debug rows miss async image-generation starts.</li>
<li>Tests/state isolation: QA suites now fail closed on skipped summaries, missing runtime tool proof, planned-only rows, loose release limits, missing live/provider artifacts, failed agent reply markers, and package Telegram summary failures.</li>
<li>Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)</li>
<li>Sessions: the beta SQLite downgrade rescue now skips extra pre-reads for active non-empty JSON session stores, preserving cache race detection while still restoring missing or empty beta session files.</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.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
</item>
<item>
<title>2026.6.1</title>
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
@@ -193,52 +273,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
</item>
<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>
</channel>
</rss>

View File

@@ -443,6 +443,7 @@ class NodeRuntime(
updateStatus()
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
refreshHomeCanvasOverviewIfConnected()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
@@ -485,6 +486,14 @@ class NodeRuntime(
},
)
private suspend fun subscribeOperatorSessionEvents() {
try {
operatorSession.request("sessions.subscribe", null)
} catch (err: Throwable) {
Log.d("OpenClawRuntime", "sessions.subscribe failed: ${err.message ?: err::class.java.simpleName}")
}
}
private val nodeSession =
GatewaySession(
scope = scope,

View File

@@ -311,7 +311,6 @@ class ChatController(
}
}
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
fun handleGatewayEvent(
event: String,
payloadJson: String?,
@@ -321,7 +320,6 @@ class ChatController(
scope.launch { pollHealthIfNeeded(force = false) }
}
"health" -> {
// If we receive a health snapshot, the gateway is reachable.
_healthOk.value = true
}
"seqGap" -> {
@@ -332,6 +330,17 @@ class ChatController(
if (payloadJson.isNullOrBlank()) return
handleChatEvent(payloadJson)
}
"sessions.changed" -> {
if (payloadJson.isNullOrBlank()) {
refreshSessionsForCurrentWindow()
} else {
handleSessionsChangedEvent(payloadJson)
}
}
"session.message" -> {
if (payloadJson.isNullOrBlank()) return
handleSessionMessageEvent(payloadJson)
}
"agent" -> {
if (payloadJson.isNullOrBlank()) return
handleAgentEvent(payloadJson)
@@ -353,6 +362,7 @@ class ChatController(
)
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
updateSessionFromHistory(history)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
@@ -388,6 +398,10 @@ class ChatController(
}
}
private fun refreshSessionsForCurrentWindow() {
scope.launch { fetchSessions(limit = _sessions.value.size.takeIf { it > 0 } ?: 100) }
}
private suspend fun pollHealthIfNeeded(force: Boolean) {
val now = System.currentTimeMillis()
val last = lastHealthPollAtMs
@@ -457,6 +471,7 @@ class ChatController(
sessionKey = currentSessionKey,
previousMessages = _messages.value,
)
updateSessionFromHistory(history)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
@@ -472,6 +487,31 @@ class ChatController(
}
}
private fun handleSessionsChangedEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
if (payload["reason"].asStringOrNull() == "delete") {
removeSessionEntry(payload["sessionKey"].asStringOrNull() ?: payload["key"].asStringOrNull())
return
}
val entry = parseEventSessionEntry(payload)
if (entry != null) {
upsertSessionEntry(entry)
} else {
refreshSessionsForCurrentWindow()
}
}
private fun handleSessionMessageEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val entry = parseEventSessionEntry(payload)
if (entry != null) {
upsertSessionEntry(entry)
}
}
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun handleAgentEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val sessionKey = payload["sessionKey"].asStringOrNull()?.trim()
@@ -600,6 +640,7 @@ class ChatController(
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
val sid = root["sessionId"].asStringOrNull()
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
val sessionInfo = root["sessionInfo"].asObjectOrNull()?.let { parseSessionEntry(it, fallbackKey = sessionKey) }
val array = root["messages"].asArrayOrNull() ?: JsonArray(emptyList())
val messages =
@@ -622,20 +663,69 @@ class ChatController(
sessionId = sid,
thinkingLevel = thinkingLevel,
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
sessionInfo = sessionInfo,
)
}
private fun parseSessions(jsonString: String): List<ChatSessionEntry> {
val root = json.parseToJsonElement(jsonString).asObjectOrNull() ?: return emptyList()
val sessions = root["sessions"].asArrayOrNull() ?: return emptyList()
return sessions.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
if (key.isEmpty()) return@mapNotNull null
val updatedAt = obj["updatedAt"].asLongOrNull()
val displayName = obj["displayName"].asStringOrNull()?.trim()
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
}
return sessions.mapNotNull { item -> parseSessionEntry(item.asObjectOrNull()) }
}
private fun parseSessionEntry(
obj: JsonObject?,
fallbackKey: String? = null,
): ChatSessionEntry? {
if (obj == null) return null
val key =
obj["key"].asStringOrNull()?.trim().orEmpty()
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
.ifEmpty { fallbackKey?.trim().orEmpty() }
if (key.isEmpty()) return null
return ChatSessionEntry(
key = key,
updatedAtMs = obj["updatedAt"].asLongOrNull(),
displayName = obj["displayName"].asStringOrNull()?.trim(),
totalTokens = obj["totalTokens"].asLongOrNull(),
totalTokensFresh = obj["totalTokensFresh"].asBooleanOrNull(),
contextTokens = obj["contextTokens"].asLongOrNull(),
hasContextUsageMetadata =
"totalTokens" in obj ||
"totalTokensFresh" in obj ||
"contextTokens" in obj,
)
}
private fun updateSessionFromHistory(history: ChatHistory) {
val info = history.sessionInfo ?: return
upsertSessionEntry(info, preserveExistingContextUsageWithoutTotal = true)
}
private fun upsertSessionEntry(
entry: ChatSessionEntry,
preserveExistingContextUsageWithoutTotal: Boolean = false,
) {
val current = _sessions.value
val index = current.indexOfFirst { it.key == entry.key }
_sessions.value =
if (index >= 0) {
current.toMutableList().also {
it[index] =
mergeChatSessionEntry(
existing = it[index],
next = entry,
preserveExistingContextUsageWithoutTotal = preserveExistingContextUsageWithoutTotal,
)
}
} else {
listOf(entry) + current
}
}
private fun removeSessionEntry(sessionKey: String?) {
val key = sessionKey?.trim()?.takeIf { it.isNotEmpty() } ?: return
_sessions.value = _sessions.value.filterNot { it.key == key }
}
private fun parseRunId(resJson: String): String? =
@@ -857,3 +947,44 @@ private fun JsonElement?.asLongOrNull(): Long? =
is JsonPrimitive -> content.toLongOrNull()
else -> null
}
private fun JsonElement?.asBooleanOrNull(): Boolean? =
when (this) {
is JsonPrimitive -> content.toBooleanStrictOrNull()
else -> null
}
internal fun mergeChatSessionEntry(
existing: ChatSessionEntry,
next: ChatSessionEntry,
preserveExistingContextUsageWithoutTotal: Boolean = false,
): ChatSessionEntry {
val preserveExistingContextUsage = preserveExistingContextUsageWithoutTotal && next.totalTokens == null
return existing.copy(
updatedAtMs = next.updatedAtMs ?: existing.updatedAtMs,
displayName = next.displayName ?: existing.displayName,
totalTokens =
when {
preserveExistingContextUsage -> existing.totalTokens
next.hasContextUsageMetadata -> next.totalTokens
else -> null
},
totalTokensFresh =
when {
preserveExistingContextUsage -> existing.totalTokensFresh
next.hasContextUsageMetadata -> next.totalTokensFresh
else -> null
},
contextTokens =
when {
preserveExistingContextUsage -> next.contextTokens ?: existing.contextTokens
next.hasContextUsageMetadata -> next.contextTokens
else -> null
},
hasContextUsageMetadata =
when {
preserveExistingContextUsage -> existing.hasContextUsageMetadata || next.contextTokens != null
else -> next.hasContextUsageMetadata
},
)
}

View File

@@ -40,6 +40,10 @@ data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val displayName: String? = null,
val totalTokens: Long? = null,
val totalTokensFresh: Boolean? = null,
val contextTokens: Long? = null,
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
)
/**
@@ -50,6 +54,7 @@ data class ChatHistory(
val sessionId: String?,
val thinkingLevel: String?,
val messages: List<ChatMessage>,
val sessionInfo: ChatSessionEntry? = null,
)
/**

View File

@@ -74,6 +74,7 @@ import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
@Composable
@@ -95,6 +96,7 @@ fun ChatScreen(
val sessions by viewModel.chatSessions.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
val context = LocalContext.current
val resolver = context.contentResolver
val scope = rememberCoroutineScope()
@@ -196,6 +198,7 @@ fun ChatScreen(
onValueChange = { input = it },
attachments = attachments,
thinkingLevel = thinkingLevel,
contextUsage = contextUsage,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onThinkingLevelChange = viewModel::setChatThinkingLevel,
@@ -685,6 +688,7 @@ private fun ChatComposer(
onValueChange: (String) -> Unit,
attachments: List<PendingImageAttachment>,
thinkingLevel: String,
contextUsage: ChatContextUsage,
healthOk: Boolean,
pendingRunCount: Int,
onThinkingLevelChange: (String) -> Unit,
@@ -699,7 +703,11 @@ private fun ChatComposer(
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
ChatContextMeter(
thinkingLevel = thinkingLevel,
contextUsage = contextUsage,
onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) },
)
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
@@ -735,8 +743,10 @@ private fun ChatComposer(
@Composable
private fun ChatContextMeter(
thinkingLevel: String,
contextUsage: ChatContextUsage,
onClick: () -> Unit,
) {
val contextFraction = contextMeterWidth(contextUsage) ?: 0f
Row(
modifier = Modifier.width(178.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -755,7 +765,13 @@ private fun ChatContextMeter(
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
Text(
text = contextMeterLabel(contextUsage, thinkingLevel),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Box(
@@ -768,7 +784,7 @@ private fun ChatContextMeter(
Box(
modifier =
Modifier
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
.fillMaxWidth(contextFraction)
.height(3.dp)
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
)
@@ -902,6 +918,32 @@ private fun isActiveSessionChoice(
return choiceKey == current
}
internal data class ChatContextUsage(
val totalTokens: Long?,
val totalTokensFresh: Boolean?,
val contextTokens: Long?,
)
internal fun resolveChatContextUsage(
sessionKey: String,
mainSessionKey: String,
sessions: List<ChatSessionEntry>,
): ChatContextUsage {
val entry =
sessions.firstOrNull {
isActiveSessionChoice(
choiceKey = it.key,
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
}
return ChatContextUsage(
totalTokens = entry?.totalTokens,
totalTokensFresh = entry?.totalTokensFresh,
contextTokens = entry?.contextTokens,
)
}
@Composable
private fun SendButton(
enabled: Boolean,
@@ -958,16 +1000,28 @@ private fun nextThinkingValue(value: String): String =
else -> "off"
}
/** Maps thinking presets to the visual context meter fill fraction. */
private fun thinkingMeterWidth(value: String): Float =
when (value.lowercase(Locale.US)) {
"low" -> 0.34f
"medium" -> 0.58f
"high" -> 0.82f
else -> 0.18f
}
internal fun contextMeterWidth(usage: ChatContextUsage): Float? {
if (usage.totalTokensFresh == false) return null
val total = usage.totalTokens?.takeIf { it >= 0L } ?: return null
val context = usage.contextTokens?.takeIf { it > 0L } ?: return null
return (total.toDouble() / context.toDouble()).coerceIn(0.0, 1.0).toFloat()
}
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
internal fun contextMeterLabel(
usage: ChatContextUsage,
thinkingLevel: String,
): String {
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}"
}
internal fun contextMeterThinkingLabel(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))

View File

@@ -59,4 +59,96 @@ class ChatControllerSessionPolicyTest {
),
)
}
@Test
fun sessionMergeClearsUsageWhenNewSnapshotOmitsUsageMetadata() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
displayName = "Phone",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
displayName = "Phone renamed",
hasContextUsageMetadata = false,
)
val merged = mergeChatSessionEntry(existing, next)
assertEquals("agent:main:phone", merged.key)
assertEquals(2L, merged.updatedAtMs)
assertEquals("Phone renamed", merged.displayName)
assertEquals(null, merged.totalTokens)
assertEquals(null, merged.totalTokensFresh)
assertEquals(null, merged.contextTokens)
assertFalse(merged.hasContextUsageMetadata)
}
@Test
fun sessionMergePreservesUsageWhenHistorySnapshotOmitsTotalTokens() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
displayName = "Phone",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
displayName = "Phone renamed",
totalTokensFresh = false,
contextTokens = 120_000L,
)
val merged =
mergeChatSessionEntry(
existing = existing,
next = next,
preserveExistingContextUsageWithoutTotal = true,
)
assertEquals(2L, merged.updatedAtMs)
assertEquals("Phone renamed", merged.displayName)
assertEquals(41_000L, merged.totalTokens)
assertEquals(true, merged.totalTokensFresh)
assertEquals(120_000L, merged.contextTokens)
assertTrue(merged.hasContextUsageMetadata)
}
@Test
fun sessionMergeAppliesExplicitStaleUsageMetadata() {
val existing =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 1L,
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
)
val next =
ChatSessionEntry(
key = "agent:main:phone",
updatedAtMs = 2L,
totalTokens = 82_000L,
totalTokensFresh = false,
contextTokens = 100_000L,
)
val merged = mergeChatSessionEntry(existing, next)
assertEquals(82_000L, merged.totalTokens)
assertEquals(false, merged.totalTokensFresh)
assertEquals(100_000L, merged.contextTokens)
assertTrue(merged.hasContextUsageMetadata)
}
}

View File

@@ -0,0 +1,84 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatSessionEntry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ChatContextMeterTest {
@Test
fun contextMeterUsesActiveSessionTokenBudget() {
val sessions =
listOf(
ChatSessionEntry(key = "main", updatedAtMs = 1L, displayName = "Main", totalTokens = 8_000L, totalTokensFresh = true, contextTokens = 10_000L),
ChatSessionEntry(
key = "agent:main:mobile:test-device",
updatedAtMs = 2L,
displayName = "Phone",
totalTokens = 1_250L,
totalTokensFresh = true,
contextTokens = 5_000L,
),
)
val usage =
resolveChatContextUsage(
sessionKey = "agent:main:mobile:test-device",
mainSessionKey = "main",
sessions = sessions,
)
assertEquals(ChatContextUsage(totalTokens = 1_250L, totalTokensFresh = true, contextTokens = 5_000L), usage)
assertEquals(0.25f, contextMeterWidth(usage))
assertEquals("Context 25% · high", contextMeterLabel(usage, "high"))
}
@Test
fun contextMeterResolvesCanonicalMainAlias() {
val sessions =
listOf(
ChatSessionEntry(
key = "agent:main:node-phone",
updatedAtMs = 1L,
displayName = "Main",
totalTokens = 41_000L,
totalTokensFresh = true,
contextTokens = 100_000L,
),
)
val usage =
resolveChatContextUsage(
sessionKey = "main",
mainSessionKey = "agent:main:node-phone",
sessions = sessions,
)
assertEquals(ChatContextUsage(totalTokens = 41_000L, totalTokensFresh = true, contextTokens = 100_000L), usage)
assertEquals("Context 41% · off", contextMeterLabel(usage, "off"))
}
@Test
fun contextMeterDoesNotInventPercentWhenBudgetIsMissing() {
val usage = ChatContextUsage(totalTokens = 8_200L, totalTokensFresh = true, contextTokens = null)
assertNull(contextMeterWidth(usage))
assertEquals("Context -- · medium", contextMeterLabel(usage, "medium"))
}
@Test
fun contextMeterClampsOverfullSessions() {
val usage = ChatContextUsage(totalTokens = 150_000L, totalTokensFresh = true, contextTokens = 100_000L)
assertEquals(1.0f, contextMeterWidth(usage))
assertEquals("Context 100% · low", contextMeterLabel(usage, "low"))
}
@Test
fun contextMeterDoesNotDisplayStaleTokenUsage() {
val usage = ChatContextUsage(totalTokens = 82_000L, totalTokensFresh = false, contextTokens = 100_000L)
assertNull(contextMeterWidth(usage))
assertEquals("Context -- · high", contextMeterLabel(usage, "high"))
}
}

18
apps/ios/.periphery.yml Normal file
View File

@@ -0,0 +1,18 @@
project: OpenClaw.xcodeproj
schemes:
- OpenClaw
retain_codable_properties: true
retain_swift_ui_previews: true
retain_objc_accessible: true
retain_unused_protocol_func_params: true
retain_assign_only_properties: true
relative_results: true
disable_update_check: true
report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchExtension/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

View File

@@ -202,10 +202,4 @@ final class ContactsService: ContactsServicing {
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
emails: contact.emailAddresses.map { String($0.value) })
}
#if DEBUG
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
}
#endif
}

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct AgentProDreamingDestination: View {
@Environment(NodeAppModel.self) private var appModel
let headerLeadingAction: OpenClawSidebarHeaderAction?
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let overviewLoading: Bool
@@ -20,6 +21,7 @@ struct AgentProDreamingDestination: View {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.header
self.detailSummaryCard(
icon: "moon",
title: "Dreaming",
@@ -57,6 +59,23 @@ struct AgentProDreamingDestination: View {
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var header: some View {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Dreaming",
subtitle: self.dreamingDetail,
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private enum DreamAction: String, CaseIterable, Identifiable {
case backfill
case repair

View File

@@ -3,6 +3,7 @@ import SwiftUI
import UIKit
struct AgentProNodesDestination: View {
let headerLeadingAction: OpenClawSidebarHeaderAction?
let overview: AgentOverviewSnapshot?
let gatewayConnected: Bool
let agentCount: Int
@@ -16,6 +17,7 @@ struct AgentProNodesDestination: View {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.header
self.summaryCard
self.totalsCard
self.nodesList
@@ -27,16 +29,33 @@ struct AgentProNodesDestination: View {
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Nodes")
.navigationTitle("Instances")
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var header: some View {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Instances",
subtitle: self.instancesDetail,
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var summaryCard: some View {
ProCard {
HStack(spacing: 12) {
ProIconBadge(systemName: "display", color: self.instancesColor)
VStack(alignment: .leading, spacing: 3) {
Text("Nodes")
Text("Instances")
.font(.headline)
Text(self.instancesDetail)
.font(.caption)
@@ -70,16 +89,16 @@ struct AgentProNodesDestination: View {
private var nodesList: some View {
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: "Connected Nodes")
ProSectionHeader(title: "Connected Instances")
ProCard(padding: 0) {
let nodes = self.sortedPresenceEntries
if nodes.isEmpty {
self.emptyRow(
icon: "display",
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
title: self.gatewayConnected ? "No instances connected" : "Instances unavailable",
detail: self.gatewayConnected
? "The gateway did not report any system presence entries."
: "Connect a gateway to inspect connected nodes.")
: "Connect a gateway to inspect connected instances.")
.padding(14)
} else {
VStack(spacing: 0) {
@@ -114,7 +133,7 @@ struct AgentProNodesDestination: 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")
Text(Self.presenceLabel(entry) ?? "Instance")
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(Self.presenceDetail(entry))
@@ -153,7 +172,7 @@ struct AgentProNodesDestination: View {
HStack(spacing: 12) {
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
VStack(alignment: .leading, spacing: 3) {
Text(Self.presenceLabel(entry) ?? "Node")
Text(Self.presenceLabel(entry) ?? "Instance")
.font(.headline)
Text(Self.presenceDetail(entry))
.font(.caption)
@@ -192,7 +211,7 @@ struct AgentProNodesDestination: View {
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
.navigationTitle(Self.presenceLabel(entry) ?? "Instance")
.navigationBarTitleDisplayMode(.inline)
}

View File

@@ -6,10 +6,12 @@ extension AgentProTab {
@ViewBuilder
func destination(for route: AgentRoute) -> some View {
switch route {
case .agents:
self.agentsDestination
case .skills:
self.skillsDestination
case .nodes:
self.nodesDestination
case .instances:
self.instancesDestination
case .cron:
self.cronDestination
case .usage:
@@ -19,6 +21,26 @@ extension AgentProTab {
}
}
var agentsDestination: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.rosterHeader
self.agentFilters
self.agentsSection
}
.padding(.vertical, 18)
}
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Agents")
.navigationBarTitleDisplayMode(.inline)
}
var skillsDestination: some View {
ZStack {
OpenClawProBackground()
@@ -46,8 +68,9 @@ extension AgentProTab {
.navigationBarTitleDisplayMode(.inline)
}
var nodesDestination: some View {
var instancesDestination: some View {
AgentProNodesDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
agentCount: self.appModel.gatewayAgents.count,
@@ -64,6 +87,10 @@ extension AgentProTab {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.directHeader(
for: .cron,
title: "Cron Jobs",
subtitle: self.cronDetail)
self.detailSummaryCard(
icon: "clock.arrow.circlepath",
title: "Cron Jobs",
@@ -89,6 +116,10 @@ extension AgentProTab {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.directHeader(
for: .usage,
title: "Usage",
subtitle: self.usageDetail)
self.detailSummaryCard(
icon: "chart.line.uptrend.xyaxis",
title: "Usage",
@@ -111,6 +142,7 @@ extension AgentProTab {
var dreamingDestination: some View {
AgentProDreamingDestination(
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
overview: self.overview,
gatewayConnected: self.gatewayConnected,
overviewLoading: self.overviewLoading,
@@ -122,6 +154,27 @@ extension AgentProTab {
})
}
@ViewBuilder
func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View {
if let headerLeadingAction = self.directHeaderLeadingAction(for: route) {
OpenClawAdaptiveHeaderRow(
title: title,
subtitle: subtitle,
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
func directHeaderLeadingAction(for route: AgentRoute) -> OpenClawSidebarHeaderAction? {
self.directRoute == route ? self.headerLeadingAction : nil
}
func detailSummaryCard(
icon: String,
title: String,

View File

@@ -1,5 +1,4 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {

View File

@@ -5,18 +5,19 @@ 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)
OpenClawAdaptiveHeaderRow(
title: self.headerTitle,
subtitle: "\(self.sortedAgents.count) total",
titleFont: .system(size: 28, weight: .bold),
subtitleFont: .subheadline,
subtitleLineLimit: 1)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
Spacer(minLength: 8)
} accessory: {
HStack(spacing: 10) {
self.gatewayPillButton
self.headerIconButton(
systemName: "magnifyingglass",
label: "Search agents",
@@ -56,6 +57,19 @@ extension AgentProTab {
.padding(.top, 6)
}
@ViewBuilder
private var gatewayPillButton: some View {
if let openSettings {
Button(action: openSettings) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
var agentFilters: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
@@ -140,7 +154,7 @@ extension AgentProTab {
value: self.instancesValue,
detail: self.instancesDetail,
color: self.instancesColor,
route: .nodes)
route: .instances)
self.metricTile(
icon: "clock.arrow.circlepath",
title: "Cron",

View File

@@ -1,11 +1,15 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
struct AgentProTab: View {
@Environment(NodeAppModel.self) var appModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.scenePhase) var scenePhase
let directRoute: AgentRoute?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String
let openSettings: (() -> Void)?
@State var navigationPath: [AgentRoute] = []
@State var overview: AgentOverviewSnapshot?
@State var overviewErrorText: String?
@State var overviewLoading: Bool = false
@@ -31,8 +35,9 @@ struct AgentProTab: View {
@State var cronActionStatusText: String?
enum AgentRoute: Hashable {
case agents
case skills
case nodes
case instances
case cron
case usage
case dreaming
@@ -119,8 +124,40 @@ struct AgentProTab: View {
}
}
init(
directRoute: AgentRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
headerTitle: String = "Agents",
openSettings: (() -> Void)? = nil)
{
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
self.headerTitle = headerTitle
self.openSettings = openSettings
}
var body: some View {
NavigationStack {
Group {
if let directRoute {
self.directDestination(for: directRoute)
} else {
self.overviewNavigation
}
}
.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
}
}
}
private var overviewNavigation: some View {
NavigationStack(path: self.$navigationPath) {
ZStack {
OpenClawProBackground()
ScrollView {
@@ -143,15 +180,12 @@ struct AgentProTab: View {
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
}
}
}
private func directDestination(for route: AgentRoute) -> some View {
self.destination(for: route)
.toolbar(
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
for: .navigationBar)
}
}

View File

@@ -7,6 +7,25 @@ struct ChatProTab: View {
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel: OpenClawChatViewModel?
@State private var viewModelUsesAppleReviewDemoTransport = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String?
let headerSubtitle: String?
let showsAgentBadge: Bool
let openSettings: (() -> Void)?
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
headerTitle: String? = nil,
headerSubtitle: String? = nil,
showsAgentBadge: Bool = true,
openSettings: (() -> Void)? = nil)
{
self.headerLeadingAction = headerLeadingAction
self.headerTitle = headerTitle
self.headerSubtitle = headerSubtitle
self.showsAgentBadge = showsAgentBadge
self.openSettings = openSettings
}
var body: some View {
NavigationStack {
@@ -67,7 +86,30 @@ struct ChatProTab: View {
}
private var header: some View {
HStack(spacing: 11) {
OpenClawAdaptiveHeaderRow(
title: self.headerDisplayTitle,
subtitle: self.headerDisplaySubtitle,
titleFont: .headline.weight(.semibold),
subtitleFont: .caption,
subtitleLineLimit: 1)
{
HStack(spacing: 11) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
self.headerIdentityBadge
}
} accessory: {
self.connectionPillButton
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
@ViewBuilder
private var headerIdentityBadge: some View {
if self.showsAgentBadge {
Text(self.agentBadge)
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
@@ -86,24 +128,9 @@ struct ChatProTab: View {
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
} else {
ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 8)
.padding(.bottom, 4)
}
private func syncChatViewModel() {
@@ -162,37 +189,93 @@ struct ChatProTab: View {
?? "main"
}
@ViewBuilder
private var connectionPillButton: some View {
if let openSettings {
Button(action: openSettings) {
self.connectionPill
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
self.connectionPill
}
}
private var connectionPill: some View {
HStack(spacing: 6) {
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
Text(self.gatewayConnected ? "Connected" : "Connecting")
ProStatusDot(color: self.gatewayPillColor)
Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))
.font(.caption.weight(.semibold))
.lineLimit(1)
}
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
.foregroundStyle(self.gatewayPillColor)
.padding(.horizontal, 10)
.frame(height: 30)
.background {
Capsule()
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
.fill(self.gatewayPillColor.opacity(0.11))
}
.overlay {
Capsule()
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
.strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1)
}
}
private var gatewayConnected: Bool {
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
guard self.gatewayDisplayState == .connected else {
return false
}
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var gatewayDisplayState: GatewayDisplayState {
GatewayStatusBuilder.build(appModel: self.appModel)
}
private var gatewayPillColor: Color {
switch self.gatewayDisplayState {
case .connected:
self.gatewayConnected ? OpenClawBrand.ok : .secondary
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
nonisolated static func gatewayPillTitle(state: GatewayDisplayState, isGatewayUsable: Bool) -> String {
switch state {
case .connected:
isGatewayUsable ? "Connected" : "Unavailable"
case .connecting:
"Connecting"
case .error:
"Attention"
case .disconnected:
"Offline"
}
}
private var messagePlaceholder: String {
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
}
private var headerDisplayTitle: String {
self.normalized(self.headerTitle)
?? Self.defaultHeaderTitle(showsAgentBadge: self.showsAgentBadge, agentDisplayName: self.agentDisplayName)
}
private var headerDisplaySubtitle: String {
self.normalized(self.headerSubtitle) ?? "AI Assistant"
}
nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String {
showsAgentBadge ? agentDisplayName : "Chat"
}
private var chatUserAccent: Color {
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
}

View File

@@ -23,7 +23,7 @@ struct CommandPanel<Content: View>: View {
tint: self.tint,
isProminent: self.isProminent,
padding: self.padding,
radius: 12)
radius: OpenClawProMetric.cardRadius)
{
self.content
}
@@ -34,40 +34,15 @@ 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)
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
Color.white.opacity(0.34),
Color.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
.frame(height: 260)
Color.white.opacity(0.20)
.frame(height: 140)
}
}
.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 {
@@ -114,12 +89,12 @@ struct CommandSessionRow: View {
}
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
@@ -136,11 +111,11 @@ struct CommandSessionRow: View {
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
}
}
@@ -154,21 +129,21 @@ struct CommandViewMoreRow: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
}
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
}
}
@@ -199,44 +174,14 @@ struct CommandEmptyStateRow: View {
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 9)
.padding(.vertical, 8)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.06))
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.fill(Color(uiColor: .systemBackground))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
.strokeBorder(Color(uiColor: .separator).opacity(0.22), 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)
}
}

View File

@@ -2,13 +2,17 @@ import OpenClawChatUI
import SwiftUI
struct CommandCenterTab: View {
fileprivate static let recentSessionsFetchLimit = 200
static let recentSessionsFetchLimit = 200
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
var headerTitle: String = "OpenClaw"
var headerLeadingAction: OpenClawSidebarHeaderAction?
var showsHeaderMark: Bool = true
var openChat: () -> Void
var openSettings: () -> Void
@@ -31,20 +35,37 @@ struct CommandCenterTab: View {
var body: some View {
NavigationStack {
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 10) {
self.header
self.gatewayCard
self.defaultChatSessionSection
self.recentSessions
GeometryReader { geometry in
ZStack {
CommandControlBackground()
self.commandAmbientOverlay
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.header
self.gatewayCard
if Self.usesSplitSectionsLayout(
horizontalSizeClass: self.horizontalSizeClass,
containerWidth: geometry.size.width)
{
HStack(alignment: .top, spacing: 12) {
self.defaultChatSessionSection
.frame(maxWidth: .infinity, alignment: .topLeading)
self.recentSessions
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
} else {
self.defaultChatSessionSection
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.recentSessions
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
.padding(.top, 18)
.padding(.bottom, 18)
}
.padding(.top, 16)
.padding(.bottom, 18)
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
}
@@ -53,12 +74,47 @@ struct CommandCenterTab: View {
}
}
static func usesSplitSectionsLayout(
horizontalSizeClass: UserInterfaceSizeClass?,
containerWidth: CGFloat) -> Bool
{
guard horizontalSizeClass == .regular else { return false }
return containerWidth >= 1000
}
static func shouldShowHeaderMark(
hasLeadingAction: Bool,
showsHeaderMark: Bool) -> Bool
{
!hasLeadingAction && showsHeaderMark
}
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()
OpenClawAdaptiveHeaderRow(
title: self.headerTitle,
subtitle: self.gatewaySubtitle,
titleFont: .title3.weight(.semibold),
subtitleFont: .caption,
subtitleLineLimit: 1)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} else if Self.shouldShowHeaderMark(
hasLeadingAction: headerLeadingAction != nil,
showsHeaderMark: self.showsHeaderMark)
{
OpenClawProMark(size: 28, shadowRadius: 5)
}
} accessory: {
Button(action: self.openSettings) {
ProCapsule(
title: self.gatewayStateText,
color: self.gatewayStatusColor,
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
}
.buttonStyle(.plain)
.accessibilityLabel("Gateway \(self.gatewayStateText)")
.accessibilityHint("Opens Settings / Gateway")
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@@ -86,7 +142,7 @@ struct CommandCenterTab: View {
title: "Gateway",
value: self.gatewayStateText,
color: self.gatewayStatusColor,
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
HStack(spacing: 0) {
self.gatewayFact(
@@ -160,7 +216,6 @@ struct CommandCenterTab: View {
.buttonStyle(.plain)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var recentSessions: some View {
@@ -200,7 +255,6 @@ struct CommandCenterTab: View {
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func cardHeader(
@@ -213,7 +267,8 @@ struct CommandCenterTab: View {
{
HStack(spacing: 8) {
Text(title)
.font(.subheadline.weight(.bold))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
if let badgeValue {
Text(badgeValue)
.font(.caption2.weight(.bold))
@@ -403,7 +458,7 @@ struct CommandCenterTab: View {
return result
}
fileprivate static func sessionWorkItem(
static func sessionWorkItem(
for session: OpenClawChatSessionEntry,
currentSessionKey: String) -> WorkItem
{
@@ -558,14 +613,20 @@ struct CommandCenterTab: View {
}
}
private struct CommandSessionsScreen: View {
struct CommandSessionsScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var sessions: [OpenClawChatSessionEntry] = []
@State private var isLoading = false
@State private var loadErrorText: String?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let openChat: () -> Void
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openChat: @escaping () -> Void) {
self.headerLeadingAction = headerLeadingAction
self.openChat = openChat
}
var body: some View {
ZStack {
CommandControlBackground()
@@ -587,12 +648,18 @@ private struct CommandSessionsScreen: View {
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.system(size: 27, weight: .bold, design: .rounded))
Text(self.headerDetail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.system(size: 27, weight: .bold, design: .rounded))
Text(self.headerDetail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}

View File

@@ -0,0 +1,244 @@
import OpenClawChatUI
import OpenClawKit
import SwiftUI
struct IPadActivityScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.scenePhase) private var scenePhase
@State private var sessions: [OpenClawChatSessionEntry] = []
@State private var isLoading = false
@State private var loadErrorText: String?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let openChat: () -> Void
let openSettings: () -> Void
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
openChat: @escaping () -> Void,
openSettings: @escaping () -> Void)
{
self.headerLeadingAction = headerLeadingAction
self.openChat = openChat
self.openSettings = openSettings
}
var body: some View {
IPadSidebarScreenChrome(
title: "Activity",
subtitle: "Live device and gateway activity.",
headerLeadingAction: self.headerLeadingAction,
gatewayAction: self.openSettings)
{
ProMetricGrid(metrics: self.metrics)
self.activityFeed
}
.task(id: self.refreshID) {
await self.refreshSessions()
}
.refreshable {
await self.refreshSessions()
}
}
private var metrics: [ProMetric] {
[
ProMetric(
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash",
title: "Gateway",
value: self.gatewayStateText,
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary),
ProMetric(
icon: "person.2.fill",
title: "Agents",
value: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count)" : "offline",
color: OpenClawBrand.accent),
ProMetric(
icon: "bubble.left.and.text.bubble.right",
title: "Sessions",
value: self.isLoading ? "..." : "\(self.sessionRows.count)",
color: OpenClawBrand.accentHot),
]
}
private var activityFeed: some View {
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Recent activity",
value: self.isLoading ? "Loading" : nil,
actionTitle: "Refresh",
action: {
Task { await self.refreshSessions() }
})
if let pendingExecApprovalPrompt = self.appModel.pendingExecApprovalPrompt {
ProStatusRow(
icon: "hand.raised.fill",
title: "Approval needed",
detail: pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText,
value: "pending",
color: OpenClawBrand.warn,
actionTitle: nil,
action: nil)
Divider().padding(.leading, 58)
}
ProStatusRow(
icon: self.gatewayConnected ? "network" : "wifi.slash",
title: "Gateway",
detail: self.gatewayDetailText,
value: self.gatewayStateText.lowercased(),
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
actionTitle: self.gatewayConnected ? nil : "Settings",
action: self.gatewayConnected ? nil : self.openSettings)
Divider().padding(.leading, 58)
ProStatusRow(
icon: "square.and.arrow.down",
title: "Share intake",
detail: self.appModel.lastShareEventText,
value: "iPad",
color: OpenClawBrand.accent,
actionTitle: nil,
action: nil)
if self.isLoading, self.sessions.isEmpty {
Divider().padding(.leading, 58)
ProStatusRow(
icon: "hourglass",
title: "Loading sessions",
detail: "Fetching recent activity from the gateway.",
value: "loading",
color: OpenClawBrand.accent,
actionTitle: nil,
action: nil)
} else if let loadErrorText {
Divider().padding(.leading, 58)
ProStatusRow(
icon: "exclamationmark.triangle.fill",
title: "Sessions unavailable",
detail: loadErrorText,
value: "error",
color: OpenClawBrand.warn,
actionTitle: nil,
action: nil)
} else if self.sessionRows.isEmpty {
Divider().padding(.leading, 58)
ProStatusRow(
icon: "bubble.left.and.text.bubble.right",
title: self.sessionsAvailable ? "No recent sessions" : "Session activity offline",
detail: self.sessionsAvailable
? "Start a chat and it will appear here."
: "Connect to the gateway to load recent chat activity.",
value: self.sessionsAvailable ? "empty" : "offline",
color: .secondary,
actionTitle: self.sessionsAvailable ? "Chat" : nil,
action: self.sessionsAvailable ? self.openChat : nil)
} else {
ForEach(self.sessionRows) { row in
Divider().padding(.leading, 58)
ProStatusRow(
icon: row.icon,
title: row.title,
detail: row.detail,
value: row.state,
color: row.color,
actionTitle: "Open",
action: {
self.open(row)
})
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var refreshID: String {
[
self.sessionsMode,
self.appModel.chatSessionKey,
self.scenePhase == .active ? "active" : "inactive",
].joined(separator: ":")
}
private var gatewayConnected: Bool {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var gatewayStateText: String {
guard !self.gatewayConnected else { return "Online" }
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
return status.isEmpty ? "Offline" : status
}
private var gatewayDetailText: String {
self.normalized(self.appModel.gatewayRemoteAddress)
?? self.normalized(self.appModel.gatewayServerName)
?? "No gateway connection"
}
private var sessionsAvailable: Bool {
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionsMode: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
}
private var sessionRows: [CommandCenterTab.WorkItem] {
self.sessions
.filter { CommandCenterTab.isRecentChatSession(
$0.key,
defaultSessionKey: self.appModel.defaultChatSessionKey) }
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
.prefix(8)
.map {
CommandCenterTab.sessionWorkItem(
for: $0,
currentSessionKey: self.appModel.chatSessionKey)
}
}
private func refreshSessions() async {
guard self.scenePhase == .active else { return }
guard self.sessionsAvailable else {
self.sessions = []
self.loadErrorText = nil
return
}
self.isLoading = true
self.loadErrorText = nil
defer { self.isLoading = false }
do {
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
self.sessions = response.sessions
} catch {
self.sessions = []
self.loadErrorText = "Try again after the gateway reconnects."
}
}
private func open(_ item: CommandCenterTab.WorkItem) {
switch item.route {
case let .chat(sessionKey):
self.appModel.openChat(sessionKey: sessionKey)
self.openChat()
case .settings:
self.openSettings()
}
}
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,672 @@
import SwiftUI
#if DEBUG
#Preview("Activity states") {
IPadActivityStatesPreview()
}
#Preview("Workboard states") {
IPadWorkboardStatesPreview()
}
#Preview("Skill Workshop states") {
IPadSkillWorkshopStatesPreview()
}
#Preview(
"Skill Workshop iPad kanban lanes",
traits: .fixedLayout(width: 1180, height: 820))
{
IPadSkillWorkshopKanbanPreview()
}
#Preview("Workboard phone queue rows") {
IPadWorkboardCompactRowsPreview()
}
#Preview("Skill Workshop phone queue rows") {
IPadSkillWorkshopCompactRowsPreview()
}
#Preview(
"Workboard phone landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
IPadSidebarTaskScreenPreviewHost {
IPadWorkboardScreen(openChat: {}, openSettings: {})
}
}
#Preview(
"Skill Workshop phone landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
IPadSidebarTaskScreenPreviewHost {
IPadSkillWorkshopScreen(openSettings: {})
}
}
private struct IPadWorkboardCompactRowsPreview: View {
private let statuses = ["todo", "ready", "running", "review", "blocked", "done"]
private let cards = IPadWorkboardPreviewFixtures.cards
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 12) {
self.previewHeader
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Queue",
value: "\(self.cards.count)",
actionTitle: nil,
action: nil)
ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in
if index > 0 {
Divider().padding(.leading, 58)
}
IPadWorkboardQueueRow(
card: card,
statuses: self.statuses,
isBusy: card.id == "preview-running",
inspect: {},
openSession: {},
move: { _ in },
archive: {})
}
}
}
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "tray",
title: "No cards",
detail: "Create a card or change the filter.",
value: "empty",
color: .secondary,
actionTitle: nil,
action: nil)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
.environment(\.horizontalSizeClass, .compact)
.environment(\.verticalSizeClass, .regular)
}
private var previewHeader: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Phone queue")
.font(.headline)
Text("Tap for detail, swipe or long-press for card actions.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private struct IPadSkillWorkshopCompactRowsPreview: View {
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 12) {
self.previewHeader
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Queue",
value: "\(self.proposals.count)",
actionTitle: nil,
action: nil)
ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in
if index > 0 {
Divider().padding(.leading, 58)
}
IPadSkillProposalRow(
proposal: proposal,
isSelected: proposal.id == "preview-pending",
isBusy: proposal.id == "preview-held")
}
}
}
ProCard(radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "hammer",
title: "No proposals",
detail: "New proposals will appear here when agents draft skills.",
value: "empty",
color: .secondary,
actionTitle: nil,
action: nil)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
.environment(\.horizontalSizeClass, .compact)
.environment(\.verticalSizeClass, .regular)
}
private var previewHeader: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Phone proposals")
.font(.headline)
Text("Tap for detail, swipe or long-press for proposal actions.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private struct IPadSidebarTaskScreenPreviewHost<Content: View>: View {
@State private var appModel = NodeAppModel()
@ViewBuilder var content: Content
var body: some View {
NavigationStack {
self.content
}
.environment(self.appModel)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
}
}
private struct IPadActivityStatesPreview: View {
private let connectedSessions = [
CommandCenterTab.WorkItem(
id: "preview-main",
icon: "bubble.left.and.text.bubble.right",
title: "Main",
detail: "Updated just now",
state: "active",
trailing: "open",
color: OpenClawBrand.ok,
progress: nil,
route: .chat("main")),
CommandCenterTab.WorkItem(
id: "preview-ipad-audit",
icon: "bubble.left.and.text.bubble.right",
title: "iPad audit",
detail: "Updated 8m ago",
state: "recent",
trailing: "open",
color: OpenClawBrand.accent,
progress: nil,
route: .chat("ipad-audit")),
]
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.previewHeader("Connected")
self.activityCard(
gatewayTitle: "Gateway",
gatewayDetail: "tailscale.local:18789",
gatewayValue: "online",
gatewayColor: OpenClawBrand.ok,
sessionRows: self.connectedSessions,
tailRows: [])
self.previewHeader("Loading")
self.activityCard(
gatewayTitle: "Gateway",
gatewayDetail: "Fetching recent activity from the gateway.",
gatewayValue: "online",
gatewayColor: OpenClawBrand.ok,
sessionRows: [],
tailRows: [
ActivityPreviewRow(
icon: "hourglass",
title: "Loading sessions",
detail: "Fetching recent activity from the gateway.",
value: "loading",
color: OpenClawBrand.accent),
])
self.previewHeader("Empty")
self.activityCard(
gatewayTitle: "Gateway",
gatewayDetail: "tailscale.local:18789",
gatewayValue: "online",
gatewayColor: OpenClawBrand.ok,
sessionRows: [],
tailRows: [
ActivityPreviewRow(
icon: "bubble.left.and.text.bubble.right",
title: "No recent sessions",
detail: "Start a chat and it will appear here.",
value: "empty",
color: .secondary),
])
self.previewHeader("Error")
self.activityCard(
gatewayTitle: "Gateway",
gatewayDetail: "No gateway connection",
gatewayValue: "offline",
gatewayColor: .secondary,
sessionRows: [],
tailRows: [
ActivityPreviewRow(
icon: "exclamationmark.triangle.fill",
title: "Sessions unavailable",
detail: "Try again after the gateway reconnects.",
value: "error",
color: OpenClawBrand.warn),
])
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func previewHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func activityCard(
gatewayTitle: String,
gatewayDetail: String,
gatewayValue: String,
gatewayColor: Color,
sessionRows: [CommandCenterTab.WorkItem],
tailRows: [ActivityPreviewRow]) -> some View
{
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Recent activity",
value: nil,
actionTitle: "Refresh",
action: {})
ProStatusRow(
icon: gatewayValue == "online" ? "network" : "wifi.slash",
title: gatewayTitle,
detail: gatewayDetail,
value: gatewayValue,
color: gatewayColor,
actionTitle: gatewayValue == "online" ? nil : "Settings",
action: {})
Divider().padding(.leading, 58)
ProStatusRow(
icon: "square.and.arrow.down",
title: "Share intake",
detail: "No share events yet.",
value: "iPad",
color: OpenClawBrand.accent,
actionTitle: nil,
action: nil)
ForEach(sessionRows) { row in
Divider().padding(.leading, 58)
ProStatusRow(
icon: row.icon,
title: row.title,
detail: row.detail,
value: row.state,
color: row.color,
actionTitle: "Open",
action: {})
}
ForEach(tailRows) { row in
Divider().padding(.leading, 58)
ProStatusRow(
icon: row.icon,
title: row.title,
detail: row.detail,
value: row.value,
color: row.color,
actionTitle: nil,
action: nil)
}
}
}
}
private struct ActivityPreviewRow: Identifiable {
let id = UUID()
let icon: String
let title: String
let detail: String
let value: String
let color: Color
}
}
private struct IPadWorkboardStatesPreview: View {
private let statuses = ["todo", "running", "review"]
private let connectedCards = IPadWorkboardPreviewFixtures.cards
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.previewHeader("Connected")
self.connectedBoard
self.previewHeader("Empty")
IPadWorkboardKanbanColumn(
status: "todo",
cards: [],
statuses: self.statuses,
busyCardID: nil,
openSession: { _ in },
inspect: { _ in },
move: { _, _ in },
archive: { _ in })
.frame(maxWidth: 320)
self.previewHeader("Loading")
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "arrow.clockwise",
title: "Loading cards",
detail: "Refreshing the workboard from the gateway.",
value: "loading",
color: OpenClawBrand.accent,
actionTitle: nil,
action: nil)
}
self.previewHeader("Error")
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Cards unavailable",
detail: "Check the gateway connection, then refresh.",
value: "error",
color: OpenClawBrand.warn,
actionTitle: "Retry",
action: {})
}
}
.padding(18)
}
}
}
private var connectedBoard: some View {
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 12) {
ForEach(self.statuses, id: \.self) { status in
IPadWorkboardKanbanColumn(
status: status,
cards: self.connectedCards.filter { $0.status == status },
statuses: self.statuses,
busyCardID: nil,
openSession: { _ in },
inspect: { _ in },
move: { _, _ in },
archive: { _ in })
.frame(width: 282)
}
}
}
}
private func previewHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
}
private enum IPadWorkboardPreviewFixtures {
static let cards = [
IPadWorkboardCard(
id: "preview-todo",
title: "Prep iPad sidebar audit",
notes: "Confirm portrait drawer behavior before device install.",
status: "todo",
priority: "normal",
labels: ["iPad", "UI"],
agentId: "main",
sessionKey: nil,
position: 0,
updatedAt: nil,
metadata: nil),
IPadWorkboardCard(
id: "preview-running",
title: "Verify phone workboard queue",
notes: "Single-list compact flow with detail sheet actions.",
status: "running",
priority: "high",
labels: ["phone"],
agentId: "main",
sessionKey: "session-preview",
position: 1,
updatedAt: nil,
metadata: nil),
IPadWorkboardCard(
id: "preview-review",
title: "Review adaptive shell",
notes: "Make sure shared destinations stay device-specific.",
status: "review",
priority: "normal",
labels: ["shell"],
agentId: "main",
sessionKey: nil,
position: 2,
updatedAt: nil,
metadata: nil),
]
}
private struct IPadSkillWorkshopStatesPreview: View {
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 18) {
self.previewHeader("Connected")
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: nil)
self.previewHeader("Loading")
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: "preview-pending")
self.previewHeader("Empty")
ProCard(radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "hammer",
title: "No proposals",
detail: "New proposals will appear here when agents draft skills.",
value: "empty",
color: .secondary,
actionTitle: nil,
action: nil)
}
self.previewHeader("Offline / Error")
ProCard(radius: OpenClawProMetric.cardRadius) {
ProStatusRow(
icon: "wifi.slash",
title: "Workshop offline",
detail: "Connect to the gateway to load Skill Workshop proposals.",
value: "offline",
color: .secondary,
actionTitle: nil,
action: nil)
Divider().padding(.leading, 58)
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Proposal unavailable",
detail: "Try again after the gateway reconnects.",
value: "error",
color: OpenClawBrand.warn,
actionTitle: nil,
action: nil)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func previewHeader(_ title: String) -> some View {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
}
private func queueCard(
_ proposals: [IPadSkillProposal],
selectedID: String?,
busyID: String?) -> some View
{
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Queue",
value: "\(proposals.count)",
actionTitle: nil,
action: nil)
ForEach(Array(proposals.enumerated()), id: \.element.id) { index, proposal in
if index > 0 {
Divider().padding(.leading, 58)
}
IPadSkillProposalRow(
proposal: proposal,
isSelected: proposal.id == selectedID,
isBusy: proposal.id == busyID)
}
}
}
}
}
private struct IPadSkillWorkshopKanbanPreview: View {
private let lanes = IPadSkillWorkshopPreviewFixtures.kanbanStatuses
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
var body: some View {
ZStack {
OpenClawProBackground()
VStack(alignment: .leading, spacing: 18) {
self.previewHeader
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 12) {
ForEach(self.lanes, id: \.self) { status in
IPadSkillProposalKanbanColumn(
status: status,
proposals: self.proposals.filter { $0.status == status },
selectedProposalID: "preview-pending",
inspectingProposalID: "preview-needs-review",
canApplyProposalMutations: true,
busyAction: nil,
select: { _ in },
inspect: { _ in },
apply: { _ in },
reject: { _ in })
.frame(width: 282)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
.padding(.vertical, 22)
}
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .regular)
}
private var previewHeader: some View {
VStack(alignment: .leading, spacing: 4) {
Text("iPad kanban")
.font(.headline)
Text("Wide layout with populated, empty, held, and custom proposal lanes.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private enum IPadSkillWorkshopPreviewFixtures {
static let kanbanStatuses = [
"pending",
"quarantined",
"stale",
"applied",
"rejected",
"needs-review",
"manual_QA",
]
static let proposals = [
Self.proposal(
id: "preview-pending",
status: "pending",
title: "Add Tailscale gateway helper",
description: "Drafts a helper skill for checking local Tailscale reachability before pairing.",
minutesAgo: 9),
Self.proposal(
id: "preview-applied",
status: "applied",
title: "Summarize channel health",
description: "Adds a lightweight status summary for channel clients and recent routing failures.",
minutesAgo: 47),
Self.proposal(
id: "preview-held",
status: "quarantined",
title: "Desktop automation bridge",
description: "Held for review because it requests broader file access than mobile should expose.",
minutesAgo: 128),
Self.proposal(
id: "preview-needs-review",
status: "needs-review",
title: "Review pairing diagnostics",
description: "Adds a diagnostic checklist before trusting a new gateway certificate.",
minutesAgo: 32),
Self.proposal(
id: "preview-manual-qa",
status: "manual_QA",
title: "Manual QA runbook",
description: "Generates a device checklist for iPhone portrait and iPad split layouts.",
minutesAgo: 15),
]
private static func proposal(
id: String,
status: String,
title: String,
description: String,
minutesAgo: Int) -> IPadSkillProposal
{
let updatedAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(-minutesAgo * 60)))
return IPadSkillProposal(
entry: IPadSkillProposalManifestEntry(
id: id,
kind: "skill",
status: status,
title: title,
description: description,
skillName: title,
skillKey: id,
createdAt: updatedAt,
updatedAt: updatedAt,
scanState: "complete"),
previous: nil)
}
}
#endif

View File

@@ -0,0 +1,17 @@
import Foundation
struct EmptyParams: Encodable {}
enum IPadSidebarGatewayError: Error {
case offline
case invalidPayload
var message: String {
switch self {
case .offline:
"Gateway offline."
case .invalidPayload:
"Could not encode request."
}
}
}

View File

@@ -0,0 +1,71 @@
import SwiftUI
struct IPadSidebarScreenChrome<Content: View>: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
let title: String
let subtitle: String
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
@ViewBuilder var content: Content
init(
title: String,
subtitle: String,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
gatewayAction: (() -> Void)? = nil,
@ViewBuilder content: () -> Content)
{
self.title = title
self.subtitle = subtitle
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
self.content = content()
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
OpenClawAdaptiveHeaderRow(
title: self.title,
subtitle: self.subtitle,
titleFont: self.isCompactHeight ? .headline.weight(.semibold) : .title2.weight(.semibold),
subtitleLineLimit: self.isCompactHeight ? 1 : 2)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
} accessory: {
self.gatewayPill
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.content
}
.padding(.vertical, self.isCompactHeight ? 10 : 18)
}
.safeAreaPadding(.bottom, self.bottomScrollInset)
}
}
private var isCompactHeight: Bool {
self.verticalSizeClass == .compact
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
private var bottomScrollInset: CGFloat {
self.isCompactHeight ? 150 : OpenClawProMetric.bottomScrollInset
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -47,13 +47,6 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
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)
@@ -81,11 +74,6 @@ enum OpenClawBrand {
? 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(
@@ -97,40 +85,6 @@ enum OpenClawBrand {
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 {

View File

@@ -0,0 +1,129 @@
import SwiftUI
struct OpenClawDocsScreen: View {
private let docsURL = URL(string: "https://docs.openclaw.ai")!
private let gatewayURL = URL(string: "https://docs.openclaw.ai/gateway")!
private let pairingURL = URL(string: "https://docs.openclaw.ai/channels/pairing")!
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.headerCard
self.linkCard
self.versionCard
}
.padding(.vertical, 18)
}
}
.navigationTitle("Docs")
.navigationBarTitleDisplayMode(.inline)
}
private var headerCard: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
OpenClawAdaptiveHeaderRow(
title: "Docs",
subtitle: "Gateway setup, pairing, channels, and mobile node reference.",
titleFont: .headline,
subtitleFont: .caption)
{
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
ProIconBadge(systemName: "book", color: OpenClawBrand.accent)
}
} accessory: {
self.gatewayPill
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
private var linkCard: some View {
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
self.docsLinkRow(
title: "Docs Home",
detail: "Browse the current OpenClaw reference.",
icon: "book",
url: self.docsURL)
Divider().padding(.leading, 58)
self.docsLinkRow(
title: "Gateway",
detail: "Connection, auth, and diagnostics.",
icon: "network",
url: self.gatewayURL)
Divider().padding(.leading, 58)
self.docsLinkRow(
title: "Pairing",
detail: "Mobile setup codes, QR, and node approval.",
icon: "qrcode",
url: self.pairingURL)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var versionCard: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
HStack(spacing: 10) {
Text("Version")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.bold))
.foregroundStyle(.primary)
.textSelection(.enabled)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func docsLinkRow(title: String, detail: String, icon: String, url: URL) -> some View {
Link(destination: url) {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "arrow.up.right")
.font(.caption.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}

View File

@@ -1,33 +1,22 @@
import SwiftUI
enum OpenClawProMetric {
static let pagePadding: CGFloat = 20
static let cardRadius: CGFloat = 14
static let controlRadius: CGFloat = 12
static let pagePadding: CGFloat = 18
static let cardRadius: CGFloat = 10
static let controlRadius: CGFloat = 8
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)
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
.ignoresSafeArea()
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
OpenClawBrand.accent.opacity(0.05),
OpenClawBrand.accent.opacity(0.02),
.clear,
],
startPoint: .topTrailing,
endPoint: .bottomLeading)
.frame(height: 620)
Color.white.opacity(0.22)
.frame(height: 140)
.ignoresSafeArea()
}
}
@@ -66,7 +55,7 @@ struct ProSectionHeader: View {
struct ProCard<Content: View>: View {
var tint: Color?
var isProminent: Bool = false
var padding: CGFloat = 14
var padding: CGFloat = 12
var radius: CGFloat = OpenClawProMetric.cardRadius
@ViewBuilder var content: Content
@@ -91,78 +80,39 @@ private struct ProPanelBackground: 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)
}
if self.isProminent {
shape.strokeBorder(
OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07),
lineWidth: 1)
.padding(1)
}
}
}
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 base = self.isProminent
? Color(uiColor: .systemBackground)
: Color(uiColor: .secondarySystemGroupedBackground)
if let tint {
let gradient = LinearGradient(
colors: [
base,
tint.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
base,
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
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)
return AnyShapeStyle(base)
}
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))
}
}
}
AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30))
}
}
@@ -251,9 +201,9 @@ private struct ProPanelSurfaceModifier: ViewModifier {
}
.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)
color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028),
radius: self.isProminent ? 9 : 4,
y: self.isProminent ? 4 : 1)
}
}
@@ -263,16 +213,153 @@ struct ProIconBadge: View {
var body: some View {
Image(systemName: self.systemName)
.font(.subheadline.weight(.semibold))
.font(.caption.weight(.semibold))
.foregroundStyle(self.color)
.frame(width: 34, height: 34)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(self.color.opacity(0.12))
}
}
}
struct OpenClawSidebarHeaderAction {
let systemName: String
let accessibilityLabel: String
let accessibilityIdentifier: String?
let action: () -> Void
init(
systemName: String,
accessibilityLabel: String,
accessibilityIdentifier: String? = nil,
action: @escaping () -> Void)
{
self.systemName = systemName
self.accessibilityLabel = accessibilityLabel
self.accessibilityIdentifier = accessibilityIdentifier
self.action = action
}
}
struct OpenClawSidebarRevealButton: View {
let headerAction: OpenClawSidebarHeaderAction
init(action: OpenClawSidebarHeaderAction) {
self.headerAction = action
}
var body: some View {
let button = Button(action: self.headerAction.action) {
Image(systemName: self.headerAction.systemName)
.font(.system(size: 16, weight: .semibold))
.frame(width: 38, height: 38)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(OpenClawBrand.accent)
.accessibilityLabel(self.headerAction.accessibilityLabel)
if let accessibilityIdentifier = self.headerAction.accessibilityIdentifier {
button.accessibilityIdentifier(accessibilityIdentifier)
} else {
button
}
}
}
struct OpenClawSidebarHeaderLeadingSlot: View {
let action: OpenClawSidebarHeaderAction
var body: some View {
OpenClawSidebarRevealButton(action: self.action)
.frame(width: 44, height: 44, alignment: .center)
}
}
struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
let title: String
let subtitle: String
var titleFont: Font = .title3.weight(.semibold)
var subtitleFont: Font = .subheadline
var subtitleLineLimit: Int? = 2
@ViewBuilder let leading: Leading
@ViewBuilder let accessory: Accessory
init(
title: String,
subtitle: String,
titleFont: Font = .title3.weight(.semibold),
subtitleFont: Font = .subheadline,
subtitleLineLimit: Int? = 2,
@ViewBuilder leading: () -> Leading,
@ViewBuilder accessory: () -> Accessory)
{
self.title = title
self.subtitle = subtitle
self.titleFont = titleFont
self.subtitleFont = subtitleFont
self.subtitleLineLimit = subtitleLineLimit
self.leading = leading()
self.accessory = accessory()
}
var body: some View {
ViewThatFits(in: .horizontal) {
self.horizontalLayout
self.stackedLayout
}
}
private var horizontalLayout: some View {
HStack(alignment: .top, spacing: 12) {
self.leading
self.titleBlock
.layoutPriority(1)
Spacer(minLength: 8)
self.accessory
.fixedSize(horizontal: true, vertical: false)
}
}
private var stackedLayout: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
self.leading
self.titleBlock
.layoutPriority(1)
Spacer(minLength: 8)
}
HStack {
Spacer(minLength: 0)
self.accessory
.fixedSize(horizontal: true, vertical: false)
}
}
}
private var titleBlock: some View {
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(self.titleFont)
.lineLimit(2)
.minimumScaleFactor(0.86)
.fixedSize(horizontal: false, vertical: true)
Text(self.subtitle)
.font(self.subtitleFont)
.foregroundStyle(.secondary)
.lineLimit(self.subtitleLineLimit)
.fixedSize(horizontal: false, vertical: true)
}
}
}
struct ProStatusDot: View {
var color: Color
@@ -280,7 +367,6 @@ struct ProStatusDot: View {
Circle()
.fill(self.color)
.frame(width: 8, height: 8)
.shadow(color: self.color.opacity(0.35), radius: 4)
}
}
@@ -312,7 +398,7 @@ struct OpenClawProMark: View {
.resizable()
.scaledToFit()
.frame(width: self.size, height: self.size)
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
.accessibilityLabel("OpenClaw")
}
}
@@ -336,46 +422,6 @@ struct ProProgressBar: View {
}
}
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
@@ -390,7 +436,10 @@ struct ProCapsule: View {
}
Text(self.title)
.font(.caption.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.78)
}
.fixedSize(horizontal: true, vertical: false)
.foregroundStyle(self.color)
.padding(.horizontal, 10)
.padding(.vertical, 7)
@@ -405,91 +454,54 @@ struct ProCapsule: View {
}
}
struct ProSegmentedControl: View {
@Environment(\.colorScheme) private var colorScheme
let labels: [String]
@Binding var selection: Int
struct OpenClawGatewayCompactPill: View {
@Environment(NodeAppModel.self) private var appModel
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)
}
ProCapsule(
title: self.title,
color: self.color,
icon: self.icon)
.accessibilityLabel("Gateway \(self.title)")
}
private var title: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
"Online"
case .connecting:
"Connecting"
case .error:
"Attention"
case .disconnected:
"Offline"
}
}
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)
private var color: Color {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private var icon: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
"checkmark.circle.fill"
case .connecting:
"arrow.triangle.2.circlepath"
case .error:
"exclamationmark.triangle.fill"
case .disconnected:
"wifi.slash"
}
.buttonStyle(.plain)
}
}
@@ -531,48 +543,119 @@ struct ProMetricTile: View {
}
}
struct ProMetric: Identifiable {
let id = UUID()
let icon: String
let title: String
let value: String
let color: Color
}
struct ProMetricGrid: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
let metrics: [ProMetric]
var body: some View {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible()), count: self.columnCount),
spacing: 10)
{
ForEach(self.metrics) { metric in
ProMetricTile(
title: metric.title,
value: metric.value,
icon: metric.icon,
color: metric.color)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var columnCount: Int {
guard self.horizontalSizeClass != .compact else { return 1 }
return min(max(self.metrics.count, 1), 3)
}
}
struct ProPanelHeader: View {
let title: String
var value: String?
var actionTitle: String?
var actionIcon: String?
var actionAccessibilityLabel: String?
var isActionDisabled = false
var action: (() -> Void)?
var body: some View {
HStack(spacing: 8) {
Text(self.title)
.font(.subheadline.weight(.semibold))
if let value {
Text(value)
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
self.actionControl
}
.padding(.horizontal, 14)
.padding(.top, 12)
.padding(.bottom, 8)
}
@ViewBuilder
private var actionControl: some View {
if let action {
if let actionIcon {
Button(action: action) {
Image(systemName: actionIcon)
}
.accessibilityLabel(self.actionAccessibilityLabel ?? self.actionTitle ?? self.title)
.disabled(self.isActionDisabled)
} else if let actionTitle {
Button(actionTitle, action: action)
.font(.caption.weight(.semibold))
.disabled(self.isActionDisabled)
}
}
}
}
struct ProStatusRow: View {
let icon: String
let title: String
let detail: String
let value: String
let value: String?
let color: Color
var actionTitle: String?
var action: (() -> Void)?
var body: some View {
HStack(alignment: .center, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(2)
}
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)
VStack(alignment: .trailing, spacing: 6) {
if let value {
ProValuePill(value: value, color: self.color)
}
if let actionTitle, let action {
Button(actionTitle, action: action)
.font(.caption.weight(.semibold))
.buttonStyle(.bordered)
.controlSize(.mini)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
}

View File

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

View File

@@ -0,0 +1,417 @@
import OpenClawProtocol
import SwiftUI
struct RootTabsPhoneControlHub: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.verticalSizeClass) private var verticalSizeClass
@State private var navigationPath: [RootTabs.SidebarDestination] = []
@State private var didApplyInitialDestination = false
let groups: [RootTabs.SidebarGroup]
let initialDestination: RootTabs.SidebarDestination?
let openRootDestination: (RootTabs.SidebarDestination) -> Void
var body: some View {
NavigationStack(path: self.$navigationPath) {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
self.headerCard
ForEach(self.groups) { group in
self.groupSection(group)
}
self.versionFooter
}
.padding(.vertical, self.isCompactHeight ? 10 : 16)
}
.safeAreaPadding(.bottom, self.bottomScrollInset)
}
.navigationTitle("Control")
.navigationBarTitleDisplayMode(.inline)
.navigationDestination(for: RootTabs.SidebarDestination.self) { destination in
self.detail(for: destination)
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
}
.onAppear {
self.applyInitialDestinationIfNeeded()
}
}
}
@ViewBuilder
private var headerCard: some View {
if self.isCompactHeight {
ProCard(padding: 8, radius: OpenClawProMetric.cardRadius) {
HStack(spacing: 12) {
OpenClawProMark(size: 24, shadowRadius: 3)
VStack(alignment: .leading, spacing: 3) {
Text(self.sidebarActiveAgentTitle)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
} else {
ProCard(radius: OpenClawProMetric.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
OpenClawProMark(size: 32, shadowRadius: 4)
VStack(alignment: .leading, spacing: 3) {
Text(self.sidebarActiveAgentTitle)
.font(.headline)
.lineLimit(1)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
}
self.gatewayActionRow
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private var gatewayActionRow: some View {
Button {
self.openRootDestination(.gateway)
} label: {
HStack(spacing: 10) {
ProStatusDot(color: self.gatewayStateColor)
VStack(alignment: .leading, spacing: 2) {
Text(self.gatewayStateText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.gatewayDisplayLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
Text(self.gatewayActionTitle)
.font(.caption.weight(.semibold))
.foregroundStyle(OpenClawBrand.accent)
Image(systemName: "chevron.right")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(10)
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
.buttonStyle(.plain)
.accessibilityLabel("Gateway \(self.gatewayStateText)")
.accessibilityHint("Opens Settings / Gateway")
}
private func groupSection(_ group: RootTabs.SidebarGroup) -> some View {
VStack(alignment: .leading, spacing: self.isCompactHeight ? 6 : 8) {
ProSectionHeader(title: group.title.capitalized)
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ForEach(Array(group.destinations.enumerated()), id: \.element.id) { index, destination in
if index > 0 {
Divider().padding(.leading, 58)
}
self.destinationRow(destination)
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View {
if self.opensRootTab(destination) {
Button {
self.openRootDestination(destination)
} label: {
self.rowLabel(destination)
}
.buttonStyle(.plain)
} else {
Button {
self.navigationPath.append(destination)
} label: {
self.rowLabel(destination)
}
.buttonStyle(.plain)
}
}
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
HStack(alignment: .center, spacing: 12) {
ProIconBadge(systemName: destination.systemImage, color: self.color(for: destination))
VStack(alignment: .leading, spacing: 3) {
Text(destination.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(destination.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption2.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(.vertical, self.isCompactHeight ? 8 : 10)
.padding(.horizontal, 14)
.contentShape(Rectangle())
}
private var versionFooter: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
HStack {
Spacer()
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private func detail(for destination: RootTabs.SidebarDestination) -> some View {
switch destination {
case .chat, .talk, .agents, .gateway:
EmptyView()
case .overview:
CommandCenterTab(
headerTitle: "Overview",
headerLeadingAction: self.phoneDetailBackAction,
showsHeaderMark: false,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .activity:
IPadActivityScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .workboard:
IPadWorkboardScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) },
openSettings: { self.openRootDestination(.gateway) })
case .skillWorkshop:
IPadSkillWorkshopScreen(
headerLeadingAction: self.phoneDetailBackAction,
openSettings: { self.openRootDestination(.gateway) })
case .instances:
AgentProTab(
directRoute: .instances,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Instances",
openSettings: { self.openRootDestination(.gateway) })
case .sessions:
CommandSessionsScreen(
headerLeadingAction: self.phoneDetailBackAction,
openChat: { self.openRootDestination(.chat) })
case .dreaming:
AgentProTab(
directRoute: .dreaming,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Dreaming",
openSettings: { self.openRootDestination(.gateway) })
case .usage:
AgentProTab(
directRoute: .usage,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Usage",
openSettings: { self.openRootDestination(.gateway) })
case .cron:
AgentProTab(
directRoute: .cron,
headerLeadingAction: self.phoneDetailBackAction,
headerTitle: "Cron Jobs",
openSettings: { self.openRootDestination(.gateway) })
case .docs:
OpenClawDocsScreen(
headerLeadingAction: self.phoneDetailBackAction,
gatewayAction: { self.openRootDestination(.gateway) })
case .settings:
EmptyView()
}
}
private var phoneDetailBackAction: OpenClawSidebarHeaderAction {
OpenClawSidebarHeaderAction(
systemName: "chevron.left",
accessibilityLabel: "Back to Control",
accessibilityIdentifier: "OpenClawPhoneDetailBackButton",
action: { self.popPhoneDetail() })
}
private func popPhoneDetail() {
guard !self.navigationPath.isEmpty else { return }
self.navigationPath.removeLast()
}
private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool {
RootTabs.shouldOpenRootTabFromPhoneHub(destination)
}
private func applyInitialDestinationIfNeeded() {
guard !self.didApplyInitialDestination else { return }
self.didApplyInitialDestination = true
guard let initialDestination, initialDestination != .overview else { return }
if self.opensRootTab(initialDestination) {
self.openRootDestination(initialDestination)
} else {
self.navigationPath = [initialDestination]
}
}
private var sidebarActiveAgentTitle: String {
let selectedID = self.normalized(self.appModel.selectedAgentId) ?? self.resolveDefaultAgentID()
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == selectedID }) {
return self.agentTitle(for: agent)
}
return self.normalized(self.appModel.activeAgentName) ?? "Default Agent"
}
private var gatewayDisplayLabel: String {
self.normalized(self.appModel.gatewayServerName)
?? self.normalized(self.appModel.gatewayRemoteAddress)
?? self.appModel.gatewayDisplayStatusText
}
private var gatewayStateText: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected: "Online"
case .connecting: "Connecting"
case .error: "Attention"
case .disconnected: "Offline"
}
}
private var gatewayStateColor: Color {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private var gatewayActionTitle: String {
switch GatewayStatusBuilder.build(appModel: self.appModel) {
case .connected:
"Manage"
case .connecting:
"Details"
case .error:
"Fix"
case .disconnected:
"Connect"
}
}
private var isCompactHeight: Bool {
self.verticalSizeClass == .compact
}
private var bottomScrollInset: CGFloat {
Self.bottomScrollInset(verticalSizeClass: self.verticalSizeClass)
}
static func bottomScrollInset(verticalSizeClass: UserInterfaceSizeClass?) -> CGFloat {
verticalSizeClass == .compact ? 72 : 112
}
private func color(for destination: RootTabs.SidebarDestination) -> Color {
switch destination {
case .chat, .talk, .overview, .gateway:
OpenClawBrand.accent
case .instances:
Color.secondary
case .activity, .usage, .docs:
OpenClawBrand.accentHot
case .agents, .workboard, .skillWorkshop, .sessions, .dreaming, .cron, .settings:
OpenClawBrand.ok
}
}
private func resolveDefaultAgentID() -> String {
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
}
private func agentTitle(for agent: AgentSummary) -> String {
let name = self.normalized(agent.name) ?? agent.id
return name == agent.id ? name : "\(name) (\(agent.id))"
}
private func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
#if DEBUG
#Preview("Phone control hub offline") {
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
}
#Preview("Phone control hub connected") {
let appModel = NodeAppModel()
appModel.enterAppleReviewDemoMode()
return RootTabsPhoneControlHub.preview(appModel: appModel)
}
#Preview("Phone control hub connecting") {
let appModel = NodeAppModel()
appModel.gatewayStatusText = "Connecting..."
return RootTabsPhoneControlHub.preview(appModel: appModel)
}
#Preview("Phone control hub gateway error") {
let appModel = NodeAppModel()
appModel.gatewayStatusText = "Gateway error: connection refused"
return RootTabsPhoneControlHub.preview(appModel: appModel)
}
#Preview(
"Phone control hub landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
}
extension RootTabsPhoneControlHub {
fileprivate static func preview(appModel: NodeAppModel) -> some View {
RootTabsPhoneControlHub(
groups: RootTabs.phoneControlGroups,
initialDestination: nil,
openRootDestination: { _ in })
.environment(appModel)
}
}
#endif

View File

@@ -0,0 +1,667 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
struct SettingsChannelsDestination: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.scenePhase) private var scenePhase
let showsSummaryCard: Bool
@State private var snapshot: ChannelsStatusResult?
@State private var isLoading = false
@State private var errorText: String?
@State private var busyOperation: SettingsChannelOperation?
init(showsSummaryCard: Bool = true) {
self.showsSummaryCard = showsSummaryCard
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
if self.showsSummaryCard {
self.summaryCard
}
self.channelsCard
}
.task(id: self.refreshID) {
await self.loadChannels(force: false)
}
.refreshable {
await self.loadChannels(force: true)
}
}
private var summaryCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
HStack(spacing: 12) {
ProIconBadge(systemName: "point.3.connected.trianglepath.dotted", color: self.summaryColor)
VStack(alignment: .leading, spacing: 3) {
Text("Channels / Integrations")
.font(.headline)
Text(self.summaryDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
ProValuePill(value: self.summaryValue, color: self.summaryColor)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var channelsCard: some View {
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
ProPanelHeader(
title: "Message Routing",
value: self.headerValue,
actionIcon: self.isLoading ? "hourglass" : "arrow.clockwise",
actionAccessibilityLabel: "Refresh Channels",
isActionDisabled: self.isLoading,
action: {
Task { await self.loadChannels(force: true) }
})
if let errorText {
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Channel status unavailable",
detail: errorText,
value: "error",
color: OpenClawBrand.warn)
} else if !self.canRead {
ProStatusRow(
icon: "wifi.slash",
title: "Gateway offline",
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
value: "offline",
color: .secondary)
} else if self.isLoading, self.snapshot == nil {
ProStatusRow(
icon: "hourglass",
title: "Loading channels",
detail: "Fetching installed channels, accounts, and routing status from the gateway.",
value: "loading",
color: OpenClawBrand.accent)
} else if self.channelEntries.isEmpty {
ProStatusRow(
icon: "tray",
title: "No channel plugins reported",
detail: "Install or enable channel plugins on the gateway, then refresh.",
value: "empty",
color: .secondary)
} else {
ForEach(Array(self.channelEntries.enumerated()), id: \.element.id) { index, entry in
if index > 0 {
Divider().padding(.leading, 58)
}
SettingsChannelRow(
entry: entry,
canAdmin: self.canAdmin,
busyOperation: self.busyOperation,
start: { accountID in
Task { await self.run(.start, channelID: entry.id, accountID: accountID) }
},
stop: { accountID in
Task { await self.run(.stop, channelID: entry.id, accountID: accountID) }
},
logout: { accountID in
Task { await self.run(.logout, channelID: entry.id, accountID: accountID) }
})
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var refreshID: String {
[
self.canRead ? "connected" : "offline",
self.scenePhase == .active ? "active" : "inactive",
].joined(separator: ":")
}
private var canRead: Bool {
self.appModel.isOperatorGatewayConnected
}
private var canAdmin: Bool {
self.appModel.hasOperatorAdminScope
}
static func shouldEnableChannelOperation(canRead: Bool, hasOperatorAdminScope: Bool) -> Bool {
canRead && hasOperatorAdminScope
}
private var headerValue: String? {
if self.isLoading { return "Loading" }
guard self.canRead else { return "Offline" }
return "\(self.channelEntries.count)"
}
private var summaryDetail: String {
guard self.canRead else {
return "Connect to load channel integrations."
}
if let errorText {
return errorText
}
return "Installed channel clients, account state, and message-routing readiness."
}
private var summaryValue: String {
guard self.canRead else { return "offline" }
if self.isLoading { return "loading" }
if self.errorText != nil { return "error" }
let configured = self.channelEntries.count(where: { $0.configured })
return "\(configured)/\(self.channelEntries.count)"
}
private var summaryColor: Color {
guard self.canRead else { return .secondary }
if self.errorText != nil { return OpenClawBrand.warn }
return self.channelEntries.contains(where: { $0.running || $0.connected }) ? OpenClawBrand.ok : OpenClawBrand
.accent
}
private var channelEntries: [SettingsChannelEntry] {
guard let snapshot else { return [] }
let ids = snapshot.channelorder.isEmpty ? Array(snapshot.channels.keys).sorted() : snapshot.channelorder
return ids.map { self.entry(channelID: $0, snapshot: snapshot) }
}
private func entry(channelID: String, snapshot: ChannelsStatusResult) -> SettingsChannelEntry {
let summary = snapshot.channels[channelID]?.dictionaryValue ?? [:]
let accounts = self.accounts(channelID: channelID, snapshot: snapshot)
let configured = accounts.contains(where: \.configured) || summary["configured"]?.boolValue == true
let running = accounts.contains(where: \.running)
let connected = accounts.contains(where: \.connected)
let linked = accounts.contains(where: \.linked)
let label = snapshot.channellabels[channelID]?.stringValue ?? Self.fallbackLabel(channelID)
let detail = snapshot.channeldetaillabels?[channelID]?.stringValue ?? Self.fallbackDetail(channelID)
let systemImage = snapshot.channelsystemimages?[channelID]?.stringValue ?? Self.fallbackSystemImage(channelID)
let lastActivity = accounts.compactMap(\.lastActivityMs).max()
let lastError = accounts.compactMap(\.lastError).first ?? summary["lastError"]?.stringValue
return SettingsChannelEntry(
id: channelID,
label: label,
detail: detail,
systemImage: systemImage,
configured: configured,
running: running,
connected: connected,
linked: linked,
lastActivityText: lastActivity.map(Self.relativeTime),
lastError: lastError,
unavailableReason: configured ? nil : "Configure this channel on the gateway.",
accounts: accounts)
}
private func accounts(channelID: String, snapshot: ChannelsStatusResult) -> [SettingsChannelAccount] {
let rawAccounts = snapshot.channelaccounts[channelID]?.arrayValue ?? []
return rawAccounts.compactMap { raw in
guard let dict = raw.dictionaryValue else { return nil }
let accountID = dict["accountId"]?.stringValue ?? "default"
let name = dict["name"]?.stringValue
let lastActivity = [
dict["lastInboundAt"]?.intValue,
dict["lastOutboundAt"]?.intValue,
dict["lastTransportActivityAt"]?.intValue,
]
.compactMap(\.self)
.max()
return SettingsChannelAccount(
id: accountID,
name: name,
configured: dict["configured"]?.boolValue == true,
enabled: dict["enabled"]?.boolValue != false,
running: dict["running"]?.boolValue == true,
connected: dict["connected"]?.boolValue == true,
linked: dict["linked"]?.boolValue == true,
healthState: dict["healthState"]?.stringValue,
lastError: dict["lastError"]?.stringValue,
lastActivityMs: lastActivity)
}
}
private func loadChannels(force: Bool) async {
guard self.scenePhase == .active else { return }
guard self.canRead else {
self.snapshot = nil
self.errorText = nil
return
}
if self.isLoading { return }
self.isLoading = true
self.errorText = nil
defer { self.isLoading = false }
do {
let params = ChannelsStatusParams(probe: false, timeoutms: 10000, channel: nil)
let data = try await self.request(method: "channels.status", params: params, timeoutSeconds: 12)
self.snapshot = try JSONDecoder().decode(ChannelsStatusResult.self, from: data)
} catch {
if force || self.snapshot == nil {
self.errorText = Self.message(for: error)
}
}
}
private func run(_ kind: SettingsChannelOperation.Kind, channelID: String, accountID: String?) async {
guard Self.shouldEnableChannelOperation(canRead: self.canRead, hasOperatorAdminScope: self.canAdmin),
self.busyOperation == nil
else {
return
}
self.busyOperation = SettingsChannelOperation(kind: kind, channelID: channelID, accountID: accountID)
self.errorText = nil
defer { self.busyOperation = nil }
do {
switch kind {
case .start:
let params = ChannelsStartParams(channel: channelID, accountid: accountID)
_ = try await self.request(method: "channels.start", params: params, timeoutSeconds: 20)
case .stop:
let params = ChannelsStopParams(channel: channelID, accountid: accountID)
_ = try await self.request(method: "channels.stop", params: params, timeoutSeconds: 20)
case .logout:
let params = ChannelsLogoutParams(channel: channelID, accountid: accountID)
_ = try await self.request(method: "channels.logout", params: params, timeoutSeconds: 20)
}
await self.loadChannels(force: true)
} catch {
self.errorText = Self.message(for: error)
}
}
private func request(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 SettingsChannelError.invalidPayload
}
return try await self.appModel.operatorSession.request(
method: method,
paramsJSON: json,
timeoutSeconds: timeoutSeconds)
}
static func fallbackLabel(_ id: String) -> String {
if let metadata = self.fallbackMetadata[id.lowercased()] {
return metadata.label
}
return id.replacingOccurrences(of: "-", with: " ")
.replacingOccurrences(of: "_", with: " ")
.split(separator: " ")
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
}
static func fallbackDetail(_ id: String) -> String {
self.fallbackMetadata[id.lowercased()]?.detail ?? "Channel integration"
}
static func fallbackSystemImage(_ id: String) -> String {
self.fallbackMetadata[id.lowercased()]?.systemImage ?? "bubble.left.and.text.bubble.right"
}
private static let fallbackMetadata: [String: SettingsChannelFallbackMetadata] = [
"clickclack": SettingsChannelFallbackMetadata(
label: "ClickClack",
detail: "Self-hosted chat bot routing.",
systemImage: "bubble.left.and.bubble.right"),
]
private static func relativeTime(_ milliseconds: Int) -> String {
let age = max(0, Int(Date().timeIntervalSince1970 * 1000) - milliseconds)
let minutes = age / 60000
if minutes < 1 { return "now" }
if minutes < 60 { return "\(minutes)m ago" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h ago" }
return "\(hours / 24)d ago"
}
private static func message(for error: Error) -> String {
if let channelError = error as? SettingsChannelError {
return channelError.message
}
return error.localizedDescription
}
}
private struct SettingsChannelRow: View {
let entry: SettingsChannelEntry
let canAdmin: Bool
let busyOperation: SettingsChannelOperation?
let start: (String?) -> Void
let stop: (String?) -> Void
let logout: (String?) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.entry.systemImage, color: self.entry.color)
VStack(alignment: .leading, spacing: 4) {
Text(self.entry.label)
.font(.subheadline.weight(.semibold))
Text(self.entry.detailText)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let lastError = self.entry.lastError {
Text(lastError)
.font(.caption2.weight(.medium))
.foregroundStyle(OpenClawBrand.warn)
.lineLimit(2)
}
}
Spacer(minLength: 8)
ProValuePill(value: self.entry.statusValue, color: self.entry.color)
}
if !self.entry.accounts.isEmpty {
VStack(spacing: 0) {
ForEach(Array(self.entry.accounts.enumerated()), id: \.element.id) { index, account in
if index > 0 {
Divider().padding(.leading, 38)
}
self.accountRow(account)
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
private func accountRow(_ account: SettingsChannelAccount) -> some View {
HStack(spacing: 10) {
Image(systemName: account.running || account.connected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(account.color)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 2) {
Text(account.displayName)
.font(.caption.weight(.semibold))
Text(account.detailText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Menu {
if account.running {
Button("Stop") {
self.stop(account.id)
}
} else {
Button("Start") {
self.start(account.id)
}
.disabled(!account.configured || !account.enabled)
}
if account.linked {
Button("Logout", role: .destructive) {
self.logout(account.id)
}
}
} label: {
Image(systemName: self.actionMenuIcon(account))
}
.buttonStyle(.bordered)
.controlSize(.mini)
.disabled(!self.canAdmin || self.isBusy(account))
}
.padding(.vertical, 8)
}
private func actionMenuIcon(_ account: SettingsChannelAccount) -> String {
if self.isBusy(account) {
return "hourglass"
}
if !self.canAdmin {
return "lock.shield"
}
return "ellipsis.circle"
}
private func isBusy(_ account: SettingsChannelAccount) -> Bool {
self.busyOperation?.channelID == self.entry.id && self.busyOperation?.accountID == account.id
}
}
private struct SettingsChannelEntry: Identifiable {
let id: String
let label: String
let detail: String
let systemImage: String
let configured: Bool
let running: Bool
let connected: Bool
let linked: Bool
let lastActivityText: String?
let lastError: String?
let unavailableReason: String?
let accounts: [SettingsChannelAccount]
var color: Color {
if self.connected || self.running { return OpenClawBrand.ok }
if self.lastError != nil { return OpenClawBrand.warn }
return self.configured ? OpenClawBrand.accent : .secondary
}
var statusValue: String {
if self.connected { return "connected" }
if self.running { return "running" }
if self.linked { return "linked" }
if self.configured { return "configured" }
return "not set"
}
var detailText: String {
if let lastActivityText {
return "\(self.detail) • active \(lastActivityText)"
}
if let unavailableReason {
return unavailableReason
}
return self.detail
}
}
private struct SettingsChannelFallbackMetadata {
let label: String
let detail: String
let systemImage: String
}
private struct SettingsChannelAccount: Identifiable {
let id: String
let name: String?
let configured: Bool
let enabled: Bool
let running: Bool
let connected: Bool
let linked: Bool
let healthState: String?
let lastError: String?
let lastActivityMs: Int?
var displayName: String {
let trimmedName = self.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmedName.isEmpty ? self.id : "\(trimmedName) (\(self.id))"
}
var detailText: String {
let state = if self.connected {
"connected"
} else if self.running {
"running"
} else if self.linked {
"linked"
} else if self.configured {
"configured"
} else {
"not configured"
}
let enabledText = self.enabled ? "enabled" : "disabled"
if let healthState, !healthState.isEmpty {
return "\(state), \(enabledText), \(healthState)"
}
if let lastError, !lastError.isEmpty {
return "\(state), \(enabledText), error"
}
return "\(state), \(enabledText)"
}
var color: Color {
if self.connected || self.running { return OpenClawBrand.ok }
if self.lastError != nil { return OpenClawBrand.warn }
return self.configured ? OpenClawBrand.accent : .secondary
}
}
private struct SettingsChannelOperation: Equatable {
enum Kind {
case start
case stop
case logout
}
let kind: Kind
let channelID: String
let accountID: String?
}
private enum SettingsChannelError: Error {
case invalidPayload
var message: String {
switch self {
case .invalidPayload:
"Could not encode channel request."
}
}
}
#if DEBUG
#Preview("Channels states") {
SettingsChannelsStatesPreview()
}
private struct SettingsChannelsStatesPreview: View {
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.stateSection("Connected") {
SettingsChannelRow(
entry: Self.telegramEntry,
canAdmin: true,
busyOperation: nil,
start: { _ in },
stop: { _ in },
logout: { _ in })
}
self.stateSection("Loading") {
ProPanelHeader(
title: "Message Routing",
value: "Loading",
actionIcon: "hourglass",
actionAccessibilityLabel: "Refresh Channels",
isActionDisabled: true,
action: {})
ProStatusRow(
icon: "hourglass",
title: "Loading channel status",
detail: "Checking installed channel clients and account state.",
value: "loading",
color: OpenClawBrand.accent)
}
self.stateSection("Empty") {
ProPanelHeader(
title: "Message Routing",
value: "0",
actionIcon: "arrow.clockwise",
actionAccessibilityLabel: "Refresh Channels",
action: {})
ProStatusRow(
icon: "tray",
title: "No channel plugins reported",
detail: "Install or enable channel plugins on the gateway, then refresh.",
value: "empty",
color: .secondary)
}
self.stateSection("Error") {
ProStatusRow(
icon: "exclamationmark.triangle",
title: "Channel status unavailable",
detail: "Gateway returned an unexpected channel status response.",
value: "error",
color: OpenClawBrand.warn)
}
self.stateSection("Offline") {
ProStatusRow(
icon: "wifi.slash",
title: "Gateway offline",
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
value: "offline",
color: .secondary)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func stateSection(
_ title: String,
@ViewBuilder content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
content()
}
}
}
}
private static let telegramEntry = SettingsChannelEntry(
id: "telegram",
label: "Telegram",
detail: "Message routing client",
systemImage: "paperplane",
configured: true,
running: true,
connected: true,
linked: true,
lastActivityText: "4m ago",
lastError: nil,
unavailableReason: nil,
accounts: [
SettingsChannelAccount(
id: "main",
name: "OpenClaw Ops",
configured: true,
enabled: true,
running: true,
connected: true,
linked: true,
healthState: "healthy",
lastError: nil,
lastActivityMs: nil),
])
}
#endif

View File

@@ -58,9 +58,38 @@ struct SettingsProTab: View {
@State var diagnosticsLastRunText = "Not run"
@State var diagnosticsIssueCount: Int?
@State var showTalkIssueDetails = false
@State private var navigationPath: [SettingsRoute] = []
let initialRoute: SettingsRoute?
let directRoute: SettingsRoute?
let headerLeadingAction: OpenClawSidebarHeaderAction?
init(
initialRoute: SettingsRoute? = nil,
directRoute: SettingsRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
}
var body: some View {
NavigationStack {
self.settingsModalPresentation(
self.settingsLifecycle(
self.settingsContent))
}
@ViewBuilder
private var settingsContent: some View {
if let directRoute {
self.destination(for: directRoute)
} else {
self.settingsNavigationStack
}
}
private var settingsNavigationStack: some View {
NavigationStack(path: self.$navigationPath) {
ZStack {
OpenClawProBackground()
ScrollView {
@@ -78,11 +107,17 @@ struct SettingsProTab: View {
.navigationDestination(for: SettingsRoute.self) { route in
self.destination(for: route)
}
}
}
private func settingsLifecycle(_ content: some View) -> some View {
content
.task {
self.previousLocationModeRaw = self.locationModeRaw
self.syncSettingsState()
self.refreshNotificationSettings()
self.applyPendingGatewaySetupLinkIfNeeded()
self.applyInitialRouteIfNeeded()
}
.onChange(of: self.scenePhase) { _, phase in
if phase == .active {
@@ -119,66 +154,76 @@ struct SettingsProTab: View {
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.applyPendingGatewaySetupLinkIfNeeded()
}
}
.sheet(isPresented: self.$showGatewayProblemDetails) {
if let gatewayProblem = self.appModel.lastGatewayProblem {
GatewayProblemDetailsSheet(
problem: gatewayProblem,
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
onPrimaryAction: {
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
})
}
private func settingsModalPresentation(_ content: some View) -> some View {
content
.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.$showTalkIssueDetails) {
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
TalkRuntimeIssueDetailsSheet(issue: issue)
.sheet(isPresented: self.$showTalkIssueDetails) {
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
TalkRuntimeIssueDetailsSheet(issue: issue)
}
}
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedGatewayLink(link)
},
onSetupCode: { code in
self.handleScannedSetupCode(code)
},
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 }
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedGatewayLink(link)
},
onSetupCode: { code in
self.handleScannedSetupCode(code)
},
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()
.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.")
}
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 ?? "")
}
.alert(
"QR Scanner Unavailable",
isPresented: Binding(
get: { self.scannerError != nil },
set: { if !$0 { self.scannerError = nil } }))
{
Button("OK", role: .cancel) {}
} message: {
Text(self.scannerError ?? "")
}
}
private func applyInitialRouteIfNeeded() {
guard self.directRoute == nil else { return }
guard let initialRoute else { return }
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
}

View File

@@ -139,15 +139,6 @@ extension SettingsProTab {
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 }
@@ -200,7 +191,7 @@ extension SettingsProTab {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
guard await self.preflightGateway(host: host, port: port) else { return }
self.setupStatusText = "Setup code applied. Connecting..."
await self.connectManual()
}
@@ -298,7 +289,7 @@ extension SettingsProTab {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
guard await self.preflightGateway(host: host, port: port) else { return }
await self.connectManual()
}
@@ -327,7 +318,7 @@ extension SettingsProTab {
authOverride: authOverride)
}
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
func preflightGateway(host: String, port: Int) async -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
@@ -495,6 +486,7 @@ extension SettingsProTab {
case .gateway: "Gateway"
case .approvals: "Approvals"
case .permissions: "Permissions"
case .channels: "Channels"
case .voice: "Voice & Talk"
case .diagnostics: "Diagnostics"
case .privacy: "Privacy"
@@ -503,6 +495,20 @@ extension SettingsProTab {
}
}
func subtitle(for route: SettingsRoute) -> String {
switch route {
case .gateway: "Pairing, diagnostics, and Tailscale checks."
case .approvals: "Review pending agent actions."
case .permissions: "Control device capabilities."
case .channels: "Message routing and external clients."
case .voice: "Talk mode and wake phrase settings."
case .diagnostics: "Run local health checks."
case .privacy: "Data and device privacy controls."
case .notifications: "Alert permissions and delivery."
case .about: "Version and support details."
}
}
var manualPortBinding: Binding<String> {
Binding(
get: { self.manualGatewayPortText },

View File

@@ -3,10 +3,20 @@ import SwiftUI
extension SettingsProTab {
var settingsHeader: some View {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
OpenClawAdaptiveHeaderRow(
title: "Settings",
subtitle: "Gateway, permissions, voice, and device controls.",
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var appearanceSection: some View {
@@ -131,6 +141,11 @@ extension SettingsProTab {
title: "Permissions",
detail: self.permissionsDetail,
route: .permissions)
self.settingsListRow(
icon: "point.3.connected.trianglepath.dotted",
title: "Channels / Integrations",
detail: "Message routing and external channel clients.",
route: .channels)
self.settingsListRow(
icon: "waveform",
title: "Voice & Talk",
@@ -199,6 +214,9 @@ extension SettingsProTab {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
if self.headerLeadingAction != nil {
self.routeHeader(for: route)
}
switch route {
case .gateway:
self.gatewayDestination
@@ -206,6 +224,8 @@ extension SettingsProTab {
self.approvalsDestination
case .permissions:
self.permissionsDestination
case .channels:
SettingsChannelsDestination()
case .voice:
self.voiceDestination
case .diagnostics:
@@ -224,6 +244,24 @@ extension SettingsProTab {
}
.navigationTitle(self.title(for: route))
.navigationBarTitleDisplayMode(.inline)
.toolbar(self.headerLeadingAction == nil ? .visible : .hidden, for: .navigationBar)
}
func routeHeader(for route: SettingsRoute) -> some View {
OpenClawAdaptiveHeaderRow(
title: self.title(for: route),
subtitle: self.subtitle(for: route),
titleFont: .title3.weight(.semibold),
subtitleFont: .callout)
{
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.top, 6)
}
var gatewayDestination: some View {

View File

@@ -1,10 +1,12 @@
import Darwin
import OpenClawKit
import SwiftUI
enum SettingsRoute: Hashable {
case gateway
case approvals
case permissions
case channels
case voice
case diagnostics
case privacy
@@ -150,3 +152,176 @@ extension SettingsProTab {
return a == 100 && b >= 64 && b <= 127
}
}
#if DEBUG
#Preview("Gateway settings states") {
SettingsGatewayStatesPreview()
}
private struct SettingsGatewayStatesPreview: View {
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
self.stateSection("Connected") {
self.gatewayStatusCard(
title: "Gateway online",
detail: "Connected to openclaw-gateway.tailnet.ts.net.",
value: "online",
color: OpenClawBrand.ok)
self.gatewayFactsCard(
address: "100.88.41.20:18789",
server: "openclaw-gateway",
discovered: "3",
agent: "Aiden")
}
self.stateSection("Loading") {
self.gatewayStatusCard(
title: "Checking gateway",
detail: "Refreshing connection, discovery, and device trust state.",
value: "loading",
color: OpenClawBrand.accent)
self.gatewayActionsCard(isBusy: true)
}
self.stateSection("Empty") {
self.gatewayStatusCard(
title: "No gateway configured",
detail: "Scan a setup QR code, paste a setup code, or choose a discovered gateway.",
value: "setup",
color: .secondary)
self.setupActionsCard
}
self.stateSection("Error") {
GatewayProblemBanner(
problem: Self.pairingProblem,
primaryActionTitle: "Retry",
onPrimaryAction: {},
onShowDetails: {})
self.gatewayStatusCard(
title: "Tailscale warning",
detail: "Tailscale is off on this device. Turn it on, then try again.",
value: "network",
color: OpenClawBrand.warn)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
.padding(.vertical, 18)
}
}
}
private func stateSection(
_ title: String,
@ViewBuilder content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
content()
}
}
private func gatewayStatusCard(
title: String,
detail: String,
value: String,
color: Color) -> some View
{
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
ProStatusRow(
icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash",
title: title,
detail: detail,
value: value,
color: color,
actionTitle: value == "setup" ? "Scan QR" : nil,
action: value == "setup" ? {} : nil)
}
}
private func gatewayFactsCard(
address: String,
server: String,
discovered: String,
agent: String) -> some View
{
ProCard(radius: SettingsLayout.cardRadius) {
VStack(spacing: 0) {
self.factRow("Address", value: address)
Divider()
self.factRow("Server", value: server)
Divider()
self.factRow("Discovered", value: discovered)
Divider()
self.factRow("Default Agent", value: agent)
}
}
}
private func factRow(_ label: String, value: String) -> some View {
HStack {
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text(value)
.font(.caption.weight(.medium))
.lineLimit(1)
.truncationMode(.middle)
}
.frame(height: SettingsLayout.rowHeight)
}
private func gatewayActionsCard(isBusy: Bool) -> some View {
ProCard(radius: SettingsLayout.cardRadius) {
HStack(spacing: 10) {
self.previewButton("Reconnect", systemImage: "arrow.triangle.2.circlepath", isBusy: isBusy)
self.previewButton("Diagnose", systemImage: "cross.case", isBusy: isBusy)
}
}
}
private var setupActionsCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
self.previewButton("Scan QR", systemImage: "qrcode.viewfinder", isBusy: false)
self.previewButton("Connect", systemImage: "link", isBusy: false)
}
Text("Discovered gateways and manual setup live here when the gateway has not connected yet.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func previewButton(
_ title: String,
systemImage: String,
isBusy: Bool) -> some View
{
Button {} label: {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(isBusy)
}
private static let pairingProblem = GatewayConnectionProblem(
kind: .pairingRequired,
owner: .gateway,
title: "Pairing required",
message: "Run /pair approve in your OpenClaw chat before this iPad can connect.",
actionCommand: "/pair approve req-ipad-preview",
requestId: "req-ipad-preview",
retryable: false,
pauseReconnect: true)
}
#endif

View File

@@ -9,8 +9,17 @@ struct TalkProTab: View {
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
@State private var showPermissionPrompt = false
@State private var showTalkIssueDetails = false
let headerLeadingAction: OpenClawSidebarHeaderAction?
var openSettings: () -> Void
init(
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
openSettings: @escaping () -> Void)
{
self.headerLeadingAction = headerLeadingAction
self.openSettings = openSettings
}
private var state: TalkProState {
TalkProState(
gatewayConnected: self.gatewayConnected,
@@ -85,6 +94,9 @@ struct TalkProTab: View {
private var header: some View {
HStack(alignment: .center, spacing: 11) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
OpenClawProMark(size: 31, shadowRadius: 9)
VStack(alignment: .leading, spacing: 2) {
Text("Talk")

View File

@@ -3,7 +3,6 @@ import Contacts
import CoreLocation
import CoreMotion
import CryptoKit
import Darwin
import EventKit
import Foundation
import Network
@@ -169,11 +168,6 @@ final class GatewayConnectionController {
}
}
func allowAutoConnectAgain() {
self.didAutoConnect = false
self.maybeAutoConnect()
}
func restartDiscovery() {
self.discovery.stop()
self.didAutoConnect = false
@@ -522,8 +516,7 @@ final class GatewayConnectionController {
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
let tlsParams = self.resolveManualTLSParams(
stableID: stableID,
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldRequireTLS(host: manualHost))
tlsEnabled: resolvedUseTLS)
guard let url = self.buildGatewayURL(
host: manualHost,
@@ -719,8 +712,7 @@ final class GatewayConnectionController {
}
private func resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
{
let stableID = gateway.stableID
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
@@ -747,8 +739,7 @@ final class GatewayConnectionController {
private func resolveManualTLSParams(
stableID: String,
tlsEnabled: Bool,
allowTOFUReset: Bool = false) -> GatewayTLSParams?
tlsEnabled: Bool) -> GatewayTLSParams?
{
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || stored != nil {
@@ -785,126 +776,6 @@ final class GatewayConnectionController {
resolver.start()
}
}
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
switch endpoint {
case let .hostPort(host, port):
(host: host.debugDescription, port: Int(port.rawValue))
case let .service(name, type, domain, _):
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
default:
nil
}
}
private static func resolveBonjourServiceToHostPort(
name: String,
type: String,
domain: String,
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
{
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
// resume the continuation exactly once (timeout/cancel safe).
@MainActor
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
private let service: NetService
private var timeoutTask: Task<Void, Never>?
private var finished = false
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
self.cont = cont
self.service = service
super.init()
}
func start(timeoutSeconds: TimeInterval) {
self.service.delegate = self
self.service.schedule(in: .main, forMode: .default)
// NetService has its own timeout, but we keep a manual one as a backstop in case
// callbacks never arrive (e.g. local network permission issues).
self.timeoutTask = Task { @MainActor [weak self] in
guard let self else { return }
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
try? await Task.sleep(nanoseconds: ns)
self.finish(nil)
}
self.service.resolve(withTimeout: timeoutSeconds)
}
func netServiceDidResolveAddress(_ sender: NetService) {
self.finish(Self.extractHostPort(sender))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
_ = errorDict // currently best-effort; callers surface a generic failure
self.finish(nil)
}
private func finish(_ result: (host: String, port: Int)?) {
guard !self.finished else { return }
self.finished = true
self.timeoutTask?.cancel()
self.timeoutTask = nil
self.service.stop()
self.service.remove(from: .main, forMode: .default)
let c = self.cont
self.cont = nil
c?.resume(returning: result)
}
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
let port = svc.port
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
return (host: host, port: port)
}
guard let addrs = svc.addresses else { return nil }
for addrData in addrs {
let host = addrData.withUnsafeBytes { ptr -> String? in
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let rc = getnameinfo(
base.assumingMemoryBound(to: sockaddr.self),
socklen_t(ptr.count),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard rc == 0 else { return nil }
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
return String(bytes: bytes, encoding: .utf8)
}
if let host, !host.isEmpty {
return (host: host, port: port)
}
}
return nil
}
}
return await withCheckedContinuation { cont in
Task { @MainActor in
let service = NetService(domain: domain, type: type, name: name)
let resolver = Resolver(cont: cont, service: service)
// Keep the resolver alive for the lifetime of the NetService resolve.
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
resolver.start(timeoutSeconds: timeoutSeconds)
}
}
}
}
extension GatewayConnectionController {
@@ -1162,30 +1033,10 @@ extension GatewayConnectionController {
self.currentCommands()
}
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 {
DeviceInfoHelper.platformString()
}
func _test_deviceFamily() -> String {
DeviceInfoHelper.deviceFamily()
}
func _test_modelIdentifier() -> String {
DeviceInfoHelper.modelIdentifier()
}
func _test_appVersion() -> String {
DeviceInfoHelper.appVersion()
}
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
self.gateways = gateways
}
@@ -1199,10 +1050,9 @@ extension GatewayConnectionController {
}
func _test_resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
{
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
self.resolveDiscoveredTLSParams(gateway: gateway)
}
func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {

View File

@@ -9,11 +9,6 @@ enum GatewaySettingsStore {
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
private static let manualHostDefaultsKey = "gateway.manual.host"
private static let manualPortDefaultsKey = "gateway.manual.port"
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
@@ -184,24 +179,6 @@ enum GatewaySettingsStore {
enum LastGatewayConnection: Equatable {
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
case discovered(stableID: String, useTLS: Bool)
var stableID: String {
switch self {
case let .manual(_, _, _, stableID):
stableID
case let .discovered(stableID, _):
stableID
}
}
var useTLS: Bool {
switch self {
case let .manual(_, _, useTLS, _):
useTLS
case let .discovered(_, useTLS):
useTLS
}
}
}
private enum LastGatewayKind: String, Codable {
@@ -229,17 +206,6 @@ enum GatewaySettingsStore {
return nil
}
static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) {
guard let providerId = self.normalizedTalkProviderID(provider) else { return }
let account = self.talkProviderApiKeyAccount(providerId: providerId)
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
_ = KeychainStore.delete(service: self.talkService, account: account)
return
}
_ = KeychainStore.saveString(trimmed, service: self.talkService, account: account)
}
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let payload = LastGatewayConnectionData(
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
@@ -580,11 +546,4 @@ enum GatewayDiagnostics {
}
}
}
static func reset() {
guard let url = fileURL else { return }
self.queue.async {
try? FileManager.default.removeItem(at: url)
}
}
}

View File

@@ -17,15 +17,6 @@ final class LiveActivityManager {
self.hydrateCurrentAndPruneDuplicates()
}
var isActive: Bool {
guard let activity = self.currentActivity else { return false }
guard activity.activityState == .active else {
self.currentActivity = nil
return false
}
return true
}
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
self.hydrateCurrentAndPruneDuplicates()
@@ -96,10 +87,6 @@ final class LiveActivityManager {
self.endActivity(reason: "connected")
}
func handleDisconnect() {
self.endActivity(reason: "disconnected")
}
func endActivity(reason: String) {
guard let activity = self.currentActivity else { return }
self.currentActivity = nil
@@ -183,15 +170,6 @@ final class LiveActivityManager {
startedAt: self.activityStartDate)
}
private func idleState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Idle",
isIdle: true,
isDisconnected: false,
isConnecting: false,
startedAt: self.activityStartDate)
}
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Disconnected",

View File

@@ -14,39 +14,3 @@ struct OpenClawActivityAttributes: ActivityAttributes {
var startedAt: Date
}
}
#if DEBUG
extension OpenClawActivityAttributes {
static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
}
extension OpenClawActivityAttributes.ContentState {
static let connecting = OpenClawActivityAttributes.ContentState(
statusText: "Connecting...",
isIdle: false,
isDisconnected: false,
isConnecting: true,
startedAt: .now)
static let idle = OpenClawActivityAttributes.ContentState(
statusText: "Idle",
isIdle: true,
isDisconnected: false,
isConnecting: false,
startedAt: .now)
static let disconnected = OpenClawActivityAttributes.ContentState(
statusText: "Disconnected",
isIdle: false,
isDisconnected: true,
isConnecting: false,
startedAt: .now)
static let attention = OpenClawActivityAttributes.ContentState(
statusText: "Approval needed",
isIdle: false,
isDisconnected: false,
isConnecting: false,
startedAt: .now)
}
#endif

View File

@@ -12,8 +12,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
private let manager = CLLocationManager()
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
private var updatesContinuation: AsyncStream<CLLocation>.Continuation?
private var isStreaming = false
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
private var isMonitoringSignificantChanges = false
@@ -84,42 +82,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
}
func startLocationUpdates(
desiredAccuracy: OpenClawLocationAccuracy,
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
{
self.stopLocationUpdates()
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
self.manager.pausesLocationUpdatesAutomatically = true
self.manager.allowsBackgroundLocationUpdates = true
self.isStreaming = true
if significantChangesOnly {
self.manager.startMonitoringSignificantLocationChanges()
} else {
self.manager.startUpdatingLocation()
}
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
self.updatesContinuation = continuation
continuation.onTermination = { @Sendable _ in
Task { @MainActor in
self.stopLocationUpdates()
}
}
}
}
func stopLocationUpdates() {
guard self.isStreaming else { return }
self.isStreaming = false
self.manager.stopUpdatingLocation()
self.manager.stopMonitoringSignificantLocationChanges()
self.updatesContinuation?.finish()
self.updatesContinuation = nil
}
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
self.significantLocationCallback = onUpdate
guard !self.isMonitoringSignificantChanges else { return }
@@ -127,13 +89,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
self.manager.startMonitoringSignificantLocationChanges()
}
func stopMonitoringSignificantLocationChanges() {
guard self.isMonitoringSignificantChanges else { return }
self.isMonitoringSignificantChanges = false
self.significantLocationCallback = nil
self.manager.stopMonitoringSignificantLocationChanges()
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
@@ -161,9 +116,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
if let callback = self.significantLocationCallback, let latest = locs.last {
callback(latest)
}
if let latest = locs.last, let updates = self.updatesContinuation {
updates.yield(latest)
}
}
}

View File

@@ -116,6 +116,8 @@ final class NodeAppModel {
self.operatorConnected
}
private(set) var hasOperatorAdminScope: Bool = false
var gatewayServerName: String?
var gatewayRemoteAddress: String?
var connectedGatewayID: String?
@@ -131,7 +133,6 @@ final class NodeAppModel {
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
}
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
private var focusedChatSessionKey: String?
var selectedAgentId: String?
@@ -186,6 +187,7 @@ final class NodeAppModel {
@ObservationIgnored private var backgroundGraceTaskTimer: Task<Void, Never>?
private var backgroundReconnectSuppressed = false
private var backgroundReconnectLeaseUntil: Date?
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
private var lastSignificantLocationWakeAt: Date?
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
@@ -199,9 +201,6 @@ final class NodeAppModel {
private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String?
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
var gatewaySession: GatewayNodeSession {
self.nodeGateway
}
var operatorSession: GatewayNodeSession {
self.operatorGateway
@@ -212,6 +211,7 @@ final class NodeAppModel {
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
private static let foregroundResumeHealthTimeoutSeconds = 1
var cameraHUDText: String?
var cameraHUDKind: CameraHUDKind?
@@ -297,6 +297,7 @@ final class NodeAppModel {
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
self.voiceWake.setEnabled(enabled)
self.talkMode.attachGateway(self.operatorGateway)
self.refreshOperatorAdminScopeFromStore()
self.refreshLastShareEventFromRelay()
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
self.setTalkEnabled(talkEnabled)
@@ -414,9 +415,7 @@ final class NodeAppModel {
self.isBackgrounded = false
self.endBackgroundConnectionGracePeriod(reason: "scene_foreground")
self.clearBackgroundReconnectSuppression(reason: "scene_foreground")
if self.operatorConnected {
self.startGatewayHealthMonitor()
}
var shouldStartGatewayHealthMonitor = self.operatorConnected
if phase == .active {
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended)
self.backgroundVoiceWakeSuspended = false
@@ -441,6 +440,8 @@ final class NodeAppModel {
// iOS may suspend network sockets in background without a clean close.
// On foreground, force a fresh handshake to avoid "connected but dead" states.
if backgroundedFor >= 3.0 {
shouldStartGatewayHealthMonitor = false
self.foregroundGatewayResumeCheckInFlight = true
Task { [weak self] in
guard let self else { return }
let operatorWasConnected = await MainActor.run { self.operatorConnected }
@@ -449,31 +450,26 @@ final class NodeAppModel {
let healthy = await (try? self.operatorGateway.request(
method: "health",
paramsJSON: nil,
timeoutSeconds: 2)) != nil
timeoutSeconds: Self.foregroundResumeHealthTimeoutSeconds)) != nil
if healthy {
await MainActor.run { self.startGatewayHealthMonitor() }
await MainActor.run {
self.foregroundGatewayResumeCheckInFlight = false
self.startGatewayHealthMonitor()
}
return
}
}
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
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
// reconnect tasks were suppressed or otherwise got stuck in background.
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
if let cfg = self.activeGatewayConnectConfig {
self.applyGatewayConnectConfig(cfg)
}
self.foregroundGatewayResumeCheckInFlight = false
}
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
}
}
if shouldStartGatewayHealthMonitor {
self.startGatewayHealthMonitor()
}
@unknown default:
self.isBackgrounded = false
self.endBackgroundConnectionGracePeriod(reason: "scene_unknown")
@@ -724,11 +720,6 @@ final class NodeAppModel {
}
}
var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
}
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
@@ -738,12 +729,9 @@ final class NodeAppModel {
let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let ui = config["ui"] as? [String: Any]
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionBaseKey = mainKey
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
@@ -783,6 +771,12 @@ final class NodeAppModel {
func refreshGatewayOverviewIfConnected() async {
guard await self.isOperatorConnected() else { return }
if self.foregroundGatewayResumeCheckInFlight {
GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check")
try? await Task.sleep(
nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000)
guard await self.isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return }
}
await self.refreshBrandingFromGateway()
await self.refreshAgentsFromGateway()
}
@@ -1942,7 +1936,7 @@ extension NodeAppModel {
}
self.activeGatewayConnectConfig = nextConfig
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
self.prepareForGatewayConnect(stableID: effectiveStableID)
if operatorLoopRequired {
self.startOperatorGatewayLoop(
url: url,
@@ -1983,12 +1977,33 @@ extension NodeAppModel {
}
func resetGatewaySessionsForForcedReconnect() async {
self.nodeGatewayTask?.cancel()
let nodeGatewayTask = self.nodeGatewayTask
let operatorGatewayTask = self.operatorGatewayTask
nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
// Foreground recovery reuses the same config immediately after reset.
// Wait for canceled loops so their shutdown cleanup cannot clobber the new reconnect state.
if let operatorGatewayTask {
await operatorGatewayTask.value
}
if let nodeGatewayTask {
await nodeGatewayTask.value
}
}
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.resetGatewaySessionsForForcedReconnect()
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
self.talkMode.updateGatewayConnected(false)
guard let cfg = self.activeGatewayConnectConfig else { return }
self.applyGatewayConnectConfig(cfg, forceReconnect: true)
}
func disconnectGateway() {
@@ -2018,7 +2033,6 @@ extension NodeAppModel {
self.gatewayConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
ShareGatewayRelaySettings.clearConfig()
@@ -2027,7 +2041,7 @@ extension NodeAppModel {
}
extension NodeAppModel {
private func prepareForGatewayConnect(url: URL, stableID: String) {
private func prepareForGatewayConnect(stableID: String) {
self.isAppleReviewDemoModeEnabled = false
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
@@ -2631,7 +2645,6 @@ extension NodeAppModel {
self.gatewayConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.showLocalCanvasOnDisconnect()
@@ -2757,6 +2770,15 @@ extension NodeAppModel {
private func setOperatorConnected(_ connected: Bool) {
self.operatorConnected = connected
self.operatorStatusText = connected ? "Connected" : "Offline"
self.refreshOperatorAdminScopeFromStore()
}
private func refreshOperatorAdminScopeFromStore() {
let identity = DeviceIdentityStore.loadOrCreate()
self.hasOperatorAdminScope = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator")?
.scopes
.contains("operator.admin") == true
}
}
@@ -2795,7 +2817,6 @@ extension NodeAppModel {
self.talkMode.updateGatewayConnected(false)
self.talkMode.setEnabled(false)
self.talkMode.statusText = "Demo mode only"
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.selectedAgentId = nil
self.gatewayDefaultAgentId = "main"
@@ -2910,14 +2931,6 @@ extension NodeAppModel {
self.refreshLastShareEventFromRelay()
}
func reloadTalkConfig() {
Task { [weak self] in
guard let self else { return }
await self.talkMode.reloadConfig()
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "config_reload")
}
}
/// Back-compat hook retained for older gateway-connect flows.
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
@@ -3878,32 +3891,6 @@ extension NodeAppModel {
}
}
func handleExecApprovalNotificationDecision(
approvalId: String,
decision: String) async
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
}
let outcome = await self.resolveExecApprovalNotificationDecision(
approvalId: normalizedApprovalID,
decision: decision)
switch outcome {
case .resolved, .stale, .unavailable:
break
case let .failed(message):
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = message
}
}
}
private func resolveExecApprovalNotificationDecision(
approvalId: String,
decision: String,
@@ -4440,17 +4427,6 @@ extension NodeAppModel {
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
private static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
func approvePendingAgentDeepLinkPrompt() async {
guard let prompt = self.pendingAgentDeepLinkPrompt else { return }
self.pendingAgentDeepLinkPrompt = nil
@@ -4600,30 +4576,10 @@ extension NodeAppModel {
try self.encodePayload(obj)
}
func _test_isCameraEnabled() -> Bool {
self.isCameraEnabled()
}
func _test_triggerCameraFlash() {
self.triggerCameraFlash()
}
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
}
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
await self.handleCanvasA2UIAction(body: body)
}
func _test_showLocalCanvasOnDisconnect() {
self.showLocalCanvasOnDisconnect()
}
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
self.applyTalkModeSync(enabled: enabled, phase: phase)
}
func _test_queuedWatchReplyCount() -> Int {
self.watchReplyCoordinator.queuedCount
}
@@ -4636,6 +4592,10 @@ extension NodeAppModel {
self.gatewayConnected
}
func _test_refreshOperatorAdminScopeFromStore() {
self.refreshOperatorAdminScopeFromStore()
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{
@@ -4810,6 +4770,10 @@ extension NodeAppModel {
(self.nodeGatewayTask != nil, self.operatorGatewayTask != nil)
}
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
await self.restartGatewaySessionsAfterForegroundStaleConnection()
}
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
await self.handleSuccessfulBootstrapGatewayOnboarding(
url: URL(string: "wss://gateway.example")!,

View File

@@ -1,365 +0,0 @@
import Foundation
import OpenClawKit
import SwiftUI
struct GatewayOnboardingView: View {
var body: some View {
NavigationStack {
List {
Section {
Text("Connect to your gateway to get started.")
.foregroundStyle(.secondary)
}
Section {
NavigationLink("Auto detect") {
AutoDetectStep()
}
NavigationLink("Manual entry") {
ManualEntryStep()
}
}
}
.navigationTitle("Connect Gateway")
}
.gatewayTrustPromptAlert()
}
}
private struct AutoDetectStep: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
@State private var connectingGatewayID: String?
@State private var connectStatusText: String?
var body: some View {
Form {
Section {
Text("Well scan for gateways on your network and connect automatically when we find one.")
.foregroundStyle(.secondary)
}
gatewayConnectionStatusSection(
appModel: self.appModel,
gatewayController: self.gatewayController,
secondaryLine: self.connectStatusText)
Section {
Button("Retry") {
resetGatewayConnectionState(
appModel: self.appModel,
connectStatusText: &self.connectStatusText,
connectingGatewayID: &self.connectingGatewayID)
self.triggerAutoConnect()
}
.disabled(self.connectingGatewayID != nil)
}
}
.navigationTitle("Auto detect")
.onAppear { self.triggerAutoConnect() }
.onChange(of: self.gatewayController.gateways) { _, _ in
self.triggerAutoConnect()
}
}
private func triggerAutoConnect() {
guard self.appModel.gatewayServerName == nil else { return }
guard self.connectingGatewayID == nil else { return }
guard let candidate = self.autoCandidate() else { return }
self.connectingGatewayID = candidate.id
Task {
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(candidate)
}
}
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferred.isEmpty,
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
{
return match
}
if !lastDiscovered.isEmpty,
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
{
return match
}
if self.gatewayController.gateways.count == 1 {
return self.gatewayController.gateways.first
}
return nil
}
}
private struct ManualEntryStep: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@State private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualHost: String = ""
@State private var manualPortText: String = ""
@State private var manualUseTLS: Bool = true
@State private var manualToken: String = ""
@State private var manualPassword: String = ""
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State private var connectingGatewayID: String?
@State private var connectStatusText: String?
var body: some View {
Form {
Section("Setup code") {
Text("Use /pair in your bot to get a setup code.")
.font(.footnote)
.foregroundStyle(.secondary)
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Apply setup code") {
self.applySetupCode()
}
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let setupStatusText, !setupStatusText.isEmpty {
Text(setupStatusText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section {
TextField("Host", text: self.$manualHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualUseTLS)
TextField("Gateway token", text: self.$manualToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway password", text: self.$manualPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
gatewayConnectionStatusSection(
appModel: self.appModel,
gatewayController: self.gatewayController,
secondaryLine: self.connectStatusText)
Section {
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(self.connectingGatewayID != nil)
Button("Retry") {
resetGatewayConnectionState(
appModel: self.appModel,
connectStatusText: &self.connectStatusText,
connectingGatewayID: &self.connectingGatewayID)
self.resetManualForm()
}
.disabled(self.connectingGatewayID != nil)
}
}
.navigationTitle("Manual entry")
}
private func connectManual() async {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.connectStatusText = "Failed: host required"
return
}
if let port = self.manualPortValue(), !(1...65535).contains(port) {
self.connectStatusText = "Failed: invalid port"
return
}
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.manual.enabled")
defaults.set(host, forKey: "gateway.manual.host")
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
let instanceId = GatewaySettingsStore.currentInstanceID()
if !instanceId.isEmpty {
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedToken.isEmpty {
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
}
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
}
self.connectingGatewayID = "manual"
defer { self.connectingGatewayID = nil }
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: self.manualToken,
pendingOverride: self.pendingManualAuthOverride,
password: self.manualPassword)
self.pendingManualAuthOverride = nil
await self.gatewayController.connectManual(
host: host,
port: self.manualPortValue() ?? 0,
useTLS: self.manualUseTLS,
authOverride: authOverride)
}
private func manualPortValue() -> Int? {
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return Int(trimmed.filter(\.isNumber))
}
private func resetManualForm() {
self.setupCode = ""
self.setupStatusText = nil
self.manualHost = ""
self.manualPortText = ""
self.manualUseTLS = true
self.manualToken = ""
self.manualPassword = ""
}
private func applySetupCode() {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
self.setupStatusText = "Paste a setup code to continue."
return
}
if AppleReviewDemoMode.isSetupCode(raw) {
self.setupCode = ""
self.setupStatusText = "Apple Review demo mode enabled."
self.appModel.enterAppleReviewDemoMode()
return
}
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return
}
self.manualHost = link.host
self.manualPortText = String(link.port)
self.manualUseTLS = link.tls
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
if setupAuth.shouldApplyTokenField {
self.manualToken = setupAuth.token
}
if setupAuth.shouldApplyPasswordField {
self.manualPassword = setupAuth.password
}
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
if !trimmedInstanceId.isEmpty {
if setupAuth.hasBootstrapToken {
GatewayOnboardingReset.prepareForBootstrapPairing(
appModel: self.appModel,
instanceId: trimmedInstanceId)
}
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: trimmedInstanceId)
}
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
self.setupStatusText = "Setup code applied."
}
}
@MainActor
private func gatewayConnectionStatusLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController) -> [String]
{
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
}
@MainActor
private func resetGatewayConnectionState(
appModel: NodeAppModel,
connectStatusText: inout String?,
connectingGatewayID: inout String?)
{
appModel.disconnectGateway()
connectStatusText = nil
connectingGatewayID = nil
}
@MainActor
private func gatewayConnectionStatusSection(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController,
secondaryLine: String?) -> some View
{
Section("Connection status") {
ConnectionStatusBox(
statusLines: gatewayConnectionStatusLines(
appModel: appModel,
gatewayController: gatewayController),
secondaryLine: secondaryLine)
}
}
private struct ConnectionStatusBox: View {
let statusLines: [String]
let secondaryLine: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.statusLines, id: \.self) { line in
Text(line)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
}
if let secondaryLine, !secondaryLine.isEmpty {
Text(secondaryLine)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
static func defaultLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController) -> [String]
{
var lines: [String] = [
"gateway: \(appModel.gatewayDisplayStatusText)",
"discovery: \(gatewayController.discoveryStatusText)",
]
lines.append("server: \(appModel.gatewayServerName ?? "")")
lines.append("address: \(appModel.gatewayRemoteAddress ?? "")")
return lines
}
}

View File

@@ -53,10 +53,6 @@ enum OnboardingStateStore {
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
}
static func markIncomplete(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: self.completedDefaultsKey)
}
static func reset(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: self.completedDefaultsKey)
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)

View File

@@ -17,10 +17,6 @@ private enum OnboardingStep: Int, CaseIterable {
Self(rawValue: self.rawValue - 1)
}
var next: Self? {
Self(rawValue: self.rawValue + 1)
}
/// Progress label for the manual setup flow (mode connect auth success).
var manualProgressTitle: String {
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]

View File

@@ -39,10 +39,6 @@ struct PushBuildConfig {
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
}
var usesRelay: Bool {
self.transport == .relay
}
private static func readURL(bundle: Bundle, key: String) -> URL? {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -71,11 +71,6 @@ enum PushRelayRegistrationStore {
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
}
@discardableResult
static func clearRegistrationState() -> Bool {
KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
}
static func loadAppAttestKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -8,6 +8,8 @@ struct RootTabs: View {
@Environment(VoiceWakeManager.self) private var voiceWake
@Environment(GatewayConnectionController.self) private var gatewayController
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.rootTabsUserInterfaceIdiomOverride) private var userInterfaceIdiomOverride
@Environment(\.scenePhase) private var scenePhase
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
@@ -21,10 +23,14 @@ struct RootTabs: View {
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
@State private var selectedTab: AppTab = Self.initialTab
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
@State private var isSidebarDrawerLayout: Bool = false
@State private var didResolveSidebarLayout: Bool = false
@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
@@ -34,14 +40,6 @@ struct RootTabs: View {
@State private var didApplyInitialChatSession: Bool = false
@State private var handledGatewaySetupRequestID: Int = 0
private enum AppTab: Hashable {
case control
case chat
case talk
case agent
case settings
}
private static var initialTab: AppTab {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else {
@@ -66,6 +64,28 @@ struct RootTabs: View {
}
}
private static var initialSidebarDestination: SidebarDestination {
if let requested = requestedInitialSidebarDestination {
return requested
}
return Self.defaultSidebarDestination(for: initialTab)
}
private static var requestedInitialSidebarDestination: SidebarDestination? {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
let requested = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return SidebarDestination.allCases.first { $0.rawValue.lowercased() == requested }
}
private static var initialSidebarVisibility: Bool? {
requestedInitialSidebarVisibility(arguments: ProcessInfo.processInfo.arguments)
}
private static var initialChatSessionKey: String? {
let arguments = ProcessInfo.processInfo.arguments
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
@@ -87,45 +107,11 @@ struct RootTabs: View {
}
}
enum StartupPresentationRoute: Equatable {
case none
case onboarding
case settings
}
static func startupPresentationRoute(
gatewayConnected: Bool,
hasConnectedOnce: Bool,
onboardingComplete: Bool,
hasExistingGatewayConfig: Bool,
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
static func shouldUseSidebarTabs(
idiom: UIUserInterfaceIdiom,
horizontalSizeClass _: UserInterfaceSizeClass?) -> Bool
{
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
idiom == .pad
}
var body: some View {
@@ -136,20 +122,30 @@ struct RootTabs: View {
.tint(OpenClawBrand.accent))))
}
@ViewBuilder
private var tabContent: some View {
if self.usesSidebarTabs {
self.sidebarSplitContent
} else {
self.phoneTabContent
}
}
private var phoneTabContent: some View {
TabView(selection: self.$selectedTab) {
CommandCenterTab(
openChat: { self.selectedTab = .chat },
openSettings: { self.selectedTab = .settings })
.tabItem { Label("Command", systemImage: "target") }
RootTabsPhoneControlHub(
groups: Self.phoneControlGroups,
initialDestination: Self.requestedInitialSidebarDestination,
openRootDestination: { self.selectSidebarDestination($0) })
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
.tag(AppTab.control)
ChatProTab()
ChatProTab(openSettings: { self.selectSidebarDestination(.gateway) })
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
.tag(AppTab.chat)
TalkProTab(openSettings: { self.selectedTab = .settings })
TalkProTab(openSettings: { self.selectSidebarDestination(.gateway) })
.tabItem {
Label(
"Talk",
@@ -157,16 +153,394 @@ struct RootTabs: View {
}
.tag(AppTab.talk)
AgentProTab()
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
NavigationStack {
AgentProTab(
directRoute: .agents,
openSettings: { self.selectSidebarDestination(.gateway) })
}
.tabItem { Label("Agent", systemImage: "person.2.fill") }
.tag(AppTab.agent)
SettingsProTab()
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(AppTab.settings)
}
}
private var sidebarSplitContent: some View {
GeometryReader { proxy in
let isDrawerLayout = self.shouldUseSidebarDrawer(containerSize: proxy.size)
let sidebarWidth = self.sidebarWidth(containerWidth: proxy.size.width, isDrawerLayout: isDrawerLayout)
Group {
if isDrawerLayout {
self.sidebarDrawerContent(sidebarWidth: sidebarWidth)
} else {
self.sidebarNavigationSplitContent(sidebarWidth: sidebarWidth)
}
}
.animation(.easeInOut(duration: 0.22), value: self.isSidebarVisible)
.onAppear {
self.updateSidebarLayout(containerSize: proxy.size, force: false)
}
.onChange(of: proxy.size) { _, size in
self.updateSidebarLayout(containerSize: size, force: false)
}
}
}
private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View {
HStack(spacing: 0) {
if self.isSidebarVisible {
self.sidebarColumn
.frame(width: sidebarWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
self.sidebarVerticalSeparator
}
.transition(.move(edge: .leading).combined(with: .opacity))
}
self.sidebarDetailNavigationShell
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.background(OpenClawProBackground())
}
private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View {
ZStack(alignment: .topLeading) {
self.sidebarDetailNavigationShell
.frame(maxWidth: .infinity, maxHeight: .infinity)
if self.isSidebarVisible {
Color.black.opacity(0.28)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {
self.hideSidebar()
}
.transition(.opacity)
self.sidebarColumn
.frame(width: sidebarWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
self.sidebarVerticalSeparator
}
.shadow(color: .black.opacity(0.26), radius: 18, x: 8, y: 0)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
}
private var sidebarDetailShell: some View {
self.sidebarDetail
.id(self.selectedSidebarDestination.id)
}
private var sidebarColumn: some View {
VStack(spacing: 0) {
self.sidebarIdentityHeader
self.sidebarList
self.sidebarFooter
}
.safeAreaPadding(.top, 8)
.safeAreaPadding(.bottom, 8)
.background(Color(uiColor: .systemBackground))
}
private var sidebarIdentityHeader: some View {
HStack(spacing: 10) {
OpenClawProMark(size: 30, shadowRadius: 3)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text("OpenClaw")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
HStack(spacing: 4) {
Image(systemName: "circle.fill")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(self.sidebarGatewayStatusColor)
Text(self.sidebarGatewayStatusTitle)
.lineLimit(1)
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
if self.isSidebarDrawerLayout {
self.sidebarHideButton
}
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(Color(uiColor: .systemBackground))
.overlay(alignment: .bottom) {
self.sidebarHorizontalSeparator
}
.accessibilityElement(children: .combine)
.accessibilityLabel("OpenClaw \(self.sidebarGatewayStatusTitle)")
}
private var sidebarGatewayStatusTitle: String {
switch self.gatewayStatus {
case .connected:
"Online"
case .connecting:
"Connecting"
case .error:
"Needs attention"
case .disconnected:
"Offline"
}
}
private var sidebarList: some View {
List {
ForEach(Self.sidebarGroups) { group in
Section(group.title.capitalized) {
ForEach(group.destinations) { destination in
self.sidebarDestinationButton(destination)
}
}
.listSectionSeparator(.hidden, edges: .all)
}
}
.listStyle(.sidebar)
.tint(OpenClawBrand.accent)
.scrollContentBackground(.hidden)
.background(Color(uiColor: .systemBackground))
}
private var sidebarFooter: some View {
VStack(spacing: 0) {
self.sidebarHorizontalSeparator
HStack(spacing: 10) {
Text("VERSION")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Text("v\(DeviceInfoHelper.openClawVersionString())")
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.72)
ProStatusDot(color: self.sidebarGatewayStatusColor)
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
}
}
private var sidebarHorizontalSeparator: some View {
Rectangle()
.fill(Color(uiColor: .separator))
.frame(height: 1 / UIScreen.main.scale)
}
private var sidebarVerticalSeparator: some View {
Rectangle()
.fill(Color(uiColor: .separator))
.frame(width: 1 / UIScreen.main.scale)
}
private var sidebarGatewayStatusColor: Color {
switch self.gatewayStatus {
case .connected:
OpenClawBrand.ok
case .connecting:
OpenClawBrand.accent
case .error:
OpenClawBrand.warn
case .disconnected:
.secondary
}
}
private func sidebarDestinationButton(
_ destination: SidebarDestination,
title: String? = nil) -> some View
{
Button {
self.selectSidebarDestination(destination)
} label: {
Label(title ?? destination.sidebarTitle, systemImage: destination.systemImage)
.lineLimit(1)
.minimumScaleFactor(0.82)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(destination == self.selectedSidebarDestination ? OpenClawBrand.accent : .primary)
.listRowBackground(
destination == self.selectedSidebarDestination
? OpenClawBrand.accent.opacity(0.12)
: Color.clear)
.listRowSeparator(.hidden, edges: .all)
}
@ViewBuilder
private var sidebarDetail: some View {
switch self.selectedSidebarDestination {
case .chat:
ChatProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Chat",
headerSubtitle: "Agent conversation",
showsAgentBadge: false,
openSettings: { self.selectSidebarDestination(.gateway) })
case .talk:
TalkProTab(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openSettings: { self.selectSidebarDestination(.gateway) })
case .overview:
CommandCenterTab(
headerTitle: "Overview",
headerLeadingAction: self.sidebarHeaderLeadingAction,
showsHeaderMark: false,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .activity:
IPadActivityScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .workboard:
IPadWorkboardScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) },
openSettings: { self.selectSidebarDestination(.gateway) })
case .skillWorkshop:
IPadSkillWorkshopScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openSettings: { self.selectSidebarDestination(.gateway) })
case .agents:
AgentProTab(
directRoute: .agents,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Agents",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .instances:
AgentProTab(
directRoute: .instances,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Instances",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .sessions:
CommandSessionsScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
openChat: { self.selectSidebarDestination(.chat) })
case .dreaming:
AgentProTab(
directRoute: .dreaming,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Dreaming",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .usage:
AgentProTab(
directRoute: .usage,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Usage",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .cron:
AgentProTab(
directRoute: .cron,
headerLeadingAction: self.sidebarHeaderLeadingAction,
headerTitle: "Cron Jobs",
openSettings: { self.selectSidebarDestination(.gateway) })
.id(self.selectedSidebarDestination.id)
case .docs:
OpenClawDocsScreen(
headerLeadingAction: self.sidebarHeaderLeadingAction,
gatewayAction: { self.selectSidebarDestination(.gateway) })
case .settings:
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
case .gateway:
SettingsProTab(
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
headerLeadingAction: self.sidebarHeaderLeadingAction)
}
}
private var sidebarDetailNavigationShell: some View {
NavigationStack {
self.sidebarDetailShell
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.clipped()
}
private var usesSidebarTabs: Bool {
Self.shouldUseSidebarTabs(
idiom: self.userInterfaceIdiom,
horizontalSizeClass: self.horizontalSizeClass)
}
private var userInterfaceIdiom: UIUserInterfaceIdiom {
if let userInterfaceIdiomOverride {
return userInterfaceIdiomOverride
}
return UIDevice.current.userInterfaceIdiom
}
private var shouldCollapseSidebarAfterSelection: Bool {
Self.shouldCollapseSidebarAfterSelection(
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
}
private var sidebarHeaderLeadingAction: OpenClawSidebarHeaderAction? {
guard Self.shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: self.isSidebarVisible,
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
else {
return nil
}
if self.isSidebarVisible {
return OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Hide Sidebar",
accessibilityIdentifier: Self.sidebarHideButtonAccessibilityIdentifier,
action: { self.hideSidebar() })
}
return OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
accessibilityIdentifier: Self.sidebarShowButtonAccessibilityIdentifier,
action: { self.showSidebar() })
}
private var sidebarHideButton: some View {
Button {
self.hideSidebar()
} label: {
Image(systemName: self.isSidebarDrawerLayout ? "xmark" : "sidebar.left")
.font(.system(size: 15, weight: .semibold))
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())
.buttonStyle(.plain)
.foregroundStyle(OpenClawBrand.accent)
.accessibilityLabel("Hide Sidebar")
.accessibilityIdentifier(Self.sidebarHideButtonAccessibilityIdentifier)
}
private func shouldUseSidebarDrawer(containerSize: CGSize) -> Bool {
Self.sidebarLayoutMode(containerSize: containerSize) == .drawer
}
private func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
Self.sidebarWidth(containerWidth: containerWidth, isDrawerLayout: isDrawerLayout)
}
private func rootOverlays(_ content: some View) -> some View {
content
.overlay(alignment: .top) {
@@ -195,6 +569,7 @@ struct RootTabs: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.overlay {
if self.appModel.cameraFlashNonce != 0 {
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
@@ -325,7 +700,7 @@ struct RootTabs: View {
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectedTab = .chat
self.selectSidebarDestination(.chat)
}
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
self.maybeOpenSettingsForGatewaySetup()
@@ -334,10 +709,6 @@ struct RootTabs: View {
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(
@@ -504,6 +875,50 @@ struct RootTabs: View {
self.normalized(agent.name) ?? agent.id
}
private func selectSidebarDestination(_ destination: SidebarDestination) {
self.selectedSidebarDestination = destination
self.selectedTab = destination.appTab
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(false)
}
}
private func showSidebar() {
self.sidebarVisibilityUserOverridden = true
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(true)
}
}
private func hideSidebar() {
self.sidebarVisibilityUserOverridden = true
withAnimation(.easeInOut(duration: 0.22)) {
self.setSidebarVisible(false)
}
}
private func updateSidebarLayout(containerSize: CGSize, force: Bool) {
let layoutMode = Self.sidebarLayoutMode(containerSize: containerSize)
let previousLayoutMode: SidebarLayoutMode = self.isSidebarDrawerLayout ? .drawer : .split
let didResolvePreviousLayout = self.didResolveSidebarLayout
let layoutModeDidChange = layoutMode != previousLayoutMode
self.didResolveSidebarLayout = true
self.isSidebarDrawerLayout = layoutMode == .drawer
if layoutModeDidChange && didResolvePreviousLayout {
self.sidebarVisibilityUserOverridden = false
}
guard force || !self.sidebarVisibilityUserOverridden else { return }
let preferredVisibility = Self.preferredSidebarVisibility(layoutMode: layoutMode)
guard self.isSidebarVisible != preferredVisibility else { return }
self.setSidebarVisible(preferredVisibility)
}
private func setSidebarVisible(_ isVisible: Bool) {
self.isSidebarVisible = isVisible
}
private func homeCanvasBadge(for agent: AgentSummary) -> String {
if let identity = agent.identity,
let emoji = identity["emoji"]?.value as? String,
@@ -538,7 +953,7 @@ struct RootTabs: View {
} else if problem.retryable {
Task { await self.gatewayController.connectLastKnown() }
} else {
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
}
@@ -565,7 +980,7 @@ struct RootTabs: View {
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
}
@@ -591,7 +1006,7 @@ struct RootTabs: View {
shouldPresentOnLaunch: false)
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
private func maybeOpenSettingsForGatewaySetup() {
@@ -601,7 +1016,7 @@ struct RootTabs: View {
self.showOnboarding = false
self.presentedSheet = nil
self.didAutoOpenSettings = true
self.selectedTab = .settings
self.selectSidebarDestination(.gateway)
}
private func applyInitialChatSessionIfNeeded() {
@@ -681,3 +1096,120 @@ private struct RootCameraFlashOverlay: View {
}
}
}
extension EnvironmentValues {
@Entry var rootTabsUserInterfaceIdiomOverride: UIUserInterfaceIdiom?
}
#if DEBUG
#Preview(
"Shell iPhone portrait",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone)
}
#Preview(
"Shell iPhone connected",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone, gatewayState: .connected)
}
#Preview(
"Shell iPhone gateway error",
traits: .fixedLayout(width: 393, height: 852),
.portrait)
{
RootTabsPreviewHost(idiom: .phone, gatewayState: .error)
}
#Preview(
"Shell iPhone landscape",
traits: .fixedLayout(width: 852, height: 393),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .phone)
.environment(\.horizontalSizeClass, .regular)
.environment(\.verticalSizeClass, .compact)
}
#Preview(
"Shell iPad portrait drawer",
traits: .fixedLayout(width: 1024, height: 1366),
.portrait)
{
RootTabsPreviewHost(idiom: .pad)
}
#Preview(
"Shell iPad landscape split",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .connected)
}
#Preview(
"Shell iPad connecting",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .connecting)
}
#Preview(
"Shell iPad gateway error",
traits: .fixedLayout(width: 1366, height: 1024),
.landscapeLeft)
{
RootTabsPreviewHost(idiom: .pad, gatewayState: .error)
}
private struct RootTabsPreviewHost: View {
@State private var appModel: NodeAppModel
@State private var gatewayController: GatewayConnectionController
private let idiom: UIUserInterfaceIdiom
init(idiom: UIUserInterfaceIdiom, gatewayState: RootTabsPreviewGatewayState = .offline) {
let appModel = NodeAppModel()
gatewayState.apply(to: appModel)
self.idiom = idiom
_appModel = State(initialValue: appModel)
_gatewayController = State(
initialValue: GatewayConnectionController(appModel: appModel, startDiscovery: false))
}
var body: some View {
RootTabs()
.environment(self.appModel)
.environment(self.appModel.voiceWake)
.environment(self.gatewayController)
.environment(\.rootTabsUserInterfaceIdiomOverride, self.idiom)
}
}
private enum RootTabsPreviewGatewayState {
case offline
case connecting
case connected
case error
@MainActor
func apply(to appModel: NodeAppModel) {
switch self {
case .offline:
break
case .connecting:
appModel.gatewayStatusText = "Connecting..."
case .connected:
appModel.enterAppleReviewDemoMode()
case .error:
appModel.gatewayStatusText = "Gateway error: connection refused"
}
}
}
#endif

View File

@@ -0,0 +1,307 @@
import CoreGraphics
import Foundation
import SwiftUI
extension RootTabs {
private static var sidebarPersistentWidthThreshold: CGFloat {
980
}
static let sidebarSplitIdealWidth: CGFloat = 316
static let sidebarSplitMaximumWidth: CGFloat = 340
static let sidebarDrawerMaximumWidth: CGFloat = 340
static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show"
static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide"
enum AppTab: Hashable {
case control
case chat
case talk
case agent
case settings
}
enum SidebarDestination: String, CaseIterable, Hashable, Identifiable {
case chat
case talk
case overview
case activity
case agents
case workboard
case skillWorkshop
case instances
case sessions
case dreaming
case usage
case cron
case docs
case settings
case gateway
var id: String {
rawValue
}
var title: String {
switch self {
case .chat: "Chat"
case .talk: "Talk"
case .overview: "Overview"
case .activity: "Activity"
case .agents: "Agents"
case .workboard: "Workboard"
case .skillWorkshop: "Skill Workshop"
case .instances: "Instances"
case .sessions: "Sessions"
case .dreaming: "Dreaming"
case .usage: "Usage"
case .cron: "Cron Jobs"
case .docs: "Docs"
case .settings: "Settings"
case .gateway: "Settings / Gateway"
}
}
var sidebarTitle: String {
switch self {
case .gateway: "Connection"
default: self.title
}
}
var subtitle: String {
switch self {
case .chat: "Agent chat and recent work."
case .talk: "Realtime voice and fallback controls."
case .overview: "Status, entry points, health."
case .activity: "Gateway, session, and device activity."
case .agents: "Agent roster and readiness."
case .workboard: "Agent work queue and session handoff."
case .skillWorkshop: "Review and apply proposed skills."
case .instances: "Latest presence from OpenClaw nodes."
case .sessions: "Active sessions and defaults."
case .dreaming: "Memory signals and background synthesis."
case .usage: "API usage and costs."
case .cron: "Wakeups and recurring runs."
case .docs: "Reference docs and setup guides."
case .settings: "Connection, permissions, channels, and app options."
case .gateway: "Pairing, diagnostics, permissions, and device controls."
}
}
var systemImage: String {
switch self {
case .chat: "bubble.left"
case .talk: "waveform.circle"
case .overview: "chart.bar"
case .activity: "waveform.path.ecg"
case .agents: "person.2"
case .workboard: "folder"
case .skillWorkshop: "hammer"
case .instances: "dot.radiowaves.left.and.right"
case .sessions: "doc.text"
case .dreaming: "moon.stars"
case .usage: "chart.bar.xaxis"
case .cron: "timer"
case .docs: "book"
case .settings: "gearshape"
case .gateway: "gearshape"
}
}
var appTab: AppTab {
switch self {
case .chat:
.chat
case .talk:
.talk
case .agents:
.agent
case .settings, .gateway:
.settings
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
.usage,
.cron, .docs:
.control
}
}
var settingsRoute: SettingsRoute? {
switch self {
case .gateway:
.gateway
case .chat, .talk, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
.dreaming,
.usage, .cron, .settings, .docs:
nil
}
}
}
enum SidebarLayoutMode: Equatable {
case drawer
case split
}
static func sidebarLayoutMode(containerSize: CGSize) -> SidebarLayoutMode {
containerSize.width < self.sidebarPersistentWidthThreshold || containerSize.height > containerSize.width
? .drawer
: .split
}
static func preferredSidebarVisibility(layoutMode: SidebarLayoutMode) -> Bool {
layoutMode == .split
}
static func shouldCollapseSidebarAfterSelection(layoutMode: SidebarLayoutMode) -> Bool {
layoutMode == .drawer
}
static func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
if isDrawerLayout {
return min(self.sidebarDrawerMaximumWidth, max(280, containerWidth * 0.86))
}
return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25))
}
static func shouldShowSidebarRevealControl(isSidebarVisible: Bool) -> Bool {
!isSidebarVisible
}
static func shouldShowSidebarRevealInDestinationHeader(
isSidebarVisible: Bool,
layoutMode: SidebarLayoutMode) -> Bool
{
switch layoutMode {
case .split:
true
case .drawer:
self.shouldShowSidebarRevealControl(isSidebarVisible: isSidebarVisible)
}
}
static func requestedInitialSidebarVisibility(arguments: [String]) -> Bool? {
guard let flagIndex = arguments.firstIndex(of: "--openclaw-sidebar-visibility") else {
return nil
}
let valueIndex = arguments.index(after: flagIndex)
guard arguments.indices.contains(valueIndex) else { return nil }
switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "visible", "show", "shown", "open", "true", "1":
return true
case "hidden", "hide", "closed", "false", "0":
return false
default:
return nil
}
}
static func shouldOpenRootTabFromPhoneHub(_ destination: SidebarDestination) -> Bool {
switch destination {
case .chat, .talk, .agents, .gateway, .settings:
true
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
.usage,
.cron, .docs:
false
}
}
static func defaultSidebarDestination(for tab: AppTab) -> SidebarDestination {
switch tab {
case .control:
.overview
case .chat:
.chat
case .talk:
.talk
case .agent:
.agents
case .settings:
.settings
}
}
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
}
struct SidebarGroup: Identifiable {
let title: String
let destinations: [SidebarDestination]
var id: String {
self.title
}
}
static let sidebarGroups: [SidebarGroup] = [
SidebarGroup(title: "CHAT", destinations: [.chat, .talk]),
SidebarGroup(
title: "CONTROL",
destinations: [
.overview,
.activity,
.agents,
.workboard,
.skillWorkshop,
.instances,
.sessions,
.dreaming,
.usage,
.cron,
]),
SidebarGroup(
title: "SETTINGS",
destinations: [.settings]),
SidebarGroup(title: "REFERENCE", destinations: [.docs]),
]
static var phoneControlGroups: [SidebarGroup] {
self.sidebarGroups
.map { group in
SidebarGroup(
title: group.title,
destinations: group.destinations.filter { $0 != .agents })
}
.filter { !$0.destinations.isEmpty }
}
}

View File

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

View File

@@ -181,16 +181,6 @@ final class ScreenController {
return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
}
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
let image = try await self.snapshotImage(maxWidth: maxWidth)
guard let data = image.pngData() else {
throw NSError(domain: "Screen", code: 1, userInfo: [
NSLocalizedDescriptionKey: "snapshot encode failed",
])
}
return data.base64EncodedString()
}
func snapshotBase64(
maxWidth: CGFloat? = nil,
format: OpenClawCanvasSnapshotFormat,

View File

@@ -31,12 +31,7 @@ protocol LocationServicing: Sendable {
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
func startLocationUpdates(
desiredAccuracy: OpenClawLocationAccuracy,
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
func stopLocationUpdates()
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
func stopMonitoringSignificantLocationChanges()
}
@MainActor

View File

@@ -22,11 +22,4 @@ enum SessionKey {
let agentId = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
return agentId.isEmpty ? nil : agentId
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed == "global" { return true }
return trimmed.hasPrefix("agent:")
}
}

View File

@@ -1,40 +0,0 @@
import Foundation
struct SettingsHostPort: Equatable {
var host: String
var port: Int
}
enum SettingsNetworkingHelpers {
static func parseHostPort(from address: String) -> SettingsHostPort? {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("["),
let close = trimmed.firstIndex(of: "]"),
close < trimmed.endIndex
{
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<close])
let portStart = trimmed.index(after: close)
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
let portString = String(trimmed[trimmed.index(after: portStart)...])
guard let port = Int(portString) else { return nil }
return SettingsHostPort(host: host, port: port)
}
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
let host = String(trimmed[..<colon])
let portString = String(trimmed[trimmed.index(after: colon)...])
guard !host.isEmpty, let port = Int(portString) else { return nil }
return SettingsHostPort(host: host, port: port)
}
static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
if let host, let port {
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
let hostPart = needsBrackets ? "[\(host)]" : host
return "http://\(hostPart):\(port)"
}
return "http://\(fallback)"
}
}

View File

@@ -1,25 +0,0 @@
import SwiftUI
extension View {
func gatewayActionsDialog(
isPresented: Binding<Bool>,
onDisconnect: @escaping () -> Void,
onOpenSettings: @escaping () -> Void) -> some View
{
self.confirmationDialog(
"Gateway",
isPresented: isPresented,
titleVisibility: .visible)
{
Button("Disconnect", role: .destructive) {
onDisconnect()
}
Button("Open Settings") {
onOpenSettings()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Disconnect from the gateway?")
}
}
}

View File

@@ -3,7 +3,6 @@ import OpenClawKit
enum TalkModeExecutionMode {
case native
case realtimeClient
case realtimeRelay
}

View File

@@ -64,22 +64,6 @@ extension TalkModeManager {
}
}
static func permissionMessage(
kind: String,
status: AVAudioSession.RecordPermission) -> String
{
switch status {
case .denied:
return "\(kind) permission denied"
case .undetermined:
return "\(kind) permission not granted"
case .granted:
return "\(kind) permission denied"
@unknown default:
return "\(kind) permission denied"
}
}
static func permissionMessage(
kind: String,
status: SFSpeechRecognizerAuthorizationStatus) -> String

View File

@@ -70,11 +70,6 @@ final class TalkModeManager: NSObject {
self.gatewayConnected
}
var hasActiveAudioCapture: Bool {
self.isEnabled || self.isListening || self.isPushToTalkActive || self.realtimeRelaySession != nil
|| self.realtimeRelayStartInFlight
}
private enum CaptureMode {
case idle
case continuous
@@ -475,13 +470,6 @@ final class TalkModeManager: NSObject {
return wasActive
}
func setForegroundAudioCaptureAllowed(_ allowed: Bool) {
self.foregroundAudioCaptureAllowed = allowed
if !allowed {
self.cancelPendingStart()
}
}
func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async {
if wasKeptActive { return }
guard wasSuspended else { return }
@@ -489,14 +477,6 @@ final class TalkModeManager: NSObject {
await self.start()
}
func userTappedOrb() {
if let realtimeSession {
realtimeSession.cancelResponse()
}
self.realtimeRelaySession?.cancelOutput()
self.stopSpeaking()
}
func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload {
guard self.gatewayConnected else {
self.statusText = "Offline"
@@ -3104,23 +3084,6 @@ extension TalkModeManager {
self.gatewayTalkCurrentFallbackIssue
}
func _test_seedTranscript(_ transcript: String) {
self.lastTranscript = transcript
self.lastHeard = Date()
}
func _test_handleTranscript(_ transcript: String, isFinal: Bool) async {
await self.handleTranscript(transcript: transcript, isFinal: isFinal)
}
func _test_backdateLastHeard(seconds: TimeInterval) {
self.lastHeard = Date().addingTimeInterval(-seconds)
}
func _test_runSilenceCheck() async {
await self.checkSilence()
}
func _test_incrementalReset() {
self.incrementalSpeechBuffer = IncrementalSpeechBuffer()
}

View File

@@ -3,7 +3,6 @@ import SwiftUI
struct TalkPermissionPromptView: View {
enum Style {
case card
case settings
case sheet
}

View File

@@ -61,7 +61,6 @@ final class TalkRealtimeWebRTCSession: NSObject {
let runId: String?
let status: String?
let startedAt: Double?
let endedAt: Double?
let error: String?
let stopReason: String?
let timeoutPhase: String?
@@ -196,11 +195,6 @@ final class TalkRealtimeWebRTCSession: NSObject {
Self.logger.info("timeline +\(self.elapsedMs(), privacy: .public)ms \(message, privacy: .public)")
}
func cancelResponse() {
self.sendRealtimeEvent(["type": "response.cancel"])
self.cancelActiveToolCalls()
}
private func cancelActiveToolCalls() {
let runIds = Array(Set(activeToolRunIds.values))
for task in self.activeToolTasks.values {

View File

@@ -70,14 +70,6 @@ enum TalkSpeechLocale {
return (recognizer, recognizer?.locale.identifier)
}
static func normalizedExplicitLocaleID(_ raw: String?) -> String? {
TalkConfigParsing.normalizedExplicitSpeechLocaleID(raw, automaticID: self.automaticID)
}
private static func normalizedLocaleID(_ raw: String?) -> String? {
TalkConfigParsing.normalizedSpeechLocaleID(raw)
}
private static func canonicalID(_ raw: String) -> String {
raw.replacingOccurrences(of: "_", with: "-")
}

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