Compare commits

..

273 Commits

Author SHA1 Message Date
Ayaan Zaidi
c9f9661960 fix(provider-setup): harden telegram setup flow 2026-05-19 10:03:27 +05:30
Ayaan Zaidi
7154ad89c5 feat(telegram): add providers setup command 2026-05-19 08:28:19 +05:30
Ayaan Zaidi
a8d2b54fd3 feat(provider-setup): expose setup runtime sdk 2026-05-19 08:28:19 +05:30
Ayaan Zaidi
b89c57a1ba feat(provider-setup): add provider setup runtime 2026-05-19 08:27:56 +05:30
Ayaan Zaidi
131577a4dc fix(mantis): preserve telegram account queue 2026-05-19 08:15:52 +05:30
Josh Avant
e996159738 Guard final delivery session refresh (#83928)
* guard final delivery session refresh

* add changelog for final delivery refresh guard
2026-05-18 21:44:17 -05:00
clawsweeper[bot]
8bd24ad6d4 fix(codex): preserve plugin tool auth profiles (#83845)
Summary:
- This PR threads a Codex-only `toolAuthProfileStore` through embedded runner attempt params, uses it for Code ... struction, forwards auth profiles into plugin-only tools, and adds regression tests plus a changelog entry.
- Reproducibility: yes. The linked source PR includes a concrete before-fix negative control and after-fix gat ... urrent-main source inspection shows Codex dynamic tools still receive only the scoped transport auth store.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(codex): align dynamic tool auth test helper
- PR branch already contained follow-up commit before automerge: fix(codex): expose tool auth to installed harnesses
- PR branch already contained follow-up commit before automerge: test(codex): narrow auth store assertions
- PR branch already contained follow-up commit before automerge: fix(codex): preserve plugin tool auth profiles

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

Prepared head SHA: c226f54be0
Review: https://github.com/openclaw/openclaw/pull/83845#issuecomment-4483631210

Co-authored-by: Rubén Cuevas <hi@rubencu.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 02:40:40 +00:00
Josh Avant
3ee0342061 fix(codex): honor Docker bind write policy (#83849)
* fix(codex): honor docker bind write policy

* docs: note docker bind sandbox fix

* fix(codex): expose docker sandbox fallback tools
2026-05-18 21:39:18 -05:00
Bek
10b313d628 Allow trusted plugin keyed state (#83775) 2026-05-18 22:38:54 -04:00
Oviemudi.eth
e9989f3a92 fix(whatsapp): periodic delivery-queue drain so enqueued items don't wait for next reconnect (#79083)
Merged via squash.

Prepared head SHA: 9a619bb9d9
Co-authored-by: Oviemudiaga <49584793+Oviemudiaga@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
2026-05-18 23:29:15 -03:00
clawsweeper[bot]
ff4bf0c367 docker: support optional pip packages in local builds (#83850)
Summary:
- Adds `OPENCLAW_IMAGE_PIP_PACKAGES` as an opt-in Dockerfile build arg, passes it through Docker and Podman local setup, and documents/tests the new local image-build option.
- Reproducibility: not applicable. this is an additive Docker/Podman build capability, not a bug report. The s ... image importing requested Python packages, and the branch diff wires the renamed arg through Docker/Podman.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docker: support optional pip packages in local builds

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

Prepared head SHA: 0ccec19206
Review: https://github.com/openclaw/openclaw/pull/83850#issuecomment-4483676614

Co-authored-by: Stephen Redmond <stephen.redmond@straiteis.ie>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 02:23:13 +00:00
VACInc
a559ccc084 Preserve queued Telegram topic followups (#83827)
Summary:
- This PR changes queued reply followups so user_request items no longer carry or inherit a source abort signal, preserves room_event abort signals, adds focused regression coverage, and updates CHANGELOG.md.
- Reproducibility: yes. at source level. Current main attaches and later falls back to opts.abortSignal for qu ... ore-fix regression failures for the two implicated paths; I did not execute tests in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Preserve queued Telegram topic followups

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

Prepared head SHA: 96fa0f69ba
Review: https://github.com/openclaw/openclaw/pull/83827#issuecomment-4483451436

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 02:13:04 +00:00
Kevin Lin
f169e0aafd fix(codex): guard against stale codex app snapshots leading to plugin invocation failure (#83807)
* feat(codex): add plugin enable disable list commands

* fix(codex): escape plugin management output

* test(codex): narrow plugin command coverage

* fix(codex): gate plugin management writes

* test(codex): type command plugin context

* fix(codex): recover plugin app bindings

* fix(codex): fail closed on missing app inventory

* fix(codex): restore plugin thread config log signal

* revert(codex): drop plugin management commands

* fix(codex): warn on missing plugin app inventory

* fix(codex): trim plugin binding debug logs

* fix(codex): restore thread lifecycle json import

* chore(codex): remove plugin app debug logs

* fix(codex): redact plugin thread config logs
2026-05-18 18:57:48 -07:00
clawsweeper[bot]
6f7d9736e2 fix(deepseek): normalize mcp union tool schemas (#83848)
Summary:
- The PR adds DeepSeek provider-owned `anyOf`/`oneOf` tool-schema normalization, normalizes late materialized bundled tools, and updates focused tests, docs, and changelog.
- Reproducibility: yes. Source inspection shows current main appends materialized bundled MCP tools after prov ... aw/issues/83361 provides the concrete DeepSeek `400 Invalid schema` failure for an MCP `anyOf` tool schema.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(deepseek): normalize mcp union tool schemas

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

Prepared head SHA: 1bbbb44d2b
Review: https://github.com/openclaw/openclaw/pull/83848#issuecomment-4483638498

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 01:54:29 +00:00
Gio Della-Libera
8eb0a1777f fix(migrate): use resolved provider for options (#83323)
* fix(migrate): use resolved provider for options

* test(migrate): cover resolved provider apply options
2026-05-18 18:54:26 -07:00
VACInc
f526d96c98 Fix Telegram forum topic parallel flow (#83829)
Summary:
- The branch fixes Telegram forum-topic session routing, per-topic text/media buffering, media-group scoping, and outbound group send fairness, with focused Telegram regression tests and a changelog entry.
- Reproducibility: yes. source inspection of current main plus the PR body's before-proof give a high-confiden ... s_forum can collapse to the base group route, and global text/media buffer chains serialize sibling topics.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Fix Telegram forum topic parallel flow

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

Prepared head SHA: b0f78fa275
Review: https://github.com/openclaw/openclaw/pull/83829#issuecomment-4483486851

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 01:48:56 +00:00
Gio Della-Libera
19065e4a2f Improve Telegram groups config shape diagnostics (#83260)
* Improve Telegram groups config diagnostics

Add targeted guidance when channels.telegram.groups uses a non-object shape so startup/config validation and doctor explain the required group-id object map and topic nesting.

* fix(config): keep channel validation hints generic
2026-05-18 18:47:28 -07:00
Gio Della-Libera
88585da2e8 test(config): keep agent model overrides selection-only (#83319)
* fix(config): keep subagent model overrides selection-only

* fix(config): reuse agent model schema for subagents
2026-05-18 18:46:40 -07:00
Josh Avant
eb6dd2c65d Fix memory plugin CLI help dispatch (#83841)
* fix cli help for active memory plugin

* docs add changelog for memory cli help

* test fix root help mock type
2026-05-18 20:35:55 -05:00
Peter Steinberger
0b4fc26d4a codex: surface deferred dynamic tool names (#83813)
* codex: surface deferred dynamic tool names

* codex: keep prompt snapshots source-backed

* style: wrap mac voice settings help text

* style: satisfy swiftformat for voice wake help text

* style: apply swiftformat to voice wake help text

* test: load codex prompt snapshots through plugin aliases

* test: type codex source surface loader

* test: avoid extra codex loader suppression

---------

Co-authored-by: pashpashpash <nik@vault77.ai>
2026-05-19 10:32:36 +09:00
clawsweeper[bot]
48d9966aa1 fix(cli): include loopback tools in cli prompts (#83828)
Summary:
- The PR feeds loopback-scoped MCP tools into CLI system prompts and reports, persists a prompt tool-name hash for CLI session reuse, adds regression tests, and adds a changelog entry.
- Reproducibility: yes. from source inspection: current main builds the CLI prompt and report with `tools: []` ... execute a live CLI turn in this read-only review, but the source path and source PR terminal proof line up.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cli): gate prompt loopback tools on active runtime
- PR branch already contained follow-up commit before automerge: fix(cli): include loopback tools in cli prompts

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

Prepared head SHA: d196564d4d
Review: https://github.com/openclaw/openclaw/pull/83828#issuecomment-4483469332

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 01:30:06 +00:00
clawsweeper[bot]
d160342b55 fix(ui): keep delete confirm in viewport (#83825)
Summary:
- This PR changes the Control UI chat delete confirmation popover from absolute above-trigger positioning to fixed viewport-clamped placement with focused geometry tests and a changelog entry.
- Reproducibility: yes. The related delete-click report maps directly to current main code that appends an abo ... able chat thread without viewport measurement; I did not run a live browser repro in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(ui): keep delete confirm in viewport

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

Prepared head SHA: bc000c5b64
Review: https://github.com/openclaw/openclaw/pull/83825#issuecomment-4483439624

Co-authored-by: Thiago Costa <71539514+ThiagoCAltoe@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 01:28:54 +00:00
clawsweeper[bot]
1a242cd4f5 fix(memory-wiki): preserve fs-safe write diagnostics (#83839)
Summary:
- The branch narrows Memory Wiki imported-source `FsSafeError` wrapping, adds directory-collision bridge regressions, and adds a changelog entry crediting the source PR.
- Reproducibility: yes. Source inspection shows current main catches all imported-source `FsSafeError`s with symlink wording, and the linked source PR includes live bridge-sync output for the directory-collision path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(memory-wiki): normalize source page stat guard
- PR branch already contained follow-up commit before automerge: fix(memory-wiki): preserve fs-safe write diagnostics

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

Prepared head SHA: e38ae3b998
Review: https://github.com/openclaw/openclaw/pull/83839#issuecomment-4483591199

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-19 01:24:54 +00:00
Josh Avant
2c8f78e723 Harden final delivery routing refresh (#83835)
* harden final delivery routing refresh

* add changelog for final delivery hardening
2026-05-18 20:16:58 -05:00
Firas Alswihry
a9eaf0c993 test(qa-lab): add personal no-fake-progress scenario (#83824)
Summary:
- The PR adds a personal-agent QA-Lab no-fake-progress scenario, registers it in the personal-agent pack, teaches mock-openai the scripted path, and updates focused tests, docs, and changelog.
- Reproducibility: not applicable. This PR adds QA coverage rather than reporting a current-main bug; the branch supplies concrete after-patch QA-Lab/mock-openai commands and copied pass output.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(qa-lab): add personal no-fake-progress scenario

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

Prepared head SHA: 95d2e46288
Review: https://github.com/openclaw/openclaw/pull/83824#issuecomment-4483439200

Co-authored-by: Firas Alswihry <itzfiras@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-05-19 01:16:00 +00:00
Eduardo Piva
6f18decb7a fix: add Copilot IDE headers to resolved models (#82275)
* fix: add copilot headers to resolved models

* fix copilot header imports

* fix prod typecheck
2026-05-18 17:59:02 -07:00
clawsweeper[bot]
f1a55cbd52 fix(skills): refresh snapshots when watch roots change (#83823)
Summary:
- The replacement PR adds a `watch-targets` skills snapshot invalidation when `ensureSkillsWatcher` rebuilds f ... root set, reads the snapshot version after watcher setup, adds regression tests, and updates the changelog.
- Reproducibility: yes. Source inspection shows current main rebuilds the skills watcher on changed root targe ... the version before watcher setup; I did not run a live Gateway mount reproduction in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(skills): refresh snapshots when watch roots change

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

Prepared head SHA: 2677dcc35a
Review: https://github.com/openclaw/openclaw/pull/83823#issuecomment-4483425019

Co-authored-by: hclsys <hclsys@openclaw.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-05-19 00:36:27 +00:00
WhatsSkiLL
9b517b50cb fix(push): use valid default VAPID subject (#83317) 2026-05-18 17:27:11 -07:00
Peter Steinberger
8aff1807fa style: refine mac voice settings layout 2026-05-19 02:18:22 +02:00
Peter Steinberger
b4fdd1470b fix(codex): expose sandbox shell tools for ssh backends 2026-05-19 02:15:53 +02:00
Peter Steinberger
1c3ff34d75 ci(release): stabilize beta validation assertions 2026-05-19 01:05:52 +01:00
Peter Steinberger
59defa3e71 ci(release): fix beta validation gates 2026-05-19 01:05:52 +01:00
Peter Steinberger
ab398ae86d docs(release): add mac release recovery skill 2026-05-19 01:05:52 +01:00
Gio Della-Libera
87aa319568 fix(export): preserve explicit trajectory session keys (#83308) 2026-05-18 16:48:23 -07:00
Gio Della-Libera
567fe2957d fix(doctor): include channel model provider repairs (#83328)
* fix(doctor): include channel model provider repairs

* fix(doctor): include provider-keyed channel model repairs
2026-05-18 16:46:10 -07:00
Peter Steinberger
df8505b09d test: cover installer npm freshness policy 2026-05-19 00:15:49 +01:00
Peter Steinberger
0903fa61d0 docs: fix update recovery verification (#83757) 2026-05-19 01:13:51 +02:00
Peter Steinberger
bcdfbb8b84 docs: update changelog for EACCES recovery (#83757) (thanks @brokemac79) 2026-05-19 01:13:51 +02:00
brokemac79
26bcc95665 fix(update): guide EACCES manual recovery 2026-05-19 01:13:51 +02:00
Peter Steinberger
583eb711ec ci(release): disable notarytool s3 acceleration 2026-05-18 23:53:29 +01:00
openclaw-release-bot
46d53d3b59 chore(release): update appcast for 2026.5.18 2026-05-18 22:50:35 +00:00
Peter Steinberger
b77444ee48 docs(refactor): remove completed channel route plan 2026-05-19 00:49:25 +02:00
Peter Steinberger
cde6d60c18 fix(channels): compare normalized routes without serialization 2026-05-19 00:49:25 +02:00
Peter Steinberger
17eab1ed4d fix(channels): preserve route metadata on agent updates 2026-05-19 00:49:25 +02:00
Peter Steinberger
02f8fb7147 fix(channels): clear canonical stale routes 2026-05-19 00:49:25 +02:00
Peter Steinberger
8477a67faf refactor(channels): unify session route projection 2026-05-19 00:49:25 +02:00
Josh Lehman
85a3d5312f fix: bypass npm freshness for managed installs (#83761)
* fix: bypass npm freshness for managed installs

* test: tolerate npm config json differences

* test: align npm freshness bypass expectation

* fix: resolve npm config path expansions

* test: tolerate npm zero config encoding

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-19 00:31:02 +02:00
nitinjwadhawan
d761b98adc fix(memory-core): yield event loop during fallback vector search (#81172) (#83758)
Summary:
- The branch changes memory-core fallback vector search to scan chunks in 256-row rowid batches with `setImmediate` yields, updates regression tests, and adds a changelog entry.
- Reproducibility: yes. from source and supplied live output. Current main synchronously scans fallback vector ...  and the PR body shows the before/after heartbeat behavior through the actual `searchVector` fallback path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(memory-core): add boundary, parity, and concurrent-insert covera…
- PR branch already contained follow-up commit before automerge: fix(memory-core): yield event loop during fallback vector search (#81…

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

Prepared head SHA: 0ede3d7168
Review: https://github.com/openclaw/openclaw/pull/83758#issuecomment-4482137790

Co-authored-by: NW <nitinwadhawan66@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-05-18 22:18:14 +00:00
Andy Ye
98cc6df7ff fix(anthropic): preserve Claude image capability (#83756)
Summary:
- The PR adds Anthropic Claude 4.x image-capability normalization for stale text-only resolved model rows, regression tests for provider and fallback model resolution, and a changelog entry.
- Reproducibility: yes. for source-level reproduction: current main gates native images on model.input includi ... s text-only. I did not run the command locally because this review was constrained to read-only inspection.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(anthropic): preserve Claude image capability

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

Prepared head SHA: 06dd378ea3
Review: https://github.com/openclaw/openclaw/pull/83756#issuecomment-4482116499

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 22:16:01 +00:00
Peter Steinberger
83c225b243 chore: refresh release generated baselines 2026-05-18 23:11:42 +01:00
Peter Steinberger
c1579b7727 chore: bump release version to 2026.5.19 2026-05-18 23:11:42 +01:00
Tak Hoffman
9968db65db fix(github): preserve clawsweeper proof labels (#83781) 2026-05-18 17:10:35 -05:00
Super Zheng
d124c5aa20 fix(cli): fix flaky config set help text test caused by env var leakage and word wrapping (#83423)
Merged via squash.

Prepared head SHA: 7ba1bac70c
Co-authored-by: medns <1575008+medns@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-19 00:11:52 +03:00
Patrick Erichsen
721ad1587a fix: keep inter-session provenance out of transcripts (#83755) 2026-05-18 14:02:25 -07:00
Andy Ye
b2c5ba6d4c fix(outbound): resolve send-capable channel registry (#83733)
Summary:
- The PR changes outbound channel registry loading and bootstrap to fall back from pinned setup-only channel entries to the active runtime registry, with regression tests and a changelog entry.
- Reproducibility: yes. at source level. Current main can select a pinned setup-only channel entry and skip th ... module live output showing delivery after the fallback; I did not run local tests in this read-only review.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(outbound): resolve send-capable channel registry

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

Prepared head SHA: 67c20aa72b
Review: https://github.com/openclaw/openclaw/pull/83733#issuecomment-4481084888

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 20:30:51 +00:00
clawsweeper[bot]
424c6d0a5f fix(auto-reply): honor webchat textChunkLimit/chunkMode config overrides [AI-assisted] (#83742)
Summary:
- This PR removes the WebChat special-case from auto-reply chunk limit/mode resolution, adds WebChat override regression tests, and records the fix in the changelog.
- Reproducibility: yes. from source inspection rather than runtime execution: current main returns the fallbac ... bchat` before reading `cfg.channels`, so a configured `channels.webchat.textChunkLimit` cannot take effect.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(auto-reply): honor webchat textChunkLimit/chunkMode config overri…

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

Prepared head SHA: cd9ac01a36
Review: https://github.com/openclaw/openclaw/pull/83742#issuecomment-4481570742

Co-authored-by: luyao618 <364939526@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>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 20:09:40 +00:00
Andy Ye
583a60f8b5 fix(ui): render session-scoped tool events (#83734)
Summary:
- The PR routes `session.tool` Gateway frames through the Control UI tool-stream handler, adds a regression test, and adds a changelog entry.
- Reproducibility: yes. Current main emits `session.tool` frames for session subscribers, but the Control UI d ...  to the tool-stream handler, so the failure path is source-reproducible without needing a live browser run.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(ui): render session-scoped tool events

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

Prepared head SHA: 58be438acb
Review: https://github.com/openclaw/openclaw/pull/83734#issuecomment-4481086608

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 19:59:09 +00:00
Gio Della-Libera
94abfa76e2 Doctor: convert read-only health checks (#83198)
* feat(doctor): convert read-only health checks

* fix(doctor): keep read-only conversion gates green

* fix(doctor): preserve health repair preview contract

* fix(doctor): defer session snapshot lint target

* fix(doctor): avoid false-clean lint placeholders

* test(doctor): type conversion target registry check
2026-05-18 12:49:20 -07:00
Tak Hoffman
c92ebd6a41 fix(ci): preserve Barnacle proof labels (#83735)
* fix(ci): preserve sufficient proof override

* fix(ci): keep sufficient proof on label churn
2026-05-18 14:37:20 -05:00
Gio Della-Libera
1fb09069c3 fix(doctor): anchor WhatsApp TUI process matching (#83313) 2026-05-18 12:04:32 -07:00
clawsweeper[bot]
70f580041f test(qa-lab): add personal share-safe diagnostics scenario (#83717)
Summary:
- Adds a personal-agent QA-Lab share-safe diagnostics scenario with mock-openai support, pack registration/tests, docs, and changelog coverage.
- Reproducibility: not applicable. This PR adds a new QA-Lab scenario rather than fixing a current-main bug. T ... ce PR provides a clear after-patch validation path using qa-channel, a real gateway child, and mock-openai.

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

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

Prepared head SHA: 46eb0af9e4
Review: https://github.com/openclaw/openclaw/pull/83717#issuecomment-4480393933

Co-authored-by: Firas Alswihry <itzfiras@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 18:56:25 +00:00
nitinjwadhawan
9995e1b4d5 fix(nextcloud-talk): dispatch react action so agents can send reactions (#70110) (#72348)
Summary:
- This PR adds and registers a Nextcloud Talk message action adapter for add-only reactions, updates reaction docs, and adds adapter plus sender tests.
- Reproducibility: yes. Source inspection on current main shows Nextcloud Talk advertises reactions and has a  ... ion sender, but the plugin lacks `actions.handleAction`, so shared `react` dispatch has no channel handler.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(nextcloud-talk): cover reaction sender request path
- PR branch already contained follow-up commit before automerge: fix(nextcloud-talk): harden react null-guard; fix disabled-account te…
- PR branch already contained follow-up commit before automerge: fix(nextcloud-talk): reject react remove requests instead of silently…
- PR branch already contained follow-up commit before automerge: fix(nextcloud-talk): inline listEnabledAccounts helper after main cle…
- PR branch already contained follow-up commit before automerge: docs(nextcloud-talk): note add-only react support in reactions and me…

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

Prepared head SHA: 9817fed842
Review: https://github.com/openclaw/openclaw/pull/72348#issuecomment-4323046928

Co-authored-by: NW <nitinwadhawan66@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 18:54:44 +00:00
clawsweeper[bot]
bf9329486b fix(models): label picker auth via effective provider order (#83726)
Summary:
- The PR passes the effective OpenAI/Codex auth provider set into `/models` provider-header labeling, adds focused regression tests, and records the user-facing fix in the changelog.
- Reproducibility: yes. Current main lacks `acceptedProviderIds` in the shared picker header path, and the source PR's Mantis baseline/candidate proof shows the visible Telegram header mismatch and after-fix OAuth label.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(models): label picker auth via effective provider order

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

Prepared head SHA: 8ca2924adc
Review: https://github.com/openclaw/openclaw/pull/83726#issuecomment-4480805713

Co-authored-by: Stellar鱼 <2182712990@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>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 18:48:58 +00:00
Andy Ye
44c6ad7dce fix(subagents): collect unresolved announce batches (#83701)
Summary:
- The PR changes collect-mode follow-up queue routing so unresolved-origin items can batch with a single resolved route and later compatible items can resume batching after a true cross-channel drain.
- Reproducibility: yes. at source level: current main treats unkeyed-plus-same-keyed queue items as cross-chan ... failing path is directly visible in `src/utils/queue-helpers.ts` and `src/auto-reply/reply/queue/drain.ts`.

Automerge notes:
- PR branch already contained follow-up commit before automerge: Merge remote-tracking branch 'origin/main' into maint-83701-20260518

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

Prepared head SHA: e6ad029e23
Review: https://github.com/openclaw/openclaw/pull/83701#issuecomment-4479943100

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-05-18 18:34:58 +00:00
clawsweeper[bot]
3e6f7494af fix(browser): preserve explicit cdpPort when cdpUrl omits port (#83707)
Summary:
- The PR adds raw explicit-port detection for browser CDP URLs, updates profile resolution precedence, adds regression tests, and records the browser fix in the changelog.
- Reproducibility: yes. Source inspection shows current main resolves a portless profile `cdpUrl` through `par ...  443, and overwrites the configured `cdpPort`; the source PR also provides live before/after Chrome output.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(browser): encapsulate explicit-port detection in parseBrowserHttpUrl
- PR branch already contained follow-up commit before automerge: fix(browser): preserve explicit cdpPort when cdpUrl omits port

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

Prepared head SHA: 070c31cdcf
Review: https://github.com/openclaw/openclaw/pull/83707#issuecomment-4480058057

Co-authored-by: Hongwei Ma <marvae24@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-05-18 18:20:55 +00:00
Agustin Rivera
78f3985c60 fix(browser): guard current tab act routes (#78523)
* fix(browser): guard current tab act routes

* fix(browser): document current-tab route guard
2026-05-18 11:19:30 -07:00
Tak Hoffman
06a39015f2 fix(ci): authenticate proof verdict markers (#83692)
Summary:
- The branch restricts exact-head ClawSweeper proof markers to GitHub App-authored comments, adds read-only issue-comment token fallback for the proof workflow, and adds focused regression tests plus a changelog entry.
- Reproducibility: yes. Source inspection of current main shows any issue comment body with a matching `clawsw ...  SHA is accepted without author/App authentication; the PR adds focused negative tests for forged comments.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(ci): authenticate proof verdict markers

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

Prepared head SHA: f4c375eaa7
Review: https://github.com/openclaw/openclaw/pull/83692#issuecomment-4479843682

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 17:42:10 +00:00
Tak Hoffman
0901801238 docs: clarify pull request motivation 2026-05-18 12:39:54 -05:00
Vincent Koc
cb408bb06b fix(release): repair broad gate regressions 2026-05-19 01:31:25 +08:00
clawsweeper[bot]
fa814eb9ed feat(browser): add evaluate timeout CLI option (#83696)
Summary:
- The branch adds `openclaw browser evaluate --timeout-ms`, forwards it to the evaluate body and request timeo ... ents and tests it, adds a changelog entry, and includes a config.patch no-op shortcut from the repair pass.
- Reproducibility: not applicable. this is a feature PR rather than a bug report. Source inspection shows current main lacks the CLI flag while the branch wires it into an already-supported evaluate `timeoutMs` payload.

Automerge notes:
- PR branch already contained follow-up commit before automerge: feat(browser): add evaluate timeout CLI option

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

Prepared head SHA: 0d81d3d93e
Review: https://github.com/openclaw/openclaw/pull/83696#issuecomment-4479900502

Co-authored-by: fred <fengruifree@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-05-18 17:30:33 +00:00
clawsweeper[bot]
a4f80f905d fix(ui): prevent reading indicator from sticking after assistant response (#83711)
Summary:
- The PR removes the Control UI chat fallback that converts a null stream into an empty stream for abortable runs, adds null-vs-empty stream regression tests, and updates the changelog.
- Reproducibility: yes. source-level reproduction is high confidence: current main converts null stream plus c ... ading indicator. The linked source PR also reports live Control UI verification after the equivalent patch.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(ui): prevent reading indicator from sticking after assistant resp…

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

Prepared head SHA: 44bea55110
Review: https://github.com/openclaw/openclaw/pull/83711#issuecomment-4480128171

Co-authored-by: 二狗子 <njuboy11@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 17:28:26 +00:00
clawsweeper[bot]
5702858553 feat(cli): support installing skills to shared global directory via --global (#83705)
Summary:
- Adds `--global` to `openclaw skills install` and `openclaw skills update`, routing ClawHub installs and updates to the shared managed skills root with docs, changelog, and CLI command tests.
- Reproducibility: not applicable. as a bug reproduction; this is a new CLI feature request. Source inspection confirms current `main` lacks `--global`, and the source PR includes after-fix terminal proof for the new path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cli): address skills global review
- PR branch already contained follow-up commit before automerge: feat(cli): support installing skills to shared global directory via -…

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

Prepared head SHA: 6eb7187fc1
Review: https://github.com/openclaw/openclaw/pull/83705#issuecomment-4480023577

Co-authored-by: Hongwei Ma <marvae24@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-05-18 17:22:00 +00:00
clawsweeper[bot]
3631af8107 fix(skill-creator): reject empty name and description in skill valida… (#83704)
Summary:
- The PR makes skill-creator quick validation reject empty or whitespace-only `name` and `description` fields, adds regression tests, and records the fix in the changelog.
- Reproducibility: yes. Source inspection on current main shows empty or whitespace-only values skip validation after `.strip()`, and the source PR includes before/after terminal output for the same path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(skill-creator): reject empty name and description in skill valida…

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

Prepared head SHA: 0fb4555cb2
Review: https://github.com/openclaw/openclaw/pull/83704#issuecomment-4479984760

Co-authored-by: jay <a1@ponys-Mac.local>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 17:10:19 +00:00
Tak Hoffman
57854219d0 docs(changelog): add Telegram delivery log entry 2026-05-18 12:08:59 -05:00
Yuval Dinodia
324a95db8b docs(gateway): troubleshoot group @mention silent suppression (#77052)
Summary:
- Adds a symptom-keyed troubleshooting block to `docs/gateway/config-channels.md` for group/channel @mentions that log `queuedFinal=false, replies=0` and explains the `visibleReplies` remedies.
- Reproducibility: yes. for the docs gap and source behavior: current main lacks the exact symptom-keyed troubleshooting entry, and the resolver/tests show when message-tool mode suppresses automatic final delivery.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs(gateway): make group reply fix restart conditional
- PR branch already contained follow-up commit before automerge: docs(gateway): qualify direct-chat reply default in troubleshooting
- PR branch already contained follow-up commit before automerge: docs(gateway): align group reply troubleshooting with current automat…
- PR branch already contained follow-up commit before automerge: docs(gateway): scope group reply suppression cause to group config

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

Prepared head SHA: e60ae89b20
Review: https://github.com/openclaw/openclaw/pull/77052#issuecomment-4367898048

Co-authored-by: yetval <yetvald@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 17:03:53 +00:00
Yufeng He
f4e17a4b54 fix: stop swallowing mkdir errors in memory ensureDir (#41259)
Summary:
- The PR removes the empty `mkdirSync({ recursive: true })` catch in the memory host SDK `ensureDir()`, adds a regression test for surfaced mkdir failures, and adds a changelog entry.
- Reproducibility: yes. from source inspection rather than a locally executed repro. Current main swallows eve ... kdir failure in `ensureDir()`, and the active memory database path calls that helper before opening SQLite.

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

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

Prepared head SHA: 0f82f185cc
Review: https://github.com/openclaw/openclaw/pull/41259#issuecomment-4326310101

Co-authored-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 17:01:52 +00:00
Rohin
9cbe28d75e fix(skills): resolve skills info name mismatches (#38713)
Summary:
- The PR updates the skills CLI formatter, tests, and changelog so `skills info` resolves case-insensitive and ... ator-normalized skill name variants only when non-exact matches are unique, and sanitizes not-found output.
- Reproducibility: yes. by source inspection. The documented `openclaw skills info <name>` command passes the  ... ormatter lookup on current main, while skill status entries can have distinct `name` and `skillKey` values.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(skills): exercise case-insensitive lookup branch
- PR branch already contained follow-up commit before automerge: style(skills): format lookup resolver signature
- PR branch already contained follow-up commit before automerge: fix(skills): sanitize not-found output and avoid ambiguous lookup mat…
- PR branch already contained follow-up commit before automerge: fix(skills): require unique case-insensitive info matches

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

Prepared head SHA: 01f3e2d468
Review: https://github.com/openclaw/openclaw/pull/38713#issuecomment-4321021300

Co-authored-by: NewdlDewdl <rohin.agrawal@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 17:00:57 +00:00
zhengzuo0-ai
1fbb4e4e6a ui: highlight WebChat code blocks (#83569)
Summary:
- The PR adds highlight.js-backed WebChat code-block highlighting, scoped token CSS, regression tests, a type shim, and a direct UI dependency.
- Reproducibility: not applicable. as a bug reproduction; this is a feature addition. The feature gap is source-evident because current main renders code blocks as escaped plaintext without hljs token markup.

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

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

Prepared head SHA: 7bb95c47ed
Review: https://github.com/openclaw/openclaw/pull/83569#issuecomment-4476990135

Co-authored-by: zhengzuo0-ai <zheng.zuo0@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
2026-05-18 16:53:24 +00:00
Firas Alswihry
46c622aa3b test(qa-lab): add dreaming shadow trial report scenario 2026-05-19 00:44:39 +08:00
Ayaan Zaidi
3fb5b4bec9 docs(changelog): note telegram native progress drafts (#83622) (thanks @akrimm702) 2026-05-18 22:14:30 +05:30
Ayaan Zaidi
890139f998 refactor(telegram): simplify native draft progress path 2026-05-18 22:14:30 +05:30
Alexander Krimm
0802a10273 fix(config): scope native telegram preview config 2026-05-18 22:14:30 +05:30
Alexander Krimm
f199cec885 docs(telegram): clarify native draft progress config 2026-05-18 22:14:30 +05:30
Alexander Krimm
a433cef05f fix(telegram): gate native tool progress drafts 2026-05-18 22:14:30 +05:30
Alexander Krimm
7cc4258dd5 feat(telegram): use native DM drafts for tool progress 2026-05-18 22:14:30 +05:30
Tak Hoffman
e4fba78d81 fix(ci): honor exact-head proof verdicts (#83688) 2026-05-18 11:39:30 -05:00
clawsweeper[bot]
9dc7bd4d05 fix(memory-wiki): make wiki_lint tool output path-safe (#83687)
Summary:
- The PR updates the memory-wiki `wiki_lint` tool to show vault-relative lint report paths in tool text and details, keeps the core linter/CLI result absolute, adds regression coverage, and adds a changelog entry.
- Reproducibility: yes. there is a high-confidence source reproduction path: current main returns the linter's ... tPath` in `wiki_lint` text and raw details. I did not execute the harness because this review is read-only.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(memory-wiki): make wiki_lint tool output path-safe

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

Prepared head SHA: df5c7db151
Review: https://github.com/openclaw/openclaw/pull/83687#issuecomment-4479682214

Co-authored-by: LLagoon3 <choonarm3@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-05-18 16:34:11 +00:00
nv-kasikritc
38f11a0844 feat(nvidia): tag NIM request origin (#81524) 2026-05-18 09:25:24 -07:00
Dallin Romney
cf194419c3 ci(proof): skip real-behavior-proof gate for private maintainers (#83418)
* ci(proof): trust maintainer label for private org members

Private organization memberships report author_association=CONTRIBUTOR
on PRs, so the real-behavior-proof gate currently demands proof from
maintainers whose membership is private. The labeler workflow already
applies the 'maintainer' label via the team-membership API (which sees
private members), so treat that label as an equivalent privileged
signal in evaluateRealBehaviorProof.

* ci(proof): drop noisy comments

* ci(proof): check maintainer team membership via GitHub App token

Replace the label-based private-maintainer skip with a direct
getMembershipForUserInOrg call using a minted GitHub App token, mirroring
the pattern labeler.yml already uses for the same lookup. Removes the
race against the labeler workflow and the implicit dependency on the
'maintainer' label having landed first.

The App-token steps are continue-on-error so the gate still runs (using
the existing author_association path) when the App key secrets are
absent or both mints fail.

* ci(proof): narrow App token to members:read

ClawSweeper review #83418: actions/create-github-app-token defaults to
the full installation permission set, but the proof gate only needs the
org-members read scope used by teams.getMembershipForUserInOrg. Set
permission-members: read on both the primary and fallback mint steps.

* docs(changelog): private maintainers skip the real-behavior-proof gate
2026-05-18 09:22:59 -07:00
Elarwei
9657b8e8ce fix(image-generate): allow distinct active image requests (#83614)
Summary:
- This PR prompt-scopes `image_generate` duplicate detection, adds same-prompt and distinct-prompt regression tests, and updates task guardrail docs and changelog.
- Reproducibility: yes. Current-main source shows the duplicate guard runs before prompt parsing and active lookup ignores prompt identity, matching the linked distinct-second-image failure mode.

Automerge notes:
- PR branch already contained follow-up commit before automerge: docs(tasks): clarify image generation guardrail
- PR branch already contained follow-up commit before automerge: fix(image-generate): allow distinct active image requests

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

Prepared head SHA: 9f19a96427
Review: https://github.com/openclaw/openclaw/pull/83614#issuecomment-4478236891

Co-authored-by: Elarwei <elarweis@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>
2026-05-18 16:01:12 +00:00
Ted Li
fffb8c9e2c fix(lmstudio): resolve env-template API keys (#80568)
Merged via squash.

Prepared head SHA: 03224c8c27
Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
2026-05-18 23:46:10 +08:00
yaoyi1222
023e33cb07 fix(transcript): skip trailing custom entries in tail assistant reader (#83427) (#83635)
Summary:
- The branch updates the transcript tail assistant reader to skip trailing non-message rows, adds cache-ttl gap-fill regression tests, and adds a changelog entry.
- Reproducibility: yes. Source inspection shows cache-ttl custom rows can sit after the canonical assistant me ... r stops on that row; the PR body also supplies a concrete live three-turn CLI reproduction after the patch.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(transcript): skip trailing custom entries in tail assistant reade…

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

Prepared head SHA: 866aa27ca8
Review: https://github.com/openclaw/openclaw/pull/83635#issuecomment-4478637780

Co-authored-by: yaoyi1222 <yaoyi_1222@163.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-05-18 15:43:09 +00:00
Ayaan Zaidi
98256b192b fix(mantis): suppress auto no-proof comments 2026-05-18 21:11:21 +05:30
Jai Govindani
8c2a390fbc fix(cron): link isolated task runs to cron session (#83606)
Summary:
- The PR updates cron timer task-run creation to derive `childSessionKey` for isolated agent-turn jobs from the stable cron session key, adds focused timer coverage, and records the fix in the changelog.
- Reproducibility: yes. Current main's timer task creation copies only `job.sessionKey`, while isolated cron e ... Id>:cron:<jobId>` later; the supplied before-test output matches that source path by receiving `undefined`.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cron): link isolated task runs to cron session

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

Prepared head SHA: 748998b018
Review: https://github.com/openclaw/openclaw/pull/83606#issuecomment-4478039217

Co-authored-by: Jai Govindani <jai.g@ewa-services.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-05-18 15:39:56 +00:00
Ayaan Zaidi
4af590a5f8 docs(changelog): note Telegram transcript mirror fix (#83631) (thanks @kurplunkin) 2026-05-18 20:46:58 +05:30
Tyler Bea
03c303d953 fix(telegram): avoid progress transcript mirrors 2026-05-18 20:46:58 +05:30
Arulprashath
27c7e1e07b Fix sidebar tree collapse not hiding child items (#42223)
Merged via squash.

Prepared head SHA: a6bf8f4511
Co-authored-by: Aroool <90670606+Aroool@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-18 18:08:47 +03:00
Coy Geek
516356835d fix: Admin HTTP RPC can execute against another live gateway instance (#83487)
* fix(ar-gdn-cross-gateway-admin-rpc-context-confusion): apply security fix

Generated by staged fix workflow.

* fix(ar-gdn-cross-gateway-admin-rpc-context-confusion): apply security fix

Generated by staged fix workflow.

* fix(gateway): bind plugin HTTP dispatch to server context

* fix(gateway): scope dynamic plugin HTTP routes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-18 15:59:28 +01:00
Peter Steinberger
cce00498cd fix(doctor): preserve legacy Claude CLI runtime intent 2026-05-18 15:58:55 +01:00
Peter Steinberger
ae29d14abf test: speed up slow test fixtures 2026-05-18 15:55:40 +01:00
Peter Steinberger
13deea2a9d fix(macos): normalize settings pane margins 2026-05-18 15:37:36 +01:00
Krzysztof Probola
1912be8619 fix(codex): complete dynamic tool diagnostics
fix(codex): complete dynamic tool diagnostics

Co-authored-by: 0x505badc0de <32790662+rozmiarD@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-18 15:37:24 +01:00
Peter Steinberger
c49d909b60 fix(slack): persist inbound delivery dedupe 2026-05-18 15:28:07 +01:00
Peter Steinberger
f0b43bfd34 fix(ci): restore release e2e checks 2026-05-18 15:20:27 +01:00
yetval
1b82c0e3d9 fix(followup,reply): stop model-fallback retries duplicating session entries
Follow-up and main reply paths re-entered each embedded fallback candidate
with the same queued transcript prompt. After the first candidate persisted
that queued user message, later candidates appended it again. Failed
embedded candidates could also persist an assistant error stub on each
retry, leaving same-role transcript runs that downstream providers reject.

The fallback callers now keep two persistence latches for one fallback run:
queuedUserMessagePersistedAcrossFallback flips from onUserMessagePersisted,
and assistantErrorPersistedAcrossFallback flips only after the session guard
actually persists an assistant stopReason="error" message. Later candidates
suppress only the entries that were already written, so CLI or otherwise
non-persisting failures do not hide the first embedded error separator.

Plumb the assistant-error persistence callback through the embedded runner,
attempt params, and session guard wrapper. Add guard and runner regression
tests for all-embedded fallback retries and CLI-to-embedded fallback.

Closes #83404
2026-05-18 15:01:46 +01:00
Peter Steinberger
4f4d108639 chore(lint): remove underscore-dangle allow list (#83542)
* chore(lint): reduce underscore-dangle exceptions

* chore(lint): reduce more underscore exceptions

* chore(lint): remove underscore-dangle allow list

* fix(lint): repair underscore cleanup regressions

* test(lint): track version define suppression
2026-05-18 14:56:06 +01:00
jasonyliu
5613f5fd05 fix(gateway): clear CLI bindings on session reset
Clear stale CLI provider resume bindings when a normal gateway session is reset, while preserving spawned subagent bindings.

Also isolate target normalization in the outbound source-delivery unit test so the CI shard does not load provider/plugin runtime state for a pure matcher case.

Co-authored-by: psyphix-claw <262498103+psyphix-claw@users.noreply.github.com>
2026-05-18 14:51:05 +01:00
LLagoon3
35cd2af159 Expose reload kind in config schema lookup (#81612)
Merged via squash.

Prepared head SHA: 9517cfa718
Co-authored-by: LLagoon3 <115124830+LLagoon3@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-18 16:39:12 +03:00
Yao
6a5a1353c7 fix(agents): skip fallback for session coordination errors
Preserve provider fallback metadata when session coordination errors are nested under provider failures.

Co-authored-by: luyao618 <364939526@qq.com>
2026-05-18 14:30:58 +01:00
Peter Steinberger
220d3ec26f docs: clarify formatter-friendly code shape 2026-05-18 14:26:00 +01:00
LLagoon3
bf95f762b5 fix(gateway): rotate failed sessions with missing transcripts 2026-05-18 14:19:40 +01:00
tanshanshan
40a5942091 fix(memory): keep qmd archived session hits visible
Keep QMD-exported archived session transcript hits visible by resolving QMD `.md` archive stems back to their live session ids before applying session visibility policy. Preserve normal markdown session ids that only resemble archive names, reject ambiguous slug fallback matches, and keep deleted same-agent QMD archives readable when the live store entry is gone.

Fixes #83506.

Co-authored-by: tanshanshan <tanshanshan@users.noreply.github.com>
2026-05-18 14:15:30 +01:00
Nimrod Gutman
b823a5a266 fix(ios): improve live activity lifecycle (#83597)
Merged via squash.

Prepared head SHA: 6bd991dafb
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-05-18 16:11:54 +03:00
Nimrod Gutman
29f39db857 fix(whatsapp): lower upload-file media sends (#81883)
Merged via squash.

Prepared head SHA: 3b2ae9c80d
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-05-18 16:09:24 +03:00
Ayaan Zaidi
651ec2027d fix(android): isolate timed out permission requests 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
4f5e817782 fix(android): escape call log like filters 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
d204ec0cc9 style(android): fix voice ktlint formatting 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
4712931e71 fix(android): filter unsafe markdown links 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
ce039eb103 fix(android): bound inline chat image payloads 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
d43f2f73f7 fix(android): reject unsupported gateway schemes 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
db2858cec7 fix(android): shorten talk mode final wait 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
ae25afdb62 fix(android): reset wake command dedupe per cycle 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
022a422755 fix(android): try final scaled jpeg size 2026-05-18 18:32:58 +05:30
Ayaan Zaidi
f1f92b8656 fix(android): restart gateway session on reconnect 2026-05-18 18:32:57 +05:30
Ayaan Zaidi
5fb9c0c937 fix(mantis): crop telegram proof chat pane 2026-05-18 18:30:36 +05:30
Peter Steinberger
880b39f061 refactor(messages): clarify Codex source delivery defaults (#83602) 2026-05-18 13:59:05 +01:00
Peter Steinberger
d29f77bece docs(agents): prefer cleaner code shape 2026-05-18 13:51:21 +01:00
Peter Steinberger
c32878d1b7 fix(messages): keep Codex source replies tool-gated 2026-05-18 13:51:21 +01:00
Peter Steinberger
4b35003051 fix(messages): keep Codex direct replies automatic 2026-05-18 13:51:21 +01:00
Peter Steinberger
0ed24da686 test: update gateway config write expectation 2026-05-18 13:47:49 +01:00
Peter Steinberger
e973aa278f test: add codex media path docker e2e 2026-05-18 13:45:35 +01:00
Peter Steinberger
2bb448908d fix: keep config writes independent of auth profile refs 2026-05-18 13:34:49 +01:00
Ayaan Zaidi
125ebd0987 fix(mantis): load telegram credential validator 2026-05-18 18:01:03 +05:30
Nimrod Gutman
a7ab09fa4e fix(gateway): allow mobile OS metadata refresh (#83490)
Merged via squash.

Prepared head SHA: 5fae3757e9
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
2026-05-18 15:23:55 +03:00
Peter Steinberger
384ddae86f fix(codex): keep dynamic tools available in code mode (#83583) 2026-05-18 13:13:30 +01:00
Peter Steinberger
508945965a docs: record ci snapshot closeout notes 2026-05-18 13:08:51 +01:00
Peter Steinberger
67f8683ca3 fix: reduce strict-agentic activation logging 2026-05-18 13:07:41 +01:00
Ayaan Zaidi
2fa86c6a42 fix(mantis): point telegram proof skill at workflow command 2026-05-18 17:36:12 +05:30
Peter Steinberger
86885f31c1 docs(changelog): note Discord subagent thread fix 2026-05-18 13:02:39 +01:00
Craig
70c326f2be test(discord): accept delivery origin on spawn 2026-05-18 13:02:39 +01:00
Craig
61d583d59d fix(discord): return subagent thread delivery origin 2026-05-18 13:02:39 +01:00
Eva
2a0350b5b4 Separate prompt surfaces by selected harness (#83454)
* fix: scope agent prompt surfaces

* fix(codex): preserve lightweight project doc suppression

* fix(codex): demote openclaw context for native turns

* fix(codex): report demoted prompt context

* fix(codex): align demoted prompt observability

* docs: format codex runtime table

* docs: align codex prompt overlay docs

* test: align codex prompt snapshots

* test: update prompt snapshot contract

---------

Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-18 13:00:53 +01:00
Peter Steinberger
3132969c68 fix: fall back from official ClawHub artifact blocks (#83566)
* fix: fall back from official ClawHub artifact blocks

* test: refresh codex prompt snapshots

* test: refresh code mode prompt snapshots

* test: refresh linux prompt snapshots
2026-05-18 13:00:05 +01:00
Peter Steinberger
d1fa0f9628 fix(macos): keep settings sidebar visible 2026-05-18 12:53:18 +01:00
Peter Steinberger
edd97365f2 docs: add Discord realtime voice changelog (#80505) 2026-05-18 12:50:16 +01:00
Colin
6e80294079 Fix Discord realtime voice playback stability 2026-05-18 12:50:16 +01:00
Ayaan Zaidi
1f01ab3a30 chore(release): bump Android version to 2026.5.18 2026-05-18 17:13:09 +05:30
Peter Steinberger
a2d67959d7 fix(telegram): avoid reply fence spread allocation 2026-05-18 12:41:42 +01:00
Peter Steinberger
83b525bc1f fix(telegram): keep diagnostics on turn lane 2026-05-18 12:41:42 +01:00
Peter Steinberger
25aa72edbd fix(telegram): stop noninterrupting reply fences 2026-05-18 12:41:42 +01:00
Peter Steinberger
1ba9f5ded3 fix(telegram): isolate noninterrupting reply fences 2026-05-18 12:41:42 +01:00
Peter Steinberger
3bf518e518 fix(telegram): keep trajectory exports on turn lane 2026-05-18 12:41:42 +01:00
Peter Steinberger
e3d802a10b fix(telegram): harden spool timeout recovery 2026-05-18 12:41:42 +01:00
Peter Steinberger
9fa8b86891 fix: harden image metadata fallback (#83579) 2026-05-18 12:35:26 +01:00
Kaspre
fd8877b5fd fix(code-mode): honor agent scoped code mode
Fixes #83388.

- Honor per-agent `tools.codeMode` in config schema, runtime code-mode resolution, and model payload filtering.
- Preserve grouped OpenAI tool declarations when code-mode filtering keeps only `exec` and `wait`.
- Sync generated config/prompt baselines and carry a narrow media CI unblocker from current `main` fallout.

Co-authored-by: Kaspre <kaspre@gmail.com>
2026-05-18 12:26:46 +01:00
Lior Balmas
81f20d8464 feat(admin-http-rpc): allow web QR login methods (#83259)
* feat(admin-http-rpc): allow web QR login methods

* docs(changelog): note admin HTTP RPC web login methods

* test(codex): refresh prompt snapshots for code mode config

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-18 12:24:27 +01:00
Peter Steinberger
57c952f679 fix: add resilient media processing fallbacks (#83568) 2026-05-18 11:59:12 +01:00
Ayaan Zaidi
53d14d0561 fix: route Telegram topic media completions (#83556) (thanks @fuller-stack-dev) 2026-05-18 16:22:01 +05:30
fuller-stack-dev
ff47c51608 fix: route Telegram topic media completions 2026-05-18 16:22:01 +05:30
Ayaan Zaidi
6940a01e74 feat(android): show realtime talk transcripts 2026-05-18 16:19:33 +05:30
Ayaan Zaidi
55f4b66a52 fix(android): stream realtime mic continuously 2026-05-18 16:19:33 +05:30
Ayaan Zaidi
c86b89a5fe test(android): cover turn mic transcription lifecycle 2026-05-18 16:19:33 +05:30
Ayaan Zaidi
22d98b4d52 fix(android): stream turn mic through gateway transcription 2026-05-18 16:19:33 +05:30
Ayaan Zaidi
172eadbb6f fix(gateway): align transcription relay audio contract 2026-05-18 16:19:33 +05:30
Ayaan Zaidi
ade3a6a3ad fix(android): gate realtime talk responses 2026-05-18 16:19:33 +05:30
Ayaan Zaidi
3a2502de92 fix(android): surface voice recognition failures 2026-05-18 16:19:33 +05:30
Ayaan Zaidi
5c01418442 fix(android): connect operator session without gateway auth 2026-05-18 16:19:33 +05:30
Ayaan Zaidi
ea3749872a fix(android): make realtime talk mode opt-in 2026-05-18 16:19:33 +05:30
Steven Liekens
0c125be717 fix(android): use realtime relay for talk mode 2026-05-18 16:19:33 +05:30
Peter Steinberger
419eea2462 fix(codex): stop forcing code-mode-only turns (#83561) 2026-05-18 11:39:31 +01:00
joshavant
9536d66a35 add changelog for empty cli response fix 2026-05-18 11:33:29 +01:00
joshavant
76ce72cbe5 fix empty cli response handling 2026-05-18 11:33:29 +01:00
Peter Steinberger
3a5627d911 fix(macos): avoid duplicate channel config heading 2026-05-18 11:16:55 +01:00
Peter Steinberger
253b24445e docs: update changelog for gateway update check (#83520) 2026-05-18 11:16:02 +01:00
samzong
5e33bb6458 fix(gateway): defer update check startup 2026-05-18 11:16:02 +01:00
Vincent Koc
d831b8e7bd chore: dedupe non-interactive setup prompters 2026-05-18 18:02:56 +08:00
Peter Steinberger
8e9d5c43d2 fix(messages): apply tts before message-tool sends (#83543) 2026-05-18 10:51:46 +01:00
Peter Steinberger
957d50ad49 fix: hide unsupported video audio refs 2026-05-18 10:48:39 +01:00
Sliverp
e6e1696c28 fix(qqbot): shorten typing keepalive window (#83469)
* fix(qqbot): shorten typing keepalive window

* docs: note qqbot typing keepalive fix

* fix(qqbot): shorten typing keepalive window

---------

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-05-18 17:48:02 +08:00
Peter Steinberger
6c6bc7fff5 ci: update performance artifact action 2026-05-18 10:46:26 +01:00
Peter Steinberger
018a6db132 ci: use node24 artifact action tags 2026-05-18 10:45:59 +01:00
Peter Steinberger
8bfdffad32 fix: keep autoreview pnpm checks local 2026-05-18 10:43:30 +01:00
Peter Steinberger
f14a0ed6cf fix: run autoreview checks from repo root 2026-05-18 10:43:30 +01:00
Peter Steinberger
b4cf06b0d7 fix: preserve autoreview prompt review status 2026-05-18 10:43:30 +01:00
Peter Steinberger
3015eeca94 fix: preserve autoreview maintainer policy 2026-05-18 10:43:30 +01:00
Peter Steinberger
53773ccee4 fix: keep autoreview checks offline 2026-05-18 10:43:30 +01:00
Peter Steinberger
f85534fc52 chore: auto-run offline package checks in autoreview 2026-05-18 10:43:30 +01:00
Peter Steinberger
0f4eccefd4 ci: use node24 artifact actions 2026-05-18 10:39:31 +01:00
Peter Steinberger
9eda9d1114 docs: clarify autoreview base prompt fallback 2026-05-18 10:34:36 +01:00
Vincent Koc
25a4a620d1 chore: dedupe legacy OAuth sidecar helpers 2026-05-18 17:33:40 +08:00
Peter Steinberger
4e3e659a20 fix: echo xai oauth pkce token fields (#83499)
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-18 10:28:44 +01:00
Ayaan Zaidi
826eb7c552 fix: harden release stability recovery (#83503) (thanks @100yenadmin) 2026-05-18 14:58:10 +05:30
Eva (agent)
08ecc518ec fix: close adversarial release stability gaps 2026-05-18 14:58:10 +05:30
Eva (agent)
54f87184f0 fix: recover stale release-stability stalls and auth loops 2026-05-18 14:58:10 +05:30
Ayaan Zaidi
6062f90d8b refactor(models): simplify codex auth route handling 2026-05-18 14:58:10 +05:30
Eva (agent)
a792068d9d fix: harden release stability diagnostics 2026-05-18 14:58:10 +05:30
tanshanshan
a51ee5b02d chore(lint): enable no-underscore-dangle 2026-05-18 10:26:24 +01:00
Peter Steinberger
8725364cf0 fix(codex): hydrate queued inbound images 2026-05-18 10:16:37 +01:00
Jason (Json)
3553aa3763 fix(tui): bound standalone exit
Fix standalone TUI shutdown so `/exit` cannot leave onboarding-launched TUI child processes alive after `runTui` returns.

Adds the maintainer changelog entry and keeps embedded `runTui` callers unaffected by making the post-return process-exit guard CLI-only.

Verification:
- `git diff --check origin/main...HEAD`
- `node scripts/run-vitest.mjs src/tui/tui.test.ts src/tui/tui-command-handlers.test.ts src/cli/program.smoke.test.ts src/tui/tui-launch.test.ts`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/tui/tui.test.ts src/tui/tui-command-handlers.test.ts src/cli/program.smoke.test.ts src/tui/tui-launch.test.ts"`
- GitHub exact-head checks passed for `2413f48fc89b4deb9c1923ff63085c3cc2a69522`: CI `26023555738`, CodeQL `26023555736`, CodeQL Critical Quality `26023555785`, Real behavior proof `26023571192`, OpenGrep PR Diff `26023555745`, Workflow Sanity `26023555925`.
- `checks-node-core-fast` initially timed out in unrelated `src/infra/outbound/source-delivery-plan.test.ts`; rerunning failed jobs passed on the same head SHA.
2026-05-18 17:07:32 +08:00
Peter Steinberger
4b4f71a2cc fix(ui): polish reasoning labels and settings margins 2026-05-18 10:05:23 +01:00
Peter Steinberger
1e5450f23e fix(messages): keep group visible replies automatic by default (#83498)
* fix(messages): keep group visible replies automatic by default

* fix(messages): keep unauthorized slash turns quiet

* fix(messages): return boolean from slash guard

* test(messages): narrow visible reply fixtures

* test(messages): align completion delivery default
2026-05-18 09:48:58 +01:00
Josh Avant
5a7d31108e Load provider owner for Codex harness runtime (#83519)
* fix: load codex provider owner for harness runtime

* docs: add changelog for codex harness provider owner

* test: update codex harness owner expectation
2026-05-18 03:36:07 -05:00
Josh Avant
491ce8b753 fix(native-pi): pass Telegram images to Ollama (#83516)
* fix(native-pi): pass Telegram images to Ollama

* chore(changelog): note Telegram Ollama image fix
2026-05-18 03:22:10 -05:00
Vincent Koc
3a58621e72 fix(qa): use supported telegram streaming config in rtt 2026-05-18 16:22:07 +08:00
Vincent Koc
ac1b48efbc fix(macos): satisfy channel config swiftformat 2026-05-18 16:20:27 +08:00
Peter Steinberger
46bad8676c fix(macos): polish settings channel config 2026-05-18 09:16:36 +01:00
Peter Steinberger
bd69510662 feat(macos): add Dock menu shortcuts 2026-05-18 09:16:35 +01:00
Vincent Koc
4a1745281e fix(qa): decode OTLP smoke traces without generated internals 2026-05-18 16:06:43 +08:00
Vincent Koc
856a1692ff fix(qa): use final telegram replies for rtt runs 2026-05-18 16:06:10 +08:00
Josh Avant
b7735f88fa fix(telegram): recover stalled isolated spool handlers (#83505)
* fix(telegram): recover stalled isolated spool handlers

* chore(changelog): note telegram spool recovery fix

* test(telegram): satisfy spool timeout lint
2026-05-18 03:03:12 -05:00
Peter Steinberger
adc37670e8 fix(codex): preserve sandbox egress for app-server turns
Fixes #83347.
2026-05-18 09:00:51 +01:00
Peter Steinberger
3c36ea0dd7 docs: clarify lean refactor guidance 2026-05-18 08:53:27 +01:00
Peter Steinberger
4c613fbfe0 refactor(cron): centralize source delivery plan 2026-05-18 08:31:59 +01:00
Vincent Koc
81b9058cc3 chore(autoreview): scope Testbox policy to maintainers 2026-05-18 15:27:06 +08:00
computment
a9407d2f65 [codex] Fix Discord progress mode dropping final replies (#83443)
* fix(discord): deliver finals in progress mode

* docs(changelog): note Discord progress final delivery fix

---------

Co-authored-by: compoodment <compoodment@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-18 08:24:02 +01:00
Peter Steinberger
9e00234d2d ci: split cron runtime shard 2026-05-18 08:21:35 +01:00
Vincent Koc
3b5d30b5fd chore(autoreview): route OpenClaw validation to Testbox 2026-05-18 15:14:28 +08:00
samzong
27adbf9a1f [Test] Add gateway restart benchmark tooling (#83299)
* test(gateway): add repeated restart benchmark

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

* test(gateway): harden restart benchmark probes

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

* fix(gateway): count restart benchmark sample failures

* fix(gateway): harden restart benchmark portability

* fix(gateway): tighten restart benchmark attribution

* fix(gateway): preserve restart benchmark partial logs

* fix(gateway): start restart probes before sampling

* fix(gateway): avoid blocking restart probe sampling

* fix(gateway): keep missed restart outage nonfatal

---------

Signed-off-by: samzong <samzong.lu@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-18 08:13:31 +01:00
Peter Steinberger
e0bb46b93a docs: note gateway startup overlap 2026-05-18 08:03:39 +01:00
samzong
1ed4f747e9 fix(gateway): preserve deferred plugin services handle
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-05-18 08:03:39 +01:00
samzong
e7933c9137 perf(gateway): overlap startup work before ready
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-05-18 08:03:39 +01:00
Peter Steinberger
69aec10852 fix(agents): preserve code mode hook context (#83481) 2026-05-18 08:00:11 +01:00
joshavant
322f0bb7bc docs: add changelog for codex native task recovery 2026-05-18 01:52:28 -05:00
yshimadahrs-ship-it
56024b7828 fix(tasks): recover childless Codex native subagent tasks (#82836)
* fix(tasks): recover childless Codex native subagent tasks

* fix(tasks): harden codex native task recovery

---------

Co-authored-by: y.shimada <y.shimada@waishimadanoMac-mini.local>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-05-18 01:47:44 -05:00
Firas Alswihry
94c012b2ec test(qa-lab): add personal task followthrough scenario 2026-05-18 14:35:03 +08:00
Vincent Koc
fb70de8046 test(plugin-sdk): align debouncer runtime mock 2026-05-18 14:34:07 +08:00
Ayaan Zaidi
2696f2576d docs(changelog): note preview final delivery fix (#83468) 2026-05-18 11:55:03 +05:30
Ayaan Zaidi
ad4a74c884 test(reply): cover preview final delivery 2026-05-18 11:55:03 +05:30
Ayaan Zaidi
b6fd843288 fix(reply): keep final delivery after previews 2026-05-18 11:55:03 +05:30
Peter Steinberger
b3fc9fe079 fix(update): keep modern deferral metadata current 2026-05-18 07:21:42 +01:00
Peter Steinberger
394037c174 fix(update): defer configured plugin installs for shipped parents 2026-05-18 07:21:42 +01:00
Peter Steinberger
e20de0f603 fix: keep crabbox wrapper flags current 2026-05-18 07:21:42 +01:00
Peter Steinberger
2976517bc7 fix(plugins): gate onboarding ClawHub npm fallback 2026-05-18 07:21:42 +01:00
Peter Steinberger
00205cab08 fix(plugins): restrict ClawHub npm fallback scope 2026-05-18 07:21:42 +01:00
Peter Steinberger
db8de0db7a fix(update): preserve managed package manager roots 2026-05-18 07:21:42 +01:00
Peter Steinberger
aec0c56386 fix(update): harden legacy package handoff 2026-05-18 07:21:42 +01:00
Peter Steinberger
b278098a7c build: update proxyline dependency 2026-05-18 07:18:34 +01:00
Vincent Koc
29664863a5 fix(qa): stream mock response text deltas 2026-05-18 14:16:27 +08:00
Vincent Koc
61d9a6d750 fix(browser): preserve bridge diagnostic edge cases 2026-05-18 14:15:15 +08:00
Vincent Koc
ce62516251 fix(browser): tighten bridge diagnostics 2026-05-18 14:15:15 +08:00
scoootscooob
5a7b861ea2 fix(config): keep unrelated plugin diagnostics nonfatal (#83438)
* fix(config): keep unrelated plugin diagnostics nonfatal

* docs(changelog): mention config plugin validation fix
2026-05-17 23:11:15 -07:00
Peter Steinberger
e96428b008 fix(config): share subagent model schema (#83339)
* fix(config): share subagent model schema

* fix(config): remove subagent model timeout surface

* fix(config): migrate ignored agent model timeouts

* test(config): fix doctor migration lint

* test(extensions): remove retired model timeout fixture

* fix(config): collect default subagent pricing refs
2026-05-18 07:07:52 +01:00
Peter Steinberger
102e4f2c9d fix: replay Xiaomi Anthropic reasoning blocks 2026-05-18 06:53:17 +01:00
Peter Steinberger
476bd35431 fix: correct restart trace timer metrics (#83300) 2026-05-18 06:51:44 +01:00
Peter Steinberger
be17d55a5e docs: add changelog for ACPX restart trace attribution (#83300) (thanks @samzong) 2026-05-18 06:51:44 +01:00
samzong
51d44ab1fc fix: scope plugin service startup traces
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-05-18 06:51:44 +01:00
samzong
e292d3976a feat(gateway): attribute ACPX startup probe cost
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-05-18 06:51:44 +01:00
Jason (Json)
f79d842029 fix(codex): keep OpenClaw session spawn searchable
Keep OpenClaw session spawning searchable in Codex mode while steering Codex-native delegation through native subagents.

Verification:
- pnpm docs:list
- git diff --check
- pnpm prompt:snapshots:gen
- pnpm prompt:snapshots:check
- node scripts/run-vitest.mjs extensions/codex/src/app-server/dynamic-tools.test.ts extensions/codex/src/app-server/run-attempt.test.ts extensions/codex/src/app-server/thread-lifecycle.test.ts -t "turn-yield|searchable OpenClaw dynamic tools|Codex-native subagents primary"
- .agents/skills/autoreview/scripts/autoreview --mode local
- GitHub checks on d9237f7294: 69 success, 19 skipped, 1 neutral; merge state CLEAN

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-18 06:49:05 +01:00
Galin Iliev
74949eda2f fix(telegram): redact raw update logs (#82945)
* fix: redact telegram raw update logs

* fix telegram raw update log redaction

* add changelog for telegram raw update redaction

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-05-18 00:46:49 -05:00
Patrick Erichsen
9d4500f3ac test: cover gateway exec approval runtime flow (#83452)
* test: cover gateway exec approval runtime flow

* fix: satisfy exec approval e2e test types
2026-05-17 22:42:04 -07:00
Vincent Koc
3782294e92 docs(perf): record green rtt gate 2026-05-18 13:41:35 +08:00
Vincent Koc
3809ff4f2a docs(perf): record gateway rss import 2026-05-18 13:41:35 +08:00
Vincent Koc
c946ced9d5 docs(perf): link rtt importer follow-up 2026-05-18 13:41:35 +08:00
Vincent Koc
dd4790130e docs(perf): record changed-gate blocker 2026-05-18 13:41:35 +08:00
Vincent Koc
532a6a7a89 test(qa): add gateway heap checkpoints 2026-05-18 13:41:35 +08:00
Vincent Koc
c9b9fffc40 docs(perf): record rtt regression audit 2026-05-18 13:41:35 +08:00
Vincent Koc
2bf5e5f20d fix(qa): report discord scenario rtt 2026-05-18 13:41:35 +08:00
Vincent Koc
20ec5cdc42 test(qa): trace gateway rss in suite summaries 2026-05-18 13:41:35 +08:00
Vincent Koc
fc16df30dd fix(qa): preserve redacted discord rtt timestamps 2026-05-18 13:41:35 +08:00
Said Urtabajev
47b8e56e3f feat(docker): add image apt package build arg
feat(docker): add image apt package build arg

Add OPENCLAW_IMAGE_APT_PACKAGES as the preferred runtime-neutral image build arg for Docker and Podman apt package installs while keeping OPENCLAW_DOCKER_APT_PACKAGES as the legacy fallback.

Maintainer verification:
- pnpm docs:list
- node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts src/docker-setup.e2e.test.ts
- node scripts/run-vitest.mjs src/dockerfile.test.ts test/scripts/test-install-sh-docker.test.ts
- node scripts/run-vitest.mjs run --config test/vitest/vitest.cron.config.ts src/cron/isolated-agent.model-overrides.test.ts
- pnpm exec oxfmt --check --threads=1 docs/install/docker.md docs/install/podman.md scripts/clawdock/README.md docs/help/faq.md CHANGELOG.md
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode local
- .agents/skills/autoreview/scripts/autoreview --mode branch
- pnpm check:changed via Blacksmith Testbox tbx_01krwqmfhcdekaczvrkxnb7t59, Actions run 26014630478, exit 0

Known CI note: checks-node-core-runtime-shared timed out repeatedly in unrelated src/cron/isolated-agent.model-overrides.test.ts on GitHub Actions; the same test passes locally after this rebase.

Co-authored-by: Said Urtabajev <said@bumpclub.ee>
2026-05-18 06:37:16 +01:00
Vincent Koc
57bc26893e test(cron): force PI for timeout override assertions 2026-05-18 13:33:07 +08:00
Peter Steinberger
eca402da79 ci: consolidate short CI shards 2026-05-18 06:29:14 +01:00
Peter Steinberger
e453a39d6b build: align node version floor 2026-05-18 06:28:14 +01:00
Peter Steinberger
f7196e3b53 build: update pi dependencies to 0.75.1 2026-05-18 06:22:36 +01:00
Vincent Koc
9d5db92cda test(cron): type OpenAI PI override config 2026-05-18 13:22:23 +08:00
Vincent Koc
939712fbbf test(cron): keep OpenAI override assertions on PI 2026-05-18 13:12:01 +08:00
Peter Steinberger
55ca2df62a fix: polish Mac settings layout 2026-05-18 06:11:14 +01:00
Jesse Merhi
198f20fd20 Fix approval runtime gateway calls (#83433)
* fix approval runtime gateway calls

* docs: credit approval runtime fix contributors

* docs: include maintainer changelog credit
2026-05-18 15:10:15 +10:00
Vincent Koc
e3d5518838 test(agents): cover Codex preflight plugin load 2026-05-18 12:55:02 +08:00
Ayaan Zaidi
46f27b6e07 docs(xai): clarify oauth account eligibility 2026-05-18 04:53:57 +00:00
Vincent Koc
8f27b3e21f fix(agents): fail closed on missing Codex harness 2026-05-18 12:47:51 +08:00
Ayaan Zaidi
cd15ce35a0 fix(qa): keep telegram user creds mantis-only 2026-05-18 10:04:58 +05:30
Josh Avant
395bd578d2 Fix Telegram hot reload polling restarts (#83410)
* fix(telegram): preserve hot reload polling restarts

* docs: add changelog for telegram hot reload fix
2026-05-17 23:24:04 -05:00
1649 changed files with 45393 additions and 9339 deletions

View File

@@ -28,6 +28,7 @@ Use when:
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
- Do not push just to review. Push only when the user requested push/ship/PR update.
- For OpenClaw maintainers, keep autoreview validation Crabbox/Testbox-aware when maintainer validation mode is enabled (`OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`). A review pass may inspect files and run cheap non-Node probes, but it must not start local `pnpm`, Vitest, `tsgo`, `npm test`, or `node scripts/run-vitest.mjs` from a Codex/worktree review unless the operator explicitly requested local proof. For runtime proof, use existing evidence or route through Crabbox/Testbox and report the id. Do not apply this rule to ordinary contributors who do not have maintainer Testbox access.
## Pick Target
@@ -50,7 +51,11 @@ git fetch origin
codex review --base origin/main
```
Do not pass an inline prompt with `--base`; current CLI rejects `--base` + `[PROMPT]` even though help text is ambiguous. If custom instructions are needed, run the plain base review first, then do a local/manual follow-up pass.
Do not pass any prompt with `--base`. Some Codex CLI versions reject both inline
and stdin prompt forms, including the helper's `codex review --base <ref> -`,
with `--base <BRANCH> cannot be used with [PROMPT]`. If the helper hits this
error, run plain `codex review --base <ref>` and report that the helper prompt
injection was skipped.
If an open PR exists, use its actual base:
@@ -107,6 +112,7 @@ The helper:
- chooses dirty `--uncommitted` first
- otherwise uses current PR base if `gh pr view` works
- otherwise uses `origin/main` for non-main branches
- auto-runs `PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check` in parallel when a repo has `package.json`, `pnpm-lock.yaml`, `node_modules`, and a `check` script; disable with `AUTOREVIEW_AUTO_TESTS=0`
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
@@ -115,6 +121,8 @@ The helper:
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
- supports `--dry-run`, `--parallel-tests`, and commit refs
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
- injects maintainer-only OpenClaw validation policy into native Codex review when `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, so local memory-heavy Node/Vitest checks are avoided in favor of Crabbox/Testbox proof
- branch mode may fail on Codex CLI versions that reject `--base` plus the helper's stdin prompt; on that exact parser error, rerun plain `codex review --base <ref>` instead of falling back to a non-Codex reviewer
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
@@ -128,3 +136,10 @@ Include:
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
## PR / CI Closeout
- Prefer direct run/job APIs after CI starts: `gh run view <run-id> --json jobs`; use PR rollup only for final mergeability.
- After rebase, compare `origin/main..HEAD`; drop CI-fix commits already upstream before pushing.
- For prompt snapshot CI failures, prove/generate with Linux Node 24 before rerunning the failed job.
- Update PR body once near the final head unless proof labels are missing or stale enough to block CI.

View File

@@ -24,9 +24,14 @@ Options:
--no-yolo Run nested Codex review with normal sandbox/approval prompts.
--output FILE Also save output to file.
--parallel-tests CMD Run review and test command concurrently.
Default: PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check when available.
--dry-run Print selected commands, do not run.
-h, --help Show help.
Environment:
OPENCLAW_TESTBOX=1 or AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1
Enable maintainer-only OpenClaw Crabbox/Testbox validation policy.
Modes:
local codex review --uncommitted
branch codex review --base <base>
@@ -50,7 +55,12 @@ codex_args=()
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
parallel_tests=
parallel_tests_auto=false
dry_run=false
codex_review_prompt=
codex_review_stdin_prompt=false
codex_review_prompt_file=false
openclaw_maintainer_validation=${AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION:-${OPENCLAW_TESTBOX:-0}}
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -159,6 +169,21 @@ case "$fallback_reviewer" in
esac
repo_root=$(git rev-parse --show-toplevel)
printf -v quoted_repo_root '%q' "$repo_root"
has_package_check_script() {
command -v node >/dev/null 2>&1 || return 1
node -e 'const { readFileSync } = require("node:fs"); const p = JSON.parse(readFileSync(process.argv[1], "utf8")); process.exit(p.scripts?.check ? 0 : 1)' \
"$repo_root/package.json" \
>/dev/null 2>&1
}
auto_tests_disabled() {
case "${AUTOREVIEW_AUTO_TESTS:-${CODEX_REVIEW_AUTO_TESTS:-1}}" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) return 0 ;;
*) return 1 ;;
esac
}
current_branch=$(git branch --show-current 2>/dev/null || true)
dirty=false
@@ -201,6 +226,38 @@ else
review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref")
fi
repo_url=$(git -C "$repo_root" config --get remote.origin.url 2>/dev/null || true)
case "$openclaw_maintainer_validation" in
1|true|True|TRUE|yes|Yes|YES|on|On|ON) openclaw_maintainer_validation=1 ;;
*) openclaw_maintainer_validation=0 ;;
esac
if [[ -z "$parallel_tests" && "$openclaw_maintainer_validation" != 1 ]] &&
! auto_tests_disabled; then
if [[ -f "$repo_root/package.json" && -f "$repo_root/pnpm-lock.yaml" && -d "$repo_root/node_modules" ]] &&
command -v pnpm >/dev/null 2>&1 &&
has_package_check_script; then
parallel_tests="cd $quoted_repo_root && PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check"
parallel_tests_auto=true
fi
fi
if [[ "$repo_url" == *"openclaw/openclaw"* && "$openclaw_maintainer_validation" == 1 ]]; then
codex_review_prompt=$(cat <<'EOF'
OpenClaw maintainer autoreview validation policy:
- Review the diff by reading code, tests, and dependency contracts.
- Do not run local memory-heavy Node validation from review mode. This includes local pnpm checks/tests, Vitest, tsgo, npm test, and node scripts/run-vitest.mjs.
- If runtime proof is needed, use existing proof or route validation through Crabbox / Blacksmith Testbox and report the exact provider and id.
- If remote validation is not necessary for the finding, state the targeted proof that should be run instead of starting local tests.
EOF
)
if [[ "$review_kind" == local ]]; then
review_cmd+=(-)
codex_review_stdin_prompt=true
else
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
codex_review_prompt_file=true
fi
fi
printf 'autoreview target: %s\n' "$review_kind"
printf 'branch: %s\n' "${current_branch:-detached}"
if [[ -n "$pr_url" ]]; then
@@ -221,11 +278,18 @@ if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
printf 'review:'
printf ' %q' "${review_cmd[@]}"
printf '\n'
if [[ "$codex_review_stdin_prompt" == true || "$codex_review_prompt_file" == true ]]; then
printf 'review policy: OpenClaw maintainer Crabbox/Testbox-aware validation prompt injected\n'
fi
else
printf 'review: %s prompt review\n' "$reviewer"
fi
if [[ -n "$parallel_tests" ]]; then
printf 'tests: %s\n' "$parallel_tests"
printf 'tests: %s' "$parallel_tests"
if [[ "$parallel_tests_auto" == true ]]; then
printf ' (auto)'
fi
printf '\n'
fi
if [[ "$review_kind" == branch ]]; then
printf 'fetch: git fetch origin --quiet\n'
@@ -264,8 +328,20 @@ cleanup() {
trap cleanup EXIT
run_review() {
local status=0
mkdir -p "$(dirname "$review_output")"
"${review_cmd[@]}" 2>&1 | tee "$review_output"
if [[ "$codex_review_prompt_file" == true ]]; then
build_prompt_file || return
"${review_cmd[@]}" < "$prompt_file" 2>&1 | tee "$review_output"
status=${PIPESTATUS[0]}
rm -f "$prompt_file"
prompt_file=
return "$status"
elif [[ "$codex_review_stdin_prompt" == true ]]; then
printf '%s\n' "$codex_review_prompt" | "${review_cmd[@]}" 2>&1 | tee "$review_output"
else
"${review_cmd[@]}" 2>&1 | tee "$review_output"
fi
}
diff_for_review() {
@@ -306,6 +382,7 @@ Rules:
- Review the proposed code change as a closeout reviewer.
- Focus on the diff below. If your CLI exposes read-only repository tools, inspect surrounding code and tests to verify findings; never modify files.
- Do not modify files.
${codex_review_prompt}
- Report only discrete, actionable issues introduced by this change.
- Prioritize correctness, regressions, security, data loss, performance cliffs, and missing tests that would catch a real bug.
- Do not report pre-existing issues, speculative risks, broad rewrites, style nits, changelog gaps, or findings that depend on unstated assumptions.

View File

@@ -0,0 +1,64 @@
---
name: openclaw-docker-e2e-authoring
description: "Author OpenClaw Docker E2E and live provider Docker lanes."
---
# OpenClaw Docker E2E Authoring
Use this when adding or changing Docker E2E lanes, release-path Docker tests,
or live-provider Docker proof.
## Lane Choice
- Deterministic Docker: fake the dependency/server and assert the exact runtime
contract crossing the boundary.
- Live Docker: use real provider credentials/model only when user-visible
behavior needs the real service.
- Prefer both when they prove different risks: deterministic for byte/payload
routing, live for actual provider behavior.
## Authoring Rules
- Test-only helpers live in `test/helpers` or `scripts/e2e/lib/<lane>/`, not
`src/**`, unless production imports them.
- Package-installed app runs from `/app`; mount only explicit harness/helper
paths read-only.
- Fake servers should log boundary requests as JSONL and clients should assert
the real dependency payload, not just process success.
- Add the package script and `scripts/lib/docker-e2e-scenarios.mjs` lane in the
same change.
- If a lane installs a plugin from npm, default the spec via env so published
and local override paths are both testable.
## Media And Vision
- Expected answer must exist only in pixels or provider output being tested.
- Use neutral filenames, neutral prompts, and no metadata leaks.
- Random bitmap/OCR tokens reuse the repo OCR-safe alphabet `24567ACEF` unless
the test owns a stronger glyph set.
- Make the expected answer unique per run when proving real image
understanding.
## `chat.send` E2E
- Require `chat.send` to return `status: "started"` and a string `runId`.
- Wait for completion with `agent.wait`.
- Assert final user-visible text via `chat.history` when event ordering is not
the behavior under test.
- Keep originating channel/account metadata only when the bug path needs queued
inbound/channel context.
## Verification
Run the smallest proof that covers the touched lane:
```bash
pnpm exec oxfmt --write <changed files>
node --check <new .mjs files>
bash -n <new .sh files>
node scripts/run-vitest.mjs test/scripts/docker-e2e-plan.test.ts
OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:<lane>
```
For real-provider lanes, run the matching live Docker script after deterministic
Docker is green. Finish with `$autoreview` before commit/PR.

View File

@@ -0,0 +1,95 @@
---
name: openclaw-mac-release
description: "Run or recover OpenClaw macOS release signing, notarization, appcast, and asset promotion."
---
# OpenClaw Mac Release
Use with `$openclaw-release-maintainer`, `$openclaw-release-ci`, and `$one-password` when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
## Credentials
- Canonical ASC item: vault `Molty`, title `API Key - App Store Connect - Personal - Release`.
- Fields: `private_key_p8`, `key_id`, `issuer_id`.
- Current known good key id: `AKVLXW849T`.
- Legacy mirror: vault `Private`, title `API Key - App Store Connect - Personal`; keep it synced for older refs.
- Stale/revoked key symptom: `xcrun notarytool submit` fails with `HTTP status code: 401. Unauthenticated`.
- Validate candidate ASC credentials with `xcrun notarytool history` before setting GitHub secrets.
## 1Password
- Use `$one-password`: all `op` work inside one persistent tmux session, no secret output.
- Prefer `OP_SERVICE_ACCOUNT_TOKEN` from `~/.profile` for Molty reads.
- Do not assume `MOLTY_OP_SERVICE_ACCOUNT_TOKEN` is alive; it has previously pointed at a deleted service account.
- If a service token fails, run status-only checks: token present/length and `op whoami`; never print token values.
- If desktop app auth is needed but Touch ID is unavailable, set `OP_BIOMETRIC_UNLOCK_ENABLED=false` for the manual `op account add --signin` path.
## GitHub Secrets
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
Set only after local notary auth validation:
- `APP_STORE_CONNECT_API_KEY_P8`
- `APP_STORE_CONNECT_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
Do not update these from mixed sources. All three ASC fields must come from the same 1Password item.
## Workflow Shape
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
## Notarization
- OpenClaw uses `scripts/notarize-mac-artifact.sh`.
- `xcrun notarytool submit` should use `--no-s3-acceleration`; accelerated upload can surface misleading 401s even when `notarytool history` succeeds.
- If signing succeeds but notarization fails immediately with 401, check ASC key freshness first.
- If notarization stays in progress for several minutes after key-file write, that is normal Apple wait time; do not edit blindly.
## Dispatch
Private preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D \
-f preflight_only=true \
-f smoke_test_only=false \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
```
Private validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D
```
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f preflight_only=false \
-f smoke_test_only=false \
-f preflight_run_id=<successful-preflight-run> \
-f validate_run_id=<successful-validation-run> \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
```
## Verify
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.

View File

@@ -27,7 +27,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
use the Crabbox wrapper with the provider that matches the proof surface.
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
@@ -131,6 +131,8 @@ gh run view <run-id> --job <job-id> --log
- Check exact SHA. Ignore newer unrelated `main` unless asked.
- For cancelled same-branch runs, confirm whether a newer run superseded it.
- Fetch full logs only for failed or relevant jobs.
- Prefer `gh run view <run-id> --json jobs` over PR rollup while debugging; rollup can be stale/noisy.
- For `prompt:snapshots:check` failures, treat Linux Node 24 as CI truth. If macOS passes but CI drifts, reproduce in a Linux Node 24 container or Testbox, commit that generated output, then rerun.
## GitHub Release Workflows

View File

@@ -17,7 +17,8 @@ artifact bundle. The runner leases the shared burner account from Convex.
Run from the OpenClaw repo and branch under test:
```bash
pnpm qa:telegram-user:crabbox -- start \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" start \
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
```
@@ -39,7 +40,8 @@ For deterministic visual repros, put the exact mock-model reply in a file and
pass it to `start`:
```bash
pnpm qa:telegram-user:crabbox -- start \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" start \
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
--mock-response-file .artifacts/qa-e2e/telegram-user-crabbox/reply.txt \
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
@@ -55,15 +57,16 @@ For visual proof, first send or identify a bottom marker message, then open the
group/topic directly by message id:
```bash
pnpm qa:telegram-user:crabbox -- view \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" view \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--message-id <message-id>
```
This uses Telegram Desktop directly with `tg://privatepost`, not `xdg-open`.
It also resizes Telegram to `650x1000` at the tested desktop position so
Telegram switches to single-chat mode with no left chat list or right info
pane. Do not press Escape after this; Escape can close the selected chat.
the crop can isolate the chat pane even if Telegram keeps a split/sidebar
layout. Do not press Escape after this; Escape can close the selected chat.
Bottom behavior matters:
@@ -71,13 +74,14 @@ Bottom behavior matters:
later messages appear live in the recording
- deep-linking to an older message does not auto-scroll to new arrivals; link
again to the newest/final marker instead of clicking the down-arrow
- `650px` is the largest tested clean width; `660px` switches Telegram back to
split/sidebar layout
- the cropped GIF intentionally uses the chat pane, not the whole desktop or
whole Telegram window
Send as the real Telegram user:
```bash
pnpm qa:telegram-user:crabbox -- send \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" send \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--text /status
```
@@ -87,7 +91,8 @@ For slash commands, omit the bot username; the runner targets the SUT bot.
Run arbitrary commands on the Crabbox:
```bash
pnpm qa:telegram-user:crabbox -- run \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" run \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
-- bash -lc 'source /tmp/openclaw-telegram-user-crabbox/env.sh && python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py transcript --limit 20 --json'
```
@@ -106,14 +111,16 @@ python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py probe --text '@{sut}
Capture the current desktop without ending the session:
```bash
pnpm qa:telegram-user:crabbox -- screenshot \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" screenshot \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
```
Check lease state and get the WebVNC command:
```bash
pnpm qa:telegram-user:crabbox -- status \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" status \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
```
@@ -122,7 +129,8 @@ pnpm qa:telegram-user:crabbox -- status \
Always finish or explicitly keep the box:
```bash
pnpm qa:telegram-user:crabbox -- finish \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" finish \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--preview-crop telegram-window
```
@@ -150,7 +158,8 @@ Attach only the useful visual artifact to the PR unless logs are needed. The
runner is GIF-only by default:
```bash
pnpm qa:telegram-user:crabbox -- publish \
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" publish \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--pr <pr-number> \
--summary 'Telegram real-user Crabbox session motion GIF'
@@ -189,7 +198,8 @@ experiments unless those artifacts are explicitly needed.
For a fast one-shot check, use:
```bash
pnpm qa:telegram-user:crabbox -- --text /status
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" --text /status
```
This is a start/send/finish shortcut. Prefer the held session for PR review,

View File

@@ -18,6 +18,10 @@ Hard limits:
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
outcome when GIFs would be misleading, but it is not proof that the PR passed.
- Do not skip Telegram-visible PRs just because the proof needs a specific
message, mock response, media attachment, command, button, reaction, stop
timing, approval prompt, or progress/final delivery sequence. First write a
concrete proof plan and try the standard harness path.
- Keep public-facing manifest summaries short and user-domain. Do not mention
harness internals, mock-provider limits, secret/trust boundaries, local paths,
transcript seeding, or workflow implementation details in the summary.
@@ -42,7 +46,15 @@ Required workflow:
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
`gh pr diff "$MANTIS_PR_NUMBER"`.
3. Decide whether the PR has a visibly reproducible Telegram Desktop
before/after. If it does not, write
before/after. Treat these as visible until proven otherwise: message text
formatting/content, progress drafts, native drafts, final delivery, media or
document delivery, inline buttons, approval prompts, stop/abort behavior,
reactions/status indicators, guest/inline responses, TTS/voice/audio
delivery, and routing changes whose result is visible in the chat. For those
PRs, define the exact Telegram stimulus and expected main/PR visual delta
before deciding to skip.
If the PR does not have a Telegram-visible before/after, write
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
artifacts, and a summary that starts with
`Mantis did not generate before/after GIFs because`. Include a short
@@ -78,8 +90,9 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
```
If the PR appears visual but proof is blocked by Telegram Desktop session
state, authorization, credentials, Crabbox, or another capture-infrastructure
issue, do not describe it as a no-visual PR. Write a manifest with
state, authorization, credentials, Crabbox, missing Telegram client support,
unavailable media/provider setup, or another capture-infrastructure issue,
do not describe it as a no-visual PR. Write a manifest with
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
starts with `Mantis could not capture Telegram Desktop proof because`. The
publisher will keep that out of PR comments so the failure stays in the

View File

@@ -5,7 +5,7 @@ Describe the problem and fix in 25 bullets:
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
- Problem:
- Why it matters:
- Solution:
- What changed:
- What did NOT change (scope boundary):
@@ -35,6 +35,12 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
- Related #
- [ ] This PR fixes a bug or regression
## Motivation
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
-
## Real behavior proof (required for external PRs)
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only. Screenshots are encouraged even for CLI, console, text, or log changes; terminal screenshots and copied live output count. Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.

View File

@@ -60,14 +60,11 @@ jobs:
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }}
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
run_check: ${{ steps.manifest.outputs.run_check }}
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
@@ -134,6 +131,7 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
@@ -175,6 +173,7 @@ jobs:
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
const runNodeFastOnly =
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
@@ -199,7 +198,7 @@ jobs:
const checksFastCoreTasks = [];
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
);
} else {
if (runNodeFastCiRouting) {
@@ -248,21 +247,12 @@ jobs:
runNodeFull ? createChannelContractTestShards() : [],
),
run_checks: runNodeFull,
checks_matrix: createMatrix(
runNodeFull
? [
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
]
: [],
),
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_check: runNodeFull,
run_check_additional: runNodeFull,
run_build_smoke: runNodeFull,
run_check_docs: docsChanged,
run_check_docs: docsChanged && eventName !== "push",
run_control_ui_i18n: runControlUiI18n,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
@@ -297,9 +287,9 @@ jobs:
}
EOF
# Run the fast security/SCM checks in parallel with scope detection so the
# Run dependency-free security checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
security-scm-fast:
security-fast:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
@@ -392,22 +382,6 @@ jobs:
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
security-dependency-audit:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_ref || github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
@@ -417,35 +391,6 @@ jobs:
- name: Audit production dependencies
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
security-fast:
permissions: {}
needs: [security-scm-fast, security-dependency-audit]
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 5
steps:
- name: Verify fast security jobs
env:
DEPENDENCY_AUDIT_RESULT: ${{ needs.security-dependency-audit.result }}
SCM_RESULT: ${{ needs.security-scm-fast.result }}
run: |
set -euo pipefail
failed=0
for result in \
"security-scm-fast=${SCM_RESULT}" \
"security-dependency-audit=${DEPENDENCY_AUDIT_RESULT}"
do
job="${result%%=*}"
status="${result#*=}"
if [ "$status" != "success" ]; then
echo "::error::${job} ended with ${status}"
failed=1
fi
done
exit "$failed"
# Build dist once for Node-relevant changes and share it with downstream jobs.
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
# test/build feedback sooner instead of waiting behind a full `check` pass.
@@ -733,14 +678,9 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
bundled)
bundled-protocol)
pnpm test:bundled
;;
contracts-channels)
pnpm test:contracts:channels
;;
contracts-plugins)
pnpm test:contracts:plugins
pnpm protocol:check
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
@@ -923,71 +863,6 @@ jobs:
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
checks-fast-protocol:
permissions:
contents: read
name: "checks-fast-protocol"
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 30
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run protocol check
run: pnpm protocol:check
checks-node-compat:
permissions:
contents: read
@@ -1048,7 +923,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.18.0"
node-version: "22.19.0"
cache-key-suffix: "node22-pnpm11"
install-bun: "false"
@@ -1188,8 +1063,8 @@ jobs:
fail-fast: false
matrix:
include:
- check_name: check-preflight-guards
task: preflight-guards
- check_name: check-guards
task: guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-prod-types
task: prod-types
@@ -1200,15 +1075,9 @@ jobs:
- check_name: check-dependencies
task: dependencies
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-policy-guards
task: policy-guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-test-types
task: test-types
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-strict-smoke
task: strict-smoke
runner: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
shell: bash
@@ -1271,12 +1140,18 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
preflight-guards)
guards)
pnpm check:no-conflict-markers
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
prod-types)
pnpm tsgo:prod
@@ -1293,19 +1168,9 @@ jobs:
pnpm deadcode:ci
fi
;;
policy-guards)
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
;;
test-types)
pnpm check:test-types
;;
strict-smoke)
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
*)
echo "Unsupported check task: $TASK" >&2
exit 1
@@ -1335,15 +1200,9 @@ jobs:
- check_name: check-additional-boundaries-a
group: boundaries
boundary_shard: 1/4
- check_name: check-additional-boundaries-b
- check_name: check-additional-boundaries-bcd
group: boundaries
boundary_shard: 2/4
- check_name: check-additional-boundaries-c
group: boundaries
boundary_shard: 3/4
- check_name: check-additional-boundaries-d
group: boundaries
boundary_shard: 4/4
boundary_shard: 2/4,3/4,4/4
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled

View File

@@ -43,7 +43,7 @@ jobs:
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
node-version: "24.x"
- name: Clone publish repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''

View File

@@ -36,5 +36,15 @@ jobs:
with:
install-bun: "false"
- name: Checkout ClawHub docs source
uses: actions/checkout@v6
with:
repository: openclaw/clawhub
path: clawhub-source
fetch-depth: 1
persist-credentials: false
- name: Check docs
env:
OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source
run: pnpm check:docs

View File

@@ -168,7 +168,7 @@ jobs:
- name: Upload Mantis artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/mantis/

View File

@@ -528,7 +528,7 @@ jobs:
- name: Upload Mantis status reaction artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}

View File

@@ -536,7 +536,7 @@ jobs:
- name: Upload Mantis thread attachment artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}

View File

@@ -359,7 +359,7 @@ jobs:
- name: Upload Mantis Slack desktop artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}

View File

@@ -311,11 +311,13 @@ jobs:
while true; do
blockers="$(
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
for status in queued in_progress waiting pending requested; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done
done | sort -u
)"
if [[ -z "$blockers" ]]; then
@@ -479,7 +481,7 @@ jobs:
- name: Upload Mantis Telegram desktop artifacts
id: upload_artifact
if: ${{ always() && steps.inspect.outputs.output_dir != '' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.inspect.outputs.output_dir }}

View File

@@ -275,11 +275,13 @@ jobs:
while true; do
blockers="$(
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
for status in queued in_progress waiting pending requested; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done
done | sort -u
)"
if [[ -z "$blockers" ]]; then
@@ -479,7 +481,7 @@ jobs:
- name: Upload Mantis Telegram artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}

View File

@@ -270,7 +270,7 @@ jobs:
- name: Upload npm Telegram E2E artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/

View File

@@ -321,9 +321,6 @@ jobs:
set -euo pipefail
trusted_reason=""
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch --tags origin '+refs/tags/*:refs/tags/*'
# Resolve here instead of in actions/checkout so short SHAs work too.
if ! selected_sha="$(git rev-parse --verify "${INPUT_REF}^{commit}")"; then
echo "Ref '${INPUT_REF}' could not be resolved to a commit." >&2

View File

@@ -468,7 +468,7 @@ jobs:
- name: Upload Kova artifacts
if: ${{ always() && steps.lane.outputs.run == 'true' }}
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
with:
name: openclaw-performance-${{ matrix.lane }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |

View File

@@ -794,7 +794,7 @@ jobs:
- name: Upload parity lane artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -830,7 +830,7 @@ jobs:
install-bun: "true"
- name: Download parity lane artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -853,7 +853,7 @@ jobs:
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -948,7 +948,7 @@ jobs:
- name: Upload runtime parity artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -983,7 +983,7 @@ jobs:
install-bun: "true"
- name: Download runtime parity artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -999,7 +999,7 @@ jobs:
- name: Upload runtime tool coverage artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
@@ -1079,7 +1079,7 @@ jobs:
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1175,7 +1175,7 @@ jobs:
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1271,7 +1271,7 @@ jobs:
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1370,7 +1370,7 @@ jobs:
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1466,7 +1466,7 @@ jobs:
- name: Upload Slack QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/

View File

@@ -62,7 +62,7 @@ jobs:
- name: Upload SARIF as workflow artifact
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: opengrep-full-sarif
path: .opengrep-out/precise.sarif

View File

@@ -92,7 +92,7 @@ jobs:
- name: Upload SARIF as workflow artifact
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: opengrep-pr-diff-sarif
path: .opengrep-out/precise.sarif

View File

@@ -222,7 +222,7 @@ jobs:
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
@@ -312,7 +312,7 @@ jobs:
- name: Upload live runtime token-efficiency artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -389,7 +389,7 @@ jobs:
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -474,7 +474,7 @@ jobs:
- name: Upload Matrix QA shard artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -569,7 +569,7 @@ jobs:
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -664,7 +664,7 @@ jobs:
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -762,7 +762,7 @@ jobs:
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -857,7 +857,7 @@ jobs:
- name: Upload Slack QA artifacts
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}

View File

@@ -18,6 +18,7 @@ jobs:
name: Real behavior proof
permissions:
contents: read
issues: read
pull-requests: read
runs-on: ubuntu-24.04
steps:
@@ -25,5 +26,25 @@ jobs:
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@v3
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
permission-issues: read
permission-members: read
- uses: actions/create-github-app-token@v3
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
continue-on-error: true
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
permission-issues: read
permission-members: read
- name: Check real behavior proof
env:
GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/github/real-behavior-proof-check.mjs

View File

@@ -8,6 +8,7 @@
},
"rules": {
"curly": "error",
"eslint/no-underscore-dangle": "error",
"eslint-plugin-unicorn/prefer-array-find": "error",
"eslint/no-array-constructor": "error",
"eslint/no-await-in-loop": "off",

View File

@@ -35,9 +35,11 @@ Skills own workflows; root owns hard policy and routing.
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
- Fix shape: prefer bounded owner-boundary refactors over local patches/shims when they remove stale abstractions, duplicate policy, or wrong ownership.
- Compat default: no new internal shims, aliases, fallback APIs, or legacy names just to reduce diff. Migrate callers and delete old paths.
- Public plugin API is the only compat exception: document/version breaks, aggressively deprecate unused SDK surface, and migrate ALL bundled/internal plugins to the modern API in the same change.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
- Lean code is a goal. No internal shims, aliases, legacy names, broad fallbacks, or defensive branches just to reduce diff or handle unrealistic edge cases.
- Handle real production states, shipped upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
- Public plugin SDK/API is the compat exception. New API first, old path only via named compat/deprecation metadata, docs, warnings when useful, tests for old+new, planned removal.
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
@@ -50,7 +52,7 @@ Skills own workflows; root owns hard policy and routing.
## Commands
- Runtime: Node 22+. Keep Node + Bun paths working.
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
- Package manager/runtime: repo defaults only. No swaps without approval.
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
- Sharp/Homebrew libvips source-build fail: `SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install`.
@@ -77,6 +79,7 @@ Skills own workflows; root owns hard policy and routing.
- If proof is blocked, say exactly what is missing and why.
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
- Prompt snapshots: CI truth is Linux Node 24. If macOS local passes but CI drifts, reproduce/generate in Linux before rerun.
## GitHub / PRs
@@ -108,6 +111,10 @@ Skills own workflows; root owns hard policy and routing.
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
- External boundaries: prefer `zod` or existing schema helpers.
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.

View File

@@ -4,12 +4,30 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Fixes
- WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga.
## 2026.5.19
### Changes
- Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.
- Dependencies: update `@openclaw/proxyline` to 0.3.3.
- Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.
- Docker/Podman: add `OPENCLAW_IMAGE_APT_PACKAGES` as the runtime-neutral image build arg for extra apt packages while keeping `OPENCLAW_DOCKER_APT_PACKAGES` as a legacy fallback. (#62431) Thanks @urtabajev.
- Gateway/ACPX: attribute startup probe, config, runtime, and resource-count costs in restart traces without changing readiness behavior. (#83300) Thanks @samzong.
- Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving `/readyz` sidecar gating. (#83301) Thanks @samzong.
- Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
- Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
- Skills CLI: allow `openclaw skills install` and `openclaw skills update` to target shared managed skills with `--global`. (#74466) Thanks @Marvae.
- Browser: surface pending and recently handled modal dialogs in snapshots, return `blockedByDialog` when an action opens a modal, and allow `browser dialog --dialog-id` to answer pending dialogs.
- Browser CLI: add `openclaw browser evaluate --timeout-ms` so long-running page functions can extend both the evaluate action and request timeout budgets. (#83447) Thanks @eefreenyc.
- Codex app-server: scope OpenClaw prompt guidance by runtime surface so native Codex keeps Codex-owned base/personality instructions while OpenClaw contributes only runtime context, delivery guidance, and explicitly scoped command hints. (#83454) Thanks @100yenadmin.
- Docker/Podman: add `OPENCLAW_IMAGE_PIP_PACKAGES` for opt-in Python package installation in local image builds. (#83771) Thanks @stephenredmond-straiteis.
- Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.
- Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.
- CLI/plugins: add `defineToolPlugin` plus `openclaw plugins build`, `validate`, and `init` for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.
@@ -17,6 +35,7 @@ Docs: https://docs.openclaw.ai
- Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`.
- Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.
- Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated.
- Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection.
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.
- QA-Lab: add `openclaw qa suite --runtime-parity-tier` and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.
@@ -27,29 +46,124 @@ Docs: https://docs.openclaw.ai
- QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.
- QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.
- QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.
- QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1.
- QA-Lab: add a report-only dreaming shadow-trial scenario so candidate memory promotion can be evaluated without mutating `MEMORY.md`. Thanks @iFiras-Max1.
- Gateway/performance: add `pnpm test:restart:gateway` benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong.
- Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens.
- Gateway/config: expose config lookup reload metadata so tools can distinguish restart-required, hot-reloadable, and no-op fields before applying config edits. Fixes #81409. (#81612) Thanks @LLagoon3.
- Telegram: add allowlisted native DM draft previews for transient tool progress while keeping final answers on the normal persistent delivery path. (#83622) Thanks @akrimm702.
- QA-Lab: add a personal-agent share-safe diagnostics artifact scenario so support handoffs keep useful status while omitting raw personal content. Thanks @iFiras-Max1.
- QA-Lab: add a personal-agent no-fake-progress scenario so completion claims stay tied to local evidence instead of unsupported external progress. (#83824) Thanks @iFiras-Max1.
### Fixes
- Codex app-server: preserve plugin tool auth profiles when Codex owns model transport so OpenClaw dynamic tools can resolve their provider credentials. (#83603) Thanks @rubencu.
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
- Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.
- Telegram: keep forum topics from blocking sibling topic traffic by routing inbound serialization, media/text buffers, and account API queues on topic-aware lanes. (#83829)
- Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc.
- CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.
- CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
- Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
- Providers/DeepSeek: normalize MCP tool schemas with `anyOf`/`oneOf` unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle.
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
- Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.
- Control UI: keep the chat delete confirmation popover clamped inside the visible viewport on small screens. (#83804) Thanks @ThiagoCAltoe.
- Browser: enforce current-tab URL allowlist checks for `/act` evaluate/batch actions and `/highlight` routes while leaving tab-management actions unblocked. (#78523)
- CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)
- Models: show the effective OpenAI/Codex auth profile in `/models` provider headers instead of falling back to the OpenAI env-key label. (#83697) Thanks @yu-xin-c.
- CLI: include active bundled loopback MCP tools in CLI system prompts and reset provider-side CLI sessions when that prompt-visible tool surface changes. (#83785) Thanks @TurboTheTurtle.
- Browser: keep a profile `cdpPort` when its `cdpUrl` omits a port, while still letting explicitly written URL ports win. (#82166) Thanks @Marvae.
- Agents/image generation: allow distinct `image_generate` prompts to start separate session-backed background tasks while same-prompt retries still return the active task status. (#83614) Thanks @Elarwei001.
- Gateway/WebChat: honor configured `channels.webchat.textChunkLimit` and `chunkMode` overrides when chunking WebChat replies. (#83713)
- Control UI: stop the chat reading indicator from sticking after an assistant response finishes. (#83515) Thanks @njuboy11.
- Skills: reject empty or whitespace-only skill names and descriptions during quick validation. (#27061)
- Sessions: skip trailing custom transcript entries when checking tail assistant replies so embedded CLI gap-fill does not duplicate canonical assistant output. (#83635) Thanks @yaoyi1222.
- Memory Wiki: keep `wiki_lint` tool output path-safe by reporting vault-internal lint reports as relative paths in tool text and details while preserving absolute report paths for CLI/file callers. (#83439) Thanks @LLagoon3.
- Telegram: keep verbose tool progress visible without mirroring non-final progress into active session transcripts, preventing embedded provider replies from aborting mid-run. (#83631) Thanks @kurplunkin.
- Telegram: log successful outbound text and media deliveries with account, chat, message, operation, thread, reply, silent, and chunk metadata while keeping message bodies out of logs. Fixes #83196. (#83247) Thanks @jrwrest.
- Cron: link isolated scheduled task runs to their stable cron session so task status and cleanup can follow the backing agent run. (#83606) Thanks @jai.
- CLI: enforce the documented Node.js 22.19 runtime floor in the source launcher.
- Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage.
- Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval.
- Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.
- Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.
- Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.
- Gateway/sessions: clear stored CLI provider resume bindings on non-subagent `/reset` so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.
- Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.
- Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.
- LM Studio: resolve env-template API keys like `${LMSTUDIO_API_KEY}` through the standard SecretInput path instead of sending the raw template as the bearer token, and preserve header-auth and discovery-key precedence when the template is unset. Fixes #80495. (#80568) Thanks @MonkeyLeeT.
- Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.
- Gateway/sessions: rotate failed agent sessions when their transcript file is missing instead of wedging per-channel lanes. Fixes #83488. (#83553) Thanks @LLagoon3.
- Agents: refresh final-delivery routing from fresh session state before declaring a no-send failure, keeping recovered runs on the normal durable delivery path. (#83835) Thanks @joshavant.
- Agents: guard final-delivery fresh session routing against mismatched logical sessions before reusing recovered delivery context. (#83928) Thanks @joshavant.
- Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.
- Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.
- Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.
- Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.
- Telegram: keep `/btw` and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.
- Agents: log strict-agentic execution contract diagnostics only when the planning-only retry path actually triggers.
- Agents: stop embedded session takeover and session write-lock errors from consuming model fallbacks while preserving provider fallback metadata. Fixes #83510. Thanks @luyao618.
- Agents/video: hide `video_generate` reference-audio parameters unless a registered video provider supports audio inputs.
- Plugins: fall back to npm for official ClawHub updates when artifact downloads are unavailable, including beta-to-default fallback and dry-run version reporting.
- Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.
- Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.
- Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.
- Codex app-server: expose OpenClaw's sandbox-routed shell as `sandbox_exec`/`sandbox_process` for non-Docker sandbox backends so SSH sandbox agents keep a correctly routed shell path without shadowing Codex native shell. Fixes #80322. Thanks @keramblock.
- Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.
- Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.
- Messages/Codex: keep Codex direct/source chats on message-tool visible delivery by default while documenting and testing `messages.visibleReplies: "automatic"` as the old-mode opt-out; channel wildcard model overrides now apply to direct chats before harness delivery defaults.
- Memory/QMD: keep archived session transcript hits visible after QMD export while preserving normal `.md` session ids that only resemble archive names. (#83518; fixes #83506) Thanks @tanshanshan.
- Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.
- Codex app-server: honor writable Docker bind mounts for sandboxed workspace-write turns while disabling native Code Mode when container-path aliases or read-only bind shadows cannot be represented safely host-side. Fixes #83737. (#83849) Thanks @joshavant.
- QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.
- Messages: default group/channel visible replies to automatic final delivery again, keeping `message_tool` opt-in for ambient/shared rooms and tool-reliable models.
- CLI/TUI: force standalone `/exit` runs to terminate after `runTui` returns so onboarding-launched TUI children do not stay alive invisibly. (#83501) Thanks @fuller-stack-dev.
- Agents/code mode: honor per-agent code-mode config in schema, runtime catalog activation, and model payload filtering. Fixes #83388. Thanks @Kaspre.
- Agents/code mode: preserve agent, session, run, and channel context in `before_tool_call` hooks for top-level `exec`/`wait` dispatches. Fixes #83387.
- QQBot: shorten C2C typing indicators to a 10-second window renewed every 5 seconds, capped to keep a final passive-reply slot available. (#83469)
- Replies: keep final payload delivery after live preview updates so channels can finalize or send the completed answer instead of losing preview-only drafts. (#83468)
- Discord: deliver final replies in progress-mode preview streams instead of deduplicating the final visible message. (#83443) Thanks @compoodment.
- Providers/Xiaomi: replay MiMo Anthropic-compatible `reasoning_content` as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for `mimo-v2-flash`. Fixes #83407. Thanks @Xgenious7.
- Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.
- Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings.
- CLI/config: keep broken discovered plugins that are not referenced by active config from failing `openclaw config validate`, while preserving fatal errors for explicitly configured plugin entries.
- GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev.
- Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.
- QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.
- Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.
- UI: show reasoning choices as plain labels instead of leaking internal override wording in session and chat pickers.
- Mac app: avoid repeating the Configuration heading inside channel quick settings.
- Mac app: keep the Settings sidebar always visible and remove the redundant titlebar hide/show control.
- Mac app: normalize Settings pane content margins so pages share the same left and right rail.
- Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.
- Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected, and keep the Settings sidebar toggle in the leading titlebar area.
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected.
- Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.
- Mac app: tighten remote Gateway fields in Settings so the Connection pane keeps readable labels and full action button text.
- Mac app: keep custom Settings card rows left-aligned and full-width so Discovery and status sections no longer appear centered or detached.
- Mac app: align Location permission controls to the same trailing column as the rest of Settings.
- Mac app: add Dashboard, Chat, Canvas, and Settings shortcuts to the Dock icon menu.
- Mac app: replace the Settings window's native split-view sidebar with an explicit layout so page content keeps its leading gutter when the sidebar is shown or hidden.
- Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane.
- Gateway/webchat: hide internal runtime-context and other `display: false` transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.
- CLI/help: keep `gateway`, `doctor`, `status`, and `health` help registration out of action/runtime imports so subcommand `--help` stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.
- CLI/help: show plugin-owned command help based on the active memory slot so LanceDB memory users see `ltm` instead of unavailable `memory` commands. Fixes #83745. (#83841) Thanks @joshavant.
- Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.
- Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.
- Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.
- Telegram: fail topic sends closed when Telegram reports `message thread not found` instead of retrying without `message_thread_id` into the base chat. Refs #83302.
- Config/subagents: remove ignored agent-model `timeoutMs` keys, keep subagent model config to primary/fallback selection, and clean shipped stale config through doctor. Fixes #83291. Thanks @giodl73-repo.
- Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.
- OpenAI/Codex: stop rejecting available `openai-codex` GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.
- Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.
- Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.
- Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.
- Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.
- Codex app-server: keep OpenClaw session spawning searchable while steering Codex-native delegation through native subagents, avoiding duplicate direct subagent surfaces. (#83329) Thanks @fuller-stack-dev.
- Codex app-server: recover stale childless Codex-native subagent task mirrors during maintenance and allow their registry rows to be cancelled without an OpenClaw child session. (#82836) Thanks @yshimadahrs-ship-it and @joshavant.
- Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.
- CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.
- Plugins: apply a default 15-second timeout to legacy `before_agent_start` hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.
@@ -66,10 +180,13 @@ Docs: https://docs.openclaw.ai
- Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.
- Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.
- Agents/Codex: keep legacy `oauthRef`-backed OAuth profiles usable while `openclaw doctor --fix` migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.
- Agents/Codex: load the selected provider owner alongside the Codex harness runtime so `openai-codex` models resolve when plugin allowlists scope runtime loading. Fixes #83380. (#83519) Thanks @joshavant.
- Telegram: fail stalled isolated-ingress handlers into tombstones and abort same-lane reply work before restarting, so later same-chat updates drain after a hung turn. Fixes #83272. (#83505) Thanks @joshavant.
- CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.
- CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like `0.0.0.0`. Fixes #83286. Thanks @giodl73-repo.
- CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly.
- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.
- CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back only to official `@openclaw/*` npm plugin packages when ClawHub plugin artifacts are unavailable, and keep managed service package roots authoritative during updates.
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
- Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.
- CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.
@@ -80,6 +197,9 @@ Docs: https://docs.openclaw.ai
- Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded `memory index` warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.
- Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.
- Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.
- Gateway/plugins: bind admin HTTP RPC dispatch to the accepting gateway instance so multi-gateway processes cannot execute plugin HTTP control-plane calls against another live gateway. Fixes #83486. (#83487) Thanks @coygeek.
- Telegram: keep hot-reload restarts from marking polling accounts manually stopped and restart isolated ingress cleanly after worker shutdown, preserving Telegram replies across config reloads. Fixes #83008. (#83410) Thanks @joshavant.
- Telegram/Ollama: pass current Telegram image attachments into native PI/Ollama vision turns so live photo prompts reach Ollama as native images. Fixes #83023. (#83516) Thanks @joshavant.
- Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.
- Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.
- Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.
@@ -93,6 +213,7 @@ Docs: https://docs.openclaw.ai
- Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.
- Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.
- Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.
- Browser/CDP: keep loopback proxy bypass active across both `NO_PROXY` casings and redact home-relative Chrome MCP profile paths in attach-failure diagnostics.
- Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward `music_generate` audio creation instead of lyric-only replies, and reserve `lyrics` for exact sung words.
- Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.
- Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)
@@ -111,6 +232,7 @@ Docs: https://docs.openclaw.ai
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
- Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.
- Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving `default` instead of silently dropping it.
- Agents/CLI: reject empty successful CLI subprocess replies as `empty_response` and keep them out of shared auth-profile health, so blank Claude CLI results no longer become green no-payload turns. Fixes #83231. (#83421) Thanks @joshavant.
- Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram `/verbose` stays visible when command events arrive only at completion.
- Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
@@ -123,6 +245,7 @@ Docs: https://docs.openclaw.ai
- Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.
- Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.
- Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.
- Telegram: redact nested raw-update identifiers and user metadata before verbose raw update logging, preserving useful update/message ids without exposing chat, user, command, or profile details. (#82945) Thanks @galiniliev and @joshavant.
- Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)
- Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style `reasoning.enabled`/`max_tokens` controls for reasoning-capable OpenAI-completions models.
- Agents/diagnostics: split slow embedded-run `attempt-dispatch` startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.
@@ -152,6 +275,11 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.
- Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng.
- Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.
- Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman.
- WhatsApp: treat `upload-file` as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman.
- iOS: end Live Activities when OpenClaw is connected, idle, or disconnected, and show compact attention states for approval-required reconnects. (#83597) Thanks @ngutman.
- Control UI: hide child nav items when collapsing the active sidebar group. Fixes #42167. (#42223) Thanks @Aroool.
- CI/proof: skip the real-behavior-proof gate for private org maintainers by minting a least-privilege (`members: read`) GitHub App token and checking active membership in the `maintainer` team, instead of treating `author_association=CONTRIBUTOR` as definitively external. (#83418) Thanks @romneyda.
## 2026.5.17
@@ -1559,6 +1687,7 @@ Docs: https://docs.openclaw.ai
- Dependencies: bump transitive `basic-ftp` to 5.3.1 so the runtime lockfile no longer includes the vulnerable 5.3.0 build flagged by the production dependency audit. (#78637) Thanks @sallyom.
- Hooks/cron: log returned `/hooks/agent` isolated-run errors and failed cron jobs with cron diagnostic summaries, so rejected `payload.model` values are visible instead of looking like accepted-but-missing runs. Fixes #78597. (#78655) Thanks @kevinslin.
- Managed proxy/security: classify raw socket callsites and proxy runtime mutations in boundary checks so new direct egress or unmanaged proxy-state changes cannot land without explicit review. (#77126) Thanks @jesse-merhi.
- Memory indexing: propagate memory directory creation failures immediately instead of reporting an unusable directory as ready. Thanks @he-yufeng.
- Channels/iMessage: surface the silent group-allowlist drop at default log level by emitting a one-time `warn` per account at monitor startup when `channels.imessage.groupPolicy: "allowlist"` is set without a `channels.imessage.groups` block, plus a one-time `warn` per `chat_id` when the runtime gate drops a specific group, naming the exact `channels.imessage.groups[...]` key to add to allow it. Fixes #78749. (#79190) Thanks @omarshahine.
- WhatsApp: stop Gateway-originated outbound echoes from advancing inbound activity in `openclaw channels status`, so outbound self-sends no longer look like handled inbound messages. Fixes #79056. (#79057) Thanks @ai-hpc and @bittoby.
- Gateway/nodes: preserve the live node registry session and invoke ownership when an older same-node WebSocket closes after reconnecting. (#78351) Thanks @samzong.
@@ -3457,6 +3586,7 @@ Docs: https://docs.openclaw.ai
- Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as `tsserver` do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.
- Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.
- Logging: write validated diagnostic trace context as top-level `traceId`, `spanId`, `parentSpanId`, and `traceFlags` fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.
- Nextcloud-Talk: wire the existing reaction sender into the channel `actions` adapter so agents can react to messages via the shared `message` tool, instead of advertising the `reactions` capability without a dispatch path. Fixes #70110. Thanks @powerpaul17.
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
- Control UI/Agents: remount the Overview model controls when switching agents so the primary-model picker cannot retain stale per-agent selection. Fixes #39392; carries forward #39401, notes the duplicate #39495 approach, and keeps #46275/#54724 broader stabilization out of scope. Thanks @daijunyi002, @SergioChan, @aworki, and @wsyjh8.
@@ -5099,6 +5229,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/skills: require unique case-insensitive fallback matches in `openclaw skills info` so case-only collisions return not-found instead of showing guidance for the wrong skill. (#38713)
- Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.
- Models/Codex: include `apiKey` in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in `models.json`. (#66180) Thanks @hoyyeva.
- Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.

View File

@@ -198,13 +198,29 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
chmod -R a+rX "$COREPACK_HOME"
# Install additional system packages needed by your skills or extensions.
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
# Example: docker build --build-arg OPENCLAW_IMAGE_APT_PACKAGES="python3 wget" .
# Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES is still accepted as a fallback.
ARG OPENCLAW_IMAGE_APT_PACKAGES
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
packages="${OPENCLAW_IMAGE_APT_PACKAGES-$OPENCLAW_DOCKER_APT_PACKAGES}"; \
if [ -n "$packages" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $packages; \
fi
# Install additional Python packages needed by your plugins or skills.
# Example: docker build --build-arg OPENCLAW_IMAGE_PIP_PACKAGES="requests humanize" .
ARG OPENCLAW_IMAGE_PIP_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_IMAGE_PIP_PACKAGES" ]; then \
if ! python3 -m pip --version >/dev/null 2>&1; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-pip; \
fi && \
python3 -m pip install --no-cache-dir --break-system-packages $OPENCLAW_IMAGE_PIP_PACKAGES; \
fi
# Optionally install Chromium and Xvfb for browser automation.

View File

@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
## Install (recommended)
Runtime: **Node 24 (recommended) or Node 22.16+**.
Runtime: **Node 24 (recommended) or Node 22.19+**.
```bash
npm install -g openclaw@latest
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node 24 (recommended) or Node 22.16+**.
Runtime: **Node 24 (recommended) or Node 22.19+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)

View File

@@ -312,7 +312,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
### Node.js Version
OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes important security patches:
OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches:
- CVE-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass vulnerability
@@ -320,7 +320,7 @@ OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes impo
Verify your Node.js version:
```bash
node --version # Should be v22.16.0 or later
node --version # Should be v22.19.0 or later
```
### Docker Security

View File

@@ -2,6 +2,222 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.5.18</title>
<pubDate>Mon, 18 May 2026 22:41:13 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026051890</sparkle:version>
<sparkle:shortVersionString>2026.5.18</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.18</h2>
<h3>Changes</h3>
<ul>
<li>Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.</li>
<li>Dependencies: update <code>@openclaw/proxyline</code> to 0.3.3.</li>
<li>Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.</li>
<li>Docker/Podman: add <code>OPENCLAW_IMAGE_APT_PACKAGES</code> as the runtime-neutral image build arg for extra apt packages while keeping <code>OPENCLAW_DOCKER_APT_PACKAGES</code> as a legacy fallback. (#62431) Thanks @urtabajev.</li>
<li>Gateway/ACPX: attribute startup probe, config, runtime, and resource-count costs in restart traces without changing readiness behavior. (#83300) Thanks @samzong.</li>
<li>Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving <code>/readyz</code> sidecar gating. (#83301) Thanks @samzong.</li>
<li>Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.</li>
<li>Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.</li>
<li>Skills: rename the repo-local Codex closeout review skill and helper to <code>autoreview</code> while preserving the Codex-first fallback behavior.</li>
<li>Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.</li>
<li>Browser: surface pending and recently handled modal dialogs in snapshots, return <code>blockedByDialog</code> when an action opens a modal, and allow <code>browser dialog --dialog-id</code> to answer pending dialogs.</li>
<li>Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.</li>
<li>Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.</li>
<li>CLI/plugins: add <code>defineToolPlugin</code> plus <code>openclaw plugins build</code>, <code>validate</code>, and <code>init</code> for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.</li>
<li>Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.</li>
<li>Skills: update the Obsidian skill to target the official <code>obsidian</code> CLI and require its registered binary instead of the third-party <code>obsidian-cli</code>.</li>
<li>Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.</li>
<li>Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy <code>interactive</code>/Slack directive producer APIs as deprecated.</li>
<li>Proxy: support HTTPS managed forward-proxy endpoints and scoped <code>proxy.tls.caFile</code> CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.</li>
<li>QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.</li>
<li>QA-Lab: add <code>openclaw qa suite --runtime-parity-tier</code> and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.</li>
<li>QA-Lab: add a live-only Codex Pi-shaped Read vocabulary canary so runtime parity catches native workspace-read prompt compatibility drift. (#80323) Thanks @100yenadmin.</li>
<li>QA-Lab: add live-only harness self-health scenarios for plugin hook crashes, manifest contract errors, and WebChat direct-reply self-message routing. (#80323) Thanks @100yenadmin.</li>
<li>QA-Lab: add runtime tool fixture scenarios and coverage reporting for Codex-native workspace tools, OpenClaw dynamic tools, and optional plugin-backed tools. Fixes #80173. Thanks @100yenadmin.</li>
<li>QA-Lab: expose runtime tool fixture coverage through <code>openclaw qa coverage --tools</code>, with optional suite-summary evaluation for parity gate artifacts. Thanks @100yenadmin.</li>
<li>QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.</li>
<li>QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.</li>
<li>QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.</li>
<li>QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1.</li>
<li>Gateway/performance: add <code>pnpm test:restart:gateway</code> benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong.</li>
<li>Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.</li>
<li>Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.</li>
<li>Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.</li>
<li>Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.</li>
<li>Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.</li>
<li>Telegram: keep <code>/btw</code> and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.</li>
<li>Agents/video: hide <code>video_generate</code> reference-audio parameters unless a registered video provider supports audio inputs.</li>
<li>Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.</li>
<li>Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.</li>
<li>Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.</li>
<li>Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.</li>
<li>Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.</li>
<li>Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.</li>
<li>QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.</li>
<li>Messages: default group/channel visible replies to automatic final delivery again, keeping <code>message_tool</code> opt-in for ambient/shared rooms and tool-reliable models.</li>
<li>CLI/TUI: force standalone <code>/exit</code> runs to terminate after <code>runTui</code> returns so onboarding-launched TUI children do not stay alive invisibly. (#83501) Thanks @fuller-stack-dev.</li>
<li>Agents/code mode: honor per-agent code-mode config in schema, runtime catalog activation, and model payload filtering. Fixes #83388. Thanks @Kaspre.</li>
<li>Agents/code mode: preserve agent, session, run, and channel context in <code>before_tool_call</code> hooks for top-level <code>exec</code>/<code>wait</code> dispatches. Fixes #83387.</li>
<li>QQBot: shorten C2C typing indicators to a 10-second window renewed every 5 seconds, capped to keep a final passive-reply slot available. (#83469)</li>
<li>Replies: keep final payload delivery after live preview updates so channels can finalize or send the completed answer instead of losing preview-only drafts. (#83468)</li>
<li>Discord: deliver final replies in progress-mode preview streams instead of deduplicating the final visible message. (#83443) Thanks @compoodment.</li>
<li>Providers/Xiaomi: replay MiMo Anthropic-compatible <code>reasoning_content</code> as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for <code>mimo-v2-flash</code>. Fixes #83407. Thanks @Xgenious7.</li>
<li>Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.</li>
<li>Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow <code>system.which</code> timeout warnings.</li>
<li>CLI/config: keep broken discovered plugins that are not referenced by active config from failing <code>openclaw config validate</code>, while preserving fatal errors for explicitly configured plugin entries.</li>
<li>GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with <code>invalid_request_body</code>. Fixes #83220. Thanks @galiniliev.</li>
<li>Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.</li>
<li>QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.</li>
<li>Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.</li>
<li>UI: show reasoning choices as plain labels instead of leaking internal override wording in session and chat pickers.</li>
<li>Mac app: avoid repeating the Configuration heading inside channel quick settings.</li>
<li>Mac app: keep the Settings sidebar always visible and remove the redundant titlebar hide/show control.</li>
<li>Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.</li>
<li>Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.</li>
<li>Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected.</li>
<li>Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.</li>
<li>Mac app: tighten remote Gateway fields in Settings so the Connection pane keeps readable labels and full action button text.</li>
<li>Mac app: keep custom Settings card rows left-aligned and full-width so Discovery and status sections no longer appear centered or detached.</li>
<li>Mac app: align Location permission controls to the same trailing column as the rest of Settings.</li>
<li>Mac app: add Dashboard, Chat, Canvas, and Settings shortcuts to the Dock icon menu.</li>
<li>Mac app: replace the Settings window's native split-view sidebar with an explicit layout so page content keeps its leading gutter when the sidebar is shown or hidden.</li>
<li>Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane.</li>
<li>Gateway/webchat: hide internal runtime-context and other <code>display: false</code> transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.</li>
<li>CLI/help: keep <code>gateway</code>, <code>doctor</code>, <code>status</code>, and <code>health</code> help registration out of action/runtime imports so subcommand <code>--help</code> stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.</li>
<li>Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.</li>
<li>Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.</li>
<li>Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.</li>
<li>Telegram: fail topic sends closed when Telegram reports <code>message thread not found</code> instead of retrying without <code>message_thread_id</code> into the base chat. Refs #83302.</li>
<li>Config/subagents: remove ignored agent-model <code>timeoutMs</code> keys, keep subagent model config to primary/fallback selection, and clean shipped stale config through doctor. Fixes #83291. Thanks @giodl73-repo.</li>
<li>Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.</li>
<li>OpenAI/Codex: stop rejecting available <code>openai-codex</code> GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.</li>
<li>Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.</li>
<li>Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.</li>
<li>Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.</li>
<li>Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.</li>
<li>Codex app-server: keep OpenClaw session spawning searchable while steering Codex-native delegation through native subagents, avoiding duplicate direct subagent surfaces. (#83329) Thanks @fuller-stack-dev.</li>
<li>Codex app-server: recover stale childless Codex-native subagent task mirrors during maintenance and allow their registry rows to be cancelled without an OpenClaw child session. (#82836) Thanks @yshimadahrs-ship-it and @joshavant.</li>
<li>Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.</li>
<li>CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.</li>
<li>Plugins: apply a default 15-second timeout to legacy <code>before_agent_start</code> hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.</li>
<li>Feishu: refresh inbound session delivery context for DM, group, and broadcast turns so later replies do not inherit stale WebChat routing. Fixes #78274.</li>
<li>Agents/subagents: require the initial subagent registry save before reporting spawn accepted, returning a spawn error instead of losing an untracked run when the registry write fails. (#83146) Thanks @yetval.</li>
<li>QA-Lab/qa-channel: attach redacted agent tool-start traces to outbound <code>QaBusMessage</code> records so scenarios can assert actual tool use instead of relying only on reply text. Fixes #67637. Thanks @100yenadmin.</li>
<li>QA-Lab: fail live runtime parity reports when assistant-message usage is missing, preventing <code>0 vs 0</code> live token rows from being reported as passing proof. Fixes #80411. Thanks @100yenadmin.</li>
<li>QA-Lab: add a runtime token-efficiency sidecar report that classifies Codex savings separately from regressions and fails only positive Codex-over-Pi live token deltas above threshold. Fixes #81093. Thanks @100yenadmin.</li>
<li>QA-Lab: fail Codex-backed OpenAI live runtime-pair runs before launching isolated workers when no portable Codex auth is available, while staging API-key fallbacks and configured Codex keys for isolated QA agents. Fixes #80412. Thanks @100yenadmin.</li>
<li>QA-Lab: refresh parity gates, mock frontier fixtures, model scenarios, and workflow artifact lanes to compare GPT-5.5 against Claude Opus 4.7. Fixes #74262. Thanks @100yenadmin.</li>
<li>QA-Lab: make mock parity dispatch provider-aware for source discovery and subagent scenarios so OpenAI and Anthropic lanes no longer share identical canned plans. Fixes #64879. Thanks @100yenadmin.</li>
<li>QA-Lab: stop returning Control UI bearer tokens from unauthenticated bootstrap payloads and bind Docker harness ports to loopback-only host addresses. (#66355) Thanks @pgondhi987.</li>
<li>Mac app: avoid a SwiftUI metadata crash when rendering the Cron Jobs settings pane.</li>
<li>Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.</li>
<li>Agents/OpenAI streams: yield via <code>setTimeout(0)</code> instead of <code>setImmediate</code> between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.</li>
<li>Agents/Codex: keep legacy <code>oauthRef</code>-backed OAuth profiles usable while <code>openclaw doctor --fix</code> migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.</li>
<li>Agents/Codex: load the selected provider owner alongside the Codex harness runtime so <code>openai-codex</code> models resolve when plugin allowlists scope runtime loading. Fixes #83380. (#83519) Thanks @joshavant.</li>
<li>Telegram: fail stalled isolated-ingress handlers into tombstones and abort same-lane reply work before restarting, so later same-chat updates drain after a hung turn. Fixes #83272. (#83505) Thanks @joshavant.</li>
<li>CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.</li>
<li>CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like <code>0.0.0.0</code>. Fixes #83286. Thanks @giodl73-repo.</li>
<li>CLI/plugins: ship the bundled memory CLI as a package entry so package-installed <code>openclaw memory</code> commands register correctly.</li>
<li>CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.</li>
<li>CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back only to official <code>@openclaw/*</code> npm plugin packages when ClawHub plugin artifacts are unavailable, and keep managed service package roots authoritative during updates.</li>
<li>Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.</li>
<li>Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.</li>
<li>CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.</li>
<li>Telegram: keep isolated long polling below the hard <code>getUpdates</code> request guard so idle bot accounts with high <code>timeoutSeconds</code> do not false-disconnect and restart-loop. Fixes #83264. Thanks @riccodecarvalho.</li>
<li>Providers/Google: preserve and recover Gemini 3 tool-call thought signatures during native replay so function-calling turns no longer fail with missing <code>thought_signature</code> 400s. Fixes #72879. (#80358) Thanks @abnershang.</li>
<li>Telegram: skip transcript-only delivery mirrors and gateway-injected rows when resolving latest assistant text, preventing retained previews from replacing final replies with stale fragments. Fixes #83159. (#83362) Thanks @joshavant.</li>
<li>Memory/QMD: keep lexical search on raw hyphenated queries while normalizing semantic QMD sub-searches, avoiding fallback to the builtin index for dashed identifiers and dates. Fixes #81328.</li>
<li>Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded <code>memory index</code> warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.</li>
<li>Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.</li>
<li>Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.</li>
<li>Telegram: keep hot-reload restarts from marking polling accounts manually stopped and restart isolated ingress cleanly after worker shutdown, preserving Telegram replies across config reloads. Fixes #83008. (#83410) Thanks @joshavant.</li>
<li>Telegram/Ollama: pass current Telegram image attachments into native PI/Ollama vision turns so live photo prompts reach Ollama as native images. Fixes #83023. (#83516) Thanks @joshavant.</li>
<li>Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.</li>
<li>Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.</li>
<li>Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.</li>
<li>Agents/Codex: use the Codex runtime context window for OpenAI-model preflight compaction and memory flush checks, so GPT-5.5 Codex sessions compact before hitting the smaller native context limit. Fixes #82982. Thanks @vliuyt.</li>
<li>QA-Lab: clean orphaned gateway temp roots when a suite parent exits and wait on gateway plus transport readiness after config restarts, reducing stale <code>qa-channel</code> noise from interrupted runs. Fixes #65506. Thanks @100yenadmin.</li>
<li>QA-Lab: wake qa-bus long polls that arrive with stale future cursors after a bus restart, preserving reconnect readiness for harness clients. (#67142) Thanks @hxy91819.</li>
<li>QA-Lab: stage Multipass transfer scripts under OpenClaw's preferred temp root instead of raw OS temp paths, keeping the VM runner inside temp-path guardrails. (#64098) Thanks @ImLukeF.</li>
<li>Agents/replies: keep surviving reply media and append a warning when other media references fail, so partial media normalization no longer drops failures silently. Thanks @Jerry-Xin.</li>
<li>Config/models: accept <code>thinkingFormat: "together"</code> in model compat config so Together routes can opt into the Together-specific thinking response shape.</li>
<li>Plugins/tokenjuice: bump the bundled tokenjuice runtime to 0.7.1, bringing Codex hook approval compatibility, pre-tool command wrapping fixes, and Rolldown/Vitest output compaction improvements into the OpenClaw plugin.</li>
<li>Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.</li>
<li>Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.</li>
<li>Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.</li>
<li>Browser/CDP: keep loopback proxy bypass active across both <code>NO_PROXY</code> casings and redact home-relative Chrome MCP profile paths in attach-failure diagnostics.</li>
<li>Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward <code>music_generate</code> audio creation instead of lyric-only replies, and reserve <code>lyrics</code> for exact sung words.</li>
<li>Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.</li>
<li>Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)</li>
<li>CLI/infer: run gateway model probes in fresh explicit sessions so one-shot provider checks do not inherit default agent transcript state. (#82861) Thanks @Kaspre.</li>
<li>Providers/Together: send video-generation requests to Together's v2 video API even when shared text-model config still points at the v1 base URL. (#82992)</li>
<li>Browser CLI: preserve browser-level options on nested commands, skip option values during lazy command registration, and keep long-running wait/download/dialog hooks open for their advertised wait window.</li>
<li>CLI/sessions: accept <code>openclaw sessions list</code> as an alias for <code>openclaw sessions</code>, matching other list-style commands. Fixes #81139. (#81163) Thanks @YB0y.</li>
<li>Channels/stream previews: widen compact progress draft lines and cut prose at word boundaries while preserving command/path suffixes, with <code>streaming.progress.maxLineChars</code> for channel-specific tuning.</li>
<li>CLI/plugins: have <code>openclaw plugins doctor</code> warn when a configured runtime needs a missing owner plugin, sharing the same install mapping as <code>openclaw doctor --fix</code>. Fixes #81326. (#81674) Thanks @Zavianx.</li>
<li>Agents/Codex: route OpenAI runs that resolve to <code>openai-codex</code> through the Codex provider and bootstrap OpenClaw's stored OAuth profile into the Codex harness when the harness owns transport, so <code>openai/*</code> model refs no longer fail with <code>No API key found for openai-codex</code> despite an existing Codex OAuth profile. (#82864) Thanks @ragesaq.</li>
<li>Agents/ACP: distinguish prompt-submitted and runtime-active child stalls from true interactive waits, including redacted proxy-env diagnostics for Codex ACP no-output runs. Fixes #44810.</li>
<li>Agents/memory: explain that memory-triggered compaction exposes only <code>read</code> and append-only <code>write</code> when configured core tools are unavailable in <code>tools.allow</code> warnings. Fixes #82941. Thanks @galiniliev.</li>
<li>Agents/OpenAI: preserve deterministic tool payload ordering for prompt-cache reuse across OpenAI Responses and chat completions calls. (#82940) Thanks @galiniliev.</li>
<li>ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.</li>
<li>Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.</li>
<li>Agents/skills: apply the full effective tool policy pipeline to inline <code>command-dispatch: tool</code> skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)</li>
<li>Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.</li>
<li>Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving <code>default</code> instead of silently dropping it.</li>
<li>Agents/CLI: reject empty successful CLI subprocess replies as <code>empty_response</code> and keep them out of shared auth-profile health, so blank Claude CLI results no longer become green no-payload turns. Fixes #83231. (#83421) Thanks @joshavant.</li>
<li>Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram <code>/verbose</code> stays visible when command events arrive only at completion.</li>
<li>Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.</li>
<li>Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.</li>
<li>Control UI: include the Control UI and Gateway protocol versions in protocol-mismatch errors so stale app/dashboard pairings identify which side needs rebuilding or restarting.</li>
<li>Gateway/protocol: restore Gateway WS protocol v4 and keep <code>message.action</code> room-event metadata on the existing <code>inboundTurnKind</code> wire field while preserving internal inbound-event classification.</li>
<li>Agents/tools: prefer non-webchat session-key routes when the message tool has stale webchat context, so message-tool-only replies keep delivering to the originating channel. Fixes #82911. (#83004) Thanks @joshavant.</li>
<li>Channels: keep direct-message last-route writes on isolated <code>per-channel-peer</code> sessions instead of contaminating the agent main session with channel delivery context. Fixes #36614. Thanks @aspenas.</li>
<li>Mac app: move the Settings sidebar toggle into the native titlebar and tighten the General pane width.</li>
<li>Mac app: keep visited Settings panes mounted so switching tabs no longer blanks and reloads their content.</li>
<li>Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.</li>
<li>Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.</li>
<li>Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.</li>
<li>Telegram: redact nested raw-update identifiers and user metadata before verbose raw update logging, preserving useful update/message ids without exposing chat, user, command, or profile details. (#82945) Thanks @galiniliev and @joshavant.</li>
<li>Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)</li>
<li>Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style <code>reasoning.enabled</code>/<code>max_tokens</code> controls for reasoning-capable OpenAI-completions models.</li>
<li>Agents/diagnostics: split slow embedded-run <code>attempt-dispatch</code> startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.</li>
<li>Agents/Codex: flatten nested tool-result middleware blocks into bounded text so successful message sends are no longer replaced with <code>Tool output unavailable due to post-processing error</code>. Fixes #82912. Thanks @joeykrug.</li>
<li>CLI/media: accept HTTP(S) URLs in <code>openclaw infer image describe --file</code>, fetching remote images through the guarded media path instead of treating URLs as local files. Fixes #82837. (#82854) Thanks @neeravmakwana.</li>
<li>Agents/subagents: keep session-backed parent runs active when the child wait call times out before the child session has actually settled, so late subagent completions are reconciled instead of being lost. Fixes #82787. Thanks @ramitrkar-hash.</li>
<li>Control UI: advertise shared Gateway protocol constants in browser connect frames, fixing protocol mismatch handshakes after protocol constant drift. Fixes #82882. Thanks @galiniliev.</li>
<li>Gateway: add rollback protocol-mismatch diagnostics, including client protocol ranges in Gateway logs and deep status/doctor hints for stale client processes. Fixes #82841. (#82908)</li>
<li>Agents/subagents: keep successful keep-mode completion payloads pending after final-delivery retry exhaustion, so requester recovery no longer loses final subagent results. Fixes #82583. (#82999) Thanks @joshavant.</li>
<li>Gateway/auth: allow same-host trusted-proxy callers to use the documented local direct <code>gateway.auth.password</code> fallback after revisiting the #78684 fail-closed policy, while keeping token fallback rejected and forwarded-header requests on the trusted-proxy path. Fixes #82607. (#82953) Thanks @joshavant.</li>
<li>Agents/subagents: wait for queued completion handoffs to reach the parent transcript before marking them announced, preventing busy parent runs from cleaning up before observing child results. Fixes #82913. (#83039) Thanks @joshavant.</li>
<li>Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.</li>
<li>Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo.</li>
<li>Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo.</li>
<li>CLI/models: reuse command-scoped plugin metadata across model listing, provider catalog, auth, and synthetic-auth checks, restoring fast <code>openclaw models</code> runs for plugin-heavy installs. Fixes #82881. (#83033) Thanks @joshavant.</li>
<li>CLI/channels: show configured official external channels such as Discord in <code>openclaw channels list</code> when their plugin package is missing, including the install and doctor repair command instead of reporting no configured channels. Fixes #82813.</li>
<li>Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.</li>
<li>Agents/tools: keep the <code>message</code> tool available in embedded runs when it is explicitly allowed through <code>tools.alsoAllow</code> or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.</li>
<li>WhatsApp: honor forced document delivery for outbound image, GIF, and video media so <code>forceDocument</code>/<code>asDocument</code> sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.</li>
<li>WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as <code>file.pdf</code> and <code>file.csv</code> instead of an extensionless <code>file</code>. Thanks @mcaxtr.</li>
<li>Process/diagnostics: report active lane blockers in lane wait warnings so <code>queueAhead=0</code> no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.</li>
<li>Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.</li>
<li>Agents/replies: classify provider conversation-state rejections and return a clear message-channel error instead of auto-resetting or falling back to a generic runner failure. (#82616) Thanks @dutifulbob.</li>
<li>Browser plugin: trust managed Chrome CDP diagnostics when launch HTTP probes race cold-start readiness, avoiding false startup failures. Fixes #82904. (#82986) Thanks @kmanan and @hclsys.</li>
<li>Android: prompt before replacing a changed Gateway TLS thumbprint, showing the old and new SHA-256 fingerprints so users can accept expected certificate rotations instead of hard failing on pin mismatch. (#83077) Thanks @sliekens.</li>
<li>CLI/status: render extra gateway-like service diagnostics as warning/info output instead of error output. Fixes #46930. (#82922) thanks @giodl73-repo.</li>
<li>Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.</li>
<li>Plugin SDK: bundle <code>openclaw/plugin-sdk/zod</code> into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local <code>zod</code> symlink. Fixes #78398. (#78515) Thanks @ggzeng.</li>
<li>Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.18/OpenClaw-2026.5.18.zip" length="53924201" type="application/octet-stream" sparkle:edSignature="cU0TfUmBZbVOpgwou+GS7RQiDhEGVUxjK+bwsl1RXiqvJi9ErsYebZIxVayH8++v5PeycoK5+LQF5gLiXQa2AA=="/>
</item>
<item>
<title>2026.5.12</title>
<pubDate>Fri, 15 May 2026 13:25:16 +0000</pubDate>
@@ -523,405 +739,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.7/OpenClaw-2026.5.7.zip" length="51130645" type="application/octet-stream" sparkle:edSignature="Zu+EzBGMRE1k7N4//L8HUxtUCPdO0ImrfDbgr2GrPMBrj7VGI1tOOl74gxNJoi/wfWvXz3fYVcBz2W/84ojuCw=="/>
</item>
<item>
<title>2026.5.2</title>
<pubDate>Sun, 03 May 2026 01:11:51 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026050290</sparkle:version>
<sparkle:shortVersionString>2026.5.2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.2</h2>
<h3>Highlights</h3>
<ul>
<li>External plugin installation, update, doctor repair, dependency reporting, and artifact metadata now cover the npm-first cutover, stale configured installs, missing package payloads, and beta-channel plugin fallback. Thanks @vincentkoc.</li>
<li>Gateway and agent hot paths are leaner across startup, session listing, task maintenance, prompt prep, plugin loading, tool descriptor planning, filesystem guards, and large runtime configs.</li>
<li>Control UI and WebChat are more resilient across Sessions, Cron, long-running Gateway WebSockets, grouped-message width, slash-command feedback, iOS PWA bounds, selection contrast, and Talk diagnostics.</li>
<li>Messaging fixes cover WhatsApp Channel/Newsletter targets, Telegram topic commands and networking, Discord delivery/startup edge cases, Slack threads, Signal groups/media, and visible reply routing.</li>
<li>Provider and media fixes cover OpenAI-compatible TTS/Realtime, OpenRouter/DeepSeek replay, Anthropic-compatible streaming, LM Studio reasoning metadata, Brave/SearXNG/Firecrawl web search, media paths, music, and voice-call routing.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Gateway/startup and restart: skip plugin-backed auth-profile overlays during startup secrets preflight, reducing gateway readiness latency while keeping reload and OAuth recovery paths overlay-capable; add <code>openclaw gateway restart --force</code> and <code>--wait <duration></code>, log active task run IDs before restart deferral timers, and report timeout restarts as explicit forced restarts. (#68327) Thanks @JIRBOY.</li>
<li>Plugins/ClawHub: make diagnostics, onboarding, doctor repair, and channel setup carry ClawPack metadata through install records while keeping explicit <code>clawhub:</code> installs on ClawHub and bare package installs on npm for the launch cutover. Thanks @vincentkoc.</li>
<li>Plugins/CLI: include package dependency install state in <code>openclaw plugins list --json</code> so scripts can spot missing plugin dependencies without runtime-loading plugins.</li>
<li>Plugins/update: on the beta OpenClaw update channel, default-line npm and ClawHub plugin updates try <code>@beta</code> first and fall back to default/latest when no plugin beta release exists.</li>
<li>Plugins/runtime: scope broad runtime preloads to the effective plugin ids derived from config, startup planning, configured channels, slots, and auto-enable rules instead of importing every discoverable plugin.</li>
<li>Agents/runtime: reuse the startup-loaded plugin registry for request-time providers, tools, channel actions, web/capability/memory/migration helpers, and memoized provider extra-params, and memoize transcript replay-policy resolution for stable config and process-env runs while preserving model-specific transport hook patches and custom-env provider behavior. Thanks @DmitryPogodaev.</li>
<li>Infra/path-guards: add a fast path for canonical absolute POSIX containment checks, avoiding repeated <code>path.resolve</code> and <code>path.relative</code> work in hot filesystem walkers. Refs #75895, #75575, and #68782. Thanks @Enderfga.</li>
<li>Tools/plugins: add a platform-level tool descriptor planner for descriptor-first visibility, generic availability checks, and executor references, and cache plugin tool descriptors captured from <code>api.registerTool(...)</code> so repeated prompt-time planning can skip plugin runtime loading while execution still loads the live plugin tool. (#76079) Thanks @shakkernerd.</li>
<li>Docs/Codex: clarify that ChatGPT/Codex subscription setups should use <code>openai/gpt-*</code> with <code>agentRuntime.id: "codex"</code> for native Codex runtime, while <code>openai-codex/*</code> remains the PI OAuth route. Thanks @pashpashpash.</li>
<li>Plugins/source checkout: load bundled plugins from the <code>extensions/*</code> pnpm workspace tree in source checkouts, so plugin-local dependencies and edits are used directly while packaged installs keep using the built runtime tree. Thanks @vincentkoc.</li>
<li>Plugins/beta: externalize ACPX behind <code>@openclaw/acpx</code> and diagnostics OpenTelemetry behind <code>@openclaw/diagnostics-otel</code>, keeping their heavier runtime stacks out of the core package until installed; prepare Google Chat, LINE, Matrix, Mattermost, BlueBubbles, diagnostics Prometheus, Google Meet, Nextcloud Talk, Nostr, Zalo, Zalo Personal, diagnostics OpenTelemetry, Discord, Diffs, Lobster, Memory LanceDB, Microsoft Teams, QQ Bot, Voice Call, WhatsApp, Brave, Codex, Feishu, Synology Chat, Tlon, and Twitch for <code>2026.5.1-beta.1</code>/<code>2026.5.1-beta.2</code> npm and ClawHub publishing, and keep publishable plugin dist trees out of the core npm package. Thanks @vincentkoc.</li>
<li>Providers/xAI: add Grok 4.3 to the bundled catalog and make it the default xAI chat model.</li>
<li>Google Meet: let API-created rooms set <code>accessType</code> and <code>entryPointAccess</code>, add <code>googlemeet end-active-conference</code> for closing managed spaces after a call, and add <code>googlemeet test-listen</code> plus the matching <code>google_meet</code> <code>test_listen</code> action so transcribe-mode joins wait for real caption or transcript movement before reporting listen-first health. (#74824; refs #72478) Thanks @BsnizND and @DougButdorf.</li>
<li>Plugins/ClawHub/onboarding: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verify ClawPack response headers and downloaded bytes, persist ClawPack digest/artifact metadata on install/update records and install-on-demand provider setup entries, and allow official bundled-plugin cutovers to record ClawHub artifact metadata while preserving npm as the launch default for bare package specs and retaining npm/local fallback paths. Thanks @vincentkoc.</li>
<li>Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall.</li>
<li>Channels/thread bindings: replace split subagent/ACP thread-spawn toggles with <code>threadBindings.spawnSessions</code>, default thread-bound spawns on, and let <code>openclaw doctor --fix</code> migrate the legacy keys. (#75943)</li>
<li>Providers/OpenAI: add <code>extraBody</code>/<code>extra_body</code> passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as <code>lang</code> in <code>/audio/speech</code> requests. Fixes #39900. Thanks @R3NK0R.</li>
<li>Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter <code>@newsletter</code> outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred.</li>
<li>Dependencies: refresh workspace, bundled runtime, and plugin dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, Marked 18.0.3, Pi 0.71.1, OpenAI 6.35.0, Codex 0.128.0, Zod 4.4.1, and Matrix 41.4.0. Thanks @mariozechner, @aws, and @microsoft.</li>
<li>Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference <code>accessGroup:<name></code> across channel auth paths. (#75813)</li>
<li>Crabbox/scripts: print the selected Crabbox binary, version, and supported providers before <code>pnpm crabbox:*</code> commands, and reject stale binaries that lack <code>blacksmith-testbox</code> provider support.</li>
<li>Agents/Codex: add committed happy-path prompt snapshots for Codex/message-tool Telegram direct, Discord group, and heartbeat turns so prompt drift can be reviewed. Thanks @pashpashpash.</li>
<li>Agents/workspace: add <code>agents.defaults.skipOptionalBootstrapFiles</code> for skipping selected optional workspace files during bootstrap without disabling required workspace setup. (#62110) Thanks @mainstay22.</li>
<li>Plugins/CLI: add first-class <code>git:</code> plugin installs with ref checkout, commit metadata, normal scanner/staging, and <code>plugins update</code> support for recorded git sources. Thanks @badlogic.</li>
<li>Google Meet: add live caption health for Chrome transcribe mode, including caption observer state, transcript counters, last caption text, and recent transcript lines in status and doctor output. Refs #72478. Thanks @DougButdorf.</li>
<li>Voice Call/Google Meet: add Twilio Meet join phase logs around pre-connect DTMF, realtime stream setup, and initial greeting handoff for easier live-call debugging. Thanks @donkeykong91 and @PfanP.</li>
<li>macOS app: move recent session context rows into a Context submenu while keeping usage and cost details root-level, so the menu bar companion stays compact with many active sessions. Thanks @guti.</li>
<li>Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc.</li>
<li>Discord: keep active buttons, selects, and forms working across Gateway restarts until they expire, so multi-step Discord interactions are less likely to break during upgrades or restarts. Thanks @amknight.</li>
<li>Messages/docs: clarify that <code>BodyForAgent</code> is the primary inbound model text while <code>Body</code> is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box.</li>
<li>Slack: publish a safe default App Home tab view on <code>app_home_opened</code>, include the Home tab event in setup manifests, and keep track of bot-participated threads across restarts so ongoing threaded conversations can continue auto-replying after the Gateway restarts. Fixes #11655; refs #52020. Thanks @TinyTb and @amknight.</li>
<li>Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok.</li>
<li>BlueBubbles: add opt-in <code>channels.bluebubbles.replyContextApiFallback</code> that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through <code>mergeAccountConfig</code>; routed through the typed <code>BlueBubblesClient</code> so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (<code>p:0/<guid></code>) are stripped before the request; concurrent webhooks for the same <code>replyToId</code> coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends <code>sanitizeForLog</code> to redact <code>?password=…</code>/<code>?token=…</code> query params and <code>Authorization:</code> headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.</li>
<li>CLI/proxy: add <code>openclaw proxy validate</code> so operators can verify effective proxy configuration, proxy reachability, and expected allow/deny destination behavior before deploying proxy-routed OpenClaw commands. (#73438) Thanks @jesse-merhi.</li>
<li>Agents/Codex: default Codex app-server dynamic tools to native-first, keeping OpenClaw integration tools while leaving file, patch, exec, and process ownership to the Codex harness; default Codex-harness direct source replies to the OpenClaw <code>message</code> tool when visible reply delivery is not explicitly configured, keeping channel-visible output as a deliberate tool call. (#75308, #75765) Thanks @pashpashpash.</li>
<li>Heartbeats/agents: add a structured <code>heartbeat_respond</code> tool for tool-capable heartbeat runs so agents can record quiet outcomes or explicit notification text without relying only on <code>HEARTBEAT_OK</code> parsing. (#75765) Thanks @pashpashpash.</li>
<li>Gateway/config: allow <code>$include</code> directives to read files from operator-approved <code>OPENCLAW_INCLUDE_ROOTS</code> directories while preserving default config-directory confinement. Thanks @ificator.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents/OpenAI: default GPT-5 API-key sessions to the SSE Responses transport unless WebSocket is explicitly selected, restoring replies in fresh Control UI and WebChat beta installs where the auto WebSocket path connected but produced no model events.</li>
<li>Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing sessions from staying stuck as running after completed or timed-out turns.</li>
<li>Gateway/CLI/status: make <code>openclaw gateway start</code> repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting; add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway; avoid repeated plugin tool descriptor config hashing so large runtime configs do not block reply startup and trigger reconnect/timeouts. Refs #49012. (#75944) Thanks @vincentkoc and @joshavant.</li>
<li>Plugins/update/config: stop treating the non-plugin <code>auth</code> command root as a bundled plugin id, keep packaged upgrades and beta external plugin installs on stable runtime aliases and matching prerelease npm specs, detect tracked plugin install records whose package directories disappeared during <code>openclaw update</code>, reinstall them before normal plugin updates, fail the update if install records still point at missing disk payloads, and validate configured web-search providers plus statically suppressed model/provider pairs against the active plugin set at config load. Thanks @vincentkoc.</li>
<li>Codex/app-server: resolve managed binaries from bundled <code>dist</code> chunks and from the <code>@openai/codex</code> package bin when installs do not provide a nearby <code>.bin/codex</code> shim, avoiding false missing-binary startup failures.</li>
<li>Status: show the <code>openai-codex</code> OAuth profile for <code>openai/gpt-*</code> sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky.</li>
<li>Status/update: resolve beta update-channel checks from the installed version when config still says <code>stable</code>, show configured channels in <code>openclaw status</code> and config-only <code>openclaw channels status</code> output even when the Gateway is unreachable, and let <code>status --deep</code> reuse live gateway channel credential state instead of warning on command-path-only token misses. Thanks @vincentkoc.</li>
<li>Plugins/externalization: add official npm-first catalogs for externalized channel, provider, and generic plugins; install official external web-search plugins before saving provider config; repair missing configured, selected-search, and env-selected plugin installs from npm by default; keep official install docs, update examples, live Codex checks, diagnostics ClawHub packages, and persisted bundled-plugin relocation on default npm tags; keep Matrix and Mattermost bundled until their npm packages cut over; and keep ACPX, Google Chat, and LINE publishable plugin dist trees out of the core package while ClawHub pack files roll out. Thanks @vincentkoc.</li>
<li>Plugins/ClawHub/source/registry: use the ClawHub artifact resolver response as the install decision before downloading, keep bare plugin package specs on npm for the launch cutover and reserve ClawHub resolution for explicit <code>clawhub:</code> specs until ClawHub pack readiness is deployed, discover source-only plugins such as Codex from <code>extensions/*</code>, install ClawPack artifacts from the explicit npm-pack <code>.tgz</code> resolver path, persist artifact kind, npm integrity, shasum, and tarball metadata for update/diagnostics flows, fall back to version metadata when the artifact resolver route is missing, keep the Docker ClawHub fixture aligned with npm-pack artifact resolution, explain unavailable explicit ClawHub ClawPack artifact downloads with a temporary npm install hint, and hash manifest/package metadata when validating persisted plugin registries so fast same-size rewrites cannot leave stale plugin metadata trusted. Thanks @vincentkoc.</li>
<li>Control UI: add validated <code>gateway.controlUi.chatMessageMaxWidth</code> instead of patched bundled CSS, ignore malformed persisted cron rows before they enter UI state, guard stale cron render paths, and bound the default Sessions tab query to recent activity and fewer rows while keeping filters editable. Fixes #67935, #55047, #54439, and #76050; supersedes #54550 and #54552. (#76051) Thanks @xiew4589-lang and @Neomail2.</li>
<li>Gateway/channels: cap startup fanout at four channel/account handoffs and recover from Bonjour ciao self-probe races, reducing Windows startup stalls with many Telegram accounts. Fixes #75687.</li>
<li>Gateway/sessions: keep <code>sessions.list</code> polling responsive on large session stores by reusing list-safe session cache/indexes and returning a lightweight compaction checkpoint preview instead of heavyweight summaries. Thanks @rolandrscheel.</li>
<li>Control UI/Gateway: keep long-running dashboard WebSocket sessions alive with protocol pings, keep Stop available after reconnect or reload by recovering session-scoped active-run abort state, contain standalone iOS PWA viewports with safe-area-aware document locking, use high-contrast text selection colors, and show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly. Fixes #70991, #60850, and #52105; supersedes #60854. Thanks @alexandre-leng, @kvncrw, @Badschaff, @efe-arv, and @MooreQiao.</li>
<li>CLI/update: treat inherited Gateway service markers as origin hints and only block package replacement when the managed Gateway is still live, so self-updates can stop the service and continue safely. (#75729) Thanks @hxy91819.</li>
<li>Agents/failover: exempt run-level timeouts that fire during tool execution from model fallback, timeout-triggered compaction, and generic timeout payload synthesis, avoiding misleading "LLM request timed out" errors after the primary model has already responded. Fixes #52147. (#75873) Thanks @simonusa.</li>
<li>Docker: copy Bun 1.3.13 from a digest-pinned image and keep CI on the same version. Fixes #74356. Thanks @fede-kamel and @sallyom.</li>
<li>Agents/compaction: keep prior context on consecutive turns against z.ai-style providers (z.ai direct, openrouter z-ai/\*, in-house GLM gateways), avoiding accidental Pi state reset after successful turns. (#76056) Thanks @openperf.</li>
<li>Doctor/plugins: run a one-time 2026.5.2 configured-plugin install repair based on <code>meta.lastTouchedVersion</code>, update stale configured plugin manifests that still declare channels without <code>channelConfigs</code>, install actively used downloadable OpenClaw plugins through the configured external source, preserve unmanaged third-party plugin <code>node_modules</code>, and then mark the config touched for the release.</li>
<li>Sessions/transcripts: use one <code>session.writeLock.acquireTimeoutMs</code> policy for session transcript lock acquisitions and raise the default wait to 60 seconds, avoiding user-visible lock timeouts during legitimate slow prep, cleanup, compaction, and mirror work. Fixes #75894. Thanks @shandutta.</li>
<li>Agents/restart recovery: match cleaned transcript locks by exact transcript lock paths plus the canonical session fallback, so interrupted main sessions using topic-suffixed transcripts resume after gateway restart. Refs #76052. Thanks @anyech.</li>
<li>Agents/runtime: cache the stable system-prompt prefix and reuse prompt-report tool schema stats during dispatch prep, reducing repeated CPU work before streaming starts. Fixes #75999; supersedes #76061. Thanks @zackchiutw and @STLI69.</li>
<li>Telegram/native commands: pass persisted session files into plugin commands for topic-bound sessions, so <code>/codex bind</code> works from Telegram forum topics. Refs #75845 and #76049. Thanks @MatthewSchleder.</li>
<li>Security audit/plugins: ignore plugin install backup, disabled, and dependency debris directories when enumerating installed plugin roots, avoiding false-positive findings for <code>.openclaw-install-backups</code> after plugin updates. Fixes #75456.</li>
<li>Telegram: honor runtime conversation bindings for native slash commands in bound top-level groups, so commands like <code>/status@bot</code> route to the active non-<code>main</code> session instead of falling back to the default route. Fixes #75405; supersedes #75558. Thanks @ziptbm and @yfge.</li>
<li>Gateway/tasks: make task registry maintenance use pass-local backing-session lookups and fresh active child-session indexes, avoiding repeated full task snapshots and session-store clones on large stale registries. Fixes #73517 and #75708; supersedes #74406 and #75709. Thanks @Lightningxxl, @glfruit, and @jared-rebel.</li>
<li>Auth/sessions: JSON-clone auth-profile cache/runtime snapshots and remaining session cleanup previews instead of using <code>structuredClone</code>, preserving mutation isolation while avoiding native-memory growth on large stores. Fixes #45438. Thanks @markus-lassfolk.</li>
<li>Models CLI: restore <code>openclaw models list --provider <id></code> catalog and registry fallback rows for unconfigured providers, so provider-specific verification commands no longer report "No models found." Fixes #75517; supersedes #75615. Thanks @lotsoftick and @koshaji.</li>
<li>Gateway/macOS: write LaunchAgent services with a canonical system PATH and stop preserving old plist PATH entries, so Volta, asdf, fnm, and pnpm shell paths no longer affect gateway child-process Node resolution. Fixes #75233; supersedes #75246. Thanks @nphyde2.</li>
<li>Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn.</li>
<li>Sessions/agents: route Gateway session-store writes, CLI cleanup maintenance, and agent-delete session purges through a dedicated in-process writer and borrow the validated mutable cache during the writer slot, avoiding runtime file locks plus repeated <code>sessions.json</code> rereads and JSON clones on hot metadata updates. Refs #68554. Thanks @henkterharmsel.</li>
<li>Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot.</li>
<li>Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser.</li>
<li>Agents/sessions: preserve pre-existing runtime model and context window after heartbeat turns so a per-run heartbeat model override does not bleed into shared-session status. Fixes #75452. Thanks @zhangguiping-xydt.</li>
<li>Model commands: clarify direct and inline <code>/model</code> acknowledgements for non-default selections as session-scoped. Thanks @addu2612.</li>
<li>Doctor/gateway: stop warning that non-existent, unconfigured user-bin directories are required in the Gateway service PATH. Fixes #76017. Thanks @xiphis.</li>
<li>TUI/setup: skip full provider model normalization during context-window warmup and bound Terminal hatch bootstrap provider requests, avoiding cold-start stalls with large model registries and first-run hatching stuck behind the watchdog. (#76241) Thanks @547895019 and @joshavant.</li>
<li>Agents: enable malformed tool-call argument repair for Codex and Azure OpenAI Responses transports while keeping generic OpenAI Responses paths out of the repair gate. Fixes #75154. Thanks @Nimraakram22.</li>
<li>Memory Wiki: accept relative Markdown links that include the <code>.md</code> suffix during broken-wikilink validation, avoiding false positives for native render-mode links. Thanks @Kenneth8128.</li>
<li>OpenAI Codex: show the device-pairing code in the interactive SSH/headless prompt while keeping the short-lived code out of persistent runtime logs. Fixes #74212. Thanks @da22le123.</li>
<li>QA Lab: stop gateway children when the suite parent disappears, so interrupted local QA runs cannot leave hot orphaned gateways behind.</li>
<li>Codex/app-server/plugins: tolerate second connection closes during startup recovery, include retry counts plus stringified restart errors, and allow the official npm Codex plugin to install without the unsafe-install override while keeping <code>/codex</code> command ownership and covering the real npm Docker live path through managed <code>.openclaw/npm</code> dependencies plus uninstall failure proof.</li>
<li>Plugins/CLI: cache plugin CLI registration entries per command program so completion state generation does not repeat the full plugin sweep in one invocation. Thanks @ScientificProgrammer.</li>
<li>Plugins: reuse gateway-bindable plugin loader cache entries for later default-mode loads without serving default-built registries to gateway-bound requests, reducing repeated plugin registration during dispatch. Refs #61756. Thanks @DmitryPogodaev.</li>
<li>Gateway/secrets: include the caught error message in <code>secrets.reload</code> and <code>secrets.resolve</code> warning logs while keeping RPC errors generic, so operators can diagnose reload and permission failures. Thanks @davidangularme.</li>
<li>Providers/OpenRouter/LM Studio/Anthropic: fill DeepSeek V4 <code>reasoning_content</code> replay placeholders for <code>openrouter/deepseek/deepseek-v4-flash</code> and <code>openrouter/deepseek/deepseek-v4-pro</code>, normalize binary LM Studio reasoning metadata from Gemma 4 and other local models, and recover Anthropic-compatible stream text deltas that arrive before their matching content block. Fixes #76018 and #76007. Thanks @cloph-dsp and @vliuyt.</li>
<li>fix(infra): block workspace state-directory env override [AI]. (#75940) Thanks @pgondhi987.</li>
<li>MCP/OpenAI and media: normalize parameter-free MCP tool schemas before OpenAI tool submission, honor explicit short <code>[[tts:text]]...[[/tts:text]]</code> blocks while keeping untagged short auto-TTS suppressed, and accept home-relative <code>MEDIA:~/...</code> attachment paths under the existing file-read policy. Fixes #75362, #73758, and #73796. Thanks @tolkonepiu, @SymbolStar, @yfge, and @fabkury.</li>
<li>Hooks/doctor: warn when <code>hooks.transformsDir</code> points outside the canonical hooks transform directory, so invalid workspace skill paths get a direct recovery hint before the Gateway crash-loops. Fixes #75853. Thanks @midobk.</li>
<li>Proxy/audio: convert standard <code>FormData</code> bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send <code>[object FormData]</code> when <code>HTTP_PROXY</code> or <code>HTTPS_PROXY</code> is configured. Fixes #48554. Thanks @dco5.</li>
<li>Discord/setup/startup/native commands: write resolved guild/channel allowlist selections to the selected guild and channel, persist slash-command deploy hashes across process restarts, treat abort-time Carbon reconnect-exhausted events as expected shutdown during stale-socket restarts, allow explicit ack reactions in tool-only guild channels, and warn when slash dispatch or direct plugin execution produces no visible reply. Fixes #74922 and #58986; carries forward #58216; supersedes #47788, #73949, and #62057. Thanks @samvilian, @BlueBirdBack, @Eldersonar, @Perttulands, and @jb510.</li>
<li>Discord/delivery/media: use session-backed A2A announce target lookup for multi-account <code>sessions_send</code>, keep typing indicators alive during long tool runs and auto-compaction, preserve multipart Content-Type headers for uploads, preserve attachment and sticker filenames, and keep non-ASCII channel names in session labels while preserving ASCII-slug allowlists. Fixes #42652 and #59744; refs #51626 and #44773; supersedes #73975. Thanks @irchelper, @dpalfox, @Lanfei, @Squirbie, @FunJim, @xela92, @rockcent, and @swjeong9.</li>
<li>Discord/threads/PluralKit: canonicalize proxied webhook turns to the original message id for dedupe, inject thread starter context only on the first effective thread turn, and resolve thread <code>ownerId</code>/<code>parentId</code> from Discord API-style snake_case payload fields so bot-owned autoThreads do not require unnecessary mentions. Fixes #41355; supersedes #44447 and #44449. Thanks @acgh213, @p3nchan, and @mgh3326.</li>
<li>Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma.</li>
<li>Gateway/pricing: defer optional model pricing catalog refresh until after sidecars and channels reach the ready path, so slow OpenRouter or LiteLLM pricing fetches cannot block Gateway readiness. Fixes #74128; supersedes #73486. Thanks @ctbritt and @alprclbi.</li>
<li>Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq.</li>
<li>Codex/app-server: make startup retry cleanup ownership-aware so concurrent Codex lanes cannot close another lane's freshly restarted shared app-server client. Thanks @vincentkoc.</li>
<li>Google Meet/Twilio/Voice Call: report missing dial-in details during setup, explain that Twilio needs a phone dial plan for Meet URLs, start the phone leg before Meet PIN DTMF, delay intro speech until after post-connect dialing, log each stage, and accept provider call IDs for gateway speak/continue while reporting ended-call state from history.</li>
<li>Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427.</li>
<li>Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah.</li>
<li>CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via <code>reliability.outputLimits</code>, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840.</li>
<li>Telegram/DMs/network/commands: keep incidental <code>message_thread_id</code> reply-with-quote metadata on flat DM sessions unless topic isolation is configured, raise outbound text and typing Bot API guards to 60 seconds with safe timeout overrides and typing fallback retries, and register/clear command menus in default and group-chat scopes so <code>/status</code> and plugin commands stay available in forum topics. Fixes #75975, #76013, and #74032; updates #6457. Thanks @ProjectEvolutionEVE, @iaki1206, @dae-sun, and @WouldenShyp.</li>
<li>Providers/OpenAI: resolve <code>keychain:<service>:<account></code> <code>OPENAI_API_KEY</code> refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt.</li>
<li>Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai.</li>
<li>Voice Call: add <code>sessionScope: "per-call"</code> for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry.</li>
<li>Music generation: raise too-small tool timeouts to the provider-safe 10-second floor and collapse cascading abort fallback errors into a clearer root-cause summary. Thanks @shakkernerd.</li>
<li>Memory-core/dreaming: include the primary runtime workspace in multi-agent dreaming sweeps without mixing main-agent session transcripts into configured subagent workspaces. Fixes #70014. Thanks @ttomiczek.</li>
<li>Control UI: add tab/RPC timing attribution and decouple slow Overview/Cron secondary refreshes so Sessions navigation gets immediate visible feedback. Refs #64004. Thanks @WaMaSeDu.</li>
<li>Memory: retry transient SQLite index file swaps during atomic reindex on Windows, so brief <code>EBUSY</code>, <code>EPERM</code>, or <code>EACCES</code> locks do not fail memory rebuilds. Fixes #64187. Thanks @kunpeng-ai-lab.</li>
<li>Telegram/startup/models: use the existing <code>getMe</code> request guard and higher <code>timeoutSeconds</code> configs for slow Bot API paths, and make model picker confirmations say selections are session-scoped. Fixes #75783 and #75965. Thanks @tankotan and @sd1114820.</li>
<li>Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with <code>process is not defined</code>. Fixes #75987. Thanks @novkien.</li>
<li>Heartbeat/Discord: keep async exec completion events out of the generic <code>System (untrusted)</code> prompt block and let the dedicated exec heartbeat prompt handle them, so Discord no longer receives raw exec failure tails as separate system-style messages. Fixes #66366. Thanks @Promee-ThaBossHoss.</li>
<li>Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC <code>activeHours.timezone</code> values (e.g. <code>Asia/Shanghai</code>) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight.</li>
<li>Channels: strip plain-text MiniMax and XML tool-call scaffolding from shared user-facing reply sanitization, so messaging channels do not deliver raw model tool syntax when a provider emits it as text instead of structured tool calls. Fixes #62820. Thanks @canh0chua.</li>
<li>Infer/media: report missing image-understanding and audio-transcription provider configuration for <code>image describe</code>, <code>image describe-many</code>, and <code>audio transcribe</code> instead of blaming the input path when no provider is available. Fixes #73569 and supersedes #73593, #74288, and #74495. Thanks @bittoby, @tmimmanuel, @Linux2010, and @vyctorbrzezowski.</li>
<li>CLI/infer: reject local <code>codex/*</code> one-shot model probes before simple-completion dispatch and point operators at the Codex app-server runtime path instead of ending with an empty-output error.</li>
<li>Docs/health: clarify that session listing surfaces stored conversation rows rather than Discord/channel socket liveness, and point connectivity checks at channel status and health probes. Fixes #70420. Thanks @ashersoutherncities-art and @martingarramon.</li>
<li>WhatsApp/Cron: keep DM pairing-store approvals out of implicit cron and heartbeat recipient fallback, so scheduled automation only uses explicit targets, active configured recipients, or configured <code>allowFrom</code> entries. Fixes #62339. Thanks @kelvinisly-collab.</li>
<li>Google Meet: keep the agent-facing <code>google_meet</code> tool visible on non-macOS hosts but block local Chrome realtime actions with guidance, so Linux agents can still use transcribe, Twilio, chrome-node, and artifact flows without choosing the macOS-only BlackHole path. Refs #75950. Thanks @actual-software-inc.</li>
<li>macOS/settings: keep opening General from rewriting <code>openclaw.json</code> during Tailscale settings hydration, preserving <code>gateway</code>, <code>auth</code>, <code>meta</code>, and <code>wizard</code> until the user changes a setting. Fixes #59545. Thanks @Tengdw.</li>
<li>Discord: prioritize interaction callbacks ahead of stale background REST work without polling active REST buckets, validate oversized gateway payloads and member-intent requests before send, and forward explicit component payloads from message actions. (#75363)</li>
<li>Active Memory: use the configured recall timeout as the blocking prompt-build hook budget by default and move cold-start setup grace behind explicit <code>setupGraceTimeoutMs</code> config, so the plugin no longer silently extends 15000 ms configs to 45000 ms on the main lane. Fixes #75843. Thanks @vishutdhar.</li>
<li>Plugins/web-provider: reuse the active gateway plugin registry for runtime web provider resolution after deriving the same candidate plugin ids as the loader path, avoiding a redundant <code>loadOpenClawPlugins</code> call on every request while preserving origin and scope filters. Fixes #75513. Thanks @jochen.</li>
<li>Crestodian/CLI: exit non-zero when interactive Crestodian is invoked without a TTY, so scripts and CI no longer treat the setup error as success. Fixes #73646 and supersedes #73928 and #74059. Thanks @bittoby, @luyao618, and @Linux2010.</li>
<li>Cron: keep implicit/default isolated cron announce deliveries out of the main session awareness queue, so isolated jobs do not accumulate in the main conversation. Fixes #61426. Thanks @Lihannon.</li>
<li>Subagents: avoid duplicate parent-visible replies when a parent uses <code>sessions_send</code> on its own persistent native subagent session, while preserving announce delivery for async sends. Fixes #73550. Thanks @sylviazhang2006-design.</li>
<li>Web search/Brave: add opt-in <code>brave.http</code> diagnostics for Brave request URLs/query params, response status/timing, and cache hit/miss/write events without logging API keys or response bodies. Fixes #55196. Thanks @mecampbellsoup.</li>
<li>Web search/Brave: add <code>plugins.entries.brave.config.webSearch.baseUrl</code> for Brave-compatible proxies, including endpoint-aware cache keys for both web and LLM Context modes. Fixes #19075. Thanks @jkoprax and @vishnukool.</li>
<li>Web search/config: validate explicit <code>tools.web.search.provider</code> values against bundled and installed plugin manifests, while warning for stale third-party plugin config. Fixes #53092. Thanks @TinyTb.</li>
<li>Web search/SearXNG: retry empty non-general category searches once with the general category, so unsupported category engines do not return empty results when general search has matches. Fixes #73552. Thanks @Loukky.</li>
<li>CLI/message: skip gateway-stop hooks for read-only <code>message read</code> and bound stop-hook shutdown for other message actions, so one-shot Discord reads cannot hang behind plugin lifecycle cleanup.</li>
<li>Plugins/web-provider: cache repeated bundled web search and web fetch provider registry loads by default while preserving explicit cache opt-outs. Supersedes #75992. Thanks @DmitryPogodaev.</li>
<li>Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan.</li>
<li>Control UI/WebChat: route typed <code>/new</code> through the New Chat dashboard-session creation flow instead of <code>chat.send</code>, while keeping <code>/reset</code> as the explicit current-session reset. Fixes #69599. Thanks @WolvenRA.</li>
<li>Agents/models: keep legacy CLI runtime model refs such as <code>claude-cli/*</code> in the configured allowlist after canonical runtime migration, so cron <code>payload.model</code> overrides keep working. Fixes #75753. Thanks @RyanSandoval.</li>
<li>Codex/app-server: restart the shared Codex app-server client once when it closes during startup thread resume, preserving the existing thread binding instead of retrying <code>thread/start</code> on a closed client. Thanks @vincentkoc.</li>
<li>Gateway/watch: keep colored subsystem log prefixes in the managed tmux pane even when the parent shell exports <code>NO_COLOR</code>, while preserving explicit <code>FORCE_COLOR=0</code> opt-out. Thanks @vincentkoc.</li>
<li>Agents/compaction: submit a non-empty runtime-event marker for pre-compaction memory flush turns, so strict Anthropic providers no longer reject the silent flush as an empty user message. Fixes #75305. Thanks @sableassistant3777-source.</li>
<li>Plugin SDK: re-export <code>isPrivateIpAddress</code> from <code>plugin-sdk/ssrf-runtime</code>, restoring source-checkout builds for SearXNG and Firecrawl private-network guards. Thanks @vincentkoc.</li>
<li>Discord/message actions: advertise <code>upload-file</code> and route it through Discord's send runtime with agent-scoped media reads, so agents can discover and send file attachments. Fixes #60652 and supersedes #60808, #61087, and #61100. Thanks @claw-io, @efe-arv, @joelnishanth, and @sjhddh.</li>
<li>Sessions: suppress exact inter-session control replies such as <code>NO_REPLY</code> and keep agent-to-agent announce bookkeeping out of visible transcripts. Fixes #53145. Thanks @TarahAssistant.</li>
<li>CLI/directory: report unsupported directory operations for installed channel plugins instead of prompting to reinstall the plugin when it lacks a directory adapter. Fixes #75770. Thanks @lawong888.</li>
<li>Web search/SearXNG/Firecrawl/Kimi: show the SearXNG JSON API <code>search.formats</code> prerequisite, pass through <code>img_src</code> image URLs, fail explicitly when Kimi returns ungrounded answers, keep public provider requests on strict SSRF guards, reject private/loopback/metadata/non-HTTP(S) hosted Firecrawl scrape targets, and allow explicit self-hosted private Firecrawl endpoints. Fixes #52573, #74357, and #63877; supersedes #65592, #61416, #74360, #48133, #59666, #63941, and #74013. Thanks @evanpaul14, @sghael, @wangwllu, @fede-kamel, @kn1ghtc, @jhthompson12, @jzakirov, @Mlightsnow, and @shad0wca7.</li>
<li>CLI/models: report gateway model fallback attempts in <code>infer model run --json</code> and avoid double-prefixing provider-qualified defaults such as <code>openrouter/auto</code> in <code>models status</code>. Partially fixes #69527. Thanks @alexifra.</li>
<li>Providers/OpenRouter: strip trailing assistant prefill turns from verified OpenRouter Anthropic model requests when reasoning is enabled, so Claude 4.6 routes no longer fail with Anthropic's prefill rejection through the OpenAI-compatible adapter. Fixes #75395. Thanks @sbmilburn.</li>
<li>Voice Call: add per-number inbound routing for dialed-number greetings, response agents/models/prompts, and TTS voice overrides. Fixes #56604. Thanks @healthstatus.</li>
<li>Feishu: preserve Feishu/Lark HTTP error bodies for message sends, media sends, and chat member lookups, so HTTP 400 failures include vendor code, message, log id, and troubleshooter details. Fixes #73860. Thanks @desksk.</li>
<li>Agents/transcripts: avoid reopening large Pi transcript files through the synchronous session manager for maintenance rewrites, persisted tool-result truncation, manual compaction boundary hardening, and queued compaction rotation. Thanks @mariozechner.</li>
<li>Web search/Exa/MiniMax: accept Exa <code>webSearch.baseUrl</code> overrides with endpoint-partitioned caches, include MiniMax Search in setup, and let <code>MINIMAX_API_KEY</code> participate in MiniMax Search auto-detection. Fixes #54928; supersedes #54939 and #65828. Thanks @mrpl327, @lyfuci, and @Jah-yee.</li>
<li>Plugins/ClawHub: preserve official source-linked trust through archive installs, so OpenClaw can install trusted ClawHub plugin packages that trigger the built-in dangerous-pattern scanner. Thanks @vincentkoc.</li>
<li>Plugins/ClawHub: install package runtime dependencies for archive-backed plugin installs, so ClawHub packages such as WhatsApp load declared dependencies after download. Thanks @vincentkoc.</li>
<li>Plugins/tools: cache repeated plugin tool factory results only for matching request context, reducing per-turn tool prep without leaking sandbox, session, browser, delivery, or runtime config state. Fixes #75956. Thanks @Linux2010.</li>
<li>Providers/LM Studio: allow <code>models.providers.lmstudio.params.preload: false</code> to skip OpenClaw's native model-load call so LM Studio JIT loading, idle TTL, and auto-evict can own model lifecycle. Fixes #75921. Thanks @garyd9.</li>
<li>Agents/transcripts: keep chat history, restart recovery, fork token checks, and stale-token compaction checks on bounded async transcript reads or cached async indexes instead of reparsing large session files. Thanks @mariozechner.</li>
<li>Telegram: inherit the process DNS result order for Bot API transport and downgrade recovered sticky IPv4 fallback promotions to debug logs, while keeping pinned-IP escalation warnings visible. Fixes #75904. Thanks @highfly-hi and @neeravmakwana.</li>
<li>Sessions: keep durable external conversation pointers, including group and thread-scoped chat sessions, out of age, count, and disk-budget maintenance eviction while still allowing synthetic runtime entries to age out. Fixes #58088. Thanks @drinkflav.</li>
<li>Web search/Providers MiniMax: allow <code>MINIMAX_OAUTH_TOKEN</code> to satisfy MiniMax Search credentials and derive Coding Plan usage polling from the configured MiniMax base URL, so OAuth-authorized and global setups use the right endpoint. Fixes #65768 and #65054. Thanks @kikibrian, @zhouhe-xydt, @sixone74, and @Yanhu007.</li>
<li>Control UI/WebChat: skip assistant-media transcript supplements when stale media refs resolve to no playable media, so text-only final replies are not stored a second time as gateway-injected assistant messages. Fixes #73956. Thanks @HemantSudarshan.</li>
<li>Sessions: reject <code>sessions_send</code> targets that resolve to thread-scoped chat sessions, so inter-agent coordination cannot be injected into active human-facing Slack or Discord threads. Fixes #52496. Thanks @barry-p5cc.</li>
<li>Subagents: honor <code>sessions_spawn</code> with <code>expectsCompletionMessage: false</code> by skipping parent completion handoff delivery while still running child cleanup. Fixes #75848. Thanks @alfredjbclaw.</li>
<li>Media/completions: treat media-only message-tool sends as delivered async completion output, avoiding duplicate raw <code>MEDIA:</code> fallback posts after video or music generation finishes.</li>
<li>Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc.</li>
<li>Codex/app-server: recover JSON-RPC frames split by raw command-output newlines and include a redacted preview when malformed app-server messages still reach the console. Thanks @vincentkoc.</li>
<li>Replies/typing: keep typing alive for queued follow-up messages that are genuinely waiting behind an active run, instead of making chat surfaces look idle while work is queued. Fixes #65685. Thanks @papag00se.</li>
<li>ACP/Discord: suppress completion announce delivery for inline thread-bound ACP session runs, so Discord thread-bound ACP replies are not delivered twice. Fixes #60780. Thanks @solavrc.</li>
<li>Discord/threads: ignore webhook-authored copies in already-bound Discord session threads even when the webhook id differs, preventing PluralKit proxy copies from creating duplicate turn pressure. Fixes #52005. Thanks @acgh213.</li>
<li>Discord/threads: return the created thread as partial success when the follow-up initial message fails, so agents do not retry thread creation and create empty duplicate threads. Fixes #48450. Thanks @dahifi.</li>
<li>Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei.</li>
<li>macOS/config: preserve existing <code>gateway.auth</code> and unrelated config keys during app fallback writes, so dashboard or Talk settings changes cannot strand Control UI clients by dropping persisted auth. Fixes #75631. Thanks @Fuma2013.</li>
<li>Control UI/TUI: keep reconnecting chat sends bound to the same backing session id and let TUI relaunches resume the last selected session, avoiding silent fresh sessions after refresh, reconnect, or terminal restart. Fixes #63195, #68162, and #73546. Thanks @bond260312-cmyk, @zhong18804784882, and @mtuwei.</li>
<li>Plugins/tools: let plugin manifests declare static tool availability so reply startup skips unavailable plugin tool runtimes instead of importing factories that only return <code>null</code>. Thanks @shakkernerd.</li>
<li>Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has <code>reactionNotifications: "off"</code>, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120.</li>
<li>CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw.</li>
<li>Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury.</li>
<li>Config: log the "newer OpenClaw" version warning once per process instead of once per config snapshot read. (#75927) Thanks @romneyda.</li>
<li>Telegram/message actions: treat benign delete-message 400s as no-op warnings instead of runtime errors, so stale or already-removed messages do not create noisy delete failures. Fixes #73726. Thanks @Avicennasis.</li>
<li>Telegram: split long default markdown sends and media follow-up text into safe HTML chunks, so outbound messages over Telegram's limit no longer fail as one oversized Bot API request. Fixes #75868. Thanks @zhengsx.</li>
<li>Gateway/chat history: merge Claude CLI transcript imports for Anthropic-routed sessions that still have a Claude CLI binding, so local chat history does not hide CLI JSONL turns. Fixes #75850. Thanks @alfredjbclaw.</li>
<li>Media: trim serialized JSON suffixes after local <code>MEDIA:</code> directive file extensions, so generated-image metadata cannot pollute the parsed media path and cause false <code>ENOENT</code> delivery failures. Fixes #75182. Thanks @TnzGit and @hclsys.</li>
<li>Plugins/runtime: hot-reload Gateway plugin runtime surfaces after plugin enable/disable changes while keeping source-changing plugin install, update, and uninstall operations restart-backed so loaded module code is not reused. Fixes #72097.</li>
<li>Cron: make scheduler reload schedule comparison tolerate malformed persisted jobs, so one bad cron entry no longer aborts the whole tick. Fixes #75886. Thanks @samfox-ai.</li>
<li>Doctor/channels: warn after migrations when default Telegram or Discord accounts have no configured token and their env fallback (<code>TELEGRAM_BOT_TOKEN</code> or <code>DISCORD_BOT_TOKEN</code>) is unavailable, with secret-safe migration docs for checking state-dir <code>.env</code>. Fixes #74298. Thanks @lolaopenclaw.</li>
<li>Gateway/diagnostics: keep idle liveness samples in telemetry instead of visible warning logs unless diagnostic work is active, waiting, or queued. Thanks @vincentkoc.</li>
<li>Channels/cron: reject provider-prefixed targets for the wrong channel and let prefixed announce targets such as <code>telegram:123</code> select their channel when delivery falls back to <code>last</code>, so Telegram IDs cannot be coerced into WhatsApp phone numbers. Fixes #56839. Thanks @bencoremans.</li>
<li>Control UI/chat: keep live replies visible when a raw session alias such as <code>main</code> sends the chat turn but Gateway emits events under the canonical session key for the same run. Fixes #73716. Thanks @teebes.</li>
<li>CLI/models: reject <code>--agent</code> on <code>openclaw models set</code> and <code>set-image</code> instead of silently writing agent-scoped requests to global model defaults. Fixes #68391. Thanks @derrickabellard.</li>
<li>CLI: stop treating the legacy singular <code>openclaw tool ...</code> token as a plugin id under restrictive <code>plugins.allow</code>, so it falls through as a normal unknown/reserved command instead of suggesting a stale allowlist entry. Fixes #64732. Thanks @efe-arv, @SweetSophia, and @hashtag1974.</li>
<li>Media: write inbound media buffers through same-directory temp files before rename, so failed disk writes do not leave zero-byte artifacts for later voice transcription. Fixes #55966. Thanks @OpenCodeEngineer.</li>
<li>TTS/Telegram: keep trusted local audio generated by the TTS tool queued for voice-note delivery even when the run-level built-in tool list omits the raw <code>tts</code> name. Fixes #74752. Thanks @Loveworld3033 and @andyliu.</li>
<li>TTS: require explicit user or config audio intent for the agent speech tool so dashboard chats stay text unless audio is requested. Fixes #69777. Thanks @alexandre-leng.</li>
<li>Plugins/config: keep bundled source-checkout plugins from being runtime-gated by install-only <code>minHostVersion</code> metadata, accept prerelease host floors, trim plugin-service startup failures to one log line, and avoid broad channel-runtime loading during base config parsing. Thanks @vincentkoc.</li>
<li>Heartbeat: strip legacy <code>[TOOL_CALL]...[/TOOL_CALL]</code> and <code>[TOOL_RESULT]...[/TOOL_RESULT]</code> pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.</li>
<li>macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.</li>
<li>Providers/xAI: give Grok <code>web_search</code> a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129.</li>
<li>Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc.</li>
<li>Slack/DMs/routing: honor <code>dmHistoryLimit</code> for fresh 1:1 DMs, keep top-level DMs on stable DM sessions even when <code>replyToMode</code> targets thread replies, send text/block-only proactive DMs directly with <code>chat.postMessage(channel=<user id>)</code>, match Slack target route syntax such as <code>channel:C...</code>, <code>user:U...</code>, or <code><@U...></code>, and match public-channel allowlists against bare runtime channel IDs. Fixes #64427, #58832, #62042, #41608, and #41264; supersedes #56530. Thanks @brantley-creator, @daye-jjeong, @MarkMolina, @Winnsolutionsadmin, @babutree, and @Realworld404.</li>
<li>Slack/delivery/capabilities: preserve missing-scope details in outbound errors, read granted scopes from <code>auth.test</code> metadata before legacy APIs, retry Slack writes only for wrapped DNS request failures such as <code>EAI_AGAIN</code>, and prefer the account bound to the outbound target peer in multi-workspace sends. Fixes #62391, #44625, and #68789; supersedes #66807. Thanks @alexey-pelykh, @Qquanwei, @martingarramon, @sonnyb9, and @rijhsinghani.</li>
<li>Slack/message actions/tools: send media before follow-up Block Kit messages for file sends, forward agent-scoped media roots through the bundled upload-file path, resolve <code><!subteam^...></code> user-group mentions before waking mention-gated channels, and let <code>read</code> fetch an exact Slack message timestamp or thread reply. Fixes #51458, #64625, #73827, and #53943. Thanks @HirokiKobayashi-R, @benpchandler, @CG-Intelligence-Agent-Jack, and @zomars.</li>
<li>PDF/Gemini: send native PDF analysis API keys in the <code>x-goog-api-key</code> header instead of the request URL, keeping secrets out of proxy and access logs. Supersedes #60600. Thanks @garagon.</li>
<li>Web search/Gemini/DuckDuckGo/Brave/fetch: route abort signals into Gemini provider fetches, late-bind managed agent <code>web_search</code> calls to the current runtime config snapshot, reuse Google provider API key/base URL as lower-priority Gemini search fallbacks, pass Gemini freshness/date filters through grounding, include DuckDuckGo in setup, honor Gemini/Grok/x_search <code>baseUrl</code> overrides, point Brave metadata at canonical docs, support Brave LLM Context freshness/date ranges, resolve external <code>webFetchProviders</code> for non-sandboxed fetches, and point missing-key errors to <code>web_fetch</code> or browser where appropriate. Fixes #72995, #75420, #66498, #65862, #65870, and #74915; supersedes #57496, #65940, #61972, #65892, and #51005. Thanks @RoseKongPS, @richardmqq, @Aoiujz, @ismael-81, @Jah-yee, @Lanfei, @Magicray1217, @remusao, @ultrahighsuper, @mingmingtsao, and @zhaoyang97.</li>
<li>Slack/directory: make <code>openclaw directory peers/groups list --channel slack</code> prefer token-backed live readers and return the connected Slack account from <code>directory self</code>, so valid Slack tokens no longer produce empty directory CLI results. Fixes #50776. Thanks @pjaillon.</li>
<li>Slack: keep assistant typing status, temporary typing reactions, and status reactions active for group/channel turns that use message-tool-only visible replies, while still suppressing automatic source replies. Fixes #75877. Thanks @teosborne.</li>
<li>Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter.</li>
<li>Replies: strip legacy <code>[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]</code> pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua.</li>
<li>WhatsApp: close long-lived web sockets through Baileys <code>end(error)</code> before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber.</li>
<li>Twitch/plugins: emit a flat JSON Schema for Twitch channel config so single-account and multi-account configs validate before runtime load, and add source-checkout diagnostics for missing pnpm workspace dependencies. Thanks @vincentkoc.</li>
<li>Gateway/sessions: move hot transcript reads and mirror appends onto async bounded IO with serialized parent-linked writes, keeping large session histories from stalling Gateway requests and channel replies. Fixes #75656. Thanks @DerFlash.</li>
<li>macOS/Talk Mode: downmix multi-channel microphone buffers before handing them to Apple Speech across Push-to-Talk, Talk Mode, Voice Wake, and the wake-word tester, so pro audio interfaces no longer produce empty transcripts. Fixes #42533. Thanks @jbuecker.</li>
<li>macOS/Talk Mode: subscribe native WebChat to active-session transcript updates and render external spoken user turns in the chat thread instead of only showing assistant replies. Fixes #75155. Thanks @SledderBling.</li>
<li>macOS/Voice Wake: accept trigger-only phrases in the built-in Voice Wake test, matching the settings UI and runtime trigger-only path instead of requiring extra command text after the wake word. Fixes #64986. Thanks @zoiks65.</li>
<li>Cron/TTS: run cron announce payloads through the normal TTS directive transform before outbound delivery, so scheduled <code>[[tts]]</code> replies generate voice payloads instead of leaking raw tags. Fixes #52125. Thanks @kenchen3000.</li>
<li>WhatsApp: save downloadable quoted image media from reply context as inbound media, so agents can inspect an image that a user replied to instead of only seeing <code><media:image></code>. Fixes #59174. Thanks @gaffner.</li>
<li>Sessions/store: stop persisting the runtime-only <code>skillsSnapshot.resolvedSkills</code> array inside each session entry, so <code>sessions.json</code> no longer carries a copy of every parsed <code>SKILL.md</code> body for every active session; <code>ensureSkillSnapshot</code> rehydrates the array from disk on cold resume so the embedded runner, the Claude CLI skills plugin, and the Claude live-session fingerprint all see populated skills, and legacy stores self-heal on the next save. Refs #11950, #6650, #15000. Thanks @amoghasgekar.</li>
<li>Doctor/WhatsApp: warn when Linux crontabs still run the legacy <code>ensure-whatsapp.sh</code> health check, which can misreport <code>Gateway inactive</code> when cron lacks the systemd user-bus environment. Fixes #60204. Thanks @mySebbe.</li>
<li>Slack/setup: print the generated app manifest as plain JSON instead of embedding it inside the framed setup note, so it can be copied into Slack without deleting border characters. Fixes #65751. Thanks @theDanielJLewis.</li>
<li>Channels/WhatsApp: route CLI logout through the live Gateway and stop runtime-backed listeners before channel removal, so removing a WhatsApp account does not leave the old socket replying until restart. Fixes #67746. Thanks @123Mismail.</li>
<li>Voice Call/Twilio: honor TTS directive text and provider voice/model overrides during telephony synthesis, so <code>[[tts:...]]</code> tags are not spoken literally and voiceId overrides reach OpenAI/ElevenLabs calls. Fixes #58114. Thanks @legonhilltech-jpg.</li>
<li>Agents/session-locks: reclaim untracked current-process session locks with matching starttime during acquisition and startup cleanup, so Gateway restarts recover from self-owned orphan <code>.jsonl.lock</code> files. Fixes #75805; refs #49603. Thanks @cdznho.</li>
<li>Agents/subagents: initialize built-in context engines before native <code>sessions_spawn</code> resolves spawn preparation, so cliBackend-only cold starts no longer fail with an unregistered <code>legacy</code> context engine. Fixes #73095. (#73904) Thanks @brokemac79.</li>
<li>Plugins/Bonjour: ship the ciao runtime dependency with packaged OpenClaw so fresh OCM envs can start default mDNS discovery without a missing-module failure. Thanks @shakkernerd.</li>
<li>Agents/tools: scope reply plugin-tool discovery to manifest-declared tool owners and already-active matching tool entries, avoiding broad plugin runtime loading for narrow or core-only tool allowlists. Thanks @shakkernerd.</li>
<li>Agents/replies: defer implicit image model discovery and keep OAuth auth-store adoption on persisted profiles during reply startup, cutting OCM MarCodex warm prep to sub-second in live checks. Thanks @shakkernerd.</li>
<li>Plugins/tools: enforce <code>contracts.tools</code> as the manifest ownership contract for plugin tool registration, rejecting undeclared runtime tool names and adding bundled plugin drift coverage. Thanks @shakkernerd.</li>
<li>Agents/Codex: stop prompting message-tool-only source turns to finish with <code>NO_REPLY</code>, so quiet turns are represented by not calling the visible message tool instead of conflicting final-text instructions. Thanks @pashpashpash.</li>
<li>Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme.</li>
<li>Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer.</li>
<li>Gateway/config: allow <code>gateway config.patch</code> to update documented subagent thinking defaults. Fixes #75764. (#75802) Thanks @kAIborg24.</li>
<li>Plugins/CLI: keep git plugin install paths credential-free, preserve existing git checkouts until replacement succeeds, honor duplicate npm install mode, and remove managed git repos on uninstall. Thanks @vincentkoc.</li>
<li>Plugins/CLI: redact authenticated git URLs from git install command failure details, so failed clone or checkout output cannot leak credentials during plugin installs. Thanks @vincentkoc.</li>
<li>Channels/status reactions: remove stale non-terminal lifecycle reactions when a run reaches done or error, so Discord does not leave a permanent thinking emoji after completion. Fixes #75458. Thanks @davelutztx.</li>
<li>Discord/doctor: migrate unsupported per-channel <code>agentId</code> entries under guild channel config into top-level <code>bindings[]</code> routes, so <code>openclaw doctor --fix</code> preserves the intended agent route instead of stripping it as an unknown key. Fixes #62455. Thanks @lobster-biscuit.</li>
<li>Discord/DMs: set inbound direct-message <code>ctx.To</code> to the semantic <code>user:<id></code> target while keeping delivery routed through the DM channel, so mirror and recovery paths do not treat DMs as channel conversations. Fixes #68126. Thanks @illuminate0623.</li>
<li>Discord/DMs: keep no-guild inbound messages on direct-message routing when Discord channel lookup is temporarily unavailable, preventing degraded DMs from forking into channel sessions. Fixes #59817. Thanks @DooPeePey.</li>
<li>Discord: retry outbound API calls on HTTP 5xx, request-timeout, and transient transport failures instead of only Discord rate limits, reducing dropped cron and agent replies during short Discord or network outages. Fixes #52396. Thanks @sunshineo.</li>
<li>Discord: include Components v2 Text Display content from referenced replies and forwarded snapshots, so component-only messages still appear in reply context. Fixes #56228. Thanks @HollandDrive.</li>
<li>Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos.</li>
<li>Discord: preserve native slash-command description localizations through command reconcile, so localized Discord descriptions no longer get overwritten by English defaults. Fixes #56580. Thanks @mhseo93.</li>
<li>Discord: add configured outbound mention aliases so known <code>@Name</code> references can be rewritten to real Discord user mentions instead of relying only on the transient directory cache. Fixes #67587. Thanks @McoreD.</li>
<li>Discord: avoid startup REST amplification by skipping native command deploy retries after Discord rate limits and deriving the bot id from parseable bot tokens instead of requiring a <code>/users/@me</code> lookup. Fixes #75341. Thanks @PrinceOfEgypt.</li>
<li>Plugins/hooks: derive hook <code>ctx.channelId</code> from the conversation target instead of the provider name, so Discord and other channel plugins can keep per-channel state isolated. Fixes #59881. Thanks @bradfreels.</li>
<li>Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.</li>
<li>Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated <code>session.stuck</code> diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu.</li>
<li>Gateway/agents: avoid rebuilding core tools for plugin-only allowlists and keep the full plugin registry cache warm across scoped plugin loads, reducing per-turn latency spikes. Fixes #75882, #75907, #75906, #75887, and #75851. (#75922) Thanks @obviyus.</li>
<li>Agents/failover: classify bare <code>status: internal server error</code> provider messages as retryable server errors so model fallback can rotate instead of stopping. (#73844) Thanks @thesomewhatyou.</li>
<li>Gateway/startup: return the shared retryable startup-sidecars error for startup-gated control-plane RPCs such as sessions.create, sessions.send, sessions.abort, agent.wait, and tools.effective, so clients can retry early sidecar races. (#76012) Thanks @scoootscooob.</li>
<li>Providers/Google: fix Gemini 2.5 Flash-Lite <code>reasoning: "minimal"</code> rejections by raising its thinking-budget floor to 512 while preserving the existing Gemini 2.5 Pro and Flash minimal presets. (#70629) Thanks @ericberic.</li>
<li>Agents/status: resolve <code>session_status(sessionKey="current")</code> for sparse channel-plugin sessions after literal current lookups miss, so Scope, Slack, Discord, and other plugin-driven agents avoid retrying through <code>Unknown sessionKey: current</code>. Fixes #74141. (#72306) Thanks @bittoby.</li>
<li>Cron: retry recurring wake-now main-session jobs through temporary heartbeat busy skips before recording success, so queued cron events no longer appear as ok ghost runs while the main lane is still busy. Fixes #75964. (#76083) Thanks @kshetrajna12 and @xuruiray.</li>
<li>Providers/Google: keep Gemini thinking-signature-only stream chunks active during reasoning, so Gemini 3.1 Pro Preview replies no longer hit idle timeouts before visible text. Fixes #76071. (#76080) Thanks @marcoschierhorn and @zhangguiping-xydt.</li>
<li>CLI/skills: show per-agent model and command visibility in <code>openclaw skills check --agent</code>, and let doctor report or disable unavailable skills allowed for the default agent. (#75983) Thanks @mbelinky.</li>
<li>Agents/runtime/tools: keep reply startup on Gateway metadata, manifest catalog rows, auth-store state, and plugin loader cache-key compatibility checks so scoped runtime registries, model allowlists, thinking metadata, media/PDF/generation tools, Comfy workflows, OpenAI Codex OAuth image generation, and image/video/music tool registration avoid broad provider/runtime loads while preserving explicit config and auth-backed providers. Thanks @shakkernerd.</li>
<li>Discord: document canonical mention formatting in agent prompt hints and channel docs so outbound replies use <code><@USER_ID></code>, <code><#CHANNEL_ID></code>, and <code><@&ROLE_ID></code> instead of legacy nickname mentions. (#75173)</li>
<li>Heartbeat scheduler: gate exec-event/notification/spawn/retry wakes through a centralized cooldown so backgrounded <code>process.start</code> exit notifications can no longer self-feed runaway heartbeat runs (configured <code>every: "30m"</code> was firing every ~10s in production, pegging the gateway event loop with <code>eventLoopDelayMaxMs >6s</code> spikes that stalled control-UI asset serving and TUI handshakes). Documented wake-now paths (<code>manual</code>, <code>wake</code>, task completion, blocked-task follow-up, <code>/hooks/wake mode=now</code>, and cron <code>--wake now</code>) remain immediate; retryable busy skips no longer poison the cooldown for the next retry; per-agent flood guard caps any unexpected feedback loop at 5 runs/60s. (#64016, refs #17797 and #75436) Thanks @hexsprite.</li>
<li>fix: block workspace CLOUDSDK_PYTHON override and always set trusted interpreter for gcloud. (#74492) Thanks @pgondhi987.</li>
<li>Providers/Z.AI: move the bundled GLM catalog and auth env metadata into the plugin manifest, so <code>models list --all --provider zai</code> shows the full known catalog without duplicated runtime seed data. Thanks @shakkernerd.</li>
<li>Providers/Qianfan and Providers/Stepfun: declare setup auth metadata (<code>api-key</code> method, <code>QIANFAN_API_KEY</code>, <code>STEPFUN_API_KEY</code>) in the plugin manifest so onboarding and <code>models setup</code> surface the expected env var without falling back to legacy <code>providerAuthEnvVars</code> runtime seed data. Thanks @shakkernerd.</li>
<li>fix(infra): block ambient Homebrew env vars from brew resolution. (#74463) Thanks @pgondhi987.</li>
<li>Onboarding/configure: avoid staging every default plugin runtime dependency after config writes, so skipped setup flows only prepare config-selected plugin deps instead of pulling broad feature-plugin packages. Thanks @vincentkoc.</li>
<li>Thinking/providers: resolve bundled provider thinking profiles through lightweight provider policy artifacts when startup-lazy providers are not active, so OpenAI Codex GPT-5.x keeps xhigh available in Gateway session validation. Fixes #74796. Thanks @maxschachere.</li>
<li>Security/Windows: ignore workspace <code>.env</code> system-path variables and resolve stale-process <code>taskkill.exe</code> from the validated Windows install root, preventing repository-local env files from redirecting cleanup helpers. Thanks @pgondhi987.</li>
<li>CLI/plugins: refresh persisted plugin registry policy in place for <code>plugins enable</code> and <code>plugins disable</code>, so routine toggles no longer rebuild and hash every plugin source when the target is already indexed. Thanks @vincentkoc.</li>
<li>Windows/install: run npm from a writable installer temp directory and pin the Bedrock runtime dependency below a Windows ARM Node 24 npm resolver failure, so global OpenClaw installs no longer fail before onboarding. Thanks @mariozechner.</li>
<li>CLI/plugins: scope install and enable slot selection to the selected plugin manifest/runtime fallback, so plugin installs no longer load every plugin runtime or broad status snapshot just to update memory/context slots. Thanks @vincentkoc.</li>
<li>Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc.</li>
<li>Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in <code>googlemeet doctor</code>, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf.</li>
<li>Plugins/tools: prefer built bundled plugin code during tool discovery and skip channel runtime hydration while preserving companion provider registrations, reducing per-run plugin-tool prep cost without dropping executable plugin tools. Fixes #75290. Thanks @thanos-openclaw.</li>
<li>Plugins/loader: scope plugin-tool registry reuse to the enabled plugin plan and stored Gateway method keys, so embedded runner tool lookup can reuse compatible startup registries without hiding enabled non-startup plugin tools. Fixes #75520. Thanks @whtoo.</li>
<li>Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps.</li>
<li>Discord/Slack: defer status-reaction cleanup until run finalization so queued, thinking, tool, and terminal reactions no longer flicker during normal progress updates. (#75582)</li>
<li>Discord/voice: leave voice off for text-only configs unless explicitly configured, rerun configured voice auto-join after gateway RESUMED events, ignore already-destroyed stale voice connections during reconnect cleanup, lengthen the default voice join Ready wait with configurable timeouts, merge configured media-understanding providers such as Deepgram into partial active registries, apply per-channel <code>systemPrompt</code> overrides to voice transcript turns, and run voice-channel turns under a voice-output policy that hides the agent <code>tts</code> tool. Fixes #73753, #40665, #63098, #65687, #47095, and #61536; refs #74044, #39825, and #65039. Thanks @sanchezm86, @SecureCloudProjO, @liz709, @darealgege, @kzicherman, @ayochim, @OneMintJulep, @qearlyao, and @aounakram.</li>
<li>Plugins/CLI: reuse the cold manifest registry while building plugin status and inspect reports, so large configured plugin sets no longer rediscover the bundled/plugin registry once per inspect row. Thanks @vincentkoc.</li>
<li>Gateway/health: refresh cached health RPC snapshots when channel runtime state diverges, so Discord and other channel status reads no longer report stale running or connected values until the cache TTL expires. (#75423)</li>
<li>Gateway/sessions: keep session-store reads from running stale prune and entry-count cap maintenance during startup, so oversized stores no longer block chat history readiness after updates while writes and <code>sessions cleanup --enforce</code> still preserve the cleanup safeguards. Fixes #70050. Thanks @tangda18.</li>
<li>Security/audit: keep plain <code>security audit</code> on the cold config/filesystem path and reserve plugin runtime security collectors for <code>--deep</code>, so large plugin installs cannot execute every plugin runtime during routine audits. Thanks @vincentkoc.</li>
<li>WhatsApp: stage <code>qrcode</code> through root mirrored runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001.</li>
<li>Interactive channel payloads: send Discord component-only interaction replies, Slack block-only slash replies, Telegram button/select fallback labels, and LINE quick-reply fallback option text instead of accepting empty renderable payloads. Thanks @vincentkoc.</li>
<li>Auto-reply/docking: require <code>/dock-*</code> route switches to start from direct chats, so group or channel participants cannot reroute a shared session's future replies into a linked DM. Thanks @vincentkoc.</li>
<li>Discord: keep text-DM main-session route updates pinned to the configured DM owner, matching component interactions so another direct-message sender cannot redirect future main-session replies. Thanks @vincentkoc.</li>
<li>Mattermost/Matrix: keep direct-message main-session route updates pinned to the configured DM owner so paired or temporarily allowed senders cannot redirect future shared-session replies. Thanks @vincentkoc.</li>
<li>Discord: keep SecretRef-backed bot tokens discoverable for message actions without resolving the token during schema generation, and resolve scoped channel SecretRefs before outbound agent message sends even when the tool is built from a config snapshot. Fixes #75324. Thanks @slideshow-dingo and @Conan-Scott.</li>
<li>Updates: run package post-install doctor repair with the managed Gateway service profile and state paths when a daemon is installed, so shell/profile mismatches no longer repair the caller state while the restarted Gateway keeps stale config. Thanks @vincentkoc.</li>
<li>Models/DeepInfra: declare DeepInfra manifest catalog discovery and derive its runtime fallback catalog from the manifest, restoring provider-filtered <code>models list --all --provider deepinfra</code> rows without duplicated static model data. Thanks @shakkernerd.</li>
<li>CLI/update: verify managed gateway restarts against the installed service port instead of the caller shell port, so package updates do not report a healthy daemon as failed when profiles use different gateway ports. Thanks @vincentkoc.</li>
<li>Gateway/agent: reject strict <code>openclaw agent --deliver</code> requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc.</li>
<li>Setup/import: honor non-interactive <code>--import-from</code> onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc.</li>
<li>Doctor/plugins: keep plain <code>doctor --non-interactive</code> from installing bundled plugin runtime dependencies, so headless health checks report missing deps while <code>doctor --fix</code> remains the explicit repair path. Thanks @vincentkoc.</li>
<li>Doctor/gateway: require an interactive confirmation before installing or rewriting the Gateway service, so <code>doctor --fix --non-interactive</code> can repair plugin/config drift without replacing the operator's launchd/systemd service from a temporary environment. Thanks @vincentkoc.</li>
<li>Plugins/runtime-deps: include packaged OpenClaw identity in bundled plugin loader cache keys, so same-path package upgrades stop reusing stale versioned runtime-deps mirrors. Fixes #75045. Thanks @sahilsatralkar.</li>
<li>Plugin SDK: restore reply-prefix and reply-pipeline helpers on the deprecated root/compat SDK surface so external plugins still using <code>openclaw/plugin-sdk</code> do not fail message dispatch after update. Fixes #75171. Thanks @zhangxiliang.</li>
<li>Plugins/runtime-deps: prune inactive same-package versioned runtime-deps roots after bundled dependency repair, so upgrades do not leave old <code>openclaw-<version>-<hash></code> package caches behind after doctor runs. Thanks @vincentkoc.</li>
<li>Plugins/runtime-deps: prune legacy version-scoped plugin runtime-deps roots during bundled dependency repair and cover the path in Package Acceptance's upgrade-survivor matrix, so upgrades from 2026.4.x no longer leave stale per-plugin runtime trees after doctor runs. Thanks @vincentkoc.</li>
<li>Plugins/runtime-deps: keep Gateway startup plugin imports and runtime plugin fallback loads verify-only after startup/config repair planning, so packaged installs no longer spawn package-manager repair from hot paths after readiness. Refs #75283 and #75069. Thanks @brokemac79 and @xiaohuaxi.</li>
<li>Plugins/runtime-deps: treat package.json runtime-deps manifests as supersets when generated materialization metadata is absent, so bundled plugin activation stops restaging already-installed dependency subsets on every activation. Fixes #75429. (#75431) Thanks @loyur.</li>
<li>iMessage: add stdin write callback and error listener to IMessageRpcClient so async EPIPE from a closed child process rejects the pending request instead of crashing the gateway with uncaughtException. Fixes #75438.</li>
<li>MCP/stdio: settle MCP stdio transport send() from the write callback instead of resolving immediately on buffer acceptance, so async write errors reject the promise instead of being lost. Refs #75438.</li>
<li>Process/exec: add stdin error listener in runCommandWithTimeout so EPIPE from a prematurely-exited child is swallowed instead of escaping to uncaughtException. Refs #75438.</li>
<li>Voice Call/realtime: add default-off fast memory/session context for <code>openclaw_agent_consult</code>, giving live calls a bounded answer-or-miss path before the full agent consult. Fixes #71849. Thanks @amzzzzzzz.</li>
<li>Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson.</li>
<li>Gateway/config: cap oversized plugin-owned schemas in the full <code>config.schema</code> response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc.</li>
<li>Plugins/update: skip ClawHub and marketplace plugin updates when the bundled version is newer than the recorded installed version, so <code>openclaw update</code> no longer overwrites working bundled plugins with older external packages. Fixes #75447. Thanks @amknight.</li>
<li>Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks and cap bulk title/last-message hydration, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc.</li>
<li>Gateway/sessions: yield during bulk transcript title/preview hydration and copy compaction checkpoints asynchronously, keeping the Gateway event loop responsive for large session stores and large transcripts. Refs #75330 and #75414. Thanks @amknight.</li>
<li>Gateway/sessions: stream bounded transcript reads for session detail, history, artifacts, compaction, and send/subscribe sequence paths so small Gateway requests no longer materialize large transcripts or OOM on oversized session logs. Thanks @vincentkoc.</li>
<li>Gateway/chat: bound chat-history transcript reads to the requested display window so large session logs no longer OOM the Gateway when clients ask for a small history page. Thanks @vincentkoc.</li>
<li>BlueBubbles: detect audio attachments by Apple UTIs (<code>public.audio</code>, <code>public.mpeg-4-audio</code>, <code>com.apple.m4a-audio</code>, <code>com.apple.coreaudio-format</code>) in addition to <code>audio/*</code> MIME, so iMessage voice notes whose webhook payload only carries the UTI are now classified as audio in the inbound <code><media:audio></code> placeholder instead of falling through to the generic <code><media:attachment></code> tag. Thanks @omarshahine.</li>
<li>Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP.</li>
<li>Docs/sandboxing: clarify that sandbox setup scripts (<code>sandbox-setup.sh</code>, <code>sandbox-common-setup.sh</code>, <code>sandbox-browser-setup.sh</code>) are only available from a source checkout, and add inline <code>docker build</code> commands for npm-installed users so sandbox image setup works without cloning the repo. Fixes #75485. Thanks @amknight.</li>
<li>Google Meet/Voice Call: play Twilio Meet DTMF before opening the realtime media stream and carry the intro as the initial Voice Call message, so the greeting is generated after Meet admits the phone participant instead of racing a live-call TwiML update. Thanks @donkeykong91 and @PfanP.</li>
<li>Google Meet/Voice Call: make Twilio setup preflight honor explicit <code>--transport twilio</code> and fail local/private Voice Call webhook URLs, including IPv6 loopback and unique-local forms, before joins. Thanks @donkeykong91 and @PfanP.</li>
<li>Voice Call/Twilio: retry transient 21220 live-call TwiML updates and catch answered-path initial-greeting failures, so a fast answered callback no longer crashes the Gateway or drops the Twilio greeting/listen transition. (#74606) Thanks @Sivan22.</li>
<li>CLI/startup: preserve <code>OPENCLAW_HIDE_BANNER</code> banner suppression for route-first startup callers that rely on the default process environment while keeping read-only status/channel paths from repairing bundled plugin runtime dependencies. Refs #75183.</li>
<li>Voice Call/Twilio: register accepted media streams immediately but wait for realtime transcription readiness before speaking the initial greeting, so reconnect grace handling stays live while OpenAI STT startup is no longer starved by TTS. Fixes #75197. (#75257) Thanks @donkeykong91 and @PfanP.</li>
<li>Voice Call CLI: run gateway-delegated <code>voicecall continue</code> through operation-id polling and protocol-shaped errors, so long conversational turns keep their transcript result without blocking a single Gateway RPC. (#75459) Thanks @serrurco and @DougButdorf.</li>
<li>Voice Call CLI: delegate operational <code>voicecall</code> commands to the running Gateway runtime and skip webhook startup during CLI-only plugin loading, preventing webhook port conflicts and <code>setup --json</code> hangs. Fixes #72345. Thanks @serrurco and @DougButdorf.</li>
<li>Agents/pi-embedded-runner: extract the <code>abortable</code> provider-call wrapper from <code>runEmbeddedAttempt</code> to module scope so its promise handlers no longer close over the run lexical context, releasing transcripts, tool buffers, and subscription callbacks when a provider call hangs past abort. (#74182) Thanks @cjboy007.</li>
<li>Docker: restore <code>python3</code> in the gateway runtime image after the slim-runtime switch. Fixes #75041.</li>
<li>Agents/session-repair: fix resumed sessions failing with repeated 400 errors on Anthropic and strict OpenAI-compatible providers (Qwen, mlx-vlm) after an interrupted conversation or blank user input. Fixes #75271 and #75313. Thanks @amknight.</li>
<li>CLI/Voice Call: scope <code>voicecall</code> command activation to the Voice Call plugin so setup and smoke checks no longer broad-load unrelated plugin runtimes or hang after printing JSON. Thanks @vincentkoc.</li>
<li>Doctor/plugins: warn when restrictive <code>plugins.allow</code> is paired with wildcard or plugin-owned tool allowlists, making the exclusive plugin allowlist behavior visible before users hit empty callable-tool runs. Refs #58009 and #64982. Thanks @KR-Python and @BKF-Gitty.</li>
<li>Google Meet/Voice Call: keep Twilio Meet joins in conversation mode and reuse the realtime intro prompt when no voice-call-specific intro is configured, so answered phone bridge calls speak instead of joining silently. Refs #72478. Thanks @DougButdorf.</li>
<li>Auto-reply/group chats: keep the <code>message</code> tool available for message-tool-only visible replies and apply group-scoped tool policy before deciding fallback delivery, so Discord/Slack-style rooms reply visibly in the correct channel after upgrades. Fixes #74842; refs #75207. Thanks @davelutztx and @aa-on-ai.</li>
<li>Agents/commitments: keep inferred follow-ups internal when heartbeat target is none, strip raw source text from stored commitments, disable tools during due-commitment heartbeat turns, bound hidden extraction queue growth, expire stale commitments, and add QA/Docker safety coverage. Thanks @vignesh07.</li>
<li>Telegram/agents: keep typing indicators and optional generation tools off the reply critical path, so fresh Telegram replies no longer stall while provider catalogs and media models load. (#75360) Thanks @obviyus.</li>
<li>Agents/commitments: run hidden follow-up extraction on the configured agent/default model instead of falling back to direct OpenAI, so OpenAI Codex OAuth-only gateways no longer spam background API-key failures. Fixes #75334. Thanks @sene1337.</li>
<li>Agents/media: keep async music generation completions on the requester-session wake path even when direct-send completion is enabled, so finished audio stays agent-mediated while video can still opt into direct channel delivery. (#75335) Thanks @vincentkoc.</li>
<li>Security/config-audit: redact CLI argv and execArgv secrets before persisting config audit records, covering write, observe, and recovery paths. Fixes #60826. Thanks @koshaji.</li>
<li>Gateway/models: keep default and configured model-list views responsive when provider catalog discovery stalls, without hiding real catalog load failures, while <code>--all</code> still waits for the exact full catalog. Fixes #75297; refs #74404. Thanks @lisandromachado and @najef1979-code.</li>
<li>Plugins/runtime-deps: accept already materialized package-level runtime-deps supersets as converged, so later lazy plugin activation no longer prunes and relaunches <code>pnpm install</code> after gateway startup pre-staging, reducing event-loop pressure from repeated runtime-deps repair on packaged installs. Fixes #75283; refs #75297 and #72338. Thanks @brokemac79, @lisandromachado, and @midhunmonachan.</li>
<li>Plugins/runtime-deps: remove OpenClaw-owned legacy runtime-deps symlinks before replacing staged bundled plugin dependencies, so updates can recover from older symlinked installs instead of failing the symlink safety guard. Thanks @goldmar.</li>
<li>Discord: retry queued REST 429s against learned bucket/global cooldowns and reacquire fresh voice upload URLs after CDN upload rate limits, so outbound sends recover without reusing stale single-use upload URLs. Thanks @discord.</li>
<li>TTS/providers: keep bundled speech-provider compat fallback available when plugins are globally disabled, so cold gateway and CLI startup can still resolve fallback speech providers instead of leaving explicit TTS provider selection with no registered providers. Refs #75265. Thanks @sliekens.</li>
<li>Discord: collapse repeated native slash-command deploy rate-limit startup logs into one non-fatal warning while keeping per-request REST timing in verbose output. Thanks @discord.</li>
<li>Discord: report native slash-command deploy aborts as REST timeouts with method, path, timeout budget, and observed duration, so startup logs explain slow Discord API calls instead of showing a generic aborted operation. Thanks @discord.</li>
<li>Security/logging: redact payment credential field names such as card number, CVC/CVV, shared payment token, and payment credential across default log and tool-payload redaction patterns so wallet-style MCP tools do not expose raw payment credentials in UI events or transcripts. Thanks @stainlu.</li>
<li>Providers/OpenAI Codex: preserve existing wrapped Codex streams during OpenAI attribution so PI OAuth bearer injection reaches ChatGPT/Codex Responses, and strip native Codex-only unsupported payload fields without touching custom compatible endpoints. (#75111) Thanks @keshavbotagent.</li>
<li>Plugins/runtime-deps: materialize newly required bundled plugin packages after local <code>openclaw onboard</code> and <code>openclaw configure</code> config writes, while keeping remote setup read-only, so first Gateway startup no longer discovers missing channel/provider deps after setup claimed success. Fixes #75309; refs #75069. Thanks @scottgl9 and @xiaohuaxi.</li>
<li>Plugins/runtime-deps: expire stale legacy install locks whose live PID cannot be tied to the current process incarnation, so Docker PID reuse no longer leaves bundled dependency repair stuck behind old <code>.openclaw-runtime-deps.lock</code> directories. Fixes #74948; refs #74950 and #74346. Thanks @dchekmarev.</li>
<li>Plugins/runtime-deps: recover interrupted bundled runtime-dependency installs whose package sentinels exist but generated materialization is incomplete, forcing npm/pnpm repair in Gateway startup, doctor, and lazy plugin loads instead of leaving channels crash-looping on missing packages. Fixes #75309; refs #75310, #75296, and #75304. Thanks @scottgl9.</li>
<li>Plugins/runtime-deps: treat no-main and export-map package sentinels without reachable entry files as incomplete, so Gateway startup, doctor, and lazy plugin loads repair interrupted bundled dependency installs instead of accepting package.json-only partial installs. Fixes #75309; refs #75183. Thanks @shakkernerd.</li>
<li>Plugins/runtime-deps: keep runtime inspection and channel maintenance commands from downloading bundled plugin dependencies, route explicit repairs through <code>openclaw plugins deps --repair</code>, and still allow Gateway/DO paths to repair missing deps before import. Refs #75069. Thanks @xiaohuaxi.</li>
<li>Updates: force non-deferred, no-cooldown update restarts after package-manager updates requested through the live Gateway control plane and fail release validation on post-swap stale chunk import crashes, so Telegram/Discord imports do not stay pointed at removed dist files. Fixes #75206. Thanks @xonaman and @faux123.</li>
<li>Agents/tool-result guard: use the resolved runtime context token budget for non-context-engine tool-result overflow checks, so long tool-heavy sessions no longer compact early when <code>contextTokens</code> is larger than native <code>contextWindow</code>. Fixes #74917. Thanks @kAIborg24.</li>
<li>Gateway/systemd: exit with sysexits 78 for supervised lock and <code>EADDRINUSE</code> conflicts so <code>RestartPreventExitStatus=78</code> stops <code>Restart=always</code> restart loops instead of repeatedly reloading plugins against an occupied port. Fixes #75115. Thanks @yhyatt.</li>
<li>Agents/runtime: skip blank visible user prompts at the embedded-runner boundary before provider submission while still allowing internal runtime-only turns and media-only prompts, so Telegram/group sessions no longer leak raw empty-input provider errors when replay history exists. Fixes #74137. Thanks @yelog, @Gracker, and @nhaener.</li>
<li>Agents/Codex: isolate local Codex app-server <code>CODEX_HOME</code> and <code>HOME</code> per agent and add a deliberate Codex migration path with selectable skill copies, so personal Codex CLI skills, plugins, config, and hooks no longer leak into OpenClaw agents unless the operator migrates them into the workspace. Thanks @pashpashpash.</li>
<li>Security/Nextcloud Talk: make webhook signature validation use the padded timing-safe compare path even when the supplied signature length is wrong, keep normalized header lookup behavior, and extend regression coverage for tampered bodies, wrong secrets, array-backed headers, and truncated signatures. Carries forward earlier contributor work from #50516 by teddytennant. (#58097) Thanks @gavyngong.</li>
<li>Plugins/runtime-deps: replace stale symlinked mirror target roots before writing runtime-mirror temp files and skip rewriting already materialized hardlinks, so cross-version container upgrades no longer crash-loop on read-only image-layer paths while warm mirrors do less churn. Fixes #75108; refs #75069. Thanks @coletebou and @xiaohuaxi.</li>
<li>Auto-reply/group chats: fall back to automatic source delivery when a channel precomputes message-tool-only replies but the <code>message</code> tool is unavailable, so Discord/Slack-style group turns do not silently complete without a visible reply. Fixes #74868. Thanks @kagura-agent.</li>
<li>Browser/gateway: share one browser control runtime across the HTTP control server and <code>browser.request</code>, and refresh browser profile config from the source snapshot, so CLI status/start honors configured <code>browser.executablePath</code>, <code>headless</code>, and <code>noSandbox</code> instead of falling back to stale auto-detection. Fixes #75087; repairs #73617. Thanks @civiltox and @martingarramon.</li>
<li>Agents/subagents: bound automatic orphan recovery with persisted recovery attempts and a wedged-session tombstone, and teach task maintenance/doctor to reconcile those sessions so restart loops no longer require manual <code>sessions.json</code> surgery. Fixes #74864. Thanks @solosage1.</li>
<li>Plugins/runtime-deps: keep bundled provider policy config loading from staging plugin runtime dependencies, so config reads no longer fail on locked-down <code>/var/lib/openclaw/plugin-runtime-deps</code> directories. Fixes #74971. Thanks @eurojojo.</li>
<li>Memory/runtime-deps: retain the native <code>node-llama-cpp</code> runtime only when local memory search is configured, so packaged installs can repair local embeddings without relying on unreachable global npm installs. Fixes #74777. Thanks @LLagoon3.</li>
<li>Gateway/startup: skip pre-bind web-fetch provider discovery for credential-free <code>tools.web.fetch</code> config, so Docker/Kubernetes gateways bind even when optional fetch limits are present. Fixes #74896. Thanks @KoykL.</li>
<li>Signal: match group allowlists against inbound Signal group ids as well as sender ids, and process explicitly configured Signal groups without requiring mentions unless <code>requireMention</code> is set. Fixes #53308. Thanks @minupla and @juan-flores077.</li>
<li>Signal: bound <code>signal-cli</code> installer release and archive downloads with explicit timeouts, declared and streamed size checks, and partial-file cleanup. Fixes #54153. Thanks @jinduwang1001-max and @juan-flores077.</li>
<li>Slack: require bot-authored room messages with <code>allowBots=true</code> to come from an explicitly channel-allowlisted bot or from a room where an explicit Slack owner is present, so broad bot relays cannot run unattended. Fixes #59284. Thanks @andrewhong-translucent.</li>
<li>Signal: derive <code>getAttachment</code> HTTP response caps from <code>channels.signal.mediaMaxMb</code> with base64 headroom, so inbound photos and videos no longer drop behind the 1 MiB RPC default. Fixes #73564. Thanks @heyhudson.</li>
<li>Signal: keep the long-lived receive SSE monitor open while idle instead of applying the 10s RPC/check deadline, so <code>signal-cli</code> 0.14.3 event streams no longer reconnect before inbound messages arrive. Fixes #74741. Thanks @fgabelmannjr and @k7n4n5t3w4rt.</li>
<li>CLI/progress: suppress nested progress spinners and line clears while TUI input owns raw stdin, so Crestodian <code>/status</code> no longer disturbs the active input row. (#75003) Thanks @velvet-shark.</li>
<li>Models/OpenAI Codex: restore <code>openai-codex/gpt-5.4-mini</code> for ChatGPT/Codex OAuth PI runs after live OAuth proof, and align the manifest, forward-compat metadata, docs, and regression tests so stale cron and heartbeat configs resolve again. Fixes #74451. Thanks @0xCyda, @hclsys, and @Marvae.</li>
<li>Plugins/runtime-deps: always write a dependency map in generated runtime-deps install manifests, so npm does not crash or prune staged bundled-plugin packages when the plan is empty. Fixes #74949. Thanks @hclsys.</li>
<li>Telegram: use durable message edits for streaming previews instead of native draft state, so generated replies no longer flicker through draft-to-message transitions that look like duplicates. (#75073) Thanks @obviyus.</li>
<li>Telegram: echo preflighted DM voice-note transcripts back to the originating chat, including Telegram DM topic thread metadata, instead of only echoing later media-understanding transcripts. Fixes #75084. Thanks @M-Lietz.</li>
<li>Telegram: clamp low long-polling client timeouts so configured <code>timeoutSeconds</code> values below the <code>getUpdates</code> poll window no longer force a fresh HTTPS connection every few seconds. Fixes #75114. Thanks @hpinho77.</li>
<li>Web search: describe <code>web_search</code> as using the configured provider instead of hard-coding Brave when DuckDuckGo or another provider is active. Fixes #75088. Thanks @sun-rongyang.</li>
<li>Infra/tmp: tolerate concurrent temp-dir permission repairs by rechecking directories that another process already tightened, so parallel ACP subprocess startup no longer throws <code>Unsafe fallback OpenClaw temp dir</code>. Fixes #66867. Thanks @Kane808-AI and @jarvisz8.</li>
<li>Agents/compaction: add an opt-in <code>agents.defaults.compaction.midTurnPrecheck</code> mid-turn precheck that detects tool-loop context pressure and triggers compaction before the next tool call instead of waiting for end-of-turn. (#73499) Thanks @marchpure and @haoxingjun.</li>
<li>Gateway/approvals: let loopback token/password-backed native approval clients resolve exec approvals without attaching stale paired Gateway identities, while remote and unauthenticated approval clients keep normal device identity behavior. (#74472)</li>
<li>Gateway/config: include rejected validation paths in foreground and service last-known-good recovery logs plus main-agent notices, so unsupported direct edits explain which key caused restore instead of looking like silent reversion. Fixes #75060. Thanks @amknight.</li>
<li>Plugins/runtime-deps: hash the OS-canonical <code>packageRoot</code> via <code>fs.realpathSync.native</code> (with <code>path.resolve</code> fallback) when computing the bundled runtime-deps stage key, so loader and channel <code>bundled-root</code> callers no longer derive divergent stage directories under <code>~/.openclaw/plugin-runtime-deps/openclaw-<version>-<hash>/</code> and bundled channels stop failing with <code>ENOENT</code> on shared dist chunks under Windows npm symlinks, junctions, or PM2 multi-instance worker layouts. Fixes #74963. (#75048) Thanks @openperf and @vincentkoc.</li>
<li>fix(logging): add redaction patterns for Tencent Cloud, Alibaba Cloud, HuggingFace and Replicate API keys (#58162). Thanks @gavyngong</li>
<li>Pairing: surface unexpected allowlist filesystem stat errors instead of treating the allowlist as missing, so permission and I/O failures are visible during pairing authorization checks. (#63324) Thanks @franciscomaestre.</li>
<li>macOS app: reserve layout space for exec approval command details so the allow dialog no longer overlaps the command, context, and action buttons. (#75470) Thanks @ngutman.</li>
<li>Agents/failover: carry <code>sessionId</code>, <code>lane</code>, <code>provider</code>, <code>model</code>, and <code>profileId</code> attribution through <code>FailoverError</code> and <code>describeFailoverError</code>/<code>coerceToFailoverError</code> so structured error logs (e.g. <code>gateway.err.log</code> ingestion) can attribute exhausted-fallback wrapper errors to the originating session and last-attempted provider instead of dropping the metadata after the per-profile errors. Fixes #42713. (#73506) Thanks @wenxu007.</li>
<li>Context Engine: treat assembled prompt as the default authority for preemptive overflow prechecks so engines that return a windowed, self-contained context no longer trigger false hard-fail compactions on huge raw history. Engines whose assembled view can hide overflow risk can opt back into the legacy behavior with <code>AssembleResult.promptAuthority: "preassembly_may_overflow"</code>. (#74255) Thanks @100yenadmin.</li>
<li>Mattermost: refresh current native slash command registrations before accepting callbacks so stale tokens from deleted or regenerated commands stop being accepted without a gateway restart while failed validations stay briefly cached and lookup starts are rate-limited per command, gate each callback against the resolved command's own startup token so a token leaked for one slash command cannot poison another command's failure cache, redact slash validation lookup errors, and add a body read timeout to the multi-account routing path so slow callback senders cannot tie up the dispatcher. Thanks @feynman-hou and @eleqtrizit.</li>
<li>Security/dotenv: block <code>COMSPEC</code> in workspace <code>.env</code> so a malicious repo cannot redirect Windows <code>cmd.exe</code> resolution, and lock in case-insensitive workspace-<code>.env</code> regression coverage for the full Windows shell trust-root family (<code>COMSPEC</code>, <code>PROGRAMFILES</code>, <code>PROGRAMW6432</code>, <code>SYSTEMROOT</code>, <code>WINDIR</code>). (#74460) Thanks @mmaps.</li>
<li>Gateway/install: drop stale version-manager and package-manager PATH entries preserved from old service files during <code>gateway install --force</code> and doctor repair, so the repair path no longer recreates <code>gateway-path-nonminimal</code> warnings. Fixes #75220. (#75440) Thanks @leonaIee, @renaudcerrato, and @aaajiao.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.2/OpenClaw-2026.5.2.zip" length="51078259" type="application/octet-stream" sparkle:edSignature="NwoecacHxJOYpltNmB/y7LV5I8ZIh5pENWSydbOM1vsfgSrcb7pRP+Zm2nih1IAq7hh1tOmQ0XWnsohic7U4DA=="/>
</item>
</channel>
</rss>

View File

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

View File

@@ -118,6 +118,8 @@ class MainViewModel(
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
val talkModeConversation: StateFlow<List<VoiceConversationEntry>> =
runtimeState(initial = emptyList()) { it.talkModeConversation }
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }

View File

@@ -50,6 +50,7 @@ import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.SystemClock
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
@@ -425,6 +426,42 @@ class NodeRuntime(
MicCaptureManager(
context = appContext,
scope = scope,
createTranscriptionSession = {
val params =
buildJsonObject {
put("mode", JsonPrimitive("transcription"))
put("transport", JsonPrimitive("gateway-relay"))
put("brain", JsonPrimitive("none"))
}
val response =
operatorSession.request(
"talk.session.create",
params.toString(),
timeoutMs = 15_000,
)
parseTalkSessionId(response)
},
appendTranscriptionAudio = { sessionId, audio, onError ->
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("audioBase64", JsonPrimitive(Base64.encodeToString(audio, Base64.NO_WRAP)))
put("timestamp", JsonPrimitive(SystemClock.elapsedRealtime()))
}
operatorSession.sendRequestFrame(
"talk.session.appendAudio",
params.toString(),
timeoutMs = 8_000,
) { error -> onError(error.message) }
},
closeTranscriptionSession = { sessionId ->
val params = buildJsonObject { put("sessionId", JsonPrimitive(sessionId)) }
operatorSession.request(
"talk.session.close",
params.toString(),
timeoutMs = 5_000,
)
},
sendToGateway = { message, onRunIdKnown ->
val idempotencyKey = UUID.randomUUID().toString()
// Notify MicCaptureManager of the idempotency key *before* the network
@@ -486,6 +523,7 @@ class NodeRuntime(
isConnected = { operatorConnected },
onBeforeSpeak = { micCapture.pauseForTts() },
onAfterSpeak = { micCapture.resumeAfterTts() },
onStoppedByRelay = { finishTalkModeAfterRelayClose() },
)
}
@@ -501,6 +539,9 @@ class NodeRuntime(
val talkModeStatusText: StateFlow<String>
get() = talkMode.statusText
val talkModeConversation: StateFlow<List<VoiceConversationEntry>>
get() = talkMode.conversation
private fun syncMainSessionKey(agentId: String?) {
val resolvedKey = resolveNodeMainSessionKey(agentId)
// Always push the resolved session key into TalkMode, even when the
@@ -969,6 +1010,14 @@ class NodeRuntime(
}
}
private fun finishTalkModeAfterRelayClose() {
if (_voiceCaptureMode.value != VoiceCaptureMode.TalkMode) return
_voiceCaptureMode.value = VoiceCaptureMode.Off
talkMode.ttsOnAllResponses = false
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off)
externalAudioCaptureActive.value = false
}
val speakerEnabled: StateFlow<Boolean>
get() = prefs.speakerEnabled
@@ -1410,6 +1459,17 @@ class NodeRuntime(
}
}
private fun parseTalkSessionId(response: String): String {
val root = json.parseToJsonElement(response).asObjectOrNull()
val sessionId =
root?.get("transcriptionSessionId").asStringOrNull()
?: root?.get("sessionId").asStringOrNull()
if (sessionId.isNullOrBlank()) {
throw IllegalStateException("talk.session.create returned no session id")
}
return sessionId
}
private suspend fun refreshBrandingFromGateway() {
if (!_isConnected.value) return
try {
@@ -1651,7 +1711,16 @@ internal fun resolveOperatorSessionConnectAuth(
)
}
return null
val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }
if (explicitBootstrapToken != null) {
return null
}
return NodeRuntime.GatewayConnectAuth(
token = null,
bootstrapToken = null,
password = null,
)
}
internal fun shouldConnectOperatorSession(

View File

@@ -17,80 +17,148 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
class PermissionRequester(
class PermissionRequester internal constructor(
private val activity: ComponentActivity,
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
) {
private val mutex = Mutex()
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
private val mainHandler = Handler(Looper.getMainLooper())
private data class PendingPermissionRequest(
val deferred: CompletableDeferred<Map<String, Boolean>>,
var timedOut: Boolean = false,
)
private val launcher: ActivityResultLauncher<Array<String>> =
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val p = pending
pending = null
p?.complete(result)
}
private class PermissionRequestSlot(
val launcher: ActivityResultLauncher<Array<String>>,
var request: PendingPermissionRequest? = null,
)
constructor(activity: ComponentActivity) : this(
activity = activity,
launcherFactory = { callback ->
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), callback)
},
)
private val mutex = Mutex()
private val requestSlotsLock = Any()
private val mainHandler = Handler(Looper.getMainLooper())
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
suspend fun requestIfMissing(
permissions: List<String>,
timeoutMs: Long = 20_000,
): Map<String, Boolean> =
mutex.withLock {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
): Map<String, Boolean> {
return mutex.withLock {
while (true) {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
return permissions.associateWith { true }
}
if (missing.isEmpty()) {
return permissions.associateWith { true }
}
val needsRationale =
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
if (needsRationale) {
val proceed = showRationaleDialog(missing)
if (!proceed) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
val needsRationale =
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
if (needsRationale) {
val proceed = showRationaleDialog(missing)
if (!proceed) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
}
}
}
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
pending = deferred
withContext(Dispatchers.Main) {
launcher.launch(missing.toTypedArray())
}
val result =
withContext(Dispatchers.Default) {
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
val deferred = CompletableDeferred<Map<String, Boolean>>()
val request = PendingPermissionRequest(deferred)
val slot = reservePermissionRequestSlot(request)
try {
withContext(Dispatchers.Main) {
slot.launcher.launch(missing.toTypedArray())
}
} catch (err: Throwable) {
clearPermissionRequestSlot(slot, request)
throw err
}
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
val result =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
request.timedOut = true
throw err
}
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
}
val denied =
merged.filterValues { !it }.keys.filter {
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
return merged
}
return merged
error("unreachable")
}
}
private fun createPermissionRequestSlot(
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
): PermissionRequestSlot {
var slot: PermissionRequestSlot? = null
val launcher = launcherFactory { result -> completePermissionRequest(checkNotNull(slot), result) }
val created = PermissionRequestSlot(launcher)
slot = created
return created
}
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
synchronized(requestSlotsLock) {
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
slot.request = request
slot
}
private fun completePermissionRequest(
slot: PermissionRequestSlot,
result: Map<String, Boolean>,
) {
val request =
synchronized(requestSlotsLock) {
slot.request.also {
slot.request = null
}
} ?: return
if (request.timedOut) return
request.deferred.complete(result)
}
private fun clearPermissionRequestSlot(
slot: PermissionRequestSlot,
request: PendingPermissionRequest,
) {
synchronized(requestSlotsLock) {
if (slot.request === request) {
slot.request = null
}
}
}
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
withContext(Dispatchers.Main) {

View File

@@ -149,7 +149,10 @@ class GatewaySession(
val tls: GatewayTlsParams?,
)
private var desired: DesiredConnection? = null
private val lifecycleLock = Any()
@Volatile private var desired: DesiredConnection? = null
private var job: Job? = null
@Volatile private var currentConnection: Connection? = null
@@ -168,26 +171,39 @@ class GatewaySession(
options: GatewayConnectOptions,
tls: GatewayTlsParams? = null,
) {
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
val connectionToClose: Connection?
synchronized(lifecycleLock) {
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
connectionToClose = currentConnection
if (job?.isActive != true) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
}
connectionToClose?.closeQuietly()
}
fun disconnect() {
desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) {
job?.cancelAndJoin()
val jobToCancel: Job?
val connectionToClose: Connection?
synchronized(lifecycleLock) {
desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
connectionToClose = currentConnection
jobToCancel = job
job = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
connectionToClose?.closeQuietly()
scope.launch(Dispatchers.IO) {
jobToCancel?.cancelAndJoin()
if (desired == null) {
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
onDisconnected("Offline")
}
}
@@ -316,6 +332,22 @@ class GatewaySession(
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
}
suspend fun sendRequestFrame(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
onError: (ErrorShape) -> Unit = {},
) {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
null
} else {
json.parseToJsonElement(paramsJson)
}
conn.sendRequestFrame(method = method, params = params, timeoutMs = timeoutMs, onError = onError)
}
private data class RpcResponse(
val id: String,
val ok: Boolean,
@@ -360,14 +392,12 @@ class GatewaySession(
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
val frame =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
sendJson(frame)
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
return try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
@@ -376,13 +406,57 @@ class GatewaySession(
}
}
suspend fun sendRequestFrame(
method: String,
params: JsonElement?,
timeoutMs: Long,
onError: (ErrorShape) -> Unit,
) {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
scope.launch(Dispatchers.IO) {
val response =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
pending.remove(id)
onError(ErrorShape("UNAVAILABLE", "request timeout"))
return@launch
}
if (!response.ok) {
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
}
}
}
suspend fun sendJson(obj: JsonObject) {
val jsonString = obj.toString()
writeLock.withLock {
socket?.send(jsonString)
if (socket?.send(jsonString) != true) {
throw IllegalStateException("gateway send failed")
}
}
}
private fun buildRequestFrame(
id: String,
method: String,
params: JsonElement?,
): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
suspend fun awaitClose() = closedDeferred.await()
fun closeQuietly() {
@@ -905,9 +979,11 @@ class GatewaySession(
conn.connect()
conn.awaitClose()
} finally {
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
if (currentConnection === conn) {
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
}
}
@@ -1090,8 +1166,10 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
role?.trim() == "node" &&
scopes.isEmpty() &&
error.details.reason == "not-paired" &&
(error.details.pauseReconnect == false ||
error.details.recommendedNextStep == "wait_then_retry")
(
error.details.pauseReconnect == false ||
error.details.recommendedNextStep == "wait_then_retry"
)
)
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false

View File

@@ -27,28 +27,23 @@ internal object JpegSizeLimiter {
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
require(maxBytes > 0) { "Invalid maxBytes" }
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var width = initialWidth
var height = initialHeight
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var best =
JpegSizeLimiterResult(
bytes = encode(width, height, clampedStartQuality),
width = width,
height = height,
quality = clampedStartQuality,
)
if (best.bytes.size <= maxBytes) return best
var best: JpegSizeLimiterResult? = null
repeat(maxScaleAttempts) {
repeat(maxScaleAttempts + 1) { scaleAttempt ->
var quality = clampedStartQuality
repeat(maxQualityAttempts) {
val bytes = encode(width, height, quality)
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
val attempt = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
best = attempt
if (bytes.size <= maxBytes) return best
if (quality <= minQuality) return@repeat
quality = max(minQuality, (quality * 0.75).roundToInt())
}
if (scaleAttempt == maxScaleAttempts) return@repeat
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
val nextScale = max(scaleStep, minScale)
val nextWidth = max(minSize, (width * nextScale).roundToInt())
@@ -58,10 +53,11 @@ internal object JpegSizeLimiter {
height = min(nextHeight, height)
}
if (best.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
val failed = checkNotNull(best)
if (failed.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${failed.bytes.size} bytes > $maxBytes bytes")
}
return best
return failed
}
}

View File

@@ -143,27 +143,15 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
?.trim()
?.lowercase(Locale.US)
.orEmpty()
val tls =
when (scheme) {
"ws", "http" -> false
"wss", "https" -> true
else -> true
}
if (scheme !in setOf("ws", "wss", "http", "https")) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
}
val tls = scheme == "wss" || scheme == "https"
if (!tls && !isLoopbackGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort =
when (scheme) {
"wss", "https" -> 443
"ws", "http" -> 18789
else -> 443
}
val displayPort =
when (scheme) {
"wss", "https" -> 443
"ws", "http" -> 80
else -> 443
}
val defaultPort = if (tls) 443 else 18789
val displayPort = if (tls) 443 else 80
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
val displayHost = if (host.contains(":")) "[$host]" else host
val displayUrl =

View File

@@ -96,8 +96,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
val talkModeListening by viewModel.talkModeListening.collectAsState()
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
val hasStreamingAssistant = activeConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val showThinkingBubble = micIsSending && !hasStreamingAssistant
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
@@ -131,8 +133,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
pendingVoicePermissionAction = null
}
LaunchedEffect(micConversation.size, showThinkingBubble) {
val total = micConversation.size + if (showThinkingBubble) 1 else 0
LaunchedEffect(voiceCaptureMode, activeConversation.size, showThinkingBubble) {
val total = activeConversation.size + if (showThinkingBubble) 1 else 0
if (total > 0) {
listState.animateScrollToItem(total - 1)
}
@@ -154,7 +156,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (micConversation.isEmpty() && !showThinkingBubble) {
if (activeConversation.isEmpty() && !showThinkingBubble) {
item {
Box(
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
@@ -185,7 +187,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
}
}
items(items = micConversation, key = { it.id }) { entry ->
items(items = activeConversation, key = { it.id }) { entry ->
VoiceTurnBubble(entry = entry)
}
@@ -347,10 +349,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "Talk speaking"
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Talk listening"
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk on"
micEnabled || micIsSending || micCooldown -> micStatusText
queueCount > 0 -> "$queueCount queued"
micIsSending -> "Sending"
micCooldown -> "Cooldown"
micEnabled -> "Listening"
else -> "Mic off"
}
val stateColor =

View File

@@ -13,7 +13,7 @@ import kotlin.math.max
import kotlin.math.roundToInt
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
internal const val CHAT_IMAGE_MAX_BASE64_CHARS = 300 * 1024
private const val CHAT_ATTACHMENT_START_QUALITY = 85
private const val CHAT_DECODE_MAX_DIMENSION = 1600
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
@@ -35,7 +35,7 @@ internal fun loadSizedImageAttachment(
if (bitmap == null) {
throw IllegalStateException("unsupported attachment")
}
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
val maxBytes = (CHAT_IMAGE_MAX_BASE64_CHARS / 4) * 3
val encoded =
JpegSizeLimiter.compressToLimit(
initialWidth = bitmap.width,

View File

@@ -75,10 +75,13 @@ import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
import java.net.URI
import java.util.Locale
import org.commonmark.node.Image as MarkdownImage
import org.commonmark.node.Text as MarkdownTextNode
private const val LIST_INDENT_DP = 14
private const val DATA_IMAGE_HEADER_MAX_CHARS = 64
private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$")
private val markdownParser: Parser by lazy {
@@ -547,15 +550,13 @@ private fun AnnotatedString.Builder.appendLinkNode(
color = linkColor,
textDecoration = TextDecoration.Underline,
)
if (destination.isEmpty()) {
withStyle(linkStyle) {
appendInlineNode(
link.firstChild,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
}
if (destination.isEmpty() || !isSafeMarkdownLinkDestination(destination)) {
appendInlineNode(
link.firstChild,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
return
}
@@ -569,6 +570,14 @@ private fun AnnotatedString.Builder.appendLinkNode(
}
}
private fun isSafeMarkdownLinkDestination(destination: String): Boolean {
val scheme =
runCatching { URI(destination).scheme?.lowercase(Locale.US) }
.getOrNull()
?: return false
return scheme == "http" || scheme == "https"
}
internal fun buildChatInlineMarkdown(
text: String,
linkColor: Color = Color.Blue,
@@ -606,9 +615,10 @@ private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
return parseDataImageDestination(only.destination)
}
private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
internal fun parseDataImageDestination(destination: String?): ParsedDataImage? {
val raw = destination?.trim().orEmpty()
if (raw.isEmpty()) return null
if (raw.length > CHAT_IMAGE_MAX_BASE64_CHARS + DATA_IMAGE_HEADER_MAX_CHARS) return null
val match = dataImageRegex.matchEntire(raw) ?: return null
val subtype =
match.groupValues
@@ -623,6 +633,7 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
?.trim()
.orEmpty()
if (base64.isEmpty()) return null
if (base64.length > CHAT_IMAGE_MAX_BASE64_CHARS) return null
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
}
@@ -650,7 +661,7 @@ private data class TableRenderRow(
val cells: List<AnnotatedString>,
)
private data class ParsedDataImage(
internal data class ParsedDataImage(
val mimeType: String,
val base64: String,
)

View File

@@ -1,29 +1,30 @@
package ai.openclaw.app.voice
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
import kotlin.coroutines.coroutineContext
enum class VoiceConversationRole {
User,
@@ -40,6 +41,13 @@ data class VoiceConversationEntry(
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
private val createTranscriptionSession: suspend () -> String,
private val appendTranscriptionAudio: suspend (
sessionId: String,
audio: ByteArray,
onError: (String) -> Unit,
) -> Unit,
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
/**
* Send [message] to the gateway and return the run ID.
* [onRunIdKnown] is called with the idempotency key *before* the network
@@ -50,15 +58,15 @@ class MicCaptureManager(
) {
companion object {
private const val tag = "MicCapture"
private const val speechMinSessionMs = 30_000L
private const val speechCompleteSilenceMs = 1_500L
private const val speechPossibleSilenceMs = 900L
private const val transcriptionSampleRateHz = 8_000
private const val transcriptionAudioFrameMs = 100
private const val pcmuBias = 0x84
private const val pcmuClip = 32635
private const val transcriptIdleFlushMs = 1_600L
private const val maxConversationEntries = 40
private const val pendingRunTimeoutMs = 45_000L
}
private val mainHandler = Handler(Looper.getMainLooper())
private val json = Json { ignoreUnknownKeys = true }
private val _micEnabled = MutableStateFlow(false)
@@ -95,9 +103,11 @@ class MicCaptureManager(
private var pendingAssistantEntryId: String? = null
private var gatewayConnected = false
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var drainJob: Job? = null
@Volatile private var transcriptionSessionId: String? = null
private var transcriptionStartJob: Job? = null
private var transcriptionCaptureJob: Job? = null
private var transcriptionAppendJob: Job? = null
private var transcriptionDrainJob: Job? = null
private var transcriptFlushJob: Job? = null
private var pendingRunTimeoutJob: Job? = null
private var stopRequested = false
@@ -153,23 +163,23 @@ class MicCaptureManager(
_statusText.value = if (_isSending.value) "Speaking · waiting for reply" else "Speaking…"
return
}
transcriptionDrainJob?.cancel()
transcriptionDrainJob = null
_micCooldown.value = false
start()
sendQueuedIfIdle()
} else {
// Give the recognizer time to finish processing buffered audio.
// Cancel any prior drain to prevent duplicate sends on rapid toggle.
drainJob?.cancel()
transcriptionDrainJob?.cancel()
_micCooldown.value = true
drainJob =
transcriptionDrainJob =
scope.launch {
delay(2000L)
stop()
// Capture any partial transcript that didn't get a final result from the recognizer
val partial = _liveTranscript.value?.trim().orEmpty()
if (partial.isNotEmpty()) {
queueRecognizedMessage(partial)
}
drainJob = null
transcriptionDrainJob = null
_micCooldown.value = false
sendQueuedIfIdle()
}
@@ -182,11 +192,9 @@ class MicCaptureManager(
ttsPauseDepth += 1
if (ttsPauseDepth > 1) return@synchronized false
resumeMicAfterTts = _micEnabled.value
val active = resumeMicAfterTts || recognizer != null || _isListening.value
val active = resumeMicAfterTts || transcriptionSessionId != null || _isListening.value
if (!active) return@synchronized false
stopRequested = true
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_isListening.value = false
@@ -196,11 +204,7 @@ class MicCaptureManager(
true
}
if (!shouldPause) return
withContext(Dispatchers.Main) {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
stopTranscription(preserveStatus = true)
}
suspend fun resumeAfterTts() {
@@ -231,9 +235,14 @@ class MicCaptureManager(
fun onGatewayConnectionChanged(connected: Boolean) {
gatewayConnected = connected
if (connected) {
if (_micEnabled.value && transcriptionSessionId == null) {
start()
}
sendQueuedIfIdle()
return
}
stopRequested = true
stopTranscription(preserveStatus = true)
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
pendingRunId = null
@@ -248,6 +257,10 @@ class MicCaptureManager(
event: String,
payloadJson: String?,
) {
if (event == "talk.event") {
handleTranscriptionEvent(payloadJson)
return
}
if (event != "chat") return
if (payloadJson.isNullOrBlank()) return
val payload =
@@ -304,93 +317,96 @@ class MicCaptureManager(
private fun start() {
stopRequested = false
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
_statusText.value = "Speech recognizer unavailable"
_micEnabled.value = false
return
}
if (!hasMicPermission()) {
_statusText.value = "Microphone permission required"
_micEnabled.value = false
return
}
mainHandler.post {
try {
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
}
startListeningSession()
} catch (err: Throwable) {
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
_micEnabled.value = false
}
if (!gatewayConnected) {
_statusText.value = "Mic on · waiting for gateway"
return
}
if (transcriptionSessionId != null || transcriptionStartJob?.isActive == true) return
val startJob =
scope.launch {
var restartAfterCancellation = false
try {
val sessionId = createTranscriptionSession()
if (stopRequested || !_micEnabled.value) {
closeTranscriptionSession(sessionId)
return@launch
}
transcriptionSessionId = sessionId
_isListening.value = true
_statusText.value = listeningStatus()
startTranscriptionCapture(sessionId)
Log.d(tag, "transcription session started sessionId=$sessionId")
} catch (err: Throwable) {
if (err is CancellationException) {
restartAfterCancellation = _micEnabled.value && gatewayConnected && !stopRequested
return@launch
}
_statusText.value = "Transcription unavailable: ${err.message ?: err::class.simpleName}"
_micEnabled.value = false
stopTranscription(preserveStatus = true)
} finally {
if (transcriptionStartJob === coroutineContext[Job]) {
transcriptionStartJob = null
}
if (restartAfterCancellation) {
start()
}
}
}
transcriptionStartJob = startJob
}
private fun stop() {
stopRequested = true
restartJob?.cancel()
restartJob = null
stopTranscription()
}
private fun stopTranscription(preserveStatus: Boolean = false) {
val status = _statusText.value
val sessionId = transcriptionSessionId
transcriptionSessionId = null
if (sessionId != null) {
transcriptionStartJob?.cancel()
transcriptionStartJob = null
} else if (transcriptionStartJob?.isActive != true) {
transcriptionStartJob = null
}
transcriptionCaptureJob?.cancel()
transcriptionAppendJob?.cancel()
transcriptionCaptureJob = null
transcriptionAppendJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_isListening.value = false
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
_inputLevel.value = 0f
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
if (!preserveStatus) {
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
} else {
_statusText.value = status
}
}
private fun startListeningSession() {
val recognizerInstance = recognizer ?: return
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs)
putExtra(
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
speechPossibleSilenceMs,
)
}
_statusText.value =
when {
_isSending.value -> "Listening · sending queued voice"
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
else -> "Listening"
}
_isListening.value = true
recognizerInstance.startListening(intent)
}
private fun scheduleRestart(delayMs: Long = 300L) {
if (stopRequested) return
if (!_micEnabled.value) return
restartJob?.cancel()
restartJob =
if (!sessionId.isNullOrBlank()) {
scope.launch {
delay(delayMs)
mainHandler.post {
if (stopRequested || !_micEnabled.value) return@post
try {
startListeningSession()
} catch (_: Throwable) {
// retry through onError
try {
closeTranscriptionSession(sessionId)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "transcription close ignored: ${err.message ?: err::class.simpleName}")
}
}
}
}
}
private fun queueRecognizedMessage(text: String) {
val message = text.trim()
_liveTranscript.value = null
if (message.isEmpty()) return
if (!message.hasTranscriptContent()) return
appendConversation(
role = VoiceConversationRole.User,
text = message,
@@ -572,23 +588,210 @@ class MicCaptureManager(
}
}
private fun disableMic(status: String) {
stopRequested = true
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_micEnabled.value = false
_isListening.value = false
_inputLevel.value = 0f
_statusText.value = status
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
@SuppressLint("MissingPermission")
private fun startTranscriptionCapture(sessionId: String) {
transcriptionCaptureJob?.cancel()
transcriptionAppendJob?.cancel()
val audioFrames =
Channel<ByteArray>(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
transcriptionAppendJob =
scope.launch(Dispatchers.IO) {
for (frame in audioFrames) {
if (transcriptionSessionId != sessionId) continue
try {
appendTranscriptionAudio(sessionId, pcm16ToPcmu(frame)) { message ->
failTranscription(sessionId, message)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "request failed")
}
}
}
transcriptionCaptureJob =
scope.launch(Dispatchers.IO) {
var audioRecord: AudioRecord? = null
try {
val frameBytes = transcriptionSampleRateHz * 2 * transcriptionAudioFrameMs / 1000
val minBuffer =
AudioRecord.getMinBufferSize(
transcriptionSampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(transcriptionSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
val buffer = ByteArray(frameBytes)
audioRecord.startRecording()
while (coroutineContext.isActive && _micEnabled.value && transcriptionSessionId == sessionId) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read <= 0) continue
_inputLevel.value = pcm16Level(buffer, read)
audioFrames.trySend(buffer.copyOf(read))
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
} finally {
audioFrames.close()
audioRecord?.let { record ->
try {
record.stop()
} catch (_: Throwable) {
}
record.release()
}
}
}
}
private fun handleTranscriptionEvent(payloadJson: String?) {
if (payloadJson.isNullOrBlank()) return
val obj =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val sessionId = obj["transcriptionSessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull()
val currentSessionId = transcriptionSessionId
if (currentSessionId == null || sessionId != currentSessionId) return
when (obj["type"].asStringOrNull()) {
"ready", "inputAudio", "speechStart" -> {
_isListening.value = true
_statusText.value = listeningStatus()
}
"partial" -> {
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
if (text.isNotEmpty()) {
_liveTranscript.value = text
scheduleTranscriptFlush(text)
}
}
"transcript" -> {
transcriptFlushJob?.cancel()
transcriptFlushJob = null
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
if (text.isNotEmpty()) {
if (text != flushedPartialTranscript) {
queueRecognizedMessage(text)
sendQueuedIfIdle()
} else {
flushedPartialTranscript = null
_liveTranscript.value = null
}
}
}
"error" -> {
val message =
obj["message"]
.asStringOrNull()
?.trim()
.orEmpty()
.ifEmpty { "transcription failed" }
failTranscription(currentSessionId, message)
}
"close" -> {
_micEnabled.value = false
stopTranscription()
}
}
}
private fun failTranscription(
sessionId: String,
message: String,
) {
if (transcriptionSessionId != sessionId) return
_statusText.value = "Transcription failed: $message"
_micEnabled.value = false
stopTranscription(preserveStatus = true)
}
private fun listeningStatus(): String =
when {
_isSending.value -> "Listening · sending queued voice"
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
else -> "Listening"
}
private fun pcm16Level(
frame: ByteArray,
length: Int,
): Float {
var total = 0L
var count = 0
var index = 0
val limit = length - (length % 2)
while (index < limit) {
val sample =
(frame[index].toInt() and 0xff) or
(frame[index + 1].toInt() shl 8)
total += kotlin.math.abs(sample.toShort().toInt())
count += 1
index += 2
}
if (count == 0) return 0f
return ((total / count).toFloat() / Short.MAX_VALUE).coerceIn(0f, 1f)
}
private fun pcm16ToPcmu(pcm16: ByteArray): ByteArray {
val output = ByteArray(pcm16.size / 2)
var inputIndex = 0
var outputIndex = 0
while (inputIndex + 1 < pcm16.size) {
val sample =
(
(pcm16[inputIndex].toInt() and 0xff) or
(pcm16[inputIndex + 1].toInt() shl 8)
).toShort().toInt()
output[outputIndex] = linear16ToPcmu(sample)
inputIndex += 2
outputIndex += 1
}
return output
}
private fun linear16ToPcmu(sample: Int): Byte {
var sign = 0
var magnitude = sample
if (magnitude < 0) {
sign = 0x80
magnitude = -magnitude
}
if (magnitude > pcmuClip) {
magnitude = pcmuClip
}
magnitude += pcmuBias
var exponent = 7
var mask = 0x4000
while ((magnitude and mask) == 0 && exponent > 0) {
exponent -= 1
mask = mask shr 1
}
val mantissa = (magnitude shr (exponent + 3)) and 0x0f
return (sign or (exponent shl 4) or mantissa).inv().toByte()
}
private fun hasMicPermission(): Boolean =
(
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
@@ -596,103 +799,10 @@ class MicCaptureManager(
)
private fun parseAssistantText(payload: JsonObject): String? = ChatEventText.assistantTextFromPayload(payload)
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
_isListening.value = true
}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {
val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
_inputLevel.value = level
}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
_inputLevel.value = 0f
scheduleRestart()
}
override fun onError(error: Int) {
if (stopRequested) return
_isListening.value = false
_inputLevel.value = 0f
val status =
when (error) {
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
SpeechRecognizer.ERROR_CLIENT -> "Client error"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
SpeechRecognizer.ERROR_SERVER -> "Server error"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device"
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device"
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected"
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying"
else -> "Speech error ($error)"
}
_statusText.value = status
if (
error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ||
error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED ||
error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE
) {
disableMic(status)
return
}
val restartDelayMs =
when (error) {
SpeechRecognizer.ERROR_NO_MATCH,
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
-> 1_200L
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L
else -> 600L
}
scheduleRestart(delayMs = restartDelayMs)
}
override fun onResults(results: Bundle?) {
transcriptFlushJob?.cancel()
transcriptFlushJob = null
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
val trimmed = text.trim()
if (trimmed != flushedPartialTranscript) {
queueRecognizedMessage(trimmed)
sendQueuedIfIdle()
} else {
flushedPartialTranscript = null
_liveTranscript.value = null
}
}
scheduleRestart()
}
override fun onPartialResults(partialResults: Bundle?) {
val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
val trimmed = text.trim()
_liveTranscript.value = trimmed
scheduleTranscriptFlush(trimmed)
}
}
override fun onEvent(
eventType: Int,
params: Bundle?,
) {}
}
}
private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun String.hasTranscriptContent(): Boolean = any { it.isLetterOrDigit() }

View File

@@ -11,8 +11,14 @@ internal data class TalkModeGatewayConfigState(
val mainSessionKey: String,
val interruptOnSpeech: Boolean?,
val silenceTimeoutMs: Long,
val executionMode: TalkModeExecutionMode,
)
internal enum class TalkModeExecutionMode {
Native,
RealtimeRelay,
}
internal object TalkModeGatewayConfigParser {
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
val talk = config?.get("talk").asObjectOrNull()
@@ -21,9 +27,22 @@ internal object TalkModeGatewayConfigParser {
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
executionMode = resolvedExecutionMode(talk),
)
}
fun resolvedExecutionMode(talk: JsonObject?): TalkModeExecutionMode {
val realtime = talk?.get("realtime").asObjectOrNull() ?: return TalkModeExecutionMode.Native
val mode = realtime["mode"].asStringOrNull()
val transport = realtime["transport"].asStringOrNull()
val brain = realtime["brain"].asStringOrNull()
return if (mode == "realtime" && transport == "gateway-relay" && (brain == null || brain == "agent-consult")) {
TalkModeExecutionMode.RealtimeRelay
} else {
TalkModeExecutionMode.Native
}
}
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
val fallback = TalkDefaults.defaultSilenceTimeoutMs
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback

View File

@@ -2,12 +2,17 @@ package ai.openclaw.app.voice
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -17,6 +22,7 @@ import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
@@ -25,17 +31,23 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.LinkedHashMap
import java.util.Locale
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@@ -62,6 +74,16 @@ data class TalkPttStopPayload(
}.toString()
}
internal data class RealtimeToolRun(
val callId: String,
val relaySessionId: String,
)
private data class RealtimeToolCompletion(
val state: String,
val messageEl: JsonElement?,
)
class TalkModeManager internal constructor(
private val context: Context,
private val scope: CoroutineScope,
@@ -70,15 +92,19 @@ class TalkModeManager internal constructor(
private val isConnected: () -> Boolean,
private val onBeforeSpeak: suspend () -> Unit = {},
private val onAfterSpeak: suspend () -> Unit = {},
private val onStoppedByRelay: () -> Unit = {},
private val talkSpeakClient: TalkSpeechSynthesizing = TalkSpeakClient(session = session),
private val talkAudioPlayer: TalkAudioPlaying = TalkAudioPlayer(context),
) {
companion object {
private const val tag = "TalkMode"
private const val realtimeSampleRateHz = 24_000
private const val realtimeAudioFrameMs = 100
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitWithSubscribeMs = 45_000L
private const val chatFinalWaitWithoutSubscribeMs = 6_000L
private const val maxCachedRunCompletions = 128
private const val maxConversationEntries = 40
}
private val mainHandler = Handler(Looper.getMainLooper())
@@ -98,6 +124,9 @@ class TalkModeManager internal constructor(
private val _lastAssistantText = MutableStateFlow<String?>(null)
val lastAssistantText: StateFlow<String?> = _lastAssistantText
private val _conversation = MutableStateFlow<List<VoiceConversationEntry>>(emptyList())
val conversation: StateFlow<List<VoiceConversationEntry>> = _conversation
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var stopRequested = false
@@ -126,8 +155,29 @@ class TalkModeManager internal constructor(
private val completedRunTexts = LinkedHashMap<String, String>()
private var chatSubscribedSessionKey: String? = null
private var configLoaded = false
private var executionMode = TalkModeExecutionMode.Native
private val startGeneration = AtomicLong(0L)
@Volatile private var playbackEnabled = true
@Volatile private var realtimeSessionId: String? = null
private var realtimeCaptureJob: Job? = null
private var realtimeAppendJob: Job? = null
private val realtimeToolRuns = LinkedHashMap<String, RealtimeToolRun>()
private val pendingRealtimeToolCalls = LinkedHashSet<String>()
private val pendingRealtimeToolCompletions = LinkedHashMap<String, RealtimeToolCompletion>()
private var realtimeUserEntryId: String? = null
private var realtimeAssistantEntryId: String? = null
private val realtimePlaybackLock = Any()
private var realtimeAudioTrack: AudioTrack? = null
private var realtimePlaybackIdleJob: Job? = null
@Volatile
private var realtimePlaybackEndsAtMs = 0L
@Volatile
private var realtimeOutputSuppressed = false
@Volatile
private var playbackEnabled = true
private val playbackGeneration = AtomicLong(0L)
private var ttsJob: Job? = null
@@ -356,6 +406,10 @@ class TalkModeManager internal constructor(
event: String,
payloadJson: String?,
) {
if (event == "talk.event") {
handleRealtimeTalkEvent(payloadJson)
return
}
if (ttsOnAllResponses) {
Log.d(tag, "gateway event: $event")
}
@@ -379,6 +433,13 @@ class TalkModeManager internal constructor(
val activeSession = mainSessionKey.ifBlank { "main" }
if (eventSession != null && eventSession != activeSession) return
if (maybeCompleteRealtimeToolCall(runId = runId, state = state, messageEl = obj["message"])) {
return
}
if (holdPendingRealtimeToolCompletion(runId = runId, state = state, messageEl = obj["message"])) {
return
}
// If this is a response we initiated, handle normally below.
// Otherwise, if ttsOnAllResponses, finish streaming TTS on terminal events.
val pending = pendingRunId
@@ -423,6 +484,7 @@ class TalkModeManager internal constructor(
if (playbackEnabled == enabled) return
playbackEnabled = enabled
if (!enabled) {
stopRealtimePlayback()
stopSpeaking()
}
}
@@ -442,16 +504,43 @@ class TalkModeManager internal constructor(
}
private fun start() {
mainHandler.post {
if (_isListening.value) return@post
stopRequested = false
listeningMode = true
Log.d(tag, "start")
if (realtimeSessionId != null || realtimeCaptureJob?.isActive == true) return
val generation = startGeneration.incrementAndGet()
stopRequested = false
listeningMode = true
Log.d(tag, "start")
scope.launch {
try {
ensureConfigLoaded()
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) return@launch
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
startRealtimeRelay(generation)
} else {
startNativeRecognition(generation)
}
} catch (err: Throwable) {
if (err is CancellationException) return@launch
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
stopRealtimeRelay(closeSession = false, preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
}
}
}
}
private suspend fun startNativeRecognition(generation: Long) {
withContext(Dispatchers.Main) {
if (generation != startGeneration.get()) return@withContext
if (!_isEnabled.value || stopRequested) return@withContext
if (_isListening.value) return@withContext
Log.d(tag, "start native")
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
_statusText.value = "Speech recognizer unavailable"
Log.w(tag, "speech recognizer unavailable")
return@post
return@withContext
}
val micOk =
@@ -460,19 +549,14 @@ class TalkModeManager internal constructor(
if (!micOk) {
_statusText.value = "Microphone permission required"
Log.w(tag, "microphone permission required")
return@post
return@withContext
}
try {
recognizer?.destroy()
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
startListeningInternal(markListening = true)
startSilenceMonitor()
Log.d(tag, "listening")
} catch (err: Throwable) {
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
}
recognizer?.destroy()
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
startListeningInternal(markListening = true)
startSilenceMonitor()
Log.d(tag, "listening")
}
}
@@ -484,6 +568,7 @@ class TalkModeManager internal constructor(
pttAutoStopEnabled = false
pttCompletion?.cancel()
pttCompletion = null
startGeneration.incrementAndGet()
pttTimeoutJob?.cancel()
pttTimeoutJob = null
restartJob?.cancel()
@@ -494,6 +579,7 @@ class TalkModeManager internal constructor(
lastHeardAtMs = null
_isListening.value = false
_statusText.value = "Off"
stopRealtimeRelay()
stopSpeaking()
chatSubscribedSessionKey = null
pendingRunId = null
@@ -512,6 +598,565 @@ class TalkModeManager internal constructor(
shutdownTextToSpeech()
}
private suspend fun startRealtimeRelay(generation: Long) {
if (!isConnected()) {
_statusText.value = "Gateway not connected"
Log.w(tag, "realtime start: gateway not connected")
disableRealtimeModeAndNotifyOwner()
return
}
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) {
_statusText.value = "Microphone permission required"
Log.w(tag, "realtime start: microphone permission required")
disableRealtimeModeAndNotifyOwner()
return
}
ensureConfigLoaded()
cancelActivePlayback()
stopTextToSpeechPlayback()
withContext(Dispatchers.Main) {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
_statusText.value = "Connecting…"
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
put("mode", JsonPrimitive("realtime"))
put("transport", JsonPrimitive("gateway-relay"))
put("brain", JsonPrimitive("agent-consult"))
}
val payload = session.request("talk.session.create", params.toString(), timeoutMs = 15_000)
val root = json.parseToJsonElement(payload).asObjectOrNull()
val relaySession = root?.get("relaySessionId").asStringOrNull()
val sessionId = relaySession ?: root?.get("sessionId").asStringOrNull()
if (sessionId.isNullOrBlank()) {
throw IllegalStateException("talk.session.create returned no session id")
}
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) {
closeRealtimeSession(sessionId)
throw CancellationException("realtime talk stopped while connecting")
}
realtimeSessionId = sessionId
realtimeOutputSuppressed = false
_isListening.value = true
_statusText.value = "Listening"
startRealtimeCapture(sessionId)
Log.d(tag, "realtime session started relaySessionId=$sessionId")
}
private fun disableRealtimeModeAndNotifyOwner() {
if (!_isEnabled.value) return
_isEnabled.value = false
_isListening.value = false
onStoppedByRelay()
}
private fun failRealtimeRelay(
sessionId: String,
message: String,
) {
if (realtimeSessionId != sessionId) return
_statusText.value = "Talk failed: $message"
stopRealtimeRelay(cancelCapture = false, cancelAppend = false, preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
}
@SuppressLint("MissingPermission")
private fun startRealtimeCapture(sessionId: String) {
realtimeCaptureJob?.cancel()
realtimeAppendJob?.cancel()
val audioFrames =
Channel<ByteArray>(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
realtimeAppendJob =
scope.launch(Dispatchers.IO) {
for (frame in audioFrames) {
if (realtimeSessionId != sessionId) continue
if (isRealtimePlaybackActive()) continue
val audioBase64 = Base64.encodeToString(frame, Base64.NO_WRAP)
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("audioBase64", JsonPrimitive(audioBase64))
put("timestamp", JsonPrimitive(SystemClock.elapsedRealtime()))
}
try {
session.sendRequestFrame(
"talk.session.appendAudio",
params.toString(),
timeoutMs = 8_000,
) { error ->
Log.w(tag, "realtime appendAudio failed: ${error.message}")
failRealtimeRelay(sessionId, error.message)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime appendAudio failed: ${err.message ?: err::class.simpleName}")
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "request failed")
}
}
}
realtimeCaptureJob =
scope.launch(Dispatchers.IO) {
var audioRecord: AudioRecord? = null
try {
val frameBytes = realtimeSampleRateHz * 2 * realtimeAudioFrameMs / 1000
val minBuffer =
AudioRecord.getMinBufferSize(
realtimeSampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(realtimeSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
val buffer = ByteArray(frameBytes)
audioRecord.startRecording()
while (coroutineContext.isActive && _isEnabled.value && realtimeSessionId == sessionId) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read <= 0) continue
if (!shouldAppendRealtimeCapturedFrame(read)) continue
audioFrames.trySend(buffer.copyOf(read))
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime capture failed: ${err.message ?: err::class.simpleName}")
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
} finally {
audioFrames.close()
audioRecord?.let { record ->
try {
record.stop()
} catch (_: Throwable) {
}
record.release()
}
}
}
}
private fun shouldAppendRealtimeCapturedFrame(length: Int): Boolean = !isRealtimePlaybackActive() && length > 0
private fun isRealtimePlaybackActive(): Boolean = _isSpeaking.value || SystemClock.elapsedRealtime() < realtimePlaybackEndsAtMs
private fun handleRealtimeTalkEvent(payloadJson: String?) {
if (payloadJson.isNullOrBlank()) return
val obj =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val sessionId = obj["relaySessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull()
val currentSessionId = realtimeSessionId
if (currentSessionId == null || sessionId != currentSessionId) return
when (val type = obj["type"].asStringOrNull()) {
"ready" -> {
_isListening.value = true
_statusText.value = "Listening"
}
"inputAudio" -> {
_isListening.value = true
}
"audio" -> {
if (realtimeOutputSuppressed) return
val audioBase64 = obj["audioBase64"].asStringOrNull() ?: return
val bytes =
try {
Base64.decode(audioBase64, Base64.DEFAULT)
} catch (err: Throwable) {
Log.w(tag, "realtime audio decode failed: ${err.message ?: err::class.simpleName}")
return
}
playRealtimeAudio(bytes)
}
"clear" -> stopRealtimePlayback()
"mark" -> Unit
"transcript" -> {
val role = obj["role"].asStringOrNull()
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
val isFinal = obj["final"].asBooleanOrNull() == true
if (text.isNotEmpty()) {
when (role) {
"user" -> upsertRealtimeConversation(VoiceConversationRole.User, text, isFinal)
"assistant" -> upsertRealtimeConversation(VoiceConversationRole.Assistant, text, isFinal)
}
}
if (role == "assistant" && text.isNotEmpty()) {
_lastAssistantText.value = text
}
if (isFinal && role == "user") {
realtimeOutputSuppressed = false
_statusText.value = "Thinking…"
} else if (isFinal && role == "assistant") {
scheduleRealtimePlaybackIdle()
}
}
"toolCall" -> {
val callId = obj["callId"].asStringOrNull() ?: return
val name = obj["name"].asStringOrNull() ?: return
handleRealtimeToolCall(
callId = callId,
name = name,
args = obj["args"],
)
}
"toolResult" -> Unit
"error" -> {
val message = obj["message"].asStringOrNull() ?: "realtime talk error"
_statusText.value = "Talk failed: $message"
Log.w(tag, "realtime error: $message")
}
"close" -> {
Log.d(tag, "realtime close reason=${obj["reason"].asStringOrNull()}")
stopRealtimeRelay(closeSession = false)
if (_isEnabled.value) {
_isEnabled.value = false
_statusText.value = "Off"
onStoppedByRelay()
}
}
else -> {
if (type != null) Log.d(tag, "ignored realtime event type=$type")
}
}
}
private fun playRealtimeAudio(bytes: ByteArray) {
if (!playbackEnabled || realtimeOutputSuppressed || bytes.isEmpty()) return
synchronized(realtimePlaybackLock) {
val track =
realtimeAudioTrack ?: run {
val minBuffer =
AudioTrack.getMinBufferSize(
realtimeSampleRateHz,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
val created =
AudioTrack
.Builder()
.setAudioAttributes(
AudioAttributes
.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
).setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(realtimeSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
).setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(maxOf(minBuffer, bytes.size * 4))
.build()
created.play()
realtimeAudioTrack = created
created
}
_isSpeaking.value = true
_statusText.value = "Speaking…"
track.write(bytes, 0, bytes.size)
val durationMs = ((bytes.size / 2.0) / realtimeSampleRateHz * 1000.0).toLong()
val now = SystemClock.elapsedRealtime()
realtimePlaybackEndsAtMs = maxOf(now, realtimePlaybackEndsAtMs) + durationMs
scheduleRealtimePlaybackIdle()
}
}
private fun scheduleRealtimePlaybackIdle() {
realtimePlaybackIdleJob?.cancel()
val delayMs = maxOf(0L, realtimePlaybackEndsAtMs - SystemClock.elapsedRealtime())
realtimePlaybackIdleJob =
scope.launch {
delay(delayMs)
val idle =
synchronized(realtimePlaybackLock) {
val playbackIdle = SystemClock.elapsedRealtime() >= realtimePlaybackEndsAtMs
if (playbackIdle) _isSpeaking.value = false
playbackIdle
}
if (idle && _isEnabled.value && realtimeSessionId != null) {
_statusText.value = "Listening"
}
}
}
private fun stopRealtimePlayback() {
realtimePlaybackIdleJob?.cancel()
realtimePlaybackIdleJob = null
realtimePlaybackEndsAtMs = 0L
synchronized(realtimePlaybackLock) {
realtimeAudioTrack?.let { track ->
try {
track.pause()
track.flush()
track.stop()
} catch (_: Throwable) {
}
track.release()
}
realtimeAudioTrack = null
}
_isSpeaking.value = false
if (_isEnabled.value) {
_statusText.value = "Listening"
}
}
private fun stopRealtimeRelay(
closeSession: Boolean = true,
cancelCapture: Boolean = true,
cancelAppend: Boolean = true,
preserveStatus: Boolean = false,
) {
val status = _statusText.value
val sessionId = realtimeSessionId
realtimeSessionId = null
realtimeOutputSuppressed = false
if (cancelCapture) {
realtimeCaptureJob?.cancel()
}
if (cancelAppend) {
realtimeAppendJob?.cancel()
}
realtimeCaptureJob = null
realtimeAppendJob = null
realtimeToolRuns.clear()
pendingRealtimeToolCalls.clear()
pendingRealtimeToolCompletions.clear()
realtimeUserEntryId = null
realtimeAssistantEntryId = null
stopRealtimePlayback()
if (preserveStatus) {
_statusText.value = status
}
_isListening.value = false
if (closeSession && !sessionId.isNullOrBlank()) {
scope.launch {
closeRealtimeSession(sessionId)
}
}
}
private suspend fun closeRealtimeSession(sessionId: String) {
try {
val params = buildJsonObject { put("sessionId", JsonPrimitive(sessionId)) }
session.request("talk.session.close", params.toString(), timeoutMs = 5_000)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "realtime close ignored: ${err.message ?: err::class.simpleName}")
}
}
}
private fun handleRealtimeToolCall(
callId: String,
name: String,
args: JsonElement?,
) {
val relaySessionId = realtimeSessionId ?: return
pendingRealtimeToolCalls.add(callId)
scope.launch {
try {
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
put("callId", JsonPrimitive(callId))
put("name", JsonPrimitive(name))
put("relaySessionId", JsonPrimitive(relaySessionId))
if (args != null) put("args", args)
}
val response =
session.request("talk.client.toolCall", params.toString(), timeoutMs = 15_000)
val runId = parseRunId(response)
if (!runId.isNullOrBlank()) {
if (realtimeSessionId != relaySessionId) return@launch
realtimeToolRuns[runId] =
RealtimeToolRun(callId = callId, relaySessionId = relaySessionId)
val completion = pendingRealtimeToolCompletions.remove(runId)
if (completion != null) {
maybeCompleteRealtimeToolCall(
runId = runId,
state = completion.state,
messageEl = completion.messageEl,
)
} else {
_statusText.value = "Thinking…"
}
} else {
submitRealtimeToolError(callId, "tool call returned no run id", relaySessionId)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime toolCall failed: ${err.message ?: err::class.simpleName}")
submitRealtimeToolError(callId, err.message ?: "tool call failed", relaySessionId)
} finally {
pendingRealtimeToolCalls.remove(callId)
}
}
}
private fun holdPendingRealtimeToolCompletion(
runId: String,
state: String,
messageEl: JsonElement?,
): Boolean {
if (realtimeSessionId == null || pendingRealtimeToolCalls.isEmpty()) return false
if (state != "final" && state != "aborted" && state != "error") return false
pendingRealtimeToolCompletions[runId] =
RealtimeToolCompletion(state = state, messageEl = messageEl)
return true
}
private fun maybeCompleteRealtimeToolCall(
runId: String,
state: String,
messageEl: JsonElement?,
): Boolean {
val toolRun = realtimeToolRuns[runId] ?: return false
if (toolRun.relaySessionId != realtimeSessionId) {
realtimeToolRuns.remove(runId)
return true
}
when (state) {
"final" -> {
realtimeToolRuns.remove(runId)
val text = extractTextFromChatEventMessage(messageEl).orEmpty()
scope.launch {
submitRealtimeToolResult(
callId = toolRun.callId,
result = buildJsonObject { put("text", JsonPrimitive(text)) },
sessionId = toolRun.relaySessionId,
)
}
return true
}
"aborted", "error" -> {
realtimeToolRuns.remove(runId)
scope.launch {
submitRealtimeToolError(toolRun.callId, state, toolRun.relaySessionId)
}
return true
}
}
return false
}
private suspend fun submitRealtimeToolError(
callId: String,
message: String,
sessionId: String? = realtimeSessionId,
) {
submitRealtimeToolResult(
callId = callId,
result = buildJsonObject { put("error", JsonPrimitive(message)) },
sessionId = sessionId,
)
}
private suspend fun submitRealtimeToolResult(
callId: String,
result: JsonObject,
sessionId: String? = realtimeSessionId,
) {
val activeSessionId = sessionId ?: return
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(activeSessionId))
put("callId", JsonPrimitive(callId))
put("result", result)
}
try {
session.request("talk.session.submitToolResult", params.toString(), timeoutMs = 15_000)
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime submitToolResult failed: ${err.message ?: err::class.simpleName}")
}
}
private fun upsertRealtimeConversation(
role: VoiceConversationRole,
text: String,
isFinal: Boolean,
) {
val entryId =
when (role) {
VoiceConversationRole.User -> realtimeUserEntryId
VoiceConversationRole.Assistant -> realtimeAssistantEntryId
}
val resolvedEntryId =
if (entryId == null) {
appendConversation(role = role, text = text, isStreaming = !isFinal)
} else {
updateConversationEntry(id = entryId, text = text, isStreaming = !isFinal)
entryId
}
when (role) {
VoiceConversationRole.User -> realtimeUserEntryId = if (isFinal) null else resolvedEntryId
VoiceConversationRole.Assistant -> realtimeAssistantEntryId = if (isFinal) null else resolvedEntryId
}
}
private fun appendConversation(
role: VoiceConversationRole,
text: String,
isStreaming: Boolean,
): String {
val id = UUID.randomUUID().toString()
_conversation.value =
(_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
.takeLast(maxConversationEntries)
return id
}
private fun updateConversationEntry(
id: String,
text: String,
isStreaming: Boolean,
) {
val current = _conversation.value
val targetIndex =
when {
current.isEmpty() -> -1
current[current.lastIndex].id == id -> current.lastIndex
else -> current.indexOfFirst { it.id == id }
}
if (targetIndex < 0) return
val entry = current[targetIndex]
if (entry.text == text && entry.isStreaming == isStreaming) return
val updated = current.toMutableList()
updated[targetIndex] = entry.copy(text = text, isStreaming = isStreaming)
_conversation.value = updated
}
private fun startListeningInternal(markListening: Boolean) {
val r = recognizer ?: return
val intent =
@@ -522,8 +1167,8 @@ class TalkModeManager internal constructor(
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
// Use cloud recognition — it handles natural speech and pauses better
// than on-device which cuts off aggressively after short silences.
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2500L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1800L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2500)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1800)
}
if (markListening) {
@@ -762,7 +1407,7 @@ class TalkModeManager internal constructor(
}
}
private suspend fun waitForChatFinal(runId: String): Boolean {
internal suspend fun waitForChatFinal(runId: String): Boolean {
consumeRunCompletion(runId)?.let { return it }
val deferred =
if (pendingRunId == runId) {
@@ -773,13 +1418,12 @@ class TalkModeManager internal constructor(
consumeRunCompletion(runId)?.let { return it }
val timeoutMs = if (supportsChatSubscribe) chatFinalWaitWithSubscribeMs else chatFinalWaitWithoutSubscribeMs
val result =
withContext(Dispatchers.IO) {
try {
kotlinx.coroutines.withTimeout(120_000) { deferred.await() }
} catch (_: Throwable) {
false
}
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
false
}
if (!result && pendingRunId == runId) {
@@ -1077,11 +1721,32 @@ class TalkModeManager internal constructor(
}
fun stopTts() {
realtimeOutputSuppressed = true
stopRealtimePlayback()
cancelRealtimeOutput(reason = "android-stop-tts")
stopSpeaking(resetInterrupt = true)
_isSpeaking.value = false
_statusText.value = "Listening"
}
private fun cancelRealtimeOutput(reason: String) {
val sessionId = realtimeSessionId ?: return
scope.launch {
try {
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("reason", JsonPrimitive(reason))
}
session.request("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "realtime cancelOutput ignored: ${err.message ?: err::class.simpleName}")
}
}
}
}
private fun stopSpeaking(resetInterrupt: Boolean = true) {
playbackGeneration.incrementAndGet()
if (!_isSpeaking.value) {
@@ -1245,9 +1910,11 @@ class TalkModeManager internal constructor(
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
silenceWindowMs = parsed.silenceTimeoutMs
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
executionMode = parsed.executionMode
configLoaded = true
} catch (_: Throwable) {
silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
executionMode = TalkModeExecutionMode.Native
configLoaded = false
}
}

View File

@@ -33,7 +33,7 @@ class VoiceWakeManager(
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var lastDispatched: String? = null
private var lastCycleDispatched: String? = null
private var stopRequested = false
fun setTriggerWords(words: List<String>) {
@@ -110,8 +110,8 @@ class VoiceWakeManager(
private fun handleTranscription(text: String) {
val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return
if (command == lastDispatched) return
lastDispatched = command
if (command == lastCycleDispatched) return
lastCycleDispatched = command
scope.launch { onCommand(command) }
_statusText.value = "Triggered"
@@ -121,6 +121,7 @@ class VoiceWakeManager(
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
lastCycleDispatched = null
_statusText.value = "Listening"
}

View File

@@ -65,7 +65,7 @@ class GatewayBootstrapAuthTest {
storedOperatorToken = "stored-token",
),
)
assertFalse(
assertTrue(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
storedOperatorToken = null,
@@ -95,6 +95,17 @@ class GatewayBootstrapAuthTest {
assertNull(resolved)
}
@Test
fun resolveOperatorSessionConnectAuthUsesNoAuthWhenGatewayHasNoAuth() {
val resolved =
resolveOperatorSessionConnectAuth(
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null),
storedOperatorToken = null,
)
assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null), resolved)
}
@Test
fun resolveOperatorSessionConnectAuthPrefersExplicitSharedAuth() {
val resolved =

View File

@@ -0,0 +1,129 @@
package ai.openclaw.app
import android.Manifest
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityOptionsCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PermissionRequesterTest {
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun timedOutRequestCallbackDoesNotCompleteNextRequest() =
runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val launchers = mutableListOf<FakePermissionLauncher>()
val requester =
PermissionRequester(activity()) { callback ->
FakePermissionLauncher(callback).also { launchers += it }
}
try {
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
runCurrent()
advanceTimeBy(11)
runCurrent()
assertTrue(first.isCompleted)
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[0].launches)
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
runCurrent()
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
launchers[0].deliver(mapOf(Manifest.permission.CAMERA to false))
runCurrent()
assertFalse(second.isCompleted)
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
runCurrent()
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
} finally {
Dispatchers.resetMain()
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun timedOutRequestWithoutCallbackDoesNotBlockNextRequest() =
runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val launchers = mutableListOf<FakePermissionLauncher>()
val requester =
PermissionRequester(activity()) { callback ->
FakePermissionLauncher(callback).also { launchers += it }
}
try {
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
runCurrent()
advanceTimeBy(11)
runCurrent()
assertTrue(first.isCompleted)
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
runCurrent()
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
runCurrent()
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
} finally {
Dispatchers.resetMain()
}
}
private fun activity(): ComponentActivity =
Robolectric
.buildActivity(ComponentActivity::class.java)
.setup()
.get()
}
private class FakePermissionLauncher(
private val callback: (Map<String, Boolean>) -> Unit,
) : ActivityResultLauncher<Array<String>>() {
val launches = mutableListOf<List<String>>()
override val contract: ActivityResultContract<Array<String>, *> = ActivityResultContracts.RequestMultiplePermissions()
override fun launch(
input: Array<String>,
options: ActivityOptionsCompat?,
) {
launches += input.toList()
}
override fun unregister() {}
fun deliver(result: Map<String, Boolean>) {
callback(result)
}
}

View File

@@ -1,10 +1,129 @@
package ai.openclaw.app.gateway
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.concurrent.ConcurrentLinkedQueue
private const val LIFECYCLE_TEST_TIMEOUT_MS = 8_000L
private const val LIFECYCLE_CONNECT_CHALLENGE_FRAME =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
private class ReconnectDeviceAuthStore : DeviceAuthTokenStore {
override fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry? = null
override fun saveToken(
deviceId: String,
role: String,
token: String,
scopes: List<String>,
) = Unit
override fun clearToken(
deviceId: String,
role: String,
) = Unit
}
private data class ReconnectHarness(
val session: GatewaySession,
val sessionJob: Job,
)
private data class ReconnectServer(
val server: MockWebServer,
val sockets: ConcurrentLinkedQueue<WebSocket>,
) {
val port: Int
get() = server.port
val requestCount: Int
get() = server.requestCount
fun shutdown() {
sockets.forEach { runCatching { it.cancel() } }
runCatching { server.shutdown() }
.onFailure { err ->
if (err.message != "Gave up waiting for queue to shut down") throw err
}
}
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionReconnectTest {
@Test
fun connectToNewGatewayClosesActiveConnectionAndStartsReplacement() =
runBlocking {
val json = Json { ignoreUnknownKeys = true }
val firstConnect = CompletableDeferred<Unit>()
val firstClosed = CompletableDeferred<Unit>()
val secondConnect = CompletableDeferred<Unit>()
val secondClosed = CompletableDeferred<Unit>()
val firstServer =
startGatewayServer(
json = json,
onClosed = { firstClosed.complete(Unit) },
) { webSocket, id, method ->
if (method == "connect") {
firstConnect.complete(Unit)
webSocket.send(connectResponseFrame(id))
}
}
val secondServer =
startGatewayServer(
json = json,
onClosed = { secondClosed.complete(Unit) },
) { webSocket, id, method ->
if (method == "connect") {
secondConnect.complete(Unit)
webSocket.send(connectResponseFrame(id))
}
}
val harness = createReconnectHarness()
try {
connectNodeSession(harness.session, firstServer.port)
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstConnect.await() }
connectNodeSession(harness.session, secondServer.port)
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { firstClosed.await() }
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondConnect.await() }
assertEquals(1, secondServer.requestCount)
harness.session.disconnect()
withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { secondClosed.await() }
} finally {
shutdownReconnectHarness(harness, firstServer, secondServer)
}
}
@Test
fun bootstrapNodePairingRequiredKeepsReconnectActive() {
val error =
@@ -113,4 +232,125 @@ class GatewaySessionReconnectTest {
),
)
}
private fun createReconnectHarness(): ReconnectHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = ReconnectDeviceAuthStore(),
onConnected = { _, _, _ -> },
onDisconnected = { _ -> },
onEvent = { _, _ -> },
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
)
return ReconnectHarness(session = session, sessionJob = sessionJob)
}
private suspend fun connectNodeSession(
session: GatewaySession,
port: Int,
) {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|$port",
name = "test",
host = "127.0.0.1",
port = port,
tlsEnabled = false,
),
token = "test-token",
bootstrapToken = null,
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
}
private suspend fun shutdownReconnectHarness(
harness: ReconnectHarness,
vararg servers: ReconnectServer,
) {
harness.session.disconnect()
harness.sessionJob.cancelAndJoin()
servers.forEach { it.shutdown() }
}
private fun connectResponseFrame(id: String): String = """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
private fun startGatewayServer(
json: Json,
onClosed: () -> Unit = {},
onRequestFrame: (webSocket: WebSocket, id: String, method: String) -> Unit,
): ReconnectServer {
val sockets = ConcurrentLinkedQueue<WebSocket>()
val server =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
sockets += webSocket
webSocket.send(LIFECYCLE_CONNECT_CHALLENGE_FRAME)
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
onRequestFrame(webSocket, id, method)
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
onClosed()
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
onClosed()
}
},
)
}
start()
}
return ReconnectServer(server = server, sockets = sockets)
}
}

View File

@@ -44,4 +44,27 @@ class JpegSizeLimiterTest {
assertEquals(600, result.height)
assertEquals(90, result.quality)
}
@Test
fun triesFinalScaledImageBeforeFailing() {
val result =
JpegSizeLimiter.compressToLimit(
initialWidth = 1000,
initialHeight = 800,
startQuality = 90,
maxBytes = 100,
minSize = 1,
scaleStep = 0.5,
maxScaleAttempts = 1,
maxQualityAttempts = 1,
encode = { width, _, _ ->
if (width == 500) ByteArray(80) else ByteArray(120)
},
)
assertEquals(500, result.width)
assertEquals(400, result.height)
assertEquals(90, result.quality)
assertEquals(80, result.bytes.size)
}
}

View File

@@ -268,6 +268,14 @@ class GatewayConfigResolverTest {
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
}
@Test
fun parseGatewayEndpointResultRejectsUnsupportedSchemes() {
val parsed = parseGatewayEndpointResult("ftp://gateway.example:21")
assertNull(parsed.config)
assertEquals(GatewayEndpointValidationError.INVALID_URL, parsed.error)
}
@Test
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")

View File

@@ -2,6 +2,7 @@ package ai.openclaw.app.ui.chat
import androidx.compose.ui.text.LinkAnnotation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -32,6 +33,22 @@ class ChatMarkdownTest {
assertEquals("https://docs.openclaw.ai/help/testing", (links.single().item as LinkAnnotation.Url).url)
}
@Test
fun markdownLinksDropUnsafeDestinations() {
listOf(
"intent://example/#Intent;scheme=openclaw;end",
"file:///sdcard/Download/x",
"content://downloads/public_downloads/1",
"tel:+15551234567",
"javascript:alert(1)",
).forEach { destination ->
val annotated = buildChatInlineMarkdown("Open [settings]($destination)")
assertEquals("Open settings", annotated.text)
assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty())
}
}
@Test
fun plainTextDoesNotAddLinkAnnotations() {
val annotated = buildChatInlineMarkdown("No link here")
@@ -39,4 +56,20 @@ class ChatMarkdownTest {
assertEquals("No link here", annotated.text)
assertTrue(annotated.getLinkAnnotations(0, annotated.length).isEmpty())
}
@Test
fun parseDataImageDestinationAcceptsBoundedPayloads() {
val parsed = parseDataImageDestination("data:image/png;base64,QUJD")
assertEquals(ParsedDataImage(mimeType = "image/png", base64 = "QUJD"), parsed)
}
@Test
fun parseDataImageDestinationRejectsOversizedPayloads() {
val oversized = "A".repeat(CHAT_IMAGE_MAX_BASE64_CHARS + 1)
val parsed = parseDataImageDestination("data:image/png;base64,$oversized")
assertNull(parsed)
}
}

View File

@@ -0,0 +1,263 @@
package ai.openclaw.app.voice
import android.Manifest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MicCaptureManagerTest {
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun transcriptionFinalQueuesGatewayMessage() =
runTest {
val sentMessages = mutableListOf<String>()
val manager =
createManager(
scope = this,
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
null
},
)
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
manager.onGatewayConnectionChanged(true)
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"partial","text":"hello"}""",
)
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":"hello world","final":true}""",
)
runCurrent()
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-1", text = "reply"))
advanceUntilIdle()
assertNull(manager.liveTranscript.value)
assertEquals(listOf("hello world"), sentMessages)
val conversation = manager.conversation.value.first()
assertEquals(VoiceConversationRole.User, conversation.role)
assertEquals("hello world", conversation.text)
}
@Test
fun transcriptionErrorDisablesMic() {
val manager = createManager()
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"error","message":"provider unavailable"}""",
)
assertEquals(false, manager.micEnabled.value)
assertEquals("Transcription failed: provider unavailable", manager.statusText.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun punctuationOnlyTranscriptDoesNotSendTurn() =
runTest {
val sentMessages = mutableListOf<String>()
val manager =
createManager(
scope = this,
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
"run-1"
},
)
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
manager.onGatewayConnectionChanged(true)
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":".","final":true}""",
)
advanceUntilIdle()
assertEquals(emptyList<String>(), sentMessages)
assertEquals(emptyList<VoiceConversationEntry>(), manager.conversation.value)
}
@Test
fun pcm16FramesAreEncodedAsPcmuFrames() {
val manager = createManager()
val method = manager.javaClass.getDeclaredMethod("pcm16ToPcmu", ByteArray::class.java)
method.isAccessible = true
val encoded = method.invoke(manager, byteArrayOf(0, 0, 0, 0)) as ByteArray
assertEquals(2, encoded.size)
assertEquals(0xff.toByte(), encoded[0])
assertEquals(0xff.toByte(), encoded[1])
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun disablingMicDuringSessionCreateClosesReturnedSession() =
runTest {
val createdSession = CompletableDeferred<String>()
val closedSessions = mutableListOf<String>()
val manager =
createManager(
scope = this,
createTranscriptionSession = { createdSession.await() },
closeTranscriptionSession = { sessionId -> closedSessions += sessionId },
)
manager.onGatewayConnectionChanged(true)
manager.setMicEnabled(true)
manager.setMicEnabled(false)
createdSession.complete("transcription-1")
advanceUntilIdle()
assertEquals(listOf("transcription-1"), closedSessions)
assertEquals(false, manager.isListening.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun disablingMicKeepsSessionOpenForFinalTranscript() =
runTest {
val manager = createManager(scope = this)
setPrivateMutableStateFlowValue(manager, "_micEnabled", true)
setPrivateField(manager, "transcriptionSessionId", "transcription-1")
manager.setMicEnabled(false)
manager.handleGatewayEvent(
"talk.event",
"""{"transcriptionSessionId":"transcription-1","type":"transcript","text":"testing testing 1 2 3","final":true}""",
)
runCurrent()
assertEquals(
"testing testing 1 2 3",
manager.conversation.value
.single()
.text,
)
assertEquals("transcription-1", privateField<String?>(manager, "transcriptionSessionId"))
privateField<Job?>(manager, "transcriptionDrainJob")?.cancel()
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun reconnectRestartsAfterPendingCreateCancellation() =
runTest {
val firstCreate = CompletableDeferred<String>()
val secondCreate = CompletableDeferred<String>()
var createCalls = 0
val manager =
createManager(
scope = this,
createTranscriptionSession = {
createCalls += 1
if (createCalls == 1) firstCreate.await() else secondCreate.await()
},
)
manager.onGatewayConnectionChanged(true)
manager.setMicEnabled(true)
runCurrent()
manager.onGatewayConnectionChanged(false)
manager.onGatewayConnectionChanged(true)
firstCreate.completeExceptionally(CancellationException("connection closed"))
runCurrent()
assertEquals(2, createCalls)
assertEquals(true, manager.micEnabled.value)
manager.setMicEnabled(false)
secondCreate.completeExceptionally(CancellationException("test complete"))
runCurrent()
}
private fun createManager(
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
createTranscriptionSession: suspend () -> String = { "transcription-1" },
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
onRunIdKnown("run-1")
"run-1"
},
): MicCaptureManager =
MicCaptureManager(
context =
RuntimeEnvironment.getApplication().also { app ->
shadowOf(app).grantPermissions(Manifest.permission.RECORD_AUDIO)
},
scope = scope,
createTranscriptionSession = createTranscriptionSession,
appendTranscriptionAudio = { _, _, _ -> },
closeTranscriptionSession = closeTranscriptionSession,
sendToGateway = sendToGateway,
)
private fun setPrivateField(
target: Any,
name: String,
value: Any?,
) {
val field = target.javaClass.getDeclaredField(name)
field.isAccessible = true
field.set(target, value)
}
@Suppress("UNCHECKED_CAST")
private fun setPrivateMutableStateFlowValue(
target: Any,
name: String,
value: Boolean,
) {
val field = target.javaClass.getDeclaredField(name)
field.isAccessible = true
(field.get(target) as MutableStateFlow<Boolean>).value = value
}
@Suppress("UNCHECKED_CAST")
private fun <T> privateField(
target: Any,
name: String,
): T {
val field = target.javaClass.getDeclaredField(name)
field.isAccessible = true
return field.get(target) as T
}
private fun chatFinalPayload(
runId: String,
text: String,
): String =
"""
{
"runId": "$runId",
"state": "final",
"message": {
"role": "assistant",
"content": [
{ "type": "text", "text": "$text" }
]
}
}
""".trimIndent()
}

View File

@@ -62,4 +62,37 @@ class TalkModeConfigParsingTest {
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
)
}
@Test
fun defaultsToNativeTalkMode() {
val talk =
buildJsonObject {
put("realtime", buildJsonObject { put("transport", "webrtc") })
}
assertEquals(
TalkModeExecutionMode.Native,
TalkModeGatewayConfigParser.resolvedExecutionMode(talk),
)
}
@Test
fun usesRealtimeRelayWhenGatewayRelayIsConfigured() {
val talk =
buildJsonObject {
put(
"realtime",
buildJsonObject {
put("mode", "realtime")
put("transport", "gateway-relay")
put("brain", "agent-consult")
},
)
}
assertEquals(
TalkModeExecutionMode.RealtimeRelay,
TalkModeGatewayConfigParser.resolvedExecutionMode(talk),
)
}
}

View File

@@ -4,12 +4,16 @@ import ai.openclaw.app.gateway.DeviceAuthEntry
import ai.openclaw.app.gateway.DeviceAuthTokenStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import android.os.SystemClock
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -19,6 +23,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
@RunWith(RobolectricTestRunner::class)
@@ -91,6 +96,87 @@ class TalkModeManagerTest {
assertEquals(0L, playbackGeneration(manager).get())
}
@Test
fun realtimeToolFinalDoesNotUseAllResponseTts() {
val manager = createManager()
manager.ttsOnAllResponses = true
setPrivateField(manager, "realtimeSessionId", "relay-1")
realtimeToolRuns(manager)["run-tool"] =
RealtimeToolRun(callId = "call-1", relaySessionId = "relay-1")
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-tool", text = "tool result"))
assertEquals(0L, playbackGeneration(manager).get())
assertTrue(realtimeToolRuns(manager).isEmpty())
}
@Test
fun realtimeTranscriptsPopulateVoiceConversation() {
val manager = createManager()
setPrivateField(manager, "realtimeSessionId", "relay-1")
manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello"))
manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "user", text = "hello world", final = true))
manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "hi"))
manager.handleGatewayEvent("talk.event", realtimeTranscriptPayload(role = "assistant", text = "hi there", final = true))
assertEquals(
listOf(
VoiceConversationEntry(
id = manager.conversation.value[0].id,
role = VoiceConversationRole.User,
text = "hello world",
),
VoiceConversationEntry(
id = manager.conversation.value[1].id,
role = VoiceConversationRole.Assistant,
text = "hi there",
),
),
manager.conversation.value,
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun realtimeStartWithoutGatewayTurnsTalkOff() =
runTest {
val stoppedByRelay = AtomicBoolean(false)
val manager =
createManager(
scope = this,
isConnected = { false },
onStoppedByRelay = { stoppedByRelay.set(true) },
)
setPrivateField(manager, "executionMode", TalkModeExecutionMode.RealtimeRelay)
setPrivateField(manager, "configLoaded", true)
manager.setEnabled(true)
advanceUntilIdle()
assertFalse(manager.isEnabled.value)
assertFalse(manager.isListening.value)
assertEquals("Gateway not connected", manager.statusText.value)
assertTrue(stoppedByRelay.get())
}
@Test
fun staleRealtimeToolFinalDoesNotUseAllResponseTts() {
val manager = createManager()
manager.ttsOnAllResponses = true
setPrivateField(manager, "realtimeSessionId", "relay-2")
realtimeToolRuns(manager)["run-tool"] =
RealtimeToolRun(callId = "call-1", relaySessionId = "relay-1")
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-tool", text = "stale result"))
assertEquals(0L, playbackGeneration(manager).get())
assertTrue(realtimeToolRuns(manager).isEmpty())
}
@Test
fun textReadyDoesNotEnterSpeakingUntilAudioPlaybackStarts() =
runTest {
@@ -125,9 +211,43 @@ class TalkModeManagerTest {
job.join()
}
@Test
fun realtimeAudioFramesStreamUntilPlaybackStarts() {
val manager = createManager()
assertFalse(shouldAppendRealtimeCapturedFrame(manager, 0))
assertTrue(shouldAppendRealtimeCapturedFrame(manager, 16))
assertTrue(shouldAppendRealtimeCapturedFrame(manager, 4_800))
setPrivateField(manager, "realtimePlaybackEndsAtMs", SystemClock.elapsedRealtime() + 1_000)
assertFalse(shouldAppendRealtimeCapturedFrame(manager, 4_800))
setPrivateField(manager, "realtimePlaybackEndsAtMs", SystemClock.elapsedRealtime() - 1)
assertTrue(shouldAppendRealtimeCapturedFrame(manager, 4_800))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun chatFinalWaitWithoutSubscribeUsesShortTimeout() =
runTest {
val manager = createManager(scope = this, supportsChatSubscribe = false)
setPrivateField(manager, "pendingRunId", "run-missing-final")
setPrivateField(manager, "pendingFinal", CompletableDeferred<Boolean>())
assertFalse(manager.waitForChatFinal("run-missing-final"))
assertEquals(6_000, currentTime)
}
private fun createManager(
talkSpeakClient: TalkSpeechSynthesizing = TalkSpeakClient(),
talkAudioPlayer: TalkAudioPlaying? = null,
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
supportsChatSubscribe: Boolean = false,
isConnected: () -> Boolean = { true },
onStoppedByRelay: () -> Unit = {},
): TalkModeManager {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
@@ -142,17 +262,21 @@ class TalkModeManagerTest {
)
return TalkModeManager(
context = app,
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
scope = scope,
session = session,
supportsChatSubscribe = false,
isConnected = { true },
supportsChatSubscribe = supportsChatSubscribe,
isConnected = isConnected,
onStoppedByRelay = onStoppedByRelay,
talkSpeakClient = talkSpeakClient,
talkAudioPlayer = talkAudioPlayer ?: TalkAudioPlayer(app),
)
}
@Suppress("UNCHECKED_CAST")
private fun playbackGeneration(manager: TalkModeManager): AtomicLong = readPrivateField(manager, "playbackGeneration") as AtomicLong
private fun playbackGeneration(manager: TalkModeManager) = readPrivateField(manager, "playbackGeneration") as AtomicLong
@Suppress("UNCHECKED_CAST")
private fun realtimeToolRuns(manager: TalkModeManager) = readPrivateField(manager, "realtimeToolRuns") as MutableMap<String, RealtimeToolRun>
private fun setPrivateField(
target: Any,
@@ -173,6 +297,19 @@ class TalkModeManagerTest {
return field.get(target)
}
private fun shouldAppendRealtimeCapturedFrame(
manager: TalkModeManager,
length: Int,
): Boolean {
val method =
manager.javaClass.getDeclaredMethod(
"shouldAppendRealtimeCapturedFrame",
Int::class.javaPrimitiveType,
)
method.isAccessible = true
return method.invoke(manager, length) as Boolean
}
private fun chatFinalPayload(
runId: String,
text: String,
@@ -191,6 +328,21 @@ class TalkModeManagerTest {
}
}
""".trimIndent()
private fun realtimeTranscriptPayload(
role: String,
text: String,
final: Boolean = false,
): String =
"""
{
"relaySessionId": "relay-1",
"type": "transcript",
"role": "$role",
"text": "$text",
"final": $final
}
""".trimIndent()
}
private class FakeTalkSpeechSynthesizer : TalkSpeechSynthesizing {

View File

@@ -0,0 +1,55 @@
package ai.openclaw.app.voice
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.SpeechRecognizer
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class VoiceWakeManagerTest {
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun repeatedCommandDispatchesInNewRecognitionCycle() =
runTest {
val commands = mutableListOf<String>()
val manager =
VoiceWakeManager(
context = RuntimeEnvironment.getApplication(),
scope = this,
onCommand = { command -> commands += command },
)
manager.setTriggerWords(listOf("claude"))
val listener = recognitionListener(manager)
listener.onReadyForSpeech(null)
listener.onPartialResults(recognitionResults("claude take a photo"))
listener.onResults(recognitionResults("claude take a photo"))
advanceUntilIdle()
listener.onReadyForSpeech(null)
listener.onResults(recognitionResults("claude take a photo"))
advanceUntilIdle()
assertEquals(listOf("take a photo", "take a photo"), commands)
}
private fun recognitionResults(text: String): Bundle =
Bundle().apply {
putStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION, arrayListOf(text))
}
private fun recognitionListener(manager: VoiceWakeManager): RecognitionListener {
val field = VoiceWakeManager::class.java.getDeclaredField("listener")
field.isAccessible = true
return field.get(manager) as RecognitionListener
}
}

View File

@@ -1,6 +1,7 @@
package ai.openclaw.app.node
import android.content.Context
import android.provider.CallLog
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@@ -246,6 +247,13 @@ class CallLogHandlerTest : NodeHandlerRobolectricTest() {
assertEquals(0, source.lastRequest?.offset)
}
@Test
fun callLogLikeFiltersEscapeWildcards() {
assertEquals("${CallLog.Calls.CACHED_NAME} LIKE ? ESCAPE '\\'", buildCallLogCachedNameLikeSelection())
assertEquals("${CallLog.Calls.NUMBER} LIKE ? ESCAPE '\\'", buildCallLogNumberLikeSelection())
assertEquals("%a\\%b\\_c\\\\d%", buildCallLogLikeArg("a%b_c\\d"))
}
@Test
fun handleCallLogSearch_mapsSearchFailuresToUnavailable() {
val handler =

View File

@@ -69,13 +69,13 @@ private object SystemCallLogDataSource : CallLogDataSource {
val selectionArgs = mutableListOf<String>()
request.cachedName?.let {
selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
selectionArgs.add("%$it%")
selections.add(buildCallLogCachedNameLikeSelection())
selectionArgs.add(buildCallLogLikeArg(it))
}
request.number?.let {
selections.add("${CallLog.Calls.NUMBER} LIKE ?")
selectionArgs.add("%$it%")
selections.add(buildCallLogNumberLikeSelection())
selectionArgs.add(buildCallLogLikeArg(it))
}
// Support time range query
@@ -149,6 +149,25 @@ private object SystemCallLogDataSource : CallLogDataSource {
}
}
internal fun escapeCallLogSqlLikeLiteral(value: String): String =
buildString(value.length) {
for (ch in value) {
when (ch) {
'\\', '%', '_' -> {
append('\\')
append(ch)
}
else -> append(ch)
}
}
}
internal fun buildCallLogCachedNameLikeSelection(): String = "${CallLog.Calls.CACHED_NAME} LIKE ? ESCAPE '\\'"
internal fun buildCallLogNumberLikeSelection(): String = "${CallLog.Calls.NUMBER} LIKE ? ESCAPE '\\'"
internal fun buildCallLogLikeArg(value: String): String = "%${escapeCallLogSqlLikeLiteral(value)}%"
class CallLogHandler private constructor(
private val appContext: Context,
private val dataSource: CallLogDataSource,

View File

@@ -13,8 +13,9 @@ struct OpenClawLiveActivity: Widget {
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.statusText)
.font(.subheadline)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
DynamicIslandExpandedRegion(.trailing) {
self.trailingView(state: context.state)
@@ -22,10 +23,7 @@ struct OpenClawLiveActivity: Widget {
} compactLeading: {
self.statusDot(state: context.state)
} compactTrailing: {
Text(context.state.statusText)
.font(.caption2)
.lineLimit(1)
.frame(maxWidth: 64)
self.compactStatusIcon(state: context.state)
} minimal: {
self.statusDot(state: context.state)
}
@@ -33,39 +31,32 @@ struct OpenClawLiveActivity: Widget {
}
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
HStack(spacing: 8) {
self.statusDot(state: context.state)
.frame(width: 10, height: 10)
HStack(spacing: 10) {
self.statusIcon(state: context.state)
.frame(width: 30, height: 30)
.background(.thinMaterial, in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text("OpenClaw")
.font(.subheadline.bold())
.lineLimit(1)
Text(context.state.statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Spacer()
self.trailingView(state: context.state)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.padding(.vertical, 8)
}
@ViewBuilder
private func trailingView(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
ProgressView().controlSize(.small)
} else if state.isDisconnected {
Image(systemName: "wifi.slash")
.foregroundStyle(.red)
} else if state.isIdle {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundStyle(.green)
} else {
Text(state.startedAt, style: .timer)
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
}
self.statusIcon(state: state)
.font(.system(size: 16, weight: .semibold))
.frame(width: 28, height: 28)
}
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
@@ -74,10 +65,34 @@ struct OpenClawLiveActivity: Widget {
.frame(width: 6, height: 6)
}
@ViewBuilder
private func compactStatusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
self.statusIcon(state: state)
.font(.system(size: 12, weight: .semibold))
.frame(width: 18, height: 18)
}
@ViewBuilder
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.cyan)
} else if state.isDisconnected {
Image(systemName: "wifi.slash")
.foregroundStyle(.red)
} else if state.isIdle {
Image(systemName: "checkmark")
.foregroundStyle(.green)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
if state.isDisconnected { return .red }
if state.isConnecting { return .gray }
if state.isConnecting { return .cyan }
if state.isIdle { return .green }
return .blue
return .orange
}
}

View File

@@ -1,5 +1,9 @@
# OpenClaw iOS Changelog
## 2026.5.19 - 2026-05-19
Maintenance update for the current OpenClaw release.
## 2026.5.17 - 2026-05-17
Maintenance update for the current OpenClaw release.

View File

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

View File

@@ -8,6 +8,8 @@ final class LiveActivityManager {
static let shared = LiveActivityManager()
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
private let connectingStaleSeconds: TimeInterval = 120
private let hydrationStaleSeconds: TimeInterval = 300
private var currentActivity: Activity<OpenClawActivityAttributes>?
private var activityStartDate: Date = .now
@@ -24,11 +26,11 @@ final class LiveActivityManager {
return true
}
func startActivity(agentName: String, sessionKey: String) {
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
self.hydrateCurrentAndPruneDuplicates()
if self.currentActivity != nil {
self.handleConnecting()
self.handleConnecting(statusText: statusText)
return
}
@@ -40,11 +42,14 @@ final class LiveActivityManager {
self.activityStartDate = .now
let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey)
let state = self.connectingState(statusText: statusText)
do {
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: self.connectingState(), staleDate: nil),
content: ActivityContent(
state: state,
staleDate: Date().addingTimeInterval(self.connectingStaleSeconds)),
pushType: nil)
self.currentActivity = activity
self.logger.info("started live activity id=\(activity.id, privacy: .public)")
@@ -53,16 +58,57 @@ final class LiveActivityManager {
}
}
func handleConnecting() {
self.updateCurrent(state: self.connectingState())
func showAttention(statusText: String, agentName: String, sessionKey: String) {
self.hydrateCurrentAndPruneDuplicates()
if self.currentActivity == nil {
let authInfo = ActivityAuthorizationInfo()
guard authInfo.areActivitiesEnabled else {
self.logger.info("Live Activities disabled; skipping attention state")
return
}
self.activityStartDate = .now
let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey)
do {
let activity = try Activity.request(
attributes: attributes,
content: ActivityContent(state: self.attentionState(statusText: statusText), staleDate: nil),
pushType: nil)
self.currentActivity = activity
self.logger.info("started attention live activity id=\(activity.id, privacy: .public)")
} catch {
self.logger.error(
"failed to start attention live activity: \(error.localizedDescription, privacy: .public)")
}
return
}
self.updateCurrent(state: self.attentionState(statusText: statusText), staleDate: nil)
}
func handleConnecting(statusText: String = "Connecting...") {
self.updateCurrent(
state: self.connectingState(statusText: statusText),
staleDate: Date().addingTimeInterval(self.connectingStaleSeconds))
}
func handleReconnect() {
self.updateCurrent(state: self.idleState())
self.endActivity(reason: "connected")
}
func handleDisconnect() {
self.updateCurrent(state: self.disconnectedState())
self.endActivity(reason: "disconnected")
}
func endActivity(reason: String) {
guard let activity = self.currentActivity else { return }
self.currentActivity = nil
self.logger.info("ending live activity reason=\(reason, privacy: .public)")
Task {
await activity.end(
ActivityContent(state: self.disconnectedState(), staleDate: nil),
dismissalPolicy: .immediate)
}
}
private func hydrateCurrentAndPruneDuplicates() {
@@ -72,39 +118,71 @@ final class LiveActivityManager {
return
}
let keeper = active.max { lhs, rhs in
let now = Date()
let candidates = active.filter { activity in
let state = activity.content.state
guard activity.activityState == .active else { return false }
guard !state.isIdle, !state.isDisconnected else { return false }
return now.timeIntervalSince(state.startedAt) < self.hydrationStaleSeconds
}
guard !candidates.isEmpty else {
self.currentActivity = nil
for activity in active {
self.end(activity: activity)
}
return
}
let keeper = candidates.max { lhs, rhs in
lhs.content.state.startedAt < rhs.content.state.startedAt
} ?? active[0]
} ?? candidates[0]
self.currentActivity = keeper
self.activityStartDate = keeper.content.state.startedAt
let stale = active.filter { $0.id != keeper.id }
for activity in stale {
Task {
await activity.end(
ActivityContent(state: self.disconnectedState(), staleDate: nil),
dismissalPolicy: .immediate)
}
self.end(activity: activity)
}
}
private func updateCurrent(state: OpenClawActivityAttributes.ContentState) {
guard let activity = self.currentActivity else { return }
private func updateCurrent(state: OpenClawActivityAttributes.ContentState, staleDate: Date? = nil) {
guard let activity = self.currentActivity, activity.activityState == .active else {
self.currentActivity = nil
return
}
Task {
await activity.update(ActivityContent(state: state, staleDate: nil))
await activity.update(ActivityContent(state: state, staleDate: staleDate))
}
}
private func connectingState() -> OpenClawActivityAttributes.ContentState {
private func end(activity: Activity<OpenClawActivityAttributes>) {
Task {
await activity.end(
ActivityContent(state: self.disconnectedState(), staleDate: nil),
dismissalPolicy: .immediate)
}
}
private func connectingState(statusText: String = "Connecting...") -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Connecting...",
statusText: statusText,
isIdle: false,
isDisconnected: false,
isConnecting: true,
startedAt: self.activityStartDate)
}
private func attentionState(statusText: String) -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: statusText,
isIdle: false,
isDisconnected: false,
isConnecting: false,
startedAt: self.activityStartDate)
}
private func idleState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Idle",

View File

@@ -41,5 +41,12 @@ extension OpenClawActivityAttributes.ContentState {
isDisconnected: true,
isConnecting: false,
startedAt: .now)
static let attention = OpenClawActivityAttributes.ContentState(
statusText: "Approval needed",
isIdle: false,
isDisconnected: false,
isConnecting: false,
startedAt: .now)
}
#endif

View File

@@ -546,6 +546,7 @@ final class NodeAppModel {
self.talkMode.updateGatewayConnected(false)
if self.isBackgrounded {
self.gatewayStatusText = "Background idle"
LiveActivityManager.shared.endActivity(reason: "background_idle")
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.showLocalCanvasOnDisconnect()
@@ -1839,7 +1840,7 @@ extension NodeAppModel {
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
LiveActivityManager.shared.handleDisconnect()
LiveActivityManager.shared.endActivity(reason: "manual_disconnect")
self.gatewayHealthMonitor.stop()
Task {
await self.operatorGateway.disconnect()
@@ -1877,7 +1878,7 @@ extension NodeAppModel {
self.operatorConnected = false
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
LiveActivityManager.shared.handleDisconnect()
LiveActivityManager.shared.endActivity(reason: "new_gateway_connect")
self.gatewayDefaultAgentId = nil
self.gatewayAgents = []
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
@@ -1908,6 +1909,12 @@ extension NodeAppModel {
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
if problem.needsPairingApproval || problem.pauseReconnect {
LiveActivityManager.shared.showAttention(
statusText: problem.needsPairingApproval ? "Approval needed" : "Action required",
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
}
private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
@@ -2112,7 +2119,6 @@ extension NodeAppModel {
await self.refreshShareRouteFromGateway()
await self.registerAPNsTokenIfNeeded()
await self.startVoiceWakeSync()
await MainActor.run { LiveActivityManager.shared.handleReconnect() }
await MainActor.run { self.startGatewayHealthMonitor() }
},
onDisconnected: { [weak self] reason in
@@ -2120,7 +2126,7 @@ extension NodeAppModel {
await MainActor.run {
self.operatorConnected = false
self.talkMode.updateGatewayConnected(false)
LiveActivityManager.shared.handleDisconnect()
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
}
GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)")
await MainActor.run { self.stopGatewayHealthMonitor() }
@@ -2186,14 +2192,10 @@ extension NodeAppModel {
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
let liveActivity = LiveActivityManager.shared
if liveActivity.isActive {
liveActivity.handleConnecting()
} else {
liveActivity.startActivity(
agentName: self.selectedAgentId ?? "main",
sessionKey: self.mainSessionKey)
}
LiveActivityManager.shared.showConnecting(
statusText: (attempt == 0) ? "Connecting..." : "Reconnecting...",
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
do {
@@ -2220,6 +2222,7 @@ extension NodeAppModel {
self.gatewayConnected = true
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
LiveActivityManager.shared.handleReconnect()
}
let usedBootstrapToken =
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
@@ -2360,6 +2363,7 @@ extension NodeAppModel {
await MainActor.run {
self.lastGatewayProblem = nil
self.gatewayStatusText = "Offline"
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
self.connectedGatewayID = nil
@@ -3962,7 +3966,7 @@ extension NodeAppModel {
switch route {
case let .agent(link):
await self.handleAgentDeepLink(link, originalURL: url)
case .gateway:
case .gateway, .dashboard:
break
}
}

View File

@@ -1,3 +1,3 @@
{
"version": "2026.5.17"
"version": "2026.5.19"
}

View File

@@ -85,9 +85,7 @@ struct AboutSettings: View {
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 4)
.padding(.horizontal, 24)
.padding(.bottom, 24)
.settingsDetailContent()
.onAppear {
guard let updater, !self.didLoadUpdaterState else { return }
// Keep Sparkles auto-check setting in sync with the persisted toggle.

View File

@@ -0,0 +1,49 @@
import AppKit
@MainActor
enum AppNavigationActions {
static func openDashboard() {
NSApp.activate(ignoringOtherApps: true)
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
Task { @MainActor in
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
}
}
static func openChat() {
NSApp.activate(ignoringOtherApps: true)
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.show(sessionKey: sessionKey)
}
}
static func toggleCanvas() {
NSApp.activate(ignoringOtherApps: true)
Task { @MainActor in
if AppStateStore.shared.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
let sessionKey = await GatewayConnection.shared.mainSessionKey()
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
}
}
static func openSettings(tab: SettingsTab = .general) {
SettingsTabRouter.request(tab)
SettingsWindowOpener.shared.open()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab)
}
}
}

View File

@@ -42,35 +42,34 @@ struct ConfigSchemaForm: View {
let literals = nonNull.compactMap(\.literalValue)
if !literals.isEmpty, literals.count == nonNull.count {
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
Picker(
"",
selection: self.enumBinding(
path,
options: literals,
defaultValue: schema.explicitDefault))
{
Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index)
}
}
.pickerStyle(.menu)
})
self.renderEnumField(
path: path,
options: literals,
defaultValue: schema.explicitDefault,
label: label,
help: help))
}
}
if let options = schema.enumValues, !options.isEmpty {
return AnyView(
self.renderEnumField(
path: path,
options: options,
defaultValue: schema.explicitDefault,
label: label,
help: help))
}
switch schema.schemaType {
case "object":
if self.mode == .channelQuick, self.isChannelRoot(path) {
return AnyView(self.renderChannelQuickObject(schema, path: path, value: value))
}
let showHeader = !self.isNestedChannelQuickConfigurationObject(path: path, label: label)
return AnyView(
VStack(alignment: .leading, spacing: 12) {
if let label {
if showHeader, let label {
Text(label)
.font(.callout.weight(.semibold))
}
@@ -81,7 +80,7 @@ struct ConfigSchemaForm: View {
}
let properties = schema.properties
let sortedKeys = self.visibleObjectKeys(properties: properties, path: path)
ForEach(sortedKeys, id: \ .self) { key in
ForEach(sortedKeys, id: \.self) { key in
if let child = properties[key] {
self.renderNode(child, path: path + [.key(key)])
}
@@ -96,6 +95,13 @@ struct ConfigSchemaForm: View {
case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean":
if self.isChannelQuickLeaf(path) {
return AnyView(
SettingsCardToggleRow(
title: label ?? "Enabled",
subtitle: help,
binding: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)))
}
return AnyView(
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") }
@@ -106,6 +112,9 @@ struct ConfigSchemaForm: View {
case "string":
return AnyView(self.renderStringField(schema, path: path, label: label, help: help))
default:
if schema.literalValue != nil {
return AnyView(EmptyView())
}
return AnyView(
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
@@ -117,9 +126,19 @@ struct ConfigSchemaForm: View {
}
private func fieldLabel(for schema: ConfigSchemaNode, path: ConfigPath) -> String? {
hintForPath(path, hints: self.store.configUiHints)?.label
let label = hintForPath(path, hints: self.store.configUiHints)?.label
?? schema.title
?? labelForConfigPath(path)
return self.cleanedChannelQuickLabel(label, path: path)
}
private func cleanedChannelQuickLabel(_ label: String?, path: ConfigPath) -> String? {
guard self.mode == .channelQuick, path.count >= 3 else { return label }
guard case let .key(channelId) = path[1] else { return label }
guard let label else { return nil }
let prefix = humanizeConfigKey(channelId) + " "
guard label.hasPrefix(prefix) else { return label }
return String(label.dropFirst(prefix.count))
}
private func visibleObjectKeys(
@@ -161,7 +180,10 @@ struct ConfigSchemaForm: View {
let variants = schema.anyOf.isEmpty ? schema.oneOf : schema.anyOf
let nonNullVariants = variants.filter { !$0.isNullSchema }
if !nonNullVariants.isEmpty {
return nonNullVariants.allSatisfy(self.isSimpleField)
if nonNullVariants.count == 1, let only = nonNullVariants.first {
return self.isSimpleField(only)
}
return nonNullVariants.allSatisfy { $0.literalValue != nil }
}
if let enumValues = schema.enumValues {
return !enumValues.isEmpty
@@ -186,6 +208,13 @@ struct ConfigSchemaForm: View {
return dict.keys.contains { !reserved.contains($0) }
}
private func isChannelQuickLeaf(_ path: ConfigPath) -> Bool {
guard self.mode == .channelQuick, path.count == 3 else { return false }
guard case .key("channels") = path[0] else { return false }
guard case .key = path[1], case .key = path[2] else { return false }
return true
}
private func isChannelRoot(_ path: ConfigPath) -> Bool {
guard path.count == 2 else { return false }
guard case .key("channels") = path[0] else { return false }
@@ -193,6 +222,42 @@ struct ConfigSchemaForm: View {
return true
}
private func isNestedChannelQuickConfigurationObject(path: ConfigPath, label: String?) -> Bool {
guard self.mode == .channelQuick, path.count == 3 else { return false }
guard case .key("channels") = path[0] else { return false }
guard case .key = path[1] else { return false }
guard label == "Configuration" else { return false }
return true
}
@ViewBuilder
private func renderChannelQuickObject(
_ schema: ConfigSchemaNode,
path: ConfigPath,
value: Any?) -> some View
{
let properties = schema.properties
let sortedKeys = self.visibleObjectKeys(properties: properties, path: path)
VStack(alignment: .leading, spacing: 16) {
if sortedKeys.isEmpty {
self.renderChannelQuickEmptyState()
} else {
SettingsCardGroup("Configuration") {
ForEach(sortedKeys, id: \.self) { key in
if let child = properties[key] {
self.renderNode(child, path: path + [.key(key)])
}
}
}
}
if self.shouldRenderAdditionalProperties(schema, path: path, value: value) {
self.renderAdditionalProperties(schema, path: path, value: value)
}
}
}
private func renderChannelQuickEmptyState() -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("No quick settings for this channel.")
@@ -226,6 +291,57 @@ struct ConfigSchemaForm: View {
"webhookUrl",
]
private func renderChannelQuickField(
title: String?,
subtitle: String?,
@ViewBuilder control: () -> some View) -> some View
{
SettingsCardRow(title: title ?? "Value", subtitle: subtitle) {
control()
}
}
@ViewBuilder
private func renderEnumField(
path: ConfigPath,
options: [Any],
defaultValue: Any?,
label: String?,
help: String?) -> some View
{
let picker = Picker(
"",
selection: self.enumBinding(
path,
options: options,
defaultValue: defaultValue))
{
Text("Select…").tag(-1)
ForEach(options.indices, id: \.self) { index in
Text(String(describing: options[index])).tag(index)
}
}
.pickerStyle(.menu)
if self.isChannelQuickLeaf(path) {
self.renderChannelQuickField(title: label, subtitle: help) {
picker
.labelsHidden()
.frame(width: 180)
}
} else {
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
picker
}
}
}
@ViewBuilder
private func renderStringField(
_ schema: ConfigSchemaNode,
@@ -237,27 +353,33 @@ struct ConfigSchemaForm: View {
let placeholder = hint?.placeholder ?? ""
let sensitive = hint?.sensitive ?? isSensitivePath(path)
let defaultValue = schema.explicitDefault as? String
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
if let options = schema.enumValues {
Picker("", selection: self.enumBinding(path, options: options, defaultValue: schema.explicitDefault)) {
Text("Select…").tag(-1)
ForEach(options.indices, id: \ .self) { index in
Text(String(describing: options[index])).tag(index)
}
if self.isChannelQuickLeaf(path) {
self.renderChannelQuickField(title: label, subtitle: help) {
if sensitive {
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
.frame(width: 260)
} else {
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
.frame(width: 260)
}
}
} else {
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
if sensitive {
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
.pickerStyle(.menu)
} else if sensitive {
SecureField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: self.stringBinding(path, defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
}
@@ -271,20 +393,33 @@ struct ConfigSchemaForm: View {
{
let defaultValue = (schema.explicitDefault as? Double)
?? (schema.explicitDefault as? Int).map(Double.init)
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
if self.isChannelQuickLeaf(path) {
self.renderChannelQuickField(title: label, subtitle: help) {
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
.frame(width: 120)
}
} else {
VStack(alignment: .leading, spacing: 6) {
if let label { Text(label).font(.callout.weight(.semibold)) }
if let help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
}
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
TextField(
"",
text: self.numberBinding(
path,
isInteger: schema.schemaType == "integer",
defaultValue: defaultValue))
.textFieldStyle(.roundedBorder)
}
}
@@ -305,7 +440,7 @@ struct ConfigSchemaForm: View {
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(items.indices, id: \ .self) { index in
ForEach(items.indices, id: \.self) { index in
HStack(alignment: .top, spacing: 8) {
if let itemSchema {
self.renderNode(itemSchema, path: path + [.index(index)])
@@ -354,7 +489,7 @@ struct ConfigSchemaForm: View {
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(extras, id: \ .self) { key in
ForEach(extras, id: \.self) { key in
let itemPath: ConfigPath = path + [.key(key)]
HStack(alignment: .top, spacing: 8) {
TextField("Key", text: self.mapKeyBinding(path: path, key: key))
@@ -477,7 +612,12 @@ struct ChannelConfigForm: View {
var body: some View {
if self.store.configSchemaLoading {
ProgressView().controlSize(.small)
SettingsCardGroup("Configuration") {
SettingsCardRow(title: "Loading channel settings", subtitle: nil, showsDivider: false) {
ProgressView()
.controlSize(.small)
}
}
} else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(
store: self.store,
@@ -485,9 +625,15 @@ struct ChannelConfigForm: View {
path: [.key("channels"), .key(self.channelId)],
mode: .channelQuick)
} else {
Text("Schema unavailable for this channel.")
.font(.caption)
.foregroundStyle(.secondary)
SettingsCardGroup("Configuration") {
SettingsCardRow(
title: "Schema unavailable",
subtitle: "OpenClaw could not load editable settings for this channel.",
showsDivider: false)
{
EmptyView()
}
}
}
}
}

View File

@@ -95,9 +95,7 @@ extension ChannelsSettings {
@ViewBuilder
private func configEditorSection(channelId: String) -> some View {
self.formSection("Configuration") {
ChannelConfigForm(store: self.store, channelId: channelId)
}
ChannelConfigForm(store: self.store, channelId: channelId)
self.configStatusMessage

View File

@@ -8,6 +8,7 @@ extension ChannelsSettings {
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.settingsDetailContent()
.onAppear {
self.updateActiveWork(active: self.isActive)
self.ensureSelection(in: channels)
@@ -60,7 +61,8 @@ extension ChannelsSettings {
self.emptyDetail
}
}
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.layoutPriority(1)
}
private var emptyDetail: some View {
@@ -71,8 +73,8 @@ extension ChannelsSettings {
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
private func channelDetail(_ channel: ChannelItem) -> some View {
@@ -84,8 +86,8 @@ extension ChannelsSettings {
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
}
@@ -135,7 +137,7 @@ extension ChannelsSettings {
self.statusBadge(
self.channelSummary(channel),
color: self.channelTint(channel))
Spacer()
Spacer(minLength: 12)
self.channelHeaderActions(channel)
}

View File

@@ -19,6 +19,7 @@ struct ConfigSettings: View {
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.settingsDetailContent()
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
@@ -106,7 +107,8 @@ extension ConfigSettings {
self.schemaUnavailableDetail
}
}
.frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.layoutPriority(1)
}
private var emptyDetail: some View {
@@ -116,8 +118,8 @@ extension ConfigSettings {
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
private var schemaUnavailableDetail: some View {
@@ -128,8 +130,8 @@ extension ConfigSettings {
.foregroundStyle(.secondary)
self.actionRow
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
private func sectionDetail(_ section: ConfigSection) -> some View {
@@ -152,8 +154,8 @@ extension ConfigSettings {
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
}

View File

@@ -9,8 +9,7 @@ extension CronSettings {
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.leading, 18)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.settingsDetailContent()
.onAppear {
self.updateActiveWork(active: self.isActive)
}

View File

@@ -62,9 +62,7 @@ struct DebugSettings: View {
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.settingsDetailContent()
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
.task {

View File

@@ -11,6 +11,9 @@ struct GeneralSettings: View {
case connection
}
private static let remoteFieldWidth: CGFloat = 320
private static let remoteSecretFieldWidth: CGFloat = 300
@Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
let page: Page
@@ -43,10 +46,7 @@ struct GeneralSettings: View {
self.connectionPage
}
}
.frame(maxWidth: 760, alignment: .leading)
.padding(.bottom, 16)
.padding(.leading, 18)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.settingsDetailContent()
}
.onAppear {
self.updateActiveWork(active: self.isActive)
@@ -384,6 +384,7 @@ struct GeneralSettings: View {
self.applyDiscoveredGateway(gateway)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.overlay(alignment: .bottom) {
@@ -500,7 +501,7 @@ struct GeneralSettings: View {
SettingsCardRow(title: "SSH target", subtitle: "User and host for the remote Gateway machine.") {
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: 420)
.frame(width: Self.remoteFieldWidth)
self.remoteTestButton(disabled: !canTest)
}
if let validationMessage {
@@ -514,20 +515,20 @@ struct GeneralSettings: View {
}
private var remoteDirectRow: some View {
VStack(alignment: .leading, spacing: 0) {
SettingsCardRow(title: "Gateway URL", subtitle: "The WebSocket URL exposed by the remote Gateway.") {
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(width: 420)
self.remoteTestButton(
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
SettingsCardRow(title: "Gateway URL", subtitle: "The WebSocket URL exposed by the remote Gateway.") {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(width: Self.remoteFieldWidth)
self.remoteTestButton(
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Text(
"Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.bottom, 10)
}
}
@@ -540,7 +541,7 @@ struct GeneralSettings: View {
{
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
.textFieldStyle(.roundedBorder)
.frame(width: 360)
.frame(width: Self.remoteSecretFieldWidth)
}
if self.state.remoteTokenUnsupported {
Text(
@@ -566,6 +567,7 @@ struct GeneralSettings: View {
}
}
.buttonStyle(.borderedProminent)
.frame(minWidth: 116)
.disabled(self.remoteStatus == .checking || disabled)
}

View File

@@ -32,8 +32,7 @@ struct InstancesSettings: View {
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.leading, 18)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.settingsDetailContent()
.onAppear { self.updateActiveWork(active: self.isActive) }
.onChange(of: self.isActive) { _, active in
self.updateActiveWork(active: active)

View File

@@ -173,19 +173,7 @@ struct OpenClawApp: App {
private func openDashboardWindow() {
HoverHUDController.shared.setSuppressed(true)
self.isMenuPresented = false
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
Task { @MainActor in
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
}
AppNavigationActions.openDashboard()
}
@MainActor
@@ -250,6 +238,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat")
let updaterController: UpdaterProviding = makeUpdaterController()
func applicationDockMenu(_: NSApplication) -> NSMenu? {
let menu = NSMenu()
menu.autoenablesItems = false
menu.addItem(self.dockMenuItem(
title: "Open Dashboard",
systemImage: "gauge",
action: #selector(self.openDashboardFromDockMenu(_:))))
menu.addItem(self.dockMenuItem(
title: "Open Chat",
systemImage: "bubble.left.and.bubble.right",
action: #selector(self.openChatFromDockMenu(_:))))
let canvasTitle = AppStateStore.shared.canvasPanelVisible ? "Close Canvas" : "Open Canvas"
let canvasItem = self.dockMenuItem(
title: canvasTitle,
systemImage: "rectangle.inset.filled.on.rectangle",
action: #selector(self.toggleCanvasFromDockMenu(_:)))
canvasItem.isEnabled = AppStateStore.shared.canvasEnabled
menu.addItem(canvasItem)
menu.addItem(.separator())
menu.addItem(self.dockMenuItem(
title: "Settings…",
systemImage: "gearshape",
action: #selector(self.openSettingsFromDockMenu(_:))))
return menu
}
private func dockMenuItem(title: String, systemImage: String, action: Selector) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: title)
return item
}
@objc
private func openDashboardFromDockMenu(_: Any?) {
AppNavigationActions.openDashboard()
}
@objc
private func openChatFromDockMenu(_: Any?) {
AppNavigationActions.openChat()
}
@objc
private func toggleCanvasFromDockMenu(_: Any?) {
AppNavigationActions.toggleCanvas()
}
@objc
private func openSettingsFromDockMenu(_: Any?) {
AppNavigationActions.openSettings()
}
func application(_: NSApplication, open urls: [URL]) {
Task { @MainActor in
for url in urls {

View File

@@ -111,28 +111,19 @@ struct MenuContent: View {
self.voiceWakeMicMenu
}
Divider()
Link(destination: URL(string: "openclaw://dashboard")!) {
Button {
AppNavigationActions.openDashboard()
} label: {
Label("Open Dashboard", systemImage: "gauge")
}
Button {
Task { @MainActor in
let sessionKey = await WebChatManager.shared.preferredSessionKey()
WebChatManager.shared.show(sessionKey: sessionKey)
}
AppNavigationActions.openChat()
} label: {
Label("Open Chat", systemImage: "bubble.left.and.bubble.right")
}
if self.state.canvasEnabled {
Button {
Task { @MainActor in
if self.state.canvasPanelVisible {
CanvasManager.shared.hideAll()
} else {
let sessionKey = await GatewayConnection.shared.mainSessionKey()
// Don't force a navigation on re-open: preserve the current web view state.
_ = try? CanvasManager.shared.show(sessionKey: sessionKey, path: nil)
}
}
AppNavigationActions.toggleCanvas()
} label: {
Label(
self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas",
@@ -330,12 +321,7 @@ struct MenuContent: View {
}
private func open(tab: SettingsTab) {
SettingsTabRouter.request(tab)
NSApp.activate(ignoringOtherApps: true)
self.openSettings()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab)
}
AppNavigationActions.openSettings(tab: tab)
}
private var macNodeStatus: (label: String, color: Color)? {

View File

@@ -679,7 +679,7 @@ extension OnboardingView {
} else if !self.cliInstalled, self.cliInstallLocation == nil {
Text(
"""
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
Installs a user-space Node 22.19+ runtime and the CLI (no Homebrew).
Rerun anytime to reinstall or update.
""")
.font(.footnote)

View File

@@ -36,9 +36,7 @@ struct PermissionsSettings: View {
}
}
}
.frame(maxWidth: 760, alignment: .leading)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.padding(.vertical, 4)
.settingsDetailContent()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
@@ -80,6 +78,8 @@ struct PermissionsSettings: View {
}
private struct LocationAccessSettings: View {
private static let controlWidth: CGFloat = 180
@AppStorage(locationModeKey) private var locationModeRaw: String = OpenClawLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
@@ -97,7 +97,7 @@ private struct LocationAccessSettings: View {
}
.labelsHidden()
.pickerStyle(.menu)
.frame(width: 190)
.frame(width: Self.controlWidth, alignment: .trailing)
}
SettingsCardRow(
@@ -108,6 +108,7 @@ private struct LocationAccessSettings: View {
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.labelsHidden()
.toggleStyle(.switch)
.frame(width: Self.controlWidth, alignment: .trailing)
.disabled(self.locationMode == .off)
}
}

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.5.17</string>
<string>2026.5.19</string>
<key>CFBundleVersion</key>
<string>2026051700</string>
<string>2026051900</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -54,7 +54,7 @@ enum RuntimeResolutionError: Error {
enum RuntimeLocator {
private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime")
private static let minNode = RuntimeVersion(major: 22, minor: 16, patch: 0)
private static let minNode = RuntimeVersion(major: 22, minor: 19, patch: 0)
static func resolve(
searchPaths: [String] = CommandResolver.preferredPaths()) -> Result<RuntimeResolution, RuntimeResolutionError>
@@ -91,7 +91,7 @@ enum RuntimeLocator {
switch error {
case let .notFound(searchPaths):
[
"openclaw needs Node >=22.16.0 but found no runtime.",
"openclaw needs Node >=22.19.0 but found no runtime.",
"PATH searched: \(searchPaths.joined(separator: ":"))",
"Install Node: https://nodejs.org/en/download",
].joined(separator: "\n")
@@ -105,7 +105,7 @@ enum RuntimeLocator {
[
"Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).",
"PATH searched: \(searchPaths.joined(separator: ":"))",
"Try reinstalling or pinning a supported version (Node >=22.16.0).",
"Try reinstalling or pinning a supported version (Node >=22.19.0).",
].joined(separator: "\n")
}
}

View File

@@ -25,6 +25,7 @@ extension View {
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.openClawRowBackground(selected: selected, hovered: hovered)))
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(

View File

@@ -24,8 +24,7 @@ struct SessionsSettings: View {
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.leading, 18)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.settingsDetailContent()
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }

View File

@@ -1,7 +1,20 @@
import SwiftUI
enum SettingsLayout {
static let scrollbarGutter: CGFloat = 36
static let sidebarWidth: CGFloat = 250
static let detailHorizontalPadding: CGFloat = 22
static let detailVerticalPadding: CGFloat = 18
static let nestedSidebarWidth: CGFloat = 260
static let detailBottomPadding: CGFloat = 16
}
extension View {
func settingsDetailContent() -> some View {
self
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
.padding(.bottom, SettingsLayout.detailBottomPadding)
}
}
struct SettingsPageHeader: View {
@@ -63,9 +76,10 @@ struct SettingsCardGroup<Content: View>: View {
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
self.content
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.38), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
@@ -110,6 +124,7 @@ struct SettingsCardRow<Content: View>: View {
self.content
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.overlay(alignment: .bottom) {

View File

@@ -8,7 +8,6 @@ struct SettingsRootView: View {
@State private var monitoringPermissions = false
@State private var selectedTab: SettingsTab = .general
@State private var cachedTabs: Set<SettingsTab>
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil)
let updater: UpdaterProviding?
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -23,31 +22,14 @@ struct SettingsRootView: View {
}
var body: some View {
NavigationSplitView(columnVisibility: self.$columnVisibility) {
List(selection: self.$selectedTab) {
ForEach(self.visibleGroups) { group in
Section(group.title) {
ForEach(group.tabs) { tab in
NavigationLink(value: tab) {
Label(tab.title, systemImage: tab.systemImage)
}
}
}
}
}
.navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 240)
} detail: {
VStack(alignment: .leading, spacing: 14) {
if self.isNixMode {
self.nixManagedBanner
}
self.cachedDetailViews
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.horizontal, 22)
.padding(.vertical, 18)
HStack(spacing: 0) {
SettingsSidebar(
groups: self.visibleGroups,
selectedTab: self.$selectedTab)
.frame(width: SettingsLayout.sidebarWidth)
self.detailContainer
}
.navigationSplitViewStyle(.balanced)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(SettingsWindowChromeConfigurator())
@@ -93,6 +75,18 @@ struct SettingsRootView: View {
SettingsTabGroup.defaultGroups(showDebug: self.state.debugPaneEnabled)
}
private var detailContainer: some View {
VStack(alignment: .leading, spacing: 14) {
if self.isNixMode {
self.nixManagedBanner
}
self.cachedDetailViews
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
private var cachedDetailTabs: [SettingsTab] {
let cached = self.cachedTabs.union([self.selectedTab])
return self.visibleGroups.flatMap(\.tabs).filter { cached.contains($0) }
@@ -207,6 +201,77 @@ struct SettingsRootView: View {
}
}
private struct SettingsSidebar: View {
let groups: [SettingsTabGroup]
@Binding var selectedTab: SettingsTab
var body: some View {
ZStack(alignment: .topLeading) {
VisualEffectView(material: .sidebar)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(.white.opacity(0.09), lineWidth: 1)
}
.shadow(color: .black.opacity(0.16), radius: 18, x: 0, y: 12)
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 18) {
ForEach(self.groups) { group in
VStack(alignment: .leading, spacing: 6) {
Text(group.title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
ForEach(group.tabs) { tab in
SettingsSidebarRow(
tab: tab,
selected: self.selectedTab == tab)
{
self.selectedTab = tab
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 10)
}
}
.padding(.leading, 12)
.padding(.vertical, 10)
}
}
private struct SettingsSidebarRow: View {
let tab: SettingsTab
let selected: Bool
let select: () -> Void
var body: some View {
Label(self.tab.title, systemImage: self.tab.systemImage)
.font(.body.weight(.medium))
.labelStyle(.titleAndIcon)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.selected ? Color.white.opacity(0.13) : Color.clear)
}
.contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.onTapGesture(perform: self.select)
.accessibilityElement(children: .combine)
.accessibilityLabel(self.tab.title)
.accessibilityAddTraits(self.selected ? [.isButton, .isSelected] : .isButton)
.accessibilityAction { self.select() }
}
}
private struct SettingsTabGroup: Identifiable {
let title: String
let tabs: [SettingsTab]
@@ -235,7 +300,7 @@ private struct SettingsTabGroup: Identifiable {
enum SettingsTab: CaseIterable, Identifiable, Hashable {
case general, connection, permissions, voiceWake, channels, skills, cron
case execApprovals, sessions, instances, config, debug, about
static let windowWidth: CGFloat = 960
static let windowWidth: CGFloat = 1120
static let windowHeight: CGFloat = 790
var id: Self {

View File

@@ -3,7 +3,8 @@ import SwiftUI
extension View {
func settingsSidebarCardLayout() -> some View {
self
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.frame(width: SettingsLayout.nestedSidebarWidth, alignment: .topLeading)
.frame(maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))

View File

@@ -11,5 +11,6 @@ struct SettingsSidebarScroll<Content: View>: View {
}
.settingsSidebarCardLayout()
.padding(.leading, 16)
.padding(.trailing, 16)
}
}

View File

@@ -27,9 +27,7 @@ struct SkillsSettings: View {
self.skillsList
Spacer(minLength: 8)
}
.frame(maxWidth: 860, alignment: .leading)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.padding(.vertical, 4)
.settingsDetailContent()
}
.task {
guard !self.didScheduleInitialRefresh else { return }

View File

@@ -12,9 +12,7 @@ struct ExecApprovalsSettings: View {
SystemRunSettingsView()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.settingsDetailContent()
}
}
}

View File

@@ -186,9 +186,7 @@ struct VoiceWakeSettings: View {
Spacer(minLength: 8)
}
.frame(maxWidth: 760, alignment: .leading)
.padding(.trailing, SettingsLayout.scrollbarGutter)
.padding(.vertical, 4)
.settingsDetailContent()
}
.task {
guard !self.isPreview else { return }
@@ -253,67 +251,67 @@ struct VoiceWakeSettings: View {
private var triggerTable: some View {
SettingsCardGroup("Trigger Words") {
HStack {
Text("Wake phrases")
.font(.callout.weight(.semibold))
Spacer()
Button {
self.addWord()
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
}
.padding(.horizontal, 14)
.padding(.top, 12)
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
HStack(spacing: 8) {
TextField("Wake word", text: $entry.value)
.textFieldStyle(.roundedBorder)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
SettingsCardRow(
title: "Wake phrases",
subtitle: "Short phrases that start voice wake detection.")
{
HStack(spacing: 8) {
Button {
self.addWord()
} label: {
Label("Add word", systemImage: "plus")
}
.padding(8)
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
if entry.id != self.triggerEntries.last?.id {
Divider()
Button("Reset") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.secondary.opacity(0.25), lineWidth: 1))
.padding(.horizontal, 14)
Text(
"OpenClaw reacts when any trigger appears in a transcription. "
+ "Keep them short to avoid false positives.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
self.triggerPhraseRows
TriggerPhraseHelpRow()
}
}
private var triggerPhraseRows: some View {
Group {
if self.triggerEntries.isEmpty {
HStack(spacing: 10) {
Image(systemName: "text.badge.plus")
.font(.callout)
.foregroundStyle(.secondary)
.frame(width: 22)
Text("No wake phrases configured")
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.vertical, 14)
} else {
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
TriggerPhraseRow(
value: $entry.value,
showsDivider: entry.id != self.triggerEntries.last?.id,
onSubmit: {
self.syncTriggerEntriesToState()
},
onRemove: {
self.removeWord(id: entry.id)
})
}
}
}
}
.overlay(alignment: .bottom) {
Divider()
.padding(.leading, 14)
}
}
@@ -537,67 +535,79 @@ struct VoiceWakeSettings: View {
.frame(width: self.controlWidth)
}
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Additional languages")
.font(.footnote.weight(.semibold))
ForEach(
Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()),
id: \.offset)
{ idx, localeID in
HStack(spacing: 8) {
Picker("Extra \(idx + 1)", selection: Binding(
get: { localeID },
set: { newValue in
guard self.state
.voiceWakeAdditionalLocaleIDs.indices
.contains(idx) else { return }
self.state
.voiceWakeAdditionalLocaleIDs[idx] =
newValue
})) {
ForEach(self.availableLocales.map(\.identifier), id: \.self) { id in
Text(self.friendlyName(for: Locale(identifier: id))).tag(id)
}
}
.labelsHidden()
.frame(width: 220)
Button {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove language")
}
}
SettingsCardRow(
title: "Additional languages",
subtitle: self.additionalLanguagesSubtitle,
showsDivider: !self.state.voiceWakeAdditionalLocaleIDs.isEmpty)
{
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
Button {
if let first = availableLocales.first {
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
}
self.addAdditionalLocale()
} label: {
Label("Add language", systemImage: "plus")
Label("Add", systemImage: "plus")
}
.buttonStyle(.bordered)
.disabled(self.availableLocales.isEmpty)
}
.padding(.horizontal, 14)
.padding(.bottom, 10)
} else {
Button {
if let first = availableLocales.first {
self.state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
}
} label: {
Label("Add additional language", systemImage: "plus")
}
.buttonStyle(.link)
.disabled(self.availableLocales.isEmpty)
.padding(.horizontal, 14)
.padding(.bottom, 10)
}
if !self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
self.additionalLanguageRows
}
}
}
private var additionalLanguagesSubtitle: String {
if self.state.voiceWakeAdditionalLocaleIDs.isEmpty {
return "None configured."
}
return "Tried after the primary language."
}
private var additionalLanguageRows: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(self.state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
AdditionalLanguageRow(
index: idx,
selection: self.additionalLocaleBinding(index: idx, fallback: localeID),
localeIDs: self.availableLocales.map(\.identifier),
localeName: { id in self.friendlyName(for: Locale(identifier: id)) },
showsDivider: true,
onRemove: {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
self.state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
})
}
SettingsCardRow(title: "Add another language", showsDivider: false) {
Button {
self.addAdditionalLocale()
} label: {
Label("Add", systemImage: "plus")
}
.buttonStyle(.bordered)
.disabled(self.availableLocales.isEmpty)
}
}
}
private func additionalLocaleBinding(index: Int, fallback: String) -> Binding<String> {
Binding(
get: {
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return fallback }
return self.state.voiceWakeAdditionalLocaleIDs[index]
},
set: { newValue in
guard self.state.voiceWakeAdditionalLocaleIDs.indices.contains(index) else { return }
self.state.voiceWakeAdditionalLocaleIDs[index] = newValue
})
}
private func addAdditionalLocale() {
let selected = Set([self.state.voiceWakeLocaleID] + self.state.voiceWakeAdditionalLocaleIDs)
let next = self.availableLocales.first { !selected.contains($0.identifier) } ?? self.availableLocales.first
if let next {
self.state.voiceWakeAdditionalLocaleIDs.append(next.identifier)
}
}
@@ -718,6 +728,110 @@ struct VoiceWakeSettings: View {
}
}
private struct TriggerPhraseRow: View {
@Binding var value: String
let showsDivider: Bool
let onSubmit: () -> Void
let onRemove: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: "quote.opening")
.font(.callout.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 24)
TextField("Wake phrase", text: self.$value)
.textFieldStyle(.roundedBorder)
.font(.callout.weight(.medium))
.frame(maxWidth: 420)
.onSubmit(self.onSubmit)
Spacer(minLength: 8)
Button(action: self.onRemove) {
Image(systemName: "trash")
.font(.callout)
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.frame(width: 26, height: 26)
.contentShape(Rectangle())
.help("Remove trigger word")
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.overlay(alignment: .bottom) {
if self.showsDivider {
Divider()
.padding(.leading, 50)
}
}
}
}
private struct AdditionalLanguageRow: View {
let index: Int
@Binding var selection: String
let localeIDs: [String]
let localeName: (String) -> String
let showsDivider: Bool
let onRemove: () -> Void
var body: some View {
SettingsCardRow(
title: "Language \(self.index + 2)",
subtitle: "Fallback recognition language.",
showsDivider: self.showsDivider)
{
HStack(spacing: 10) {
Picker("Language \(self.index + 2)", selection: self.$selection) {
ForEach(self.localeIDs, id: \.self) { id in
Text(self.localeName(id)).tag(id)
}
}
.labelsHidden()
.frame(width: 220)
Button(action: self.onRemove) {
Image(systemName: "trash")
.font(.callout)
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.frame(width: 26, height: 26)
.contentShape(Rectangle())
.help("Remove language")
}
}
}
}
private struct TriggerPhraseHelpRow: View {
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "info.circle")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 18)
.padding(.top, 1)
Text(
"OpenClaw reacts when any trigger appears in a transcription. " +
"Keep phrases short to avoid false positives.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
}
}
#if DEBUG
struct VoiceWakeSettings_Previews: PreviewProvider {
static var previews: some View {

View File

@@ -48,7 +48,7 @@ import Testing
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
try makeExecutableForTests(at: nodePath)
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try "#!/bin/sh\necho v22.19.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try makeExecutableForTests(at: scriptPath)

View File

@@ -1,3 +1,4 @@
import AppKit
import SwiftUI
import Testing
@testable import OpenClaw
@@ -38,4 +39,15 @@ struct MenuContentSmokeTests {
let view = MenuContent(state: state, updater: nil)
_ = view.body
}
@Test func `dock menu exposes primary shortcuts`() throws {
let delegate = AppDelegate()
let menu = try #require(delegate.applicationDockMenu(NSApplication.shared))
let titles = menu.items.map(\.title)
#expect(titles.contains("Open Dashboard"))
#expect(titles.contains("Open Chat"))
#expect(titles.contains("Open Canvas") || titles.contains("Close Canvas"))
#expect(titles.contains("Settings…"))
}
}

View File

@@ -16,7 +16,7 @@ struct RuntimeLocatorTests {
@Test func `resolve succeeds with valid node`() throws {
let script = """
#!/bin/sh
echo v22.16.0
echo v22.19.0
"""
let node = try self.makeTempExecutable(contents: script)
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
@@ -25,13 +25,13 @@ struct RuntimeLocatorTests {
return
}
#expect(res.path == node.path)
#expect(res.version == RuntimeVersion(major: 22, minor: 16, patch: 0))
#expect(res.version == RuntimeVersion(major: 22, minor: 19, patch: 0))
}
@Test func `resolve fails on boundary below minimum`() throws {
let script = """
#!/bin/sh
echo v22.15.9
echo v22.18.9
"""
let node = try self.makeTempExecutable(contents: script)
let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path])
@@ -39,8 +39,8 @@ struct RuntimeLocatorTests {
Issue.record("Expected unsupported error, got \(result)")
return
}
#expect(found == RuntimeVersion(major: 22, minor: 15, patch: 9))
#expect(required == RuntimeVersion(major: 22, minor: 16, patch: 0))
#expect(found == RuntimeVersion(major: 22, minor: 18, patch: 9))
#expect(required == RuntimeVersion(major: 22, minor: 19, patch: 0))
#expect(path == node.path)
}
@@ -76,7 +76,7 @@ struct RuntimeLocatorTests {
@Test func `describe failure includes paths`() {
let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"]))
#expect(msg.contains("Node >=22.16.0"))
#expect(msg.contains("Node >=22.19.0"))
#expect(msg.contains("PATH searched: /tmp/a:/tmp/b"))
let parseMsg = RuntimeLocator.describeFailure(
@@ -85,7 +85,7 @@ struct RuntimeLocatorTests {
raw: "garbage",
path: "/usr/local/bin/node",
searchPaths: ["/usr/local/bin"]))
#expect(parseMsg.contains("Node >=22.16.0"))
#expect(parseMsg.contains("Node >=22.19.0"))
}
@Test func `runtime version parses with leading V and metadata`() {

View File

@@ -2757,6 +2757,7 @@ public struct ConfigSchemaResponse: Codable, Sendable {
public struct ConfigSchemaLookupResult: Codable, Sendable {
public let path: String
public let schema: AnyCodable
public let reloadkind: AnyCodable?
public let hint: [String: AnyCodable]?
public let hintpath: String?
public let children: [[String: AnyCodable]]
@@ -2764,12 +2765,14 @@ public struct ConfigSchemaLookupResult: Codable, Sendable {
public init(
path: String,
schema: AnyCodable,
reloadkind: AnyCodable?,
hint: [String: AnyCodable]?,
hintpath: String?,
children: [[String: AnyCodable]])
{
self.path = path
self.schema = schema
self.reloadkind = reloadkind
self.hint = hint
self.hintpath = hintpath
self.children = children
@@ -2778,6 +2781,7 @@ public struct ConfigSchemaLookupResult: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case path
case schema
case reloadkind = "reloadKind"
case hint
case hintpath = "hintPath"
case children

View File

@@ -125,7 +125,7 @@ const config = {
"**/*.test-helpers.ts",
"**/*.test-mocks.ts",
"**/*.test-utils.ts",
"src/gateway/live-image-probe.ts",
"test/helpers/live-image-probe.ts",
"src/secrets/credential-matrix.ts",
"src/agents/claude-cli-runner.ts",
"src/agents/pi-auth-json.ts",

View File

@@ -1,4 +1,4 @@
4b52f0bff12148f4695150a45c91d4b9bda2d1bfbc1162a79a2bb2cf62c3c1eb config-baseline.json
73e11d9d5c5b27d8d075202f59b9f19537ded361ea761ed0aef78dc9446bc82f config-baseline.core.json
fe4f1cb00d7d1dee9746779ec3cf14236e5f672c91502268a12ad6e467a2c4ad config-baseline.channel.json
e9049ce0154f484f44bb0ac174a44198269256044da5ba62a6e107e78bfd7a70 config-baseline.plugin.json
82d56352536e75291ec81540bd4d93e22aeae282e2ef864aa0f231b6deb11bba config-baseline.json
5d6aa4d0789482b1bdb6681d19fe193a8696ca25c20cbb9e07edb6d1b23ad8f2 config-baseline.core.json
e068db276fdff1727939d4f3a8001376e550c444bdff3e3443ab26812e2f8c5d config-baseline.channel.json
a87fc4c9bc6499c5fb9d9343b8c1c4f0c3381a6afbdb0a676dc8ba9e03ff5755 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
048d8ff5e4455d16f75f6762a916f67c982e1211fb7085456647234255567466 plugin-sdk-api-baseline.json
2d46a9660c9143f823a47df3c7ecfd315a4999e96af5eddb4ba4e71d9bb377a6 plugin-sdk-api-baseline.jsonl
86a7102dd571ca4c14f45c890392a95a51244e9bfba108986d87ca37bfb0b3c4 plugin-sdk-api-baseline.json
810d6807ff58ae79178dd0fadc84c10ce04813bbff738cc2fdaf12d1c2acef16 plugin-sdk-api-baseline.jsonl

View File

@@ -106,7 +106,7 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat
</Accordion>
<Accordion title="Concurrent media-generation guardrail">
While a session-backed media-generation task is still active, the tool also acts as a guardrail: repeated `image_generate`, `music_generate`, or `video_generate` calls in that same session return the active task status instead of starting a second concurrent generation. Use `action: "status"` when you want an explicit progress/status lookup from the agent side.
While a session-backed media-generation task is still active, media tools also act as guardrails for accidental retries. Repeated `image_generate` calls for the same prompt return the matching active task status, while a distinct image prompt can start its own task. `music_generate` and `video_generate` calls still return the active task status for that session instead of starting a second concurrent generation. Use `action: "status"` when you want an explicit progress/status lookup from the agent side.
</Accordion>
<Accordion title="What does not create tasks">
- Heartbeat turns - main-session; see [Heartbeat](/gateway/heartbeat)

View File

@@ -176,9 +176,9 @@ The agent-specific `agents.list[].groupChat.unmentionedInbound` value overrides
## Visible reply modes
`messages.groupChat.visibleReplies: "message_tool"` is the recommended group and channel default. It lets the agent decide when to speak by calling the message tool. If the model returns final text without calling the tool, OpenClaw keeps that final text private and logs suppressed delivery metadata.
`messages.groupChat.visibleReplies` defaults to `"automatic"` for normal group/channel user requests. Keep that default when you want final assistant text to post visibly without requiring an explicit message-tool call.
Use `messages.groupChat.visibleReplies: "automatic"` only when you want legacy behavior where normal group requests post final assistant text automatically.
For ambient always-on rooms, `messages.groupChat.visibleReplies: "message_tool"` is still recommended, especially with latest-generation, tool-reliable models such as GPT 5.5. It lets the agent decide when to speak by calling the message tool. If the model returns final text without calling the tool, OpenClaw keeps that final text private and logs suppressed delivery metadata.
Room events stay strict even when other group requests use automatic replies. Unmentioned ambient room events still require `message(action=send)` for visible output.
@@ -198,7 +198,7 @@ If the room shows typing or token usage but no visible message:
2. Confirm `requireMention: false` is set at the room level you expect.
3. Check whether `messages.groupChat.unmentionedInbound` or the agent override is `"room_event"`.
4. Inspect logs for suppressed final payload metadata or `didSendViaMessagingTool: false`.
5. Use a model/runtime that reliably calls tools, or set `messages.groupChat.visibleReplies: "automatic"` for legacy final replies on normal group requests.
5. For normal group requests, keep or restore `messages.groupChat.visibleReplies: "automatic"` if you want final replies posted automatically. For ambient rooms using `message_tool`, use a model/runtime that reliably calls tools.
If Telegram ambient rooms do not trigger at all, check BotFather privacy mode and verify the Gateway is receiving normal group messages.

View File

@@ -250,9 +250,9 @@ Once DMs are working, you can set up your Discord server as a full workspace whe
<Step title="Allow responses without @mention">
By default, your agent only responds in guild channels when @mentioned. For a private server, you probably want it to respond to every message.
In guild channels, visible Discord output should use the `message` tool by default, so the agent can lurk and only post when it decides a channel reply is useful. Ambient room events stay quiet unless the tool sends. See [Ambient room events](/channels/ambient-room-events) for the full lurk-mode config.
In guild channels, normal replies post automatically by default. For shared always-on rooms, opt into `messages.groupChat.visibleReplies: "message_tool"` so the agent can lurk and only post when it decides a channel reply is useful. This works best with latest-generation, tool-reliable models such as GPT 5.5. Ambient room events stay quiet unless the tool sends. See [Ambient room events](/channels/ambient-room-events) for the full lurk-mode config.
This means the selected model should reliably call tools. If Discord shows typing and the logs show token usage but no posted message, check whether the turn was configured as an ambient room event or use the config below to restore legacy automatic final replies for normal group requests.
If Discord shows typing and the logs show token usage but no posted message, check whether the turn was configured as an ambient room event or opted into message-tool visible replies.
<Tabs>
<Tab title="Ask your agent">
@@ -275,7 +275,7 @@ Once DMs are working, you can set up your Discord server as a full workspace whe
}
```
To restore legacy automatic final replies for group/channel rooms, set `messages.groupChat.visibleReplies: "automatic"`.
To require message-tool sends for visible group/channel replies, set `messages.groupChat.visibleReplies: "message_tool"`.
</Tab>
</Tabs>

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