Compare commits

..

293 Commits

Author SHA1 Message Date
Peter Steinberger
478b599e9b refactor: extract secrets core package 2026-05-30 19:14:25 -04:00
Vincent Koc
0b0edcdf1c fix(scripts): fail gauntlet on missing qa summaries 2026-05-31 00:37:48 +02:00
Peter Steinberger
57c88dd46e chore: remove more unused internal helpers 2026-05-30 23:26:29 +01:00
Peter Steinberger
654de643e4 perf: skip idle channel shutdown enumeration 2026-05-30 23:21:54 +01:00
Vincent Koc
ee2b90b4e2 perf(scripts): prebuild qa runtime assets 2026-05-31 00:17:39 +02:00
Jerry-Xin
f59fc0d477 fix(gateway): strip spurious tool calls on non-tool stops
Treat OpenAI-compatible streaming tool deltas as executable only when the final finish reason is `tool_calls`. This prevents malformed provider streams from triggering spurious tool execution while preserving normal tool-call responses.

Fixes #85161.

Verification:
- Local OpenAI-compatible SSE replay: spurious stop stream `finalToolCalls: 0`; valid tool-call stream `finalToolCalls: 1`.
- `pnpm test src/agents/openai-transport-stream.test.ts src/llm/providers/openai-completions.test.ts -- --reporter=verbose`
- PR CI green on `cdc2fc34753492c862cae99b37f8cf3761d9bbed`.

Co-authored-by: 忻役 <xinyi@mininglamp.com>
Co-authored-by: Jerry-Xin <jerryxin0@gmail.com>
2026-05-30 23:14:16 +01:00
Ted Li
1cab722fe0 fix(ci): ignore fenced headings in proof parser (#87390)
Harden real behavior proof parsing for fenced transcript Markdown. Ref #87341. Thanks @MonkeyLeeT.
2026-05-30 23:14:06 +01:00
Peter Steinberger
4739f0cfe2 chore: remove old unused helpers 2026-05-30 23:13:43 +01:00
Andy Ye
2442e9c178 fix(cron): preserve plugin delivery targets
Preserve plugin-resolved cron delivery targets after target resolution so provider-looking canonical target prefixes are not stripped before outbound delivery.

Adds regression coverage for plugin canonical targets returned directly and via aliases, plus a guard that generic normalized fallback targets still strip the selected prefix.

Fixes #87905

Co-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
2026-05-30 23:12:45 +01:00
Peter Steinberger
0ac61072b8 Refactor cron migrations under doctor (#88455)
* refactor: move cron migrations under doctor

* fix: break cron run log parser type cycle
2026-05-30 23:09:09 +01:00
Peter Steinberger
31099ccb1f docs(changelog): refresh 2026.5.30 notes 2026-05-30 23:04:23 +01:00
Peter Steinberger
2d23519c77 fix(agents): preserve generated media fallback routing 2026-05-30 23:03:32 +01:00
Peter Steinberger
bbd2854c45 fix: show chat errors as visible messages
Surface gateway chat failures as visible assistant messages in the Control UI, with regression coverage and Crabbox/WebVNC proof.

(cherry picked from commit 31a46638ad)
2026-05-30 23:03:32 +01:00
Josh Avant
5d3a6909fb fix subagent dm completion delivery (#88182)
(cherry picked from commit 00d87c7b5d)
2026-05-30 23:03:32 +01:00
Peter Steinberger
1e0c9d8174 test(wizard): include tokenjuice optional plugin
(cherry picked from commit dd658474a5)
2026-05-30 23:03:32 +01:00
Peter Steinberger
4c16bd2453 fix(codex-supervisor): satisfy release lint
(cherry picked from commit bac13419a6)
2026-05-30 23:03:32 +01:00
Steven
cd3d960ec5 fix(ui): add agent selector to dreaming tab (#78748)
Fixes #63558.

Adds a Dreaming-tab agent selector and propagates the selected agent through Dreaming status, diary, and diary actions while preserving default-agent fallback when agentId is omitted. Also keeps report Memory Palace cards in the Control UI wiki-preview flow and documents the optional Dreaming agentId gateway parameters.

Verification:
- GitHub CI run 26693682975 passed on 43a2b17243.
- CodeQL Critical Quality run 26693682971 passed.
- CodeQL / Security High run 26693682957 passed.
- Workflow Sanity run 26693682949 passed.
- OpenGrep PR Diff run 26693682947 passed.
- Dependency Guard run 26693682003 passed.
- Real behavior proof run 26693860539 passed.
- git diff --check origin/main...refs/remotes/origin/pr/78748 passed.
- git merge-tree --write-tree origin/main refs/remotes/origin/pr/78748 passed.

Thanks @stevenepalmer.

Co-authored-by: Steven Palmer <6134396+stevenepalmer@users.noreply.github.com>
2026-05-30 22:58:00 +01:00
Peter Steinberger
d93394e29b perf: cache validated session prompt blobs 2026-05-30 22:57:30 +01:00
Peter Steinberger
83dff5855e docs: trim release performance report tail 2026-05-30 22:54:35 +01:00
Peter Steinberger
3402477314 chore: remove unused infra helpers 2026-05-30 22:45:22 +01:00
Peter Steinberger
71b3bc87ca perf: cache serialized session prompt refs 2026-05-30 22:44:11 +01:00
Peter Steinberger
0be3ef5a38 chore: remove unused agent helpers 2026-05-30 22:43:09 +01:00
Peter Steinberger
287687da20 feat: add internal code mode namespaces (#88043)
* feat: add internal code mode namespaces

* test: add code mode namespace live proof

* test: add live code mode Docker repro

* chore: keep code mode docker repro out of package scripts

* fix: break code mode namespace type cycle

* fix: clean code mode namespace ci drift

* fix: route code mode namespaces through tools

* fix: preserve explicit agent global sessions

* docs: explain code mode namespace registry

* test: cap realtime websocket payload

* fix: normalize code mode timeout results

* fix: satisfy code mode timeout lint

* chore: rerun code mode CI

* ci: extend node shard silence watchdog

* test: avoid child process mock deadlocks

* test: fix code mode repro shebang

* fix: scope explicit agent sentinel sessions

* test: preserve child process mock actual loader

* fix: dispatch namespace tools by exact id

* test: satisfy restart execFile mock type
2026-05-30 22:42:57 +01:00
Peter Steinberger
22e4289d3f chore(release): update appcast for 2026.5.28
Promote the Sparkle appcast generated by macOS publish for v2026.5.28.
2026-05-30 22:39:55 +01:00
Vincent Koc
5367ef7bd3 fix(scripts): accept forwarded otel smoke args 2026-05-30 23:37:27 +02:00
Peter Steinberger
598e177e12 chore: remove unused changelog helper 2026-05-30 22:36:09 +01:00
Peter Steinberger
0ed9fb48c4 docs: refresh release performance sweep for 2026.5.28 2026-05-30 22:35:45 +01:00
Jason (Json)
3ea911558c fix: promote serialized tool calls via repair package
Extracts serialized plaintext tool-call parsing, scrubbing, stream normalization, and standalone promotion into the private internal @openclaw/tool-call-repair package.

Provider wrappers and the embedded runner now share one repair path for standalone serialized tool calls, including adjacent text-block splits, while preserving exact argument bytes when already valid. The public plugin SDK payload module remains as the compatibility facade.

Verification:
- pnpm test src/plugin-sdk/provider-stream-shared.test.ts src/plugin-sdk/tool-payload.test.ts src/agents/embedded-agent-runner/run/attempt.tool-call-normalization.test.ts -- --reporter=verbose
- env -u OPENCLAW_TESTBOX pnpm check:changed
- PR CI: all reported checks green/skipped/neutral on ff0b3c0a5c

Refs #86924

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
2026-05-30 22:34:57 +01:00
Yossi Eliaz
443255461c fix(slack): preserve assistant DM root thread context (#63840)
Preserve Slack Agents & Assistants DM root thread context for tool and subagent replies even when Slack omits or misreports `channel_type`, while leaving non-DM self-thread roots top-level.

Fixes #63659.

Thanks @zozo123.
2026-05-30 22:28:49 +01:00
Vincent Koc
7dde396d4d fix(scripts): accept forwarded watch regression args 2026-05-30 23:20:16 +02:00
Jason (Json)
89975eea24 feat: pass structured provider error signals to hooks
Summary:
- Pass provider status/code/type descriptors through failover hook classification.
- Keep structured provider hook dispatch scoped, while preserving legacy broad message-hook fallback for unresolved custom provider ids.
- Isolate long commands/infra Vitest lanes in fork workers and update config expectations.

Verification:
- node scripts/run-vitest.mjs src/agents/embedded-agent-helpers/errors-provider-structured-signals.test.ts src/agents/failover-error.test.ts
- OPENCLAW_VITEST_MAX_WORKERS=1 node scripts/run-vitest.mjs src/plugins/provider-runtime.test.ts
- node scripts/run-vitest.mjs src/agents/embedded-agent-helpers/errors-provider-structured-signals.test.ts src/agents/embedded-agent-helpers/provider-error-patterns.test.ts src/agents/failover-error.test.ts src/plugins/provider-runtime.test.ts test/vitest-projects-config.test.ts test/vitest-scoped-config.test.ts src/infra/vitest-config.test.ts
- pnpm tsgo:prod
- autoreview --mode branch --base origin/main --no-web-search --thinking low
- GitHub required dependency-guard: pass
- GitHub Real behavior proof: pass
- GitHub broad CI/checks visible on PR: pass

Co-authored-by: Jason (Json) <fuller-stack-dev@users.noreply.github.com>
2026-05-30 22:14:46 +01:00
zhang-guiping
dbd3e10312 fix(ui): filter sidebar recent sessions by selected agent
Fixes #88214.

Control UI dashboard Recent sessions now follows the selected agent, preserves legacy main sessions under stale identity, keeps unknown sessions unscoped, and scopes agent/default session refreshes before the session-list limit. Completed run refreshes now use the run's original session/agent target, global New Chat creates under the selected agent, and the agent switcher preserves last known target sessions across scoped refreshes without resurrecting deleted or archived sessions while accepting newer out-of-scope live rows into the switch cache. Also fixes a current-main lint issue around trusted approval params.

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
2026-05-30 22:13:37 +01:00
Vincent Koc
8b50cdd151 ci: update remaining Testbox actions 2026-05-30 22:11:51 +01:00
Peter Steinberger
a825b5576b refactor: simplify sqlite cron persistence 2026-05-30 22:11:17 +01:00
summerview1997
76b300babc Fix /acp spawn cwd inheritance for target agent workspaces (#82415)
* Fix ACP spawn cwd inheritance

* Share ACP spawn cwd guard with command path

* Fix ACP spawn cwd typing and temp dir test

* test: stabilize crabbox wrapper provider fixtures

---------

Co-authored-by: Thomas Yao <thomas@local>
2026-05-30 22:11:06 +01:00
Vincent Koc
ada22739be perf(build): skip dts for runtime build profiles 2026-05-30 23:06:25 +02:00
Peter Steinberger
8fe50a2136 build: classify release dependency ownership
Classify release dependency ownership metadata so release evidence no longer reports current root dependencies as missing ownership metadata. Also recognizes command-explainer package-file lookups for tree-sitter-bash.

Verification: jq empty scripts/lib/dependency-ownership.json; node scripts/dependency-ownership-surface-report.mjs --check; node scripts/root-dependency-ownership-audit.mjs --check; targeted Vitest for root dependency ownership and ownership surface reports; git diff --check; autoreview clean; PR CI green including Real behavior proof.
2026-05-30 22:04:54 +01:00
Peter Steinberger
b374505e7a refactor: source model catalog types from core
Source model catalog SDK types from @openclaw/model-catalog-core while preserving released compat fields and sanitized routing normalization.
2026-05-30 22:00:51 +01:00
zhang-guiping
653292901a fix(tui): surface terminal lifecycle errors
Surface terminal TUI lifecycle errors after the chat stream ends, deduplicate delayed chat errors, and allow explicit runnable Vitest config targets to run through the target planner.

Fixes #85782.

Verification:
- pnpm exec oxfmt --check src/tui/tui-event-handlers.ts src/tui/tui-event-handlers.test.ts test/scripts/test-projects.test.ts scripts/test-projects.test-support.mjs src/agents/model-catalog-visibility.test.ts
- node scripts/run-vitest.mjs src/tui/tui-event-handlers.test.ts
- node scripts/run-vitest.mjs src/tui/tui-event-handlers.test.ts src/tui/tui-command-handlers.test.ts test/scripts/test-projects.test.ts src/agents/model-catalog-visibility.test.ts
- git diff --check
- autoreview --mode local: no accepted/actionable findings
- autoreview --mode branch --base origin/main: no accepted/actionable findings
- Required CI check dependency-guard passed

Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-30 21:59:27 +01:00
Peter Steinberger
3f50485156 perf: cache manifest model suppression resolver 2026-05-30 21:56:38 +01:00
Vincent Koc
9c2744f1e1 test(scripts): require usable memory search in fd repro 2026-05-30 22:48:50 +02:00
brokemac79
3aa460409e fix: route denied exec approval followups to sessions
Routes denied async exec approval followups through the originating main session before using direct external fallback. Keeps strict inline-eval timeout denials fail-closed, while preserving suppression for subagent, cron, and no-session denial cases.

Refs #88167.

Verification:
- git diff --check origin/main...refs/remotes/pr/88417
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
- gh pr checks 88417 --repo openclaw/openclaw --watch=false

Co-authored-by: brokemac79 <martin_cleary@yahoo.co.uk>
2026-05-30 21:45:16 +01:00
Thomas Krohnfuß
48980a0f41 fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (#88019) (#88067)
* fix(responses): drop orphaned assistant msg_* id when reasoning is dropped (#88019)

When an Azure/OpenAI Responses session falls back to a non-Responses model
and later resumes a Responses model, sanitizeSessionHistory drops the
replayable reasoning (rs_*) item via downgradeOpenAIReasoningBlocks. The
paired assistant text block still carried its textSignature (the msg_* id),
so the transport replayed an assistant message item referencing msg_* with
no accompanying rs_* reasoning item. Azure Responses then rejected the next
turn with:

  400 Item 'msg_...' provided without its required 'reasoning' item: 'rs_...'

permanently poisoning the session.

Fix:
- downgradeOpenAIReasoningBlocks now strips the textSignature from a turn's
  text blocks whenever it drops a replayable reasoning item, so the msg_* id
  and its rs_* reasoning are removed together. The transport then falls back
  to a synthetic, unpaired id that Azure accepts.
- Because the synthetic fallback id is derived from the per-message msgIndex,
  multiple id-less text blocks in one assistant turn (e.g. commentary +
  final_answer) would collide on the same id. Make the fallback unique per
  text block in both Responses conversion sites
  (openai-transport-stream.ts and the shared llm provider
  openai-responses-shared.ts).

Tests:
- sanitize-session-history: model-switch path drops the paired msg_* id.
- embedded-agent-helpers: downgrade strips paired text signature(s).
- reasoning-replay: multiple id-less text blocks get distinct item ids.

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

* fix(responses): preserve phase metadata and guard malformed blocks (#88019)

Address PR review feedback on the orphaned msg_* replay fix:

- Preserve Responses phase metadata: dropping the paired msg_* id when its
  rs_* reasoning is removed previously stripped the entire textSignature,
  which also discarded the phase (commentary/final_answer). Phased text now
  keeps a phase-only signature ({v:1,phase}) so commentary is not replayed
  as user-visible output. Both parseTextSignature copies (shared provider and
  embedded transport) now accept id-less phase-only signatures and fall back
  to a synthetic id while preserving the phase.
- Guard malformed content blocks: the post-drop map no longer dereferences
  contentBlock.type unconditionally, so a corrupted transcript with a
  null/primitive block can still sanitize through a model switch.

Tests:
- sanitize-session-history: phase metadata is preserved while the paired id
  is dropped on a model switch.
- reasoning-replay: id-less phase-only signatures get distinct synthetic ids
  and retain their phase.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-30 21:45:08 +01:00
Ashd.LW.
eb170a0adb fix(agents): extend payload-less session lock grace
Payload-less session write-lock files now get a 30s grace for default/long acquire timeouts and cleanup sweeps, while short acquire timeouts keep 5s recovery. This avoids reclaiming a lock while the owner is suspended between exclusive create and metadata write.

Verified with:
- git diff --check origin/main...HEAD
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main --parallel-tests "node scripts/run-vitest.mjs src/agents/session-write-lock.test.ts"
- gh pr checks 80686 --repo openclaw/openclaw --watch=false

Thanks @wAngByg.
2026-05-30 21:42:57 +01:00
Han Kim
b7232db1b0 test(gateway): avoid brittle shutdown timer assertion
Co-authored-by: Han Kim <han.kim@Bowcaster.local>
Co-authored-by: Peter Steinberger <peter@steipete.me>
2026-05-30 21:39:49 +01:00
Peter Steinberger
a20b2dc740 refactor: extract web content core package (#88346)
Extract web-content shared runtime helpers into packages/web-content-core, move the focused tests with the new package, and split quiet CI shards so the node matrix no longer stalls past the no-output watchdog.\n\nVerification: node scripts/run-vitest.mjs test/scripts/ci-node-test-plan.test.ts test/scripts/run-vitest.test.ts src/infra/restart.test.ts src/infra/os-summary.test.ts src/infra/gateway-processes.test.ts src/infra/inline-option-token.test.ts src/infra/map-size.test.ts src/infra/machine-name.test.ts src/commands/doctor-whatsapp-responsiveness.test.ts; autoreview clean; manual CI https://github.com/openclaw/openclaw/actions/runs/26693962844; dependency guard https://github.com/openclaw/openclaw/actions/runs/26693959937. Admin merge used because optional Mantis Telegram Desktop proof was cancelled after blocking merge outside this PR's required proof.
2026-05-30 21:38:29 +01:00
Feelw00
c6b1fede5a fix(mcp): bound channel bridge pending approvals
Bound MCP channel bridge pending Claude permission and approval maps with TTL sweep and close cleanup.
Also sweep before listing pending approvals so expired requests are not exposed between periodic ticks.

Fixes #71646.
Thanks @Feelw00.
2026-05-30 21:36:50 +01:00
Zee Zheng
c80ec43325 feat(cli): add sessions tail progress view
Adds `openclaw sessions tail` as an operator-facing progress view over session trajectory events, with conservative redaction for prompt text, tool arguments, and tool result bodies. The command supports explicit session keys, store/agent scope, follow mode, relocated trajectory pointer files, and cursor-safe follow across bounded trajectory window rewrites.

Documents the new sessions tail CLI surface in `docs/cli/sessions.md`.

Fixes #83441.

Co-authored-by: zhengzuo0-ai <zheng.zuo0@gmail.com>
2026-05-30 21:29:39 +01:00
Peter Steinberger
b6891d284d docs(changelog): restore 2026.5.28 release credits 2026-05-30 21:29:16 +01:00
Peter Steinberger
ec78a21e0b docs(changelog): require complete release credits 2026-05-30 21:28:11 +01:00
Peter Steinberger
be3af54f98 perf: fast path session store json parsing 2026-05-30 21:22:14 +01:00
Peter Steinberger
3fc0df953c refactor(agents): bind subagent threads in core (#88416)
Move subagent thread binding ownership into core so session-mode spawns prepare channel bindings before launching the child agent. Deprecate the legacy subagent_spawning SDK hook in code, compatibility metadata, diagnostics, and plugin docs; plugin authors should observe subagent_spawned instead.

Verification:
- node scripts/run-vitest.mjs src/agents/sessions-spawn-hooks.test.ts src/agents/subagent-spawn.thread-binding.test.ts src/agents/subagent-spawn.workspace.test.ts src/agents/subagent-spawn.mode-session-diagnostics.test.ts
- node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core.tsbuildinfo
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode local
- CI run 26693808952 green, including checks-node-agentic-agents-core and checks-node-agentic-plugin-sdk
2026-05-30 21:19:09 +01:00
Nicolas
4ac90a5b48 fix: skip browser cleanup when browser is disabled
Skip browser lifecycle cleanup when root browser support or the browser plugin entry is disabled, and make the browser maintenance facade respect activation before cached surface use.

Also stabilize the resource-only MCP runtime test by waiting for the async rejection log that CI can observe late.

Verification:
- pnpm test src/plugin-sdk/browser-maintenance.test.ts src/browser-lifecycle-cleanup.test.ts src/auto-reply/reply/session.test.ts src/gateway/server.sessions.reset-cleanup.test.ts src/agents/auth-profiles/usage.test.ts
- pnpm test src/agents/agent-bundle-mcp-runtime.test.ts
- git diff --check
- pnpm build
- autoreview local: no accepted/actionable findings
- GitHub Actions: CI 26693713166, CodeQL 26693713159, CodeQL Critical Quality 26693713157, OpenGrep PR Diff 26693713125, Workflow Sanity 26693713149, Dependency Guard 26693712478

Co-authored-by: Nicolas Van Eenaeme <nicolas@poison.be>
2026-05-30 21:16:47 +01:00
Peter Steinberger
39e987314a perf: skip unnecessary setup auth fallback 2026-05-30 21:16:36 +01:00
Peter Steinberger
427df01d4e ci(release): checkout approval helper 2026-05-30 21:13:19 +01:00
Peter Steinberger
50b7a2ffa1 ci(release): allow direct publish recovery 2026-05-30 21:13:19 +01:00
Vincent Koc
b93ed3f93f test(scripts): expose kitchen sink command RSS 2026-05-30 22:10:25 +02:00
Peter Steinberger
a2b2c4a76c refactor(msteams): persist conversation and poll stores in sqlite
Move MSTeams conversation and poll plugin-local stores to plugin-state SQLite. Legacy JSON stores import once without overwriting existing SQLite state; conversation and poll IDs are hashed for plugin-state keys; poll votes are sharded with bounded row-cap headroom and prune cleanup; MSTeams docs now describe SQLite storage. SSO and delegated token stores are unchanged. Verified with focused MSTeams tests, docs sanity, autoreview, Testbox check:changed, and green PR CI.
2026-05-30 21:08:39 +01:00
Feelw00
a9a86f788b fix(agents): dedupe subagent browser session cleanup
Deduplicate the browser lifecycle cleanup wrapper for embedded subagent completions while preserving retire and announce finalization for duplicate callers.\n\nAdds regression coverage for parallel completion callers and the held-first-cleanup duplicate-tail path.\n\nFixes #68668.\n\nCo-authored-by: Feelw00 <dhrtn1006@naver.com>
2026-05-30 21:04:37 +01:00
keshavbotagent
371a8abe9d fix(build): avoid stale agent-core dts warnings (#87915)
* fix(build): avoid stale agent-core dts warnings

* test(secrets): secure plugin entrypoint fixtures

* fix(agent-core): normalize compaction summary timestamps

* test(secrets): secure platform preset fixture

* fix(build): preserve tracked package dts on skip builds

* test(secrets): secure platform preset resolver fixture

* fix(build): keep declarations during skip dts clean

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-30 21:03:49 +01:00
Peter Steinberger
005da57957 Move cron persistence to SQLite (#88285)
* refactor: move cron persistence to sqlite

* fix: repair sqlite cron migration regressions

* fix: move cron legacy migration to doctor

* test: align cron sqlite migration fixtures

* test: fix cron sqlite rebase gates

* test: align cron sqlite runtime tests

* test: fix doctor e2e migration mock

* test: fix doctor shard e2e isolation

* test: fix infra child-process mocks
2026-05-30 21:03:41 +01:00
brokemac79
d11e82aeea fix(ui): keep selected chat model visible after session switch
Fixes #86597. Thanks @brokemac79.
2026-05-30 20:53:47 +01:00
Coder
adcac404e1 fix(llm): repair invalid streaming unicode escapes
Repair invalid \u escapes during streaming JSON parsing without changing valid Unicode escapes. Split oversized node CI doctor/infra shards and fix the restart test mock deadlock so PR CI stays under the no-output threshold.\n\nCo-authored-by: Coder <83845889+coder999999999@users.noreply.github.com>
2026-05-30 20:53:26 +01:00
Vincent Koc
eb5e80f58a ci: update Blacksmith Testbox actions 2026-05-30 20:51:31 +01:00
Peter Steinberger
5891cfec3e refactor: move model catalog normalization into core package
Move model catalog normalization and package-owned catalog schema/types into model-catalog-core while keeping public plugin SDK model catalog declarations on the existing SDK surface. Verified focused tests, package-boundary compile, full build, changed gate, declaration leak grep, CI, and autoreview.
2026-05-30 20:51:11 +01:00
Abner Shang
961691def2 fix(codex): keep app-server continuation turns alive
Keep Codex app-server continuation turns alive after post-tool, raw assistant, and progress notifications, and reschedule continuation idle watches when shorter progress timeouts apply.

Add regression coverage for the plugin-sdk child_process mock helper deadlock that blocked CI shards on this PR.

Co-authored-by: abnershang <abner.shang@gmail.com>
2026-05-30 20:41:04 +01:00
Vincent Koc
2780f540f8 test(agents): wait for MCP method-not-found log 2026-05-30 20:39:52 +01:00
Vincent Koc
37058ad75a fix(scripts): quiet minimal runtime asset copies
Stop minimal cliStartup and gatewayWatch builds from copying generated plugin static assets they intentionally do not build.\n\nVerified with focused Vitest, autoreview, AWS Crabbox startup-memory proof, and AWS Crabbox changed gate run_bd9ea01e6a12 plus rebased changed gate run_bd9ea01e6a12.
2026-05-30 20:38:19 +01:00
Peter Steinberger
37c6e2dfa0 ci: skip codeql network shard for test-only changes 2026-05-30 20:29:19 +01:00
Shakker
473993f73a fix: remove redundant unknown union 2026-05-30 20:28:29 +01:00
Peter Steinberger
e24a9c5457 ci: keep harness changes on fast checks (#88429) 2026-05-30 20:27:59 +01:00
Shakker
d9c0d09f1a chore: remove inert skill workshop package 2026-05-30 20:15:31 +01:00
Peter Steinberger
0c7ab411e5 fix(auth): bound oauth mirror expiry 2026-05-30 15:11:14 -04:00
Alix-007
5811693c7f fix(export-html): guard msg.content and result.content filter/iteration paths against non-array values (#88271)
* fix(export-html): guard all msg.content and result.content filter/iteration paths

Three call sites in the export HTML template called `.filter()` or iterated
with `for...of` directly on `msg.content` or `result.content` without first
checking `Array.isArray`. When a transcript message row carries a non-array
content value (null, undefined, or any scalar), those paths throw:

  TypeError: msg.content.filter is not a function

Fix: normalize with `Array.isArray(x) ? x : []` before every unguarded
filter and iteration on `msg.content` (computeStats stats path and the
renderEntry assistant render loops) and `result.content` (renderToolCall
text/image accessors).

Regression test added: renderTemplate resolves without throwing for assistant
messages with null, undefined, string, and numeric content values.

Closes #88255

* fix(export-html): guard user message text extraction path against non-array content

The user-message render path in the export HTML template extracted text with
`content.filter(...)` without checking whether `content` is an array. A
persisted user message row with null, undefined, or any non-string scalar
content crashed during export with the same TypeError class as the assistant
path.

Fix: normalize the ternary so a non-string, non-array value falls through to
an empty string rather than calling `.filter` on it.

Regression test added for null, undefined, and numeric user message content.

Addresses feedback from ClawSweeper review on #88271.

* fix(export-html): preserve string content guards

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-30 20:10:43 +01:00
Peter Steinberger
445ff22018 fix(agents): bound auth health expiry 2026-05-30 15:09:37 -04:00
Peter Steinberger
602364f1c7 ci: stabilize changed checks 2026-05-30 20:07:38 +01:00
Peter Steinberger
c73e8eedf4 fix(agents): bound discovery auth expiry 2026-05-30 15:07:04 -04:00
Shakker
dcc329ac09 chore: keep skill workshop package manifest inert 2026-05-30 20:04:52 +01:00
Shakker
515d4ffc21 fix: refresh skill workshop CI expectations 2026-05-30 20:04:52 +01:00
Shakker
28290a496f fix: allow concise skill update descriptions 2026-05-30 20:04:52 +01:00
Shakker
3cd368edec fix: approve final skill workshop tool params 2026-05-30 20:04:52 +01:00
Shakker
908fc35b97 fix: preserve trusted policy checks for skill workshop 2026-05-30 20:04:52 +01:00
Shakker
d6d1cc2a3e fix: serialize skill proposal creation limits 2026-05-30 20:04:52 +01:00
Shakker
41044a207c fix: serialize skill proposal lifecycle mutations 2026-05-30 20:04:52 +01:00
Shakker
7d19f89094 fix: harden skill workshop proposal results 2026-05-30 20:04:52 +01:00
Shakker
43e4b9dc1c fix: keep autonomous skill capture opt-in 2026-05-30 20:04:52 +01:00
Shakker
77c6bee421 fix: refresh skill workshop generated surfaces 2026-05-30 20:04:52 +01:00
Shakker
0b49710e8d fix: preserve auto-captured skill updates 2026-05-30 20:04:52 +01:00
Shakker
3a9e7dfa1a fix: bound skill workshop descriptions 2026-05-30 20:04:52 +01:00
Shakker
e4905ce4c9 fix: enforce skill workshop proposal bounds 2026-05-30 20:04:52 +01:00
Shakker
131e662924 fix: scan skill proposal prompt content 2026-05-30 20:04:52 +01:00
Shakker
7051bf16f0 fix: align skill proposal revise validation 2026-05-30 20:04:52 +01:00
Shakker
6eb6730137 refactor: move skill research capture logic 2026-05-30 20:04:52 +01:00
Shakker
2383cfd303 refactor: rename skill workshop agent tool 2026-05-30 20:04:52 +01:00
Shakker
c09e1efe99 fix: clean up skill workshop lint issues 2026-05-30 20:04:52 +01:00
Shakker
308fdbe7fb refactor: remove skill workshop plugin package 2026-05-30 20:04:52 +01:00
Shakker
c5af09e378 refactor: route agent end side effects through harness 2026-05-30 20:04:52 +01:00
Shakker
3037646d22 feat: add skill workshop runtime policy 2026-05-30 20:04:52 +01:00
Shakker
3ea82adf97 fix: show skill proposal support files on inspect 2026-05-30 20:04:52 +01:00
Shakker
bc6d570659 fix: reject non-text skill proposal files 2026-05-30 20:04:52 +01:00
Shakker
f7729028ae fix: guard skill proposal apply writes 2026-05-30 20:04:52 +01:00
Shakker
11d6ce15e8 fix: harden skill proposal boundaries 2026-05-30 20:04:52 +01:00
Shakker
897a7efe15 fix: preserve skill proposal target state 2026-05-30 20:04:52 +01:00
Shakker
fafa4c8b65 fix: scope skill workshop proposal access 2026-05-30 20:04:52 +01:00
Shakker
186182fe9e feat: let skill research manage proposal lifecycle 2026-05-30 20:04:52 +01:00
Shakker
e5455b61c3 feat: let skill research manage proposal discovery 2026-05-30 20:04:52 +01:00
Shakker
e89417d77b fix: keep skill research available to agents 2026-05-30 20:04:52 +01:00
Shakker
e9b0a5f69e feat: revise pending skill proposals 2026-05-30 20:04:52 +01:00
Shakker
339e212c85 fix: expose skill proposal gateway methods 2026-05-30 20:04:52 +01:00
Shakker
199cdc1052 fix: enforce canonical workshop skill names 2026-05-30 20:04:52 +01:00
Shakker
ab0613c9d3 feat: support skill proposal files 2026-05-30 20:04:52 +01:00
Shakker
91ba5fd4fe fix: store skill workshop proposals in state 2026-05-30 20:04:52 +01:00
Shakker
9da7498d31 fix: satisfy skill workshop lint 2026-05-30 20:04:52 +01:00
Shakker
67298c4bd8 fix: satisfy skill workshop changed checks 2026-05-30 20:04:52 +01:00
Shakker
9417a3f39f fix: rebuild corrupt skill proposal manifests 2026-05-30 20:04:52 +01:00
Shakker
1609fcaff3 docs: document skill workshop proposals 2026-05-30 20:04:52 +01:00
Shakker
5205b94d84 feat: expose skill workshop gateway methods 2026-05-30 20:04:52 +01:00
Shakker
7f48ee1e57 feat: add skill research proposal tool 2026-05-30 20:04:52 +01:00
Shakker
c4be8d8730 feat: add skill workshop cli commands 2026-05-30 20:04:52 +01:00
Shakker
bc1c3701c4 feat: add skill workshop proposal store 2026-05-30 20:04:52 +01:00
Peter Steinberger
4e8b74568f refactor: move model catalog refs into core package
Move model catalog ref helpers into @openclaw/model-catalog-core/model-catalog-refs and update internal callers/package-boundary aliases. Also fix the timestamp predicate typing that blocked prod type checks on current main.
2026-05-30 20:04:16 +01:00
Peter Steinberger
b80dcbd650 fix(plugin-sdk): bound copilot token expiry 2026-05-30 15:04:03 -04:00
Peter Steinberger
417aba7b9b fix(infra): bound session delivery recovery deadline 2026-05-30 15:02:02 -04:00
Peter Steinberger
ed63523db9 test(release): expect public latest in installer smoke 2026-05-30 20:01:22 +01:00
Peter Steinberger
677f7c80dc fix(plugin-sdk): bound oauth result expiry 2026-05-30 14:59:59 -04:00
Vincent Koc
231d0b28bd fix(agents): harden message dts and block timestamps 2026-05-30 20:58:21 +02:00
Peter Steinberger
979907e004 fix(outbound): bound delivery recovery deadline 2026-05-30 14:57:47 -04:00
Peter Steinberger
9eb17a0277 fix(shared): bound epoch expiry helpers 2026-05-30 14:55:37 -04:00
Peter Steinberger
06e0fd3347 fix(media): bound provider operation deadlines 2026-05-30 14:54:03 -04:00
Peter Steinberger
51cceaf70c fix(agents): bound run drain deadlines 2026-05-30 14:51:59 -04:00
Peter Steinberger
471164afbd fix(github-copilot): bound device code expiry 2026-05-30 14:49:34 -04:00
Peter Steinberger
99ce71ddbb feat: improve MCP operability
Summary:
- Add MCP status, probe, and projected-tools CLI surfaces.
- Add per-server MCP tool filters plus resource/prompt utility projection.
- Harden MCP runtime discovery, listChanged invalidation, request-failure backoff, and metadata sanitization.
- Preserve current main type health by narrowing the shared future timestamp guard.

Verification:
- pnpm test src/shared/number-coercion.test.ts src/agents/auth-profiles/usage.test.ts src/cli/mcp-cli.test.ts src/agents/agent-bundle-mcp-runtime.test.ts src/agents/agent-bundle-mcp-tools.materialize.test.ts -- --reporter=verbose
- pnpm lint
- pnpm tsgo:prod
- pnpm build
- git diff --check origin/main...HEAD
- GitHub Actions: dependency-guard, real behavior proof, security high MCP boundary, build/lint/types/guards/docs, gateway/plugin/agent shards green on PR head.

Known proof gap:
- Existing checks-node-agentic-commands-doctor no-output watchdog reproduced locally outside touched paths.
2026-05-30 19:48:52 +01:00
Peter Steinberger
9cb9851bf8 fix(models): bound pasted token expiry 2026-05-30 14:47:41 -04:00
Peter Steinberger
2b31c02163 fix(plugins): bound scheduled turn delays 2026-05-30 14:44:24 -04:00
Coder
878e433d81 fix(skill-creator): sort .skill entries deterministically
Fixes #37748.

Sort skill package archive entries by relative POSIX archive name so generated `.skill` bundles are reproducible regardless of filesystem traversal order.

Verification:
- `PYTHONDONTWRITEBYTECODE=1 python3 skills/skill-creator/scripts/test_package_skill.py`
- `git diff --check origin/main...HEAD`
- GitHub CI run 26690938925 on `43a0fdf7175f33a5c74bc7ff92723ebf5efc4df9`: all checks passed except repeated unrelated no-output timeouts in `checks-node-agentic-commands-doctor` and `checks-node-core-runtime-infra-state` after visible tests passed.
2026-05-30 19:42:55 +01:00
Peter Steinberger
dfbed5053a fix(qqbot): bound reminder schedule time 2026-05-30 14:41:39 -04:00
Peter Steinberger
caac9733a7 fix(memory): bound qmd embed backoff 2026-05-30 14:39:33 -04:00
Peter Steinberger
6399b6a445 fix(discord): bound timeout member expiry 2026-05-30 14:34:40 -04:00
Peter Steinberger
472606de9b fix(qqbot): skip token cache on invalid clock 2026-05-30 14:33:04 -04:00
Peter Steinberger
177496552b fix(infra): bound device bootstrap expiry 2026-05-30 14:31:30 -04:00
Peter Steinberger
e0248fc11f fix(cron): bound relative at timestamps 2026-05-30 14:29:39 -04:00
Peter Steinberger
6a753ade78 fix(crestodian): bound rescue approval expiry 2026-05-30 14:28:25 -04:00
Peter Steinberger
53812bd8aa fix(agents): bound codex cli fallback expiry 2026-05-30 14:26:17 -04:00
Lellansin Huang
fe3c3ac5cd fix(gateway): forward stop sequences across providers
Forward OpenAI-compatible stop sequences from gateway chat completions through the agent runner into provider transports.

The gateway now normalizes stop into sampling extras, agent transports pass it into the shared stream options, and OpenAI, Anthropic, Mistral, Google, and Vertex-backed simple providers map it to their native request fields. Provider/gateway/agent coverage plus Crabbox live gateway proof verify valid stop dispatch and invalid stop rejection.

Refs #87920
2026-05-30 19:24:21 +01:00
Peter Steinberger
5435b453ca feat: expand workboard orchestration metadata (#88408) 2026-05-30 19:22:19 +01:00
Peter Steinberger
abc26b072b fix(discord): bound rest rate-limit deadlines 2026-05-30 14:22:16 -04:00
Jiatai Wang
64533bab65 fix(agents): show exec target node in tool display
Show the remote node name in exec tool transparency details when an exec call targets `host=node`, while ignoring stray `node` values for gateway, sandbox, and auto-host calls.

Covers node-only, cwd+node, absent-node, and non-node-host regression cases in the tool display tests.

Fixes #77719.

Co-authored-by: JiataiWang <wangjiatai@proton.me>
2026-05-30 19:19:17 +01:00
Peter Steinberger
7d4bf8f285 fix(telegram): bound transport cooldown expiry 2026-05-30 14:16:57 -04:00
Peter Steinberger
bdb0fde0ea test(release): harden live release checks 2026-05-30 19:14:27 +01:00
Peter Steinberger
926a165a52 fix(anthropic): bound setup token expiry 2026-05-30 14:14:13 -04:00
Peter Steinberger
70b6fdd149 fix(bedrock): bound mantle runtime token expiry 2026-05-30 14:09:59 -04:00
Peter Steinberger
9ad7f5bbde fix(agents): bound sqlite cache expiry 2026-05-30 14:07:32 -04:00
Peter Steinberger
1ee751ddb1 fix(agents): bound google prompt cache expiry 2026-05-30 14:02:50 -04:00
Peter Steinberger
30e3ca08a5 fix(agents): bound auth profile block expiry 2026-05-30 14:00:46 -04:00
Peter Steinberger
1f6c1eacf0 fix(telegram): bound error cooldown expiry 2026-05-30 13:59:06 -04:00
Peter Steinberger
8654353be8 fix(discord): bound component registry expiry 2026-05-30 13:57:13 -04:00
Peter Steinberger
c5aa3ff02f fix(msteams): bound delegated token probe expiry 2026-05-30 13:54:56 -04:00
Peter Steinberger
5fde637ba8 fix(codex): bound app inventory cache expiry 2026-05-30 13:53:13 -04:00
guanbear
044f5a814e Expose subagent resolved model metadata (#80037)
Co-authored-by: guanbear <guanbear@macmini.bearhome>
2026-05-30 18:52:21 +01:00
Peter Steinberger
3ae521745e fix(voice-call): bound webhook replay cache expiry 2026-05-30 13:51:12 -04:00
Peter Steinberger
f89f5d930f fix(gateway): bound system run event expiry 2026-05-30 13:49:03 -04:00
Vincent Koc
13c77f00c3 fix(agents): classify code mode deadline interrupts 2026-05-30 18:47:42 +01:00
chuanchuan
3b8ab4e112 fix(feishu): stream plain replies as cards
Feishu `channels.feishu.streaming=true` now streams ordinary assistant replies through CardKit in auto mode, while keeping tool-summary delivery on the existing message path.

Also discards stale partial previews when final delivery intentionally suppresses text for voice media or duplicate final text, and preserves streamed partial text for regular media-only finals.

Verification:
- `node scripts/run-vitest.mjs run extensions/feishu/src/reply-dispatcher.test.ts`
- `pnpm tsgo:extensions`
- `pnpm test:extensions:package-boundary:compile`
- `pnpm exec oxfmt --check extensions/feishu/src/reply-dispatcher.ts extensions/feishu/src/reply-dispatcher.test.ts extensions/feishu/src/streaming-card.ts`
- `git diff --check`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`
- GitHub PR checks on run 26689677607 passed except repeated unrelated broad Vitest no-output timeouts in `checks-node-agentic-commands-doctor` and `checks-node-core-runtime-infra-state`.

Co-authored-by: 传妈 <chuanmother@chuanMac-Mini.local>
2026-05-30 18:47:03 +01:00
Peter Steinberger
ca4a12381a fix(gateway): bound chat abort expiry registration 2026-05-30 13:46:16 -04:00
Steven
86e33d6985 fix(models): preserve exact provider refs before aliases
Fixes #88218.

Preserves exact configured provider/model defaults before bare alias target reverse matches, while retaining slash-form aliases and auth-profile alias behavior.

Co-authored-by: Steven Palmer <palmer.e.steven@gmail.com>
2026-05-30 18:46:11 +01:00
Peter Steinberger
9ef699fedc fix(gateway): bound maintenance run expiry checks 2026-05-30 13:43:21 -04:00
Peter Steinberger
912a276ca1 fix(gateway): bound talk handoff expiry 2026-05-30 13:41:14 -04:00
Brian
6f20f29688 fix(discord): carry reply typing feedback through queue
Carry Discord reply typing feedback through preflight, queued dispatch, and cleanup so delayed accepted replies keep typing alive at the actual dispatch target without duplicate keepalives. Adds focused Discord queue/process policy coverage and stronger lifecycle invariant comments.
2026-05-30 18:39:39 +01:00
Merlin
b6d253eefb fix(discord): omit undefined component registry fields
Prunes undefined Discord component and modal registry metadata before persisting it so SQLite-backed plugin state never receives JSON-incompatible undefined values. Adds direct regression coverage for undefined own properties on component, modal, and nested field entries.
2026-05-30 18:39:26 +01:00
Peter Steinberger
0a87f6e4ad fix(gateway): bound node pending work expiry 2026-05-30 13:38:54 -04:00
Ashd.LW.
bc77f7a00a fix(gateway): explain ignored restart signal
Add actionable operator guidance when an unauthorized SIGUSR1 gateway restart is ignored because unmanaged restart is disabled.

The change is log-only: restart authorization and scheduling semantics are unchanged, and the existing run-loop test now asserts both the reason warning and the recovery hint.

Refs #79577
Refs #78110
Refs #82433

Co-authored-by: wAngByg <281221101+wAngByg@users.noreply.github.com>
2026-05-30 18:38:35 +01:00
ToToKr
9e3d5310cc fix(media): dedupe duplicate inbound media path urls
Dedupe prompt-side inbound media note suffixes when sanitized MediaPath and MediaUrl render to the same value, while preserving genuinely distinct remote URLs.\n\nFixes #47587.\nThanks @MoerAI for the patch and @yzjJosh for the report.
2026-05-30 18:37:42 +01:00
Peter Steinberger
4d9366fecb fix(gateway): bound plugin node capability expiry 2026-05-30 13:35:43 -04:00
Sebastien Tardif
1c9851e115 fix(install): show npm install progress without gum
Show the same Installing OpenClaw package progress line in the no-gum npm install fallback before redirecting npm output to the temp log.

Fixes #82305

Co-authored-by: Sebastien Tardif <sebtardif@ncf.ca>
2026-05-30 18:35:32 +01:00
Peter Steinberger
a4f62400a7 fix(commitments): bound terminal failure cooldown expiry 2026-05-30 13:33:06 -04:00
吴杨帆
8d3fe21b53 test(tasks): cover task domain view mappers (#86755)
Adds focused coverage for task-domain view mapper DTO contracts, including summary cloning, task run/detail mapping, flow view/detail mapping, and implicit summary computation.

Test-only PR. Verified with git diff --check and PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm test src/tasks/task-domain-views.test.ts on the current-main merge result.

Thanks @leno23.

Co-authored-by: wuyangfan <yangfan.wu@succaiss.com>
2026-05-30 18:30:36 +01:00
Peter Steinberger
becd45325b fix(imessage): bound private api negative cache expiry 2026-05-30 13:28:17 -04:00
Peter Steinberger
84a965a1a2 refactor(matrix): move ephemeral state to plugin sqlite (#88387)
* refactor(matrix): persist ephemeral state in plugin sqlite

* test(channels): wire matrix contract plugin state
2026-05-30 18:26:29 +01:00
Peter Steinberger
f4d461bbff fix(imessage): bound approval reaction poll expiry 2026-05-30 13:25:45 -04:00
Peter Steinberger
cbad1b6e69 fix(agents): bound exec followup handoff expiry 2026-05-30 13:23:03 -04:00
Peter Steinberger
f4cd5e4050 fix(sandbox): bound novnc observer token expiry 2026-05-30 13:21:26 -04:00
Peter Steinberger
0e7773d1a6 test(release): wait for live probe cleanup 2026-05-30 18:21:01 +01:00
Peter Steinberger
d8e7734d27 fix(agents): bound exec approval request expiry 2026-05-30 13:19:42 -04:00
Peter Steinberger
da7fb64aa4 fix(google): bound realtime browser session expiry 2026-05-30 13:16:22 -04:00
Peter Steinberger
3fffb34ba0 fix(msteams): bound delegated token expiry 2026-05-30 13:13:56 -04:00
Peter Steinberger
0dd67e2f25 fix(workboard): bound claim expiry checks 2026-05-30 13:11:14 -04:00
Peter Steinberger
4df27b9626 fix(browser): bound armed dialog expiry 2026-05-30 13:08:52 -04:00
Peter Steinberger
e708a872a1 fix(commands): bound private approval route expiry 2026-05-30 13:06:29 -04:00
zhang-guiping
2dacc6da28 fix(agents): hide sessions_send alias normalization
Co-authored-by: 张贵萍0668001030 <zhang.guiping@xydigit.com>
2026-05-30 18:06:22 +01:00
Peter Steinberger
9660e42fe2 fix(plugin-state): bound ttl expiry writes 2026-05-30 13:03:24 -04:00
Peter Steinberger
522da25932 fix(skills): bound upload expiry checks 2026-05-30 13:00:52 -04:00
Peter Steinberger
d44621b544 fix(exec): bound approval pending expiry 2026-05-30 12:58:59 -04:00
Peter Steinberger
6fe0539992 test(release): skip unavailable anthropic live models 2026-05-30 17:58:01 +01:00
Peter Steinberger
283238fd77 fix(matrix): bound allowlist store cache expiry 2026-05-30 12:56:54 -04:00
Peter Steinberger
5568ecc7aa fix(discord): bound unbound webhook echo expiry 2026-05-30 12:54:25 -04:00
Peter Steinberger
743d5378d2 fix(zalouser): bound group context cache expiry 2026-05-30 12:52:24 -04:00
史启明(QimingShi)
63a3676d3c fix(tui): distinguish /new and /reset descriptions
Fixes #49517.

Updates the TUI command catalog so /new describes spawning an isolated session while /reset describes resetting the current session. Adds a focused regression test for the two descriptions.

Co-authored-by: KhanCold <119404710+KhanCold@users.noreply.github.com>
2026-05-30 17:50:14 +01:00
Peter Steinberger
2a39c217c8 fix(voice-call): bound realtime stream token expiry 2026-05-30 12:49:36 -04:00
NianJiu
a2fc4ca7ad feat(ui): add collapsible recent sessions section
Adds a persisted collapse state for the Control UI Recent sessions sidebar group, including storage and browser coverage.

Also narrows gateway run miss cache expiry typing so the rebased branch stays clean against current main.

Closes #85510

Co-authored-by: NianJiuZst <3235467914@qq.com>
2026-05-30 17:48:29 +01:00
Peter Steinberger
8eeaa45729 refactor: route model catalog imports to core package
Route internal model catalog imports to the extracted @openclaw/model-catalog-core package and delete obsolete internal facades.

Keep public SDK declarations self-contained by wrapping core helpers at public boundaries instead of leaking private package imports.

Verification:
- pnpm test src/plugins/contracts/model-catalog-core-imports.test.ts src/plugins/sdk-alias.test.ts packages/model-catalog-core/src/configured-model-refs.test.ts packages/model-catalog-core/src/provider-model-id-normalize.test.ts packages/model-catalog-core/src/provider-model-id-normalization.test.ts src/config/config.model-ref-validation.test.ts src/agents/model-selection.test.ts src/plugin-sdk/provider-model-shared.test.ts -- --reporter=verbose
- pnpm check:test-types
- pnpm test:extensions:package-boundary:compile
- pnpm build
- rg "@openclaw/model-catalog-core" dist/plugin-sdk packages/plugin-sdk/dist -n --glob '*.d.ts' || true
- git diff --check
- autoreview clean after fix

CI note: merged with admin override because checks-node-agentic-commands-doctor and checks-node-core-runtime-infra-state failed twice with exit 143/no-output watchdog termination after prior passing test output, while relevant local proof and the rest of CI were green.
2026-05-30 17:48:18 +01:00
Vincent Koc
4d13055ca5 fix(sessions): repair prompt blobs on fast updates 2026-05-30 17:47:07 +01:00
Peter Steinberger
bfceffa2f7 fix(qqbot): bound upload cache expiry 2026-05-30 12:46:56 -04:00
Peter Steinberger
031583e8f5 fix(gateway): bound exec approval expiry 2026-05-30 12:44:39 -04:00
Vincent Koc
2ccbc673df fix(scripts): prebuild gateway cpu private qa artifacts 2026-05-30 18:42:17 +02:00
Peter Steinberger
11b5728faa fix(agents): bound code mode snapshot expiry 2026-05-30 12:42:07 -04:00
samzong
4decdf6245 [Fix] Deliver restart recovery replies (#86089)
* fix(agents): deliver restart recovery replies

* fix(auto-reply): import session entry updater

* test(auto-reply): use current embedded agent mock

* test(feishu): refresh typed account fixture

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-30 17:39:43 +01:00
Peter Steinberger
ac0fb976c8 fix(feishu): bound card action token expiry 2026-05-30 12:37:24 -04:00
Vincent Koc
1de9f99ea8 fix(ci): repair current test type fixtures 2026-05-30 17:35:02 +01:00
Peter Steinberger
60f8e18372 fix(nvidia): bound featured model cache expiry 2026-05-30 12:34:53 -04:00
Peter Steinberger
e52b4bce01 fix(bedrock): bound discovery cache expiry 2026-05-30 12:33:07 -04:00
mushuiyu_xydt
f93a558892 fix(plugins): ignore helper files in extension roots
Fixes #88198.

Ignore top-level helper scripts in auto-discovered global/workspace extension roots so they do not become manifestless plugin candidates during config validation. Standalone plugin files remain supported when explicitly configured through `plugins.load.paths`, and docs now call out the supported path.

Verification:
- `node scripts/run-vitest.mjs src/plugins/discovery.test.ts src/config/config.plugin-validation.test.ts`
- `node scripts/run-oxlint.mjs src/plugins/discovery.ts src/plugins/discovery.test.ts src/config/config.plugin-validation.test.ts`
- `git diff --check`
- GitHub CI green at `93073bfa85ee294e644c623881ba59ba71d90975`
- `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main`

Thanks @mushuiyu886 for the fix and @mmhzlrj for the report.
2026-05-30 17:31:53 +01:00
Peter Steinberger
5ba3505fed fix(bedrock): bound mantle iam token expiry 2026-05-30 12:31:08 -04:00
Peter Steinberger
18e7d28b21 perf(gateway): reuse stable turn metadata 2026-05-30 17:30:47 +01:00
Peter Steinberger
02ca283716 fix(outbound): bound current conversation expiry 2026-05-30 12:27:26 -04:00
Peter Steinberger
4f0e3cb621 fix(plugin-sdk): bound live catalog cache expiry 2026-05-30 12:25:14 -04:00
Martin Kessler
73a69d9e64 fix(outbound): pack newline-mode paragraphs up to limit
Pack newline-mode outbound paragraphs up to the configured text limit instead of sending one message per blank-line-separated paragraph. Preserves markdown fence guardrails and adds focused chunking plus outbound delivery regressions.\n\nVerified: autoreview clean; node scripts/run-vitest.mjs src/auto-reply/chunk.test.ts src/infra/outbound/deliver.test.ts; git diff --check origin/main...HEAD.\n\nThanks @kesslerio.
2026-05-30 17:24:57 +01:00
Peter Steinberger
b1911a7cd3 fix(gateway): bound run session miss cache expiry 2026-05-30 12:22:24 -04:00
Peter Steinberger
450642a897 fix(agents): bound native permission approval expiry 2026-05-30 12:20:29 -04:00
Vincent Koc
f7a1903bfc fix(discord): avoid private test session intersection 2026-05-30 17:18:51 +01:00
Peter Steinberger
61cf22f147 fix(agents): bound native hook relay expiry 2026-05-30 12:17:36 -04:00
Peter Steinberger
55505776fb fix(gateway): bound transcription relay session expiry 2026-05-30 12:15:06 -04:00
brokemac79
3c91928bae fix(codex): refresh stale managed runtime plugin
Refresh stale managed Codex runtime plugin installs during doctor repair and restore Codex status usage attribution. Thanks @brokemac79.
2026-05-30 17:15:04 +01:00
Peter Steinberger
6ac7564918 fix(gateway): bound realtime relay session expiry 2026-05-30 12:13:10 -04:00
Peter Steinberger
23e1aac9b2 fix(feishu): bound sender name cache expiry 2026-05-30 12:10:19 -04:00
Peter Steinberger
c65af78853 fix(discord): bound realtime wake followup expiry 2026-05-30 12:06:57 -04:00
Vincent Koc
4155ac1c0d fix(scripts): make kitchen sink rpc help inert 2026-05-30 18:04:44 +02:00
Peter Steinberger
cfe5544b30 fix(qqbot): honor legacy c2c stream progress 2026-05-30 17:02:41 +01:00
Peter Steinberger
d7b901a1e7 fix(discord): bound speaker context cache expiry 2026-05-30 12:02:18 -04:00
Peter Steinberger
5225a8c644 fix(gateway): bound config schema cache expiry 2026-05-30 12:00:37 -04:00
Peter Steinberger
fc50f949d4 Add per-agent SQLite cache store (#88349)
* feat: add per-agent sqlite cache store

* fix: preserve sqlite cache adapter scope

* chore: mark sqlite cache scaffold intentional
2026-05-30 17:00:24 +01:00
samzong
f6b40861f7 fix(qqbot): deliver partial tool progress
Fixes #66509.

QQBot now sends text-only tool progress immediately when partial streaming is enabled instead of buffering it until a fallback timer that is cleared by the final block. Immediate progress uses QQ plain-text sends so markdown-enabled accounts do not reinterpret media-like progress text, while streaming-off behavior remains final-only.

Thanks @gabrielduartesignart for the report.

Co-authored-by: samzong <samzong.lu@gmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-05-30 17:00:09 +01:00
Peter Steinberger
f491d420f7 fix(tailscale): bound whois cache expiry 2026-05-30 11:58:07 -04:00
Peter Steinberger
ef0882e17e fix(google): bound gemini oauth token expiry 2026-05-30 11:55:02 -04:00
Peter Steinberger
697bafa9c9 fix(google): bound vertex adc token cache expiry 2026-05-30 11:52:19 -04:00
Peter Steinberger
77761f4a3e fix(msteams): bound parent thread cache expiry 2026-05-30 11:49:47 -04:00
Peter Steinberger
0e2694ff47 fix(msteams): bound team id cache expiry 2026-05-30 11:47:00 -04:00
Peter Steinberger
5eb71927b7 fix(whatsapp): bound group metadata cache expiry 2026-05-30 11:45:05 -04:00
Vincent Koc
cbd8049b9f fix(scripts): parse forwarded package script options 2026-05-30 17:44:14 +02:00
Peter Steinberger
19f22b5924 fix(feishu): bound approval card expiry 2026-05-30 11:41:43 -04:00
Peter Steinberger
05634708e0 fix(feishu): bound quick action launcher expiry 2026-05-30 11:38:50 -04:00
Vincent Koc
536c00991f fix(gateway): guard traced channel handoff stops 2026-05-30 16:36:43 +01:00
Peter Steinberger
c94c43d3bb fix(feishu): bound card action chat cache clocks 2026-05-30 11:36:19 -04:00
Nimrod Gutman
8a99c0d17a feat(ios): refresh app store metadata (#88235)
Merged via squash.

Prepared head SHA: a54d2ffad2
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-30 18:36:01 +03:00
Peter Steinberger
30e1556cda refactor: extract model catalog core package
* refactor: extract model catalog core package

* refactor: route model catalog imports through package boundary

* build: include model catalog in plugin sdk package dts

* fix: preserve static fallback model metadata
2026-05-30 16:33:45 +01:00
Peter Steinberger
ec15f90a55 fix(feishu): bound group name cache clocks 2026-05-30 11:33:30 -04:00
Peter Steinberger
3da34a4673 fix(feishu): bound probe cache expiry clocks 2026-05-30 11:31:16 -04:00
Peter Steinberger
f91ddefbfb fix(zalo): bound hosted media expiry clocks 2026-05-30 11:29:33 -04:00
Vincent Koc
84385898ec fix(deps): remove photon root runtime 2026-05-30 16:27:48 +01:00
Peter Steinberger
6c7642b532 fix(workboard): bound claim expiry timestamps 2026-05-30 11:27:09 -04:00
Peter Steinberger
9988a37d37 fix(phone-control): bound arm expiry timestamps 2026-05-30 11:24:36 -04:00
Peter Steinberger
37b33d11ce test: isolate channel manager teardown 2026-05-30 16:24:34 +01:00
Peter Steinberger
7086e34533 feat(workboard): persist orchestration metadata in sqlite
Persist Workboard orchestration data in plugin SQLite KV storage, including board metadata, cards, notification subscriptions, decomposition history, and board lifecycle/RPC support.
2026-05-30 16:24:14 +01:00
Peter Steinberger
20fbb8bd14 fix(mattermost): bound slash validation cache clocks 2026-05-30 11:22:25 -04:00
Peter Steinberger
8e90a1cad9 fix(slack): bound subteam member cache clocks 2026-05-30 11:19:34 -04:00
Peter Steinberger
7e3ebb8e10 fix(slack): bound external menu cache clocks 2026-05-30 11:17:13 -04:00
Peter Steinberger
06b2bf1c0a fix(telegram): bound forum flag cache clocks 2026-05-30 11:15:03 -04:00
Peter Steinberger
d649548a7a fix(active-memory): bound recall cache clocks 2026-05-30 11:13:04 -04:00
Vincent Koc
5adc681238 refactor: share approval lookup state 2026-05-30 17:12:03 +02:00
Vincent Koc
53e8dc6a54 fix(scripts): stop parsing after option terminators 2026-05-30 17:10:36 +02:00
Peter Steinberger
2d0a0c5e43 test: clear channel manager restart timers 2026-05-30 16:09:38 +01:00
Peter Steinberger
b668ffe7ca fix(slack): bound thread resolution cache clocks 2026-05-30 11:09:21 -04:00
Peter Steinberger
6736936cbc fix(slack): bound thread starter cache clocks 2026-05-30 11:06:47 -04:00
Peter Steinberger
8539e0283a fix(slack): bound app mention retry clocks 2026-05-30 11:04:24 -04:00
Peter Steinberger
ef88f0f949 perf(sessions): skip prompt hydration for metadata reads 2026-05-30 16:03:39 +01:00
Peter Steinberger
816c692035 fix(slack): bound member cache clocks 2026-05-30 11:01:19 -04:00
Peter Steinberger
c635e560d0 build: update rastermill to 0.3.1 2026-05-30 16:01:14 +01:00
Vincent Koc
ccb59d989b fix(scripts): honor memory fd option terminator 2026-05-30 17:00:54 +02:00
Vincent Koc
642f85dc5b test(sdk): resolve local package deps in pack smoke 2026-05-30 15:57:18 +01:00
Vincent Koc
53300a5c1a refactor: share skills method validation 2026-05-30 16:56:36 +02:00
Vincent Koc
b51610a1c3 fix(ci): serialize gateway server vitest project 2026-05-30 15:56:25 +01:00
Peter Steinberger
5269924ff8 fix(imessage): bound probe cache clocks 2026-05-30 10:55:53 -04:00
Peter Steinberger
62fa5692cb fix(imessage): bound chat list cache clocks 2026-05-30 10:52:38 -04:00
Peter Steinberger
2d4369d176 fix(signal): bound api mode cache clocks 2026-05-30 10:50:44 -04:00
Peter Steinberger
99e8cf22a8 fix(web): bound tool cache expiry clocks 2026-05-30 10:47:46 -04:00
Vincent Koc
e780a6b7ba fix(agents): type configured fallback model metadata 2026-05-30 16:45:53 +02:00
Vincent Koc
313554059c fix(docs): route anchor audit through pnpm runner 2026-05-30 16:45:52 +02:00
Peter Steinberger
77b334a984 fix(mattermost): bound reaction cache clocks 2026-05-30 10:43:44 -04:00
Peter Steinberger
ab67a198c1 fix(mattermost): bound monitor cache clocks 2026-05-30 10:41:19 -04:00
Peter Steinberger
9ef5a9afdc fix(discord): bound REST entity cache clocks 2026-05-30 10:38:26 -04:00
Vincent Koc
c39fbdb698 refactor: share web login request validation 2026-05-30 16:37:35 +02:00
Peter Steinberger
d33d6bfafa fix(discord): bound channel info cache clocks 2026-05-30 10:34:45 -04:00
Peter Steinberger
2209f71a78 fix(oauth): reject date-invalid token expiries 2026-05-30 10:31:36 -04:00
Peter Steinberger
f13a615036 fix(foundry): bound entra token expiry clocks 2026-05-30 10:29:26 -04:00
Peter Steinberger
5660b67062 fix(google-meet): bound oauth fallback expiry clocks 2026-05-30 10:26:07 -04:00
Vincent Koc
1d21646e96 fix(ci): type static catalog runtime metadata 2026-05-30 15:23:48 +01:00
Peter Steinberger
55d4456751 fix(webhook): bound replay response expiry timestamps 2026-05-30 10:21:50 -04:00
Peter Steinberger
a80d9f00f1 test(imessage): align SMS route expectations 2026-05-30 15:18:30 +01:00
Peter Steinberger
22d635080d fix(feishu): guard streaming token expiry clocks 2026-05-30 10:14:14 -04:00
Peter Steinberger
d5be702f86 fix(gateway): guard assistant media ticket clocks 2026-05-30 10:08:32 -04:00
Vincent Koc
3d66d203d0 test(daemon): keep systemd tests off real systemctl 2026-05-30 15:03:37 +01:00
Peter Steinberger
a918e93421 fix(cron): keep out-of-range atMs invalid 2026-05-30 10:00:45 -04:00
Vincent Koc
56eadf36d0 refactor: share approval resolve param parsing 2026-05-30 15:57:57 +02:00
Peter Steinberger
912f663173 fix(agents): guard compaction successor timestamps 2026-05-30 09:56:55 -04:00
1095 changed files with 53861 additions and 14870 deletions

View File

@@ -52,17 +52,29 @@ attribution.
- keep `#issue`, `(#PR)`, `Fixes #...`, and `Thanks @...`
- every human-authored merged PR represented by a user-facing entry needs
its PR ref and `Thanks @author`, even when the PR had no linked issue
- every human issue reporter for a `Fixes #...` or referenced bug issue
represented by a user-facing entry needs `Thanks @reporter` unless the
same handle is already thanked in that bullet
- every human `Co-authored-by` contributor on represented user-facing work
needs `Thanks @handle` when a GitHub handle is known
- when grouping multiple PRs/issues in one bullet, include every relevant
PR/issue ref and every human contributor handle in that same bullet
- multiple `Thanks @...` handles in one bullet are expected; do not drop or
collapse contributor credit just because the note is grouped
- if one grouped bullet covers both direct commits and PRs, keep all PR refs
and thanks, plus any issue refs from the direct commits
- before finalizing, audit the final release-note body:
- extract all `#NNN` refs from the notes
- resolve which refs are PRs and collect human PR authors
- resolve issue refs used as bug/report refs and collect human reporters
- scan represented commits for `Co-authored-by`
- compare those handles to the final `Thanks @...` set
- fix every missing human credit or explicitly record why it is omitted
- do not add GHSA references, advisory IDs, or security advisory slugs to
changelog entries or GitHub release-note text unless explicitly requested
- never thank bots, `@openclaw`, `@clawsweeper`, or `@steipete`
- if grouping multiple entries, carry all relevant refs and thanks into the
grouped bullet
- do not use GitHub's release contributor count as the source of truth; the
changelog must carry the complete human credit set itself
7. Sorting preference:
- security/data-loss and content-boundary fixes
- transcript/replay/reply delivery correctness

View File

@@ -27,7 +27,7 @@ jobs:
timeout-minutes: 35
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
with:
testbox_id: ${{ inputs.testbox_id }}
@@ -231,7 +231,7 @@ jobs:
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -26,7 +26,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Begin Testbox
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Checkout
@@ -133,7 +133,7 @@ jobs:
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d
if: success()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -601,7 +601,7 @@ jobs:
uses: actions/cache@v5
with:
path: .artifacts/build-all-cache
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
restore-keys: |
${{ runner.os }}-build-all-v3-
@@ -834,10 +834,10 @@ jobs:
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
ci-routing)
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
;;
bun-launcher)
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
@@ -1151,6 +1151,7 @@ jobs:
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "900000"
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
shell: bash
run: |
@@ -1403,7 +1404,7 @@ jobs:
packages/plugin-sdk/dist
extensions/*/dist/.boundary-tsc.tsbuildinfo
extensions/*/dist/.boundary-tsc.stamp
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-extension-package-boundary-v1-
@@ -1425,11 +1426,17 @@ jobs:
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
fi
if [ -d packages/model-catalog-core/src ]; then
find packages/model-catalog-core/src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
fi
cache_inputs=(
tsconfig.json \
tsconfig.plugin-sdk.dts.json \
packages/plugin-sdk/tsconfig.json \
packages/llm-core/package.json \
packages/model-catalog-core/package.json \
scripts/check-extension-package-tsc-boundary.mjs \
scripts/prepare-extension-package-boundary-artifacts.mjs \
scripts/write-plugin-sdk-entry-dts.ts \

View File

@@ -302,6 +302,8 @@ jobs:
esac
case "${file}" in
src/**/*.test.ts|src/**/*.test.tsx|extensions/**/*.test.ts|extensions/**/*.test.tsx)
;;
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts|packages/net-policy/src/*|packages/net-policy/src/**/*)
network_runtime=true
;;

View File

@@ -372,6 +372,11 @@ jobs:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Require trusted workflow ref for publish
env:
RELEASE_TAG: ${{ inputs.tag }}
@@ -429,12 +434,13 @@ jobs:
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
direct_recovery=true
echo "Direct OpenClaw npm recovery with release_publish_run_id; relying on this workflow's npm-release environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
publish_openclaw_npm:
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.

View File

@@ -207,6 +207,11 @@ jobs:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
@@ -222,12 +227,13 @@ jobs:
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
direct_recovery=true
echo "Direct Plugin ClawHub Release recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-release environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
preview_plugin_pack:
needs: preview_plugins_clawhub

View File

@@ -184,6 +184,11 @@ jobs:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
@@ -199,12 +204,13 @@ jobs:
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
exit 0
fi
direct_recovery=false
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
direct_recovery=true
echo "Direct Plugin NPM Release recovery with release_publish_run_id; relying on this workflow's npm-release environment approval."
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
preview_plugin_pack:
needs: preview_plugins_npm

View File

@@ -197,4 +197,4 @@ jobs:
- name: Testbox action marker
if: ${{ false }}
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
uses: useblacksmith/run-testbox@3f60ff9ceb2c10c3feefa87dc0c6490cffae059d

View File

@@ -78,7 +78,7 @@ Skills own workflows; root owns hard policy and routing.
- Gateway/plugin metadata is process-stable: installs, manifests, catalogs, generated paths, bundled metadata. Changes require restart or explicit owner reload/install/doctor flow.
- Runtime hot paths: no freshness polling (`stat`/`realpath`/JSON reread/hash). Reuse current snapshots, install records, discovery, lookup tables, root scopes, resolved paths.
- Process-local metadata caches ok when lifecycle-owned and bounded/single-slot. Freshness exceptions need named owner + tests.
- Inline comments: preserve reviewer context at the code site. Use for cross-path/state invariants, platform/dependency caps, deterministic ordering, compact encoded state, lifecycle ordering, ownership boundaries, session/id adoption, queue-depth symmetry, fallbacks, or intentional caller differences.
- Inline comments: preserve reviewer context at the code site. Required for non-obvious cross-path/state invariants, lifecycle ordering, ownership boundaries, queue/dedupe symmetry, TTL/cache expiry, cleanup/release coupling, session/id adoption, fallback behavior, platform/dependency caps, deterministic ordering, compact encoded state, or intentional caller differences.
- Comment shape: 1-3 short lines; state why the branch/helper exists, what contract it protects, and the bad outcome if removed. Cite nearby constants/helpers when useful. No syntax narration, PR/user-specific lore, or obvious mechanics.
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.

View File

@@ -15,10 +15,16 @@ Docs: https://docs.openclaw.ai
### Changes
- Skills: let the `skill_research` agent tool apply, reject, and quarantine explicit Skill Workshop proposals through the guarded proposal lifecycle. Thanks @shakkernerd.
- Skills: let Skill Workshop proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
- Skills: let pending Skill Workshop proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
- Skills: add Skill Workshop proposals with pending `PROPOSAL.md` drafts, CLI/Gateway review actions, rollback metadata, and the `skill_research` agent tool. Thanks @shakkernerd.
- Plugins: externalize Tokenjuice as the official `@openclaw/tokenjuice` plugin with npm and ClawHub publish metadata.
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
@@ -26,16 +32,21 @@ Docs: https://docs.openclaw.ai
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
- Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
@@ -44,17 +55,24 @@ Docs: https://docs.openclaw.ai
- CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, and single-entry store writes.
- CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.
- Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.
- Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.
- Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.
- Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.
## 2026.5.28
### Highlights
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375)
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334)
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682)
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, OAuth and local service startup requests are bounded, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361)
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, and viewer assets. (#86699)
- Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)
- Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)
- Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman and @BunsDev.
- Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)
- Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)
- CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy `api_key` auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.
- Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)
- Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.
### Changes
@@ -62,25 +80,41 @@ Docs: https://docs.openclaw.ai
- Status: show active subagent details in status output.
- Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.
- ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.
- iOS: refresh the dev app with Pro Command, Chat, Agents, and Settings tabs wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367) Thanks @Solvely-Colin.
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, and backport targets. (#87313, #63050) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
- PDF/tools: use ClawPDF for PDF extraction and surface MCP structured content in agent tool results. (#87670)
- iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
- PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)
- Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.
- Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.
- Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.
- Workboard: add agent coordination tools for tracking and handing off active agent work.
- Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)
- Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @piersonr and @RomneyDa.
- Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)
### Fixes
- Agents: fall back to local config pruning when the optional `agents delete` Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded/state-DB path, bound stored history scans, and close WebSocket probes cleanly.
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, and sandbox stat fields.
- Providers/agents: preserve seeded Anthropic signatures, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, and recover empty preflight compaction. (#87593)
- Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, @benjamin1492, @c19354837, @fuller-stack-dev, @pfrederiksen, and @dodge1218.
- Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @jarvis-mns1, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @lidge-jun, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.
- Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 `no_proxy` entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.
- Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.
- Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887) Thanks @chen-zhang-cs-code.
- Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)
- WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492) Thanks @lidge-jun.
- Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.
- Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)
- Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611) Thanks @ferminquant.
- Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek `reasoning_content` replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @Pluviobyte and @eleqtrizit.
- Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.
- Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)
## 2026.5.27

View File

@@ -2,6 +2,70 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.5.28</title>
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052890</sparkle:version>
<sparkle:shortVersionString>2026.5.28</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.28</h2>
<h3>Highlights</h3>
<ul>
<li>Agent and Codex runtime recovery is steadier: subagents keep cwd/workspace separation, hook context stays prompt-local, session locks release on timeout abort while live OpenClaw locks survive cleanup, stale restart continuations are avoided, and Codex app-server/helper failures no longer tear down shared runtime state. (#87218, #86875, #87409, #87399, #87375, #88129)</li>
<li>Channel delivery and session identity got safer across outbound plugin hooks, Matrix room ids, iMessage reactions/approvals, Slack final replies, Discord recovered tool warnings, runtime-config message actions, WhatsApp profile auth roots, Telegram polling, and Microsoft Teams service URL trust checks. (#73706, #75670, #87366, #87451, #87334, #84535, #82492, #83304, #87160)</li>
<li>Mobile and chat surfaces got a broader refresh: the iOS Pro UI, hosted push relay default, realtime Talk tab playback, Gateway chat transport, onboarding, Talk permissions, WebChat reconnect delivery, and session picker behavior now preserve more state across reconnects and empty searches. (#87367, #87531, #87682, #88096, #88105) Thanks @ngutman.</li>
<li>Browser, channel, and automation inputs are stricter: Browser tool timeouts, viewport/tab indices, Gateway ports, cron retry handling, Discord component ids, schema array refs, Telegram callback pages, and channel progress callbacks now reject malformed values earlier and preserve the intended delivery context. (#82887)</li>
<li>Provider, media, and document coverage expands with Claude Opus 4.8, Fal Krea image schemas, NVIDIA featured models, MiniMax streaming music responses, encrypted PDF extraction, voice model catalogs, GitHub Copilot agent runtime support, and a Codex Supervisor plugin path for delegated Codex workflows. (#87845, #87890, #80775, #84764, #87751, #87794)</li>
<li>CLI, auth, doctor, and provider paths fail faster and recover more clearly: malformed numeric/version options are rejected, workspace dotenv provider credentials are ignored, heartbeat defaults, OAuth/token lifetimes, and local service startup requests are bounded, agent auth health labels are clearer, legacy <code>api_key</code> auth profiles migrate to canonical form, and restart guidance is actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #88088, #85924) Thanks @vincentkoc and @giodl73-repo.</li>
<li>Plugin and Gateway hot paths do less repeated work while preserving cache correctness for install records, config JSON parsing, tool search catalogs, session stores, manifest model rows, auto-enabled plugin config, browser tokens, viewer assets, and release-split external plugin packages. (#86699)</li>
<li>Release, QA, and E2E validation now bound more log, artifact, harness, and cross-OS waits so failing lanes produce proof instead of hanging or false-greening.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Status: show active subagent details in status output.</li>
<li>Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.</li>
<li>ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.</li>
<li>iOS: refresh the dev app with Pro Command, Chat, Agents, Settings, hosted push relay defaults, and realtime Talk playback wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367, #88096, #88105) Thanks @Solvely-Colin and @ngutman.</li>
<li>Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, CLI setup flow compatibility, Notte cloud browser CDP setup, and backport targets. (#87313, #63050, #87685) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.</li>
<li>PDF/tools: use ClawPDF for PDF extraction, support encrypted PDF extraction, and surface MCP structured content in agent tool results. (#87670, #87751)</li>
<li>Providers: add Claude Opus 4.8 support, Fal Krea image model schemas, NVIDIA featured model catalogs, MiniMax streaming music responses, and provider-backed voice model catalogs. (#87845, #87890, #80775, #84764, #87794) Thanks @eleqtrizit and @vincentkoc.</li>
<li>Codex/GitHub: add the GitHub Copilot agent runtime and the Codex Supervisor plugin package.</li>
<li>Plugins: externalize GitHub Copilot and Tokenjuice as official install-on-demand plugins with npm and ClawHub publish metadata.</li>
<li>Workboard: add agent coordination tools for tracking and handing off active agent work.</li>
<li>Discord: show commentary in progress drafts so live Discord runs expose useful in-progress context. (#85200)</li>
<li>Plugin SDK: add a reply payload sending hook for plugins that need to deliver channel-owned replies and flatten package types for SDK declarations. (#82823, #87165) Thanks @RomneyDa.</li>
<li>Policy: add policy comparison, ingress-channel conformance, and sandbox-posture conformance checks. (#85572, #85744, #86768)</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Agents: fall back to local config pruning when the optional <code>agents delete</code> Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.</li>
<li>Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.</li>
<li>Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.</li>
<li>Agents/Codex: keep spawned agent cwd/workspace state separated, forward ACP spawn attachments, keep hook context prompt-local, release session locks on timeout abort and runtime teardown without deleting live OpenClaw-owned locks during cleanup, avoid session event queue self-wait, clean up exec abort listeners, stream assistant deltas incrementally, recover raw missing-thread compaction failures, preserve rotated compaction session identity, keep compaction-timeout snapshots continuable, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts and prune stale bridge files, close native hook relay replacement races, keep Claude live tool progress visible for watchdog recovery, suppress abandoned requester completion handoff, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format <code>skills</code> command output, bind node auto-review to prepared plans, retry Claude CLI transcript probes, and bound compaction/steering retries. (#87218, #86875, #86123, #88129, #87399, #87375, #72574, #87383, #87400, #83022, #87671, #87738, #87747, #87706, #87546, #87541, #81048) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, @sjf, @joshavant, and @benjamin1492.</li>
<li>Codex Supervisor: keep real-home app-server MCP session listing on the loaded state path, bound stored history scans, and close WebSocket probes cleanly.</li>
<li>Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, resolve Gateway message actions against the active runtime config, preserve Telegram SecretRef prompt config and polling keepalives, preserve WhatsApp profile auth roots, QR display, document filenames, and plugin hook config, suppress Discord recovered tool warnings, preserve the Discord voice outbound helper, cap Discord/Signal/Zalo channel request and container timeouts, and block untrusted Teams service URLs while keeping TeamsSDK patterns aligned. (#73706, #75670, #87366, #87451, #87465, #87334, #84535, #76262, #83304, #82492, #87581, #77114, #86426, #85529, #87160) Thanks @zeroaltitude, @lukeboyett, @xiaotian, @funmerlin, @joshavant, @eleqtrizit, @heyitsaamir, @amittell, @liorb-mountapps, @masatohoshino, @bladin, and @giodl73-repo.</li>
<li>CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, ignore workspace dotenv provider credentials, wait for respawn child shutdown, bound heartbeat defaults plus Codex, GitHub Copilot, OpenAI, Anthropic, Google, Feishu, LM Studio, MiniMax, Xiaomi TTS, and local-provider OAuth/token/model requests, harden Codex auth probes, label auth health by agent, preserve explicit agentRuntime pins during Codex model migration, warm provider auth off the main thread, honor Codex response timeouts, stop migrating current Claude Haiku 4.5 profiles to Sonnet, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical <code>api_key</code> auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361, #88133, #83655, #87559, #87719, #88088, #85924, #84362) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, @alkor2000, @mmaps, @nxmxbbd, and @vincentkoc.</li>
<li>Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks and stale rate-limit cooldown probes, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, clear completed session active runs, clear stale chat stream buffers, and evict current plugin-state namespaces at row caps. (#87810, #87833, #75089) Thanks @joshavant and @litang9.</li>
<li>Config/parsing/network: reject partial numeric parsing, parse provider/Discord retry headers and dates strictly, honor IPv6 and bare IPv6 <code>no_proxy</code> entries, preserve empty plugin allowlists, canonicalize secret target array indexes, and reject malformed media content lengths, inspected TCP ports, marketplace content lengths, cron epochs, sandbox stat fields, unsafe duration values, empty config path segments, noncanonical schema array refs, unsafe Telegram callback pages, and invalid Teams attachment-fetch DNS targets. (#87883) Thanks @zhangguiping-xydt.</li>
<li>Browser/input hardening: reject invalid tab indexes, excessive viewport resizes, explicit zero CDP ports, malformed geolocation options, unsafe screenshot or permission-grant timeouts, loose response-body limits, invalid cookie expiries, and non-finite Browser tool delays/timeouts.</li>
<li>Cron/automation: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot, and preflight model fallbacks before skipping scheduled work. (#82887)</li>
<li>Auto-reply/directives: respect provider and relayed channel metadata during directive persistence so channel-originated decisions keep their intended context. (#87683)</li>
<li>WhatsApp: resolve the auth directory from the active profile so profile-scoped WhatsApp installs do not drift to the wrong credential root. (#82492)</li>
<li>Gateway/session state: clear completed session active runs, avoid cold-loading providers for MCP inventory, cache single-session child indexes, cap handshake timers, and bound preauth, auth-guard, media, transcript, readiness, and port options.</li>
<li>Channels/replies: preserve channel-owned progress callbacks when verbose output is off, keep group-room progress suppression intact, prefer external session delivery context, escape Discord component id delimiters, force final TUI chat repaints, show Slack reasoning previews, and normalize Discord/Matrix/Mattermost channel numeric options. (#87476, #87423)</li>
<li>Agents/tool args: harden smart-quoted argument repair for edit arrays and exact escaped arguments so model-produced tool calls recover without corrupting valid input. (#86611)</li>
<li>Providers/agents: preserve seeded Anthropic signatures, preserve signed thinking payloads, concatenate signature-delta chunks, preserve DeepSeek <code>reasoning_content</code> replay across tier suffixes, apply OpenRouter strict9 ids to Mistral routes, promote Ollama plain-text tool calls, load NVIDIA featured model catalogs, stream MiniMax music generation responses, and recover empty preflight compaction. (#87593, #87493, #80775, #84764) Thanks @eleqtrizit.</li>
<li>Media/images: skip CLI image cache refs when resolving generated images, allow trusted generated HTML attachments, and bound generated video downloads so stale refs and slow providers fail cleanly. (#87523, #87982)</li>
<li>File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.</li>
<li>Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, reuse gateway session and plugin metadata paths, skip unchanged store serialization, patch single-entry session writes, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, avoid full session snapshots for entry reads, defer configured Slack full startup, prefer bundled plugin dist entries, and slim current metadata identity caches. (#87760)</li>
<li>Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, isolate npm plugin installs per package, reject incompatible package plugin API installs, drop the leftover root Sharp dependency from package manifests after the Rastermill migration, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, QA-Lab credential broker calls, QA Matrix substrate requests, and release scenario logs, and keep release/google live guards current. (#87647, #87477) Thanks @rohitjavvadi and @vincentkoc.</li>
<li>Release/CI: bound manual git fetches, ClawHub verifier responses, ClawHub owner metadata, dependency-guard error bodies, Parallels limits, startup/test/memory budget parsing, and diffs viewer build warnings so release lanes fail with useful proof instead of hanging. (#87839)</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.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
</item>
<item>
<title>2026.5.27</title>
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
@@ -258,284 +322,5 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.26/OpenClaw-2026.5.26.zip" length="54484748" type="application/octet-stream" sparkle:edSignature="y4WXG7JT8ktJ+K7YDgllY7u5Z9BSKR/SwGiwEh0gikOJ/SWqwcQd+z2tWa2zgwvCJKWsAUFwJs1ATor880SUBg=="/>
</item>
<item>
<title>2026.5.22</title>
<pubDate>Sun, 24 May 2026 01:41:27 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026052290</sparkle:version>
<sparkle:shortVersionString>2026.5.22</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.5.22</h2>
<h3>Changes</h3>
<ul>
<li>Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.</li>
<li>Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.</li>
<li>Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.</li>
<li>Gateway/perf: cache plugin SDK public-surface alias maps and skip irrelevant macOS Linuxbrew PATH probes so Gateway startup avoids repeated filesystem walks and slow missing-directory stats.</li>
<li>Meeting Notes: add a source-only external meeting-notes plugin and SDK source-provider contract outside the core npm package, with auto-start capture config, manual transcript imports, read-only <code>openclaw meeting-notes</code> CLI access, and Discord voice as the first live source.</li>
<li>Docs/channels/config: add Signal <code>configPath</code>, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.</li>
<li>Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.</li>
<li>Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.</li>
<li>Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.</li>
<li>Docs: clarify browser CDP diagnostics, Plugin SDK allowlist imports, status-reaction timing defaults, queue steering behavior, limited-tool troubleshooting, cron HEARTBEAT handling, Telegram multi-agent groups, Bitwarden SecretRef setup, and EasyRunner deployments. Thanks @Quratulain-bilal, @mbelinky, @Mickey-, @vancece, @xenouzik, @posigit, @surlymochan, @janaka, and @choiking.</li>
<li>Crabbox/Testbox: run clean sparse-checkout Testbox syncs from a temporary full checkout and route remote changed gates through Corepack pnpm.</li>
<li>Docs: clarify IPv4-only Gateway BYOH binding, trusted-proxy scope clearing, Android pairing approval, macOS Accessibility grants, Zalo profile env vars, password-store SecretRef setup, and Chinese memory navigation. Thanks @itskai-dev, @gwh7078, @longstoryscott, @MoeJaberr, and @yuaiccc.</li>
<li>Docs: consolidate GLM under Z.AI, add the Upstash Box install guide and Gateway exposure runbook, clarify MEDIA directives, Copilot and Voyage setup, config path quoting, real behavior proof, and memory-file write guidance. Thanks @BobDu, @alitariksahin, @Jefsky, @musaabhasan, @OmerZeyveli, @leno23, @WuKongAI-CMU, @luoyanglang, and @majin1102.</li>
<li>Docs: clarify media provider credentials, Codex/OpenClaw code-mode boundaries, Slack and Telegram ack reactions, Feishu dynamic agents, secrets plaintext boundaries, memory guidance, and Chinese glossary terms. Thanks @nielskaspers, @cosmopolitan033, @drclaw-iq, @alexgduarte, @zccyman, @chengoak, and @cassthebandit.</li>
<li>Packaging: exclude documentation images and assets from the npm tarball, reducing published package size without affecting runtime docs search or CLI behavior. Thanks @SebTardif.</li>
<li>Media understanding: stop auto-probing Gemini CLI and use Antigravity CLI only as a lower-priority image/video fallback after configured provider APIs.</li>
<li>Agents/subagents: limit default sub-agent bootstrap context to <code>AGENTS.md</code> and <code>TOOLS.md</code>, keeping persona, identity, user, memory, heartbeat, and setup files out of delegated workers by default. (#85283) Thanks @100yenadmin.</li>
<li>Maintainer skills: exclude plugin SDK/API boundary work from <code>openclaw-landable-bug-sweep</code> so bugbash sweeps stay focused on small paper-cut fixes.</li>
<li>QA-Lab/diagnostics: extend the OpenTelemetry smoke harness to prove trace, metric, and log export, and add first-class Prometheus and observability smoke aliases.</li>
<li>Plugin SDK: add a generic channel-message poll sender so channel plugins can expose poll delivery without depending on channel-specific SDK facades.</li>
<li>Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as <code>docker</code> and <code>blacksmith</code>. (#85302) Thanks @hxy91819.</li>
<li>Maintainer skills: add <code>openclaw-landable-bug-sweep</code> for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.</li>
<li>Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.</li>
<li>CLI/onboarding: start classic onboarding when bare <code>openclaw</code> runs before an authored config exists, while keeping configured installs on Crestodian. (#72343) Thanks @fuller-stack-dev.</li>
<li>Discord: allow configuring a bounded <code>agentComponents.ttlMs</code> callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.</li>
<li>xAI/Grok: reuse xAI OAuth auth profiles for Grok <code>web_search</code>, thread active-agent auth through web search, add Grok model aliases, and let media providers declare default operation timeouts. (#85182) Thanks @fuller-stack-dev.</li>
<li>Plugin SDK: add row-level session workflow helpers and deprecate <code>loadSessionStore</code> so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.</li>
<li>Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.</li>
<li>Plugins/SDK: add a general <code>embeddingProviders</code> capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.</li>
<li>Dependencies: refresh provider, plugin, UI, and tooling packages, update <code>protobufjs</code> to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to <code>@agentclientprotocol/claude-agent-acp</code> 0.36.1.</li>
<li>Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.</li>
<li>QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.</li>
<li>QA-Lab: add a QA bus tool-trace visibility scenario for sanitized tool-call assertions.</li>
<li>QA-Lab: replace generic evidence framing in seeded scenario prompts with concrete observed QA behavior.</li>
<li>QA-Lab: list named scenario packs in the coverage report so personal-agent privacy coverage stays visible in audits.</li>
<li>QA-Lab: list live transport lane membership in the coverage report so real transport checks stay separate from seeded qa-channel scenarios.</li>
<li>Release/package: run package integrity checks before package acceptance lanes so public install/update validation fails before private QA assets can leak into the package.</li>
<li>QA-Lab: include the optional 100-turn runtime parity soak in release-soak artifacts so long-run Codex/Pi transcript drift stays visible outside the default gate. (#80395) Thanks @100yenadmin.</li>
<li>QA-Lab: add a live-only long-context progress watchdog scenario for Codex app-server timeout and stalled-run sentinels. (#80323) Thanks @100yenadmin.</li>
<li>QA-Lab: tag gateway restart recovery and streaming final-integrity scenarios as live-only runtime parity lanes. (#80323) Thanks @100yenadmin.</li>
<li>QA-Lab: add a personal-agent failure recovery scenario that checks honest partial status, retry boundaries, and local recovery artifacts. (#83872) Thanks @iFiras-Max1.</li>
<li>QA-Lab: include an opt-in <code>update.run</code> package self-upgrade sentinel for destructive latest-package recovery checks.</li>
<li>QA-Lab: add Codex plugin lifecycle and auth-profile fixture coverage for missing installs, pinned-version drift, first-turn install ordering, and doctor migration safety. (#80323, refs #80174) Thanks @100yenadmin.</li>
<li>Models/perf: pre-warm the provider auth-state map at gateway startup so <code>/models</code> and every model-listing call short-circuits the per-provider plugin / external-CLI discovery on the hot path. Per-call cost drops from ~20 s to ~5 ms (~4,100×); the one-time startup warm resets and re-warms after hot reloads. (#84816) Thanks @sjf.</li>
<li>Release/security: ship the root npm package and OpenClaw-owned npm plugins with generated shrinkwrap, support bundled plugin runtime dependencies for suitable plugin tarballs, and require review for lockfile/shrinkwrap changes so published installs use locked dependency graphs.</li>
<li>Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so <code>doctor-core-checks</code> no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>WebChat: summarize internal message-tool source replies so tool cards no longer duplicate the visible reply body. (#84773) Thanks @jason-allen-oneal.</li>
<li>Gateway: preserve deferred lifecycle-error cleanup across later non-terminal events so provider timeouts can persist failed session state instead of leaving sessions stuck running. (#85256, fixes #63819) Thanks @samzong.</li>
<li>Agents/subagents: report tool-only child progress during timeout summaries instead of showing no visible output.</li>
<li>Telegram/ACP: preserve explicit <code>:topic:</code> conversation suffixes when inbound ACP targets do not carry a separate thread id.</li>
<li>Browser/proxy: bypass the managed proxy for the exact local managed Chrome CDP readiness and DevTools WebSocket endpoints, so <code>openclaw browser start</code> works when the operator proxy blocks loopback egress. (#83255) Thanks @lightcap.</li>
<li>Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.</li>
<li>OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.</li>
<li>Agents/OpenAI completions: omit empty tool payload fields for proxy-like OpenAI-compatible endpoints so strict vLLM-style servers accept tool-free turns. (#85835) Thanks @rendrag-git.</li>
<li>Checks/Windows: route full <code>pnpm check</code> stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.</li>
<li>Checks/Windows: run managed child commands through explicit <code>cmd.exe</code> wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.</li>
<li>Gateway: omit internal stream-error placeholder entries from agent prompt history so failed assistant turns are not replayed as model-authored text. (#85652) Thanks @anyech.</li>
<li>Sessions: enforce the session write-lock max-hold policy during lock acquisition so long-held locks can be reclaimed before the stale-lock window. (#85764) Thanks @njuboy11.</li>
<li>Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.</li>
<li>Doctor/update: recognize junction-backed source checkouts as git installs by comparing canonical paths before showing package-manager update guidance. Fixes #82215. Thanks @igormf.</li>
<li>Channels: honor <code>/verbose on</code> for tool/progress summaries across direct chats, groups, channels, and forum topics while preserving quiet default behavior. (#85488) Thanks @kurplunkin.</li>
<li>CLI/skills: show an all-ready note with next-step commands when skill setup has no missing dependencies to install. (#85032) Thanks @aniruddhaadak80.</li>
<li>Microsoft Foundry: route DeepSeek V4 Pro and Flash models through the Foundry Responses API while keeping older DeepSeek models on their existing path. (#85549) Thanks @roslinmahmud.</li>
<li>Status/usage: show configured cost estimates for AWS SDK models in full usage output while keeping token-only usage replies cost-free. (#85619) Thanks @ItsOtherMauridian.</li>
<li>Agents/OpenAI Responses: retry non-visible reasoning-only turns for OpenAI Responses API families instead of treating them as empty failed turns. (#85603) Thanks @SebTardif.</li>
<li>Directive tags: preserve message and content-part object identity when display stripping makes no directive-tag changes. (#85682) Thanks @willamhou.</li>
<li>Telegram: send local <code>path</code>/<code>filePath</code> and structured attachment media from <code>sendMessage</code> actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.</li>
<li>Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.</li>
<li>Gateway/config: pin relative <code>OPENCLAW_STATE_DIR</code> overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.</li>
<li>Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute <code>npm.cmd</code> instead of treating it as a binary.</li>
<li>Agents/harness: pass CLI runtime aliases through harness selection so provider-owned CLI aliases no longer get rejected before reaching the right runtime. (#85631) Thanks @potterdigital.</li>
<li>Secrets: show the irreversible apply warning after interactive <code>secrets configure</code> confirmation so confirmed migrations still get the final safety prompt. (#85638) Thanks @alkor2000.</li>
<li>Agents/CLI output: ignore cumulative Claude <code>stream-json</code> result usage when assistant usage events are present, preventing inflated cache-read accounting. (#85625) Thanks @zhouhe-xydt.</li>
<li>CLI: keep <code>waitForever()</code> alive by leaving its keep-alive interval ref'd so the public helper no longer exits immediately with Node's unsettled-await code. (#85694) Thanks @m1qaweb.</li>
<li>Agents/bootstrap: guard bootstrap name checks against missing file names so malformed bootstrap entries warn and truncate instead of crashing. Fixes #85523. (#85615) Thanks @zhouhe-xydt.</li>
<li>CLI/tasks: reject partially numeric <code>openclaw tasks audit --limit</code> values so audit limits must be real positive integers instead of accepting strings like <code>5abc</code>. (#84901) Thanks @jbetala7.</li>
<li>Status/diagnostics: bound deep Docker audit probes so <code>openclaw status --deep</code> reports slow container checks instead of hanging behind unbounded inspection. (#85476) Thanks @giodl73-repo.</li>
<li>Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired <code>context-1m-2025-08-07</code> beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.</li>
<li>Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including <code>:topic:</code> and <code>:topicId</code> forms for announce delivery. Thanks @etticat.</li>
<li>Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.</li>
<li>Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.</li>
<li>Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.</li>
<li>Agents/subagents: simplify native sub-agent completion handoff so children report their latest visible assistant result to the requester without using <code>message</code>, while keeping parent-owned message-tool delivery policy intact. Fixes #85070. (#85089) Thanks @brokemac79.</li>
<li>Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.</li>
<li>Agents: let embedded compaction fallback retries proceed when PI-compatible candidates do not need agent harness plugin preparation.</li>
<li>Agents/tools: honor configured custom provider API keys when deciding whether media, image-generation, video-generation, music-generation, and PDF tools are available. (#85570)</li>
<li>StepFun: stop advertising stale generic API key auth choices so onboarding only offers runtime-backed Standard and Step Plan choices.</li>
<li>Diagnostics: keep OpenTelemetry log bodies behind explicit content capture and scrub scoped agent-session keys from OpenTelemetry and Prometheus labels while preserving bounded queue-lane prefixes.</li>
<li>Windows installer: fail Git checkout installs when <code>pnpm install</code> or <code>pnpm build</code> fails instead of writing a wrapper to a missing CLI build.</li>
<li>Sessions: surface previous-transcript archive failures during <code>/new</code> rotation so disk rename errors are logged instead of silently hiding stranded transcript files. Fixes #81984. (#85586, from #82081) Thanks @0xghost42.</li>
<li>TUI/agents: mirror internal-ui message-tool replies into final chat output so message-tool-only agents remain visible in <code>openclaw tui</code>. Fixes #85538. Thanks @danpolasek.</li>
<li>Agents: keep parallel OpenAI-compatible tool-call deltas in separate argument buffers so interleaved tool calls no longer corrupt streamed arguments. (#82263) Thanks @luna-system.</li>
<li>Memory/doctor: report missing or unusable QMD workspace directories as workspace failures instead of generic binary failures. (#63167) Thanks @sercada.</li>
<li>Debug proxy: record CONNECT client-socket errors and destroy the paired upstream socket so abrupt client disconnects no longer leak tunnel resources. (#82444) Thanks @SebTardif.</li>
<li>Diffs: continue hydrating later diff cards when one card fails so a single broken card no longer blanks the whole diff viewer. (#84775) Thanks @cosmopolitan033.</li>
<li>Mac app: use the native settings sidebar window chrome so the sidebar toggle stays on the left and content no longer clips under oversized titlebar padding.</li>
<li>QA-Lab/Codex: bundle auth/plugin fixture imports for flow scenarios and let terminal async media tools end Codex app-server turns without timing out. (#80397, refs #80323) Thanks @100yenadmin.</li>
<li>Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.</li>
<li>Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.</li>
<li>Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.</li>
<li>Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)</li>
<li>Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.</li>
<li>OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.</li>
<li>OpenAI video: send uploaded video edit requests to the documented <code>/videos/edits</code> endpoint with a <code>video</code> file instead of posting MP4 references to <code>/videos</code>. Thanks @shakkernerd.</li>
<li>Agents/channels: preserve message-tool delivery evidence through gateway agent completion handoffs so successful generated media sends are not followed by false failure messages. Thanks @shakkernerd.</li>
<li>CLI/update: repair managed npm plugin <code>openclaw</code> peer links during post-core convergence and reject stale or wrong-target peer links before restart. (#83794) Thanks @fuller-stack-dev.</li>
<li>CLI/agents: default new omitted-account bindings to all accounts when the channel has multiple configured accounts, and clarify account-scope docs. (#49769) Thanks @Gcaufy.</li>
<li>Codex app-server: let authorized <code>/codex</code> control commands such as <code>/codex detach</code> escape plugin-owned conversation bindings while keeping unknown or unauthorized slash text routed to the bound plugin. Fixes #85157. (#85188) Thanks @TurboTheTurtle.</li>
<li>Auto-reply/models: keep <code>/models</code> browse replies fast by sharing the bounded read-only catalog path with Gateway model listing. (#84735) Thanks @safrano9999.</li>
<li>Codex app-server: disable native Code Mode when the effective exec host is <code>node</code> and keep OpenClaw <code>exec</code>/<code>process</code> available, so <code>/exec host=node</code> routes shell commands through the selected node instead of the gateway. Fixes #85012. (#85090) Thanks @sahilsatralkar.</li>
<li>Agents: bound embedded auto-compaction session write-lock watchdogs to the compaction timeout instead of the full run timeout, so stuck compaction cannot hold the live session lock for the whole run window. (#84949) Thanks @luoyanglang.</li>
<li>Gateway/agents: return phase-aware <code>agent.wait</code> timeout attribution and only cool auth profiles on provider-started timeouts. Refs #65504. Thanks @100yenadmin.</li>
<li>Gateway: defer provider auth-state prewarm until after startup readiness so early gateway tool/session requests are not blocked by provider auth discovery. (#85272) Thanks @dutifulbob.</li>
<li>Gateway/models: coalesce provider auth-state rewarms after auth-profile failures and log event-loop delay for warm/rewarm work, so provider auth bursts no longer stack full auth sweeps behind channel replies.</li>
<li>Gateway/models: stop cancelled provider auth-state prewarms from continuing full provider sweeps, so reload and auth-failure bursts no longer keep startup busy.</li>
<li>Agents/Codex: show the first plan update as a transient chat status notice without counting it as final assistant content.</li>
<li>CLI/update: walk the macOS process ancestry and honor the inherited Gateway runtime PID before package updates stop the managed Gateway service, so nested in-band updater children can refuse instead of killing the LaunchAgent-supervised Gateway that owns them. Fixes #85120.</li>
<li>Gateway/LaunchAgent: wait for launchd reload bootout to finish and fall back to kickstart when bootstrap races, so reload handoff does not leave the service deregistered. Fixes #84630. (#84641) Thanks @NianJiuZst.</li>
<li>Gateway/LaunchAgent: treat a concurrent launchd bootstrap as a successful restart when the service is already loaded, avoiding false macOS Gateway restart failures. Fixes #84721. (#84722) Thanks @googlerest.</li>
<li>Gateway/service: include the active <code>openclaw</code> command bin directory in managed service PATH generation and doctor audit expectations for npm-global macOS installs. Fixes #84201. (#84475) Thanks @jbetala7.</li>
<li>Control UI/chat: disable the thinking selector for known non-reasoning models instead of showing duplicate Off choices. Fixes #84069. Thanks @DrippingMellow.</li>
<li>Memory: expand <code>~</code> in configured extra memory paths before resolving them, so home-relative folders are not treated as workspace-relative. Fixes #58026. Thanks @stadman.</li>
<li>Skills: treat <code>openclaw.os: macos</code> as Darwin when checking skill requirements, so macOS-only skills no longer report as missing on macOS hosts. Fixes #61338. Thanks @Jessecq1995.</li>
<li>Control UI/logs: strip ANSI escape sequences from displayed Gateway log messages so color codes no longer appear as raw text. Fixes #64399. Thanks @guguangxin-eng.</li>
<li>Docker: pre-create the workspace and auth-profile config mount points with <code>node</code> ownership so first-run named volumes do not start root-owned. Fixes #85076. Thanks @Noerr.</li>
<li>Telegram: pass configured markdown table mode through outbound markdown chunking so chunked sends render tables consistently. Fixes #85085. Thanks @ShuaiHui.</li>
<li>CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)</li>
<li>Agents/providers: honor per-model <code>api</code> and <code>baseUrl</code> overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.</li>
<li>Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.</li>
<li>CLI/update: start managed Gateway update handoff helpers from a stable existing directory and tolerate deleted cwd/package roots during macOS LaunchAgent handoff. Fixes #83808. (#83875) Thanks @jason-allen-oneal.</li>
<li>Skills: watch each shared skill directory once across agent workspaces instead of once per agent, preventing file-descriptor exhaustion (<code>EMFILE</code>) that disposed bundle-mcp processes and stalled sessions on multi-agent gateways. Fixes #84968. (#85130) Thanks @openperf.</li>
<li>Release/security: keep generated npm shrinkwrap package versions inside the pnpm lock graph so published package locks cannot bypass pnpm dependency age and override policy.</li>
<li>Cron: honor <code>cron.retry.retryOn: ["network"]</code> for common network error codes such as <code>EAI_AGAIN</code>, <code>EHOSTUNREACH</code>, and <code>ENETUNREACH</code>.</li>
<li>Gateway chat: broadcast returned agent-run error payloads after an agent starts so ACP/WebChat clients receive terminal idle-timeout errors. Fixes #84945.</li>
<li>Gateway chat display: preserve OpenAI-compatible <code>prompt_tokens</code>, <code>completion_tokens</code>, and <code>total_tokens</code> usage fields in sanitized chat history so llama.cpp sessions keep context counts. Fixes #77992. Thanks @MarTT79.</li>
<li>Dashboard/CLI: allow macOS browser launching through <code>open</code> even when SSH environment variables are present, while preserving Linux SSH no-display protection. Fixes #67088. Thanks @theglove44.</li>
<li>Codex app-server: keep native web search observations out of mirrored chat transcripts while preserving tool progress telemetry. Fixes #85109. Thanks @ugitmebaby.</li>
<li>OpenCode Go: strip unsupported Kimi reasoning replay fields before provider requests so repeated <code>kimi-k2.6</code> turns do not fail schema validation. Fixes #83812. Thanks @Sleeck.</li>
<li>Browser/CDP: add a WSL2 portproxy self-loop hint when Chrome DevTools endpoints accept connections but return an empty HTTP reply. Fixes #59209. Thanks @Owlock.</li>
<li>Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.</li>
<li>Doctor/Codex: point native Codex asset warnings at the canonical <code>openclaw migrate plan codex</code> preview command. Fixes #84948. Thanks @markoa.</li>
<li>CLI/models: make <code>capability model auth logout --agent</code> remove auth profiles from the selected non-default agent store. Fixes #85092. Thanks @islandpreneur007.</li>
<li>Gateway/models: reuse prepared provider auth metadata during model-listing auth checks so repeated lookups avoid broad plugin discovery while preserving synthetic local auth.</li>
<li>CLI/status: suppress systemd user-service setup hints when <code>openclaw status --deep</code> can already reach a running Gateway RPC service. Fixes #85094. Thanks @islandpreneur007.</li>
<li>CLI/devices: recover local approval when a same-device repair request replaces the request ID being approved.</li>
<li>CLI/agents: retry transient normal-close Gateway handshakes before falling back to embedded <code>openclaw agent</code> execution.</li>
<li>CLI/update: keep managed Gateway service stop/restart status lines out of <code>openclaw update --json</code> stdout so package-update automation can parse the JSON payload.</li>
<li>Plugins: resolve OpenClaw plugin SDK subpaths for native external plugin runtimes without mutating package installs or broadening process-wide module resolution.</li>
<li>Agents/OpenAI: preserve Responses and Chat Completions <code>reasoning_tokens</code> usage metadata without double-counting it in aggregate output tokens. (#85319)</li>
<li>Control UI/chat: convert pasted <code>data:image/...;base64,...</code> clipboard text into an image attachment instead of dumping the payload into the composer. Fixes #62604. Thanks @cpwilhelmi.</li>
<li>Providers/Gemini: strip fractional seconds from web-search time range filters so Gemini accepts freshness-bound search requests. (#85071) Thanks @Noerr.</li>
<li>OpenAI Codex: preserve image input support for sparse <code>openai-codex/gpt-5.5</code> catalog rows. (#85095) Thanks @sercada.</li>
<li>CLI/models: add a piped or pasted API-key path for OpenAI Codex auth and warn when API keys are pasted into token-mode auth. (#85533) Thanks @joshavant.</li>
<li>Telegram: dead-letter missing-harness isolated ingress failures so a poisoned spooled update no longer blocks later same-lane messages. Fixes #85470. (#85605) Thanks @joshavant.</li>
<li>Plugins/discovery: strip <code>-plugin</code> package suffixes when deriving plugin id hints so package names line up with manifest ids. (#85170) Thanks @JulyanXu.</li>
<li>Tlon: stop advertising a non-existent agent tool contract in the plugin manifest.</li>
<li>Telegram: preserve fenced code block languages through Markdown rendering so Telegram receives <code>language-*</code> code classes. (#85209) Thanks @leno23.</li>
<li>Windows installer: run npm and Corepack command shims from a Windows-local directory so installs launched from WSL2 UNC paths do not fail before OpenClaw is installed.</li>
<li>Windows updates: roll back git-backed updates to the previous checkout when dependency install, build, UI build, or doctor repair fails.</li>
<li>Windows installer: persist user-local portable Git on PATH and activate the repo-pinned pnpm version for git-backed installs and updates.</li>
<li>Windows installer: bootstrap a user-local portable Node.js when native Windows has no Node and no winget, Chocolatey, or Scoop, so first-run installs can continue on raw hosts.</li>
<li>Windows installer: extract the downloaded portable Node.js directory with native <code>tar</code> before falling back to .NET zip extraction, avoiding PowerShell 5.1 archive and path-length failures.</li>
<li>fix(integrations): enforce channel read target allowlists [AI]. (#84982) Thanks @pgondhi987.</li>
<li>Agents/heartbeat: route single-owner <code>session.dmScope=main</code> direct-message exec and cron event wakes back to the agent main session so async completions no longer strand context in orphan direct-DM queues. Fixes #71581. (#83743) Thanks @Kaspre.</li>
<li>Agents/code-mode: expose outer code-mode <code>exec</code> source through the <code>command</code> hook alias with <code>toolKind</code>/<code>toolInputKind</code> discriminators so exec-shaped policies can distinguish code-mode cells. (#83483) Thanks @Kaspre.</li>
<li>Agents/code mode: return structured timeout and runtime-unavailable error codes for known worker failures. Fixes #83389. (#83444) Thanks @Kaspre.</li>
<li>QA-Lab: isolate multi-scenario suite workers when scenarios need startup config patches, preventing message-routing config from leaking into unrelated scenarios.</li>
<li>QA-Lab: make the commitments heartbeat-target-none scenario request an immediate heartbeat instead of waiting for the next scheduled heartbeat.</li>
<li>Codex/Plugin SDK: deliver Codex-native subagent completions through a generic harness task runtime so harness-backed plugins can mirror durable task lifecycle and completion delivery without Codex-specific SDK imports. (#83445) Thanks @bryanpearson.</li>
<li>Gateway CLI: surface local post-challenge connect assembly failures immediately instead of waiting for the wrapper timeout. Fixes #68944. (#85253) Thanks @samzong.</li>
<li>Messages: strip unsupported web-search citation control markers from outbound replies before they reach WebChat or external channels. Fixes #85193. (#85204) Thanks @neeravmakwana.</li>
<li>Agents/exec: treat denied exec approvals as terminal instead of feeding them back into agent follow-up work, and recognize Chinese stop phrases in abort handling. Fixes #69386. (#85194) Thanks @samzong.</li>
<li>CLI/agents: abort accepted Gateway-backed <code>openclaw agent</code> runs on SIGINT/SIGTERM so cron and supervisor timeouts do not leave remote agent work alive. Fixes #71710. (#84381) Thanks @Kaspre.</li>
<li>Codex app-server: retry replay-safe stdio client-close turns once using structured failure metadata, while surfacing idle <code>turn/completed</code> timeouts instead of blindly replaying active shared-server turns. Thanks @VACInc.</li>
<li>Codex app-server: reject command overrides that embed Node or package-manager arguments and point users to <code>appServer.args</code>, so Windows startup avoids shell parsing failures. (#84417) Thanks @TurboTheTurtle.</li>
<li>Agents/Copilot: drop unsafe GitHub Copilot Responses reasoning replay items before send so Telegram direct sessions no longer fail on overlong replay IDs. Fixes #85197. (#85198) Thanks @galiniliev.</li>
<li>UI: add accessible tooltips to the topbar color-mode buttons so System, Light, and Dark choices are labeled on hover and focus. (#85227) Thanks @amknight.</li>
<li>fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.</li>
<li>Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.</li>
<li>Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.</li>
<li>Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.</li>
<li>Agents/hooks: wait for local one-shot CLI and Codex <code>agent_end</code> plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)</li>
<li>Providers/Google: preserve Gemini 3 cron <code>thinkingDefault: "low"</code> when stale catalog metadata says <code>reasoning:false</code>, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.</li>
<li>CLI/agents: allow <code>openclaw agent --session-key</code> to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.</li>
<li>Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.</li>
<li>Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.</li>
<li>Channels: treat bare abort messages such as <code>stop</code>, <code>abort</code>, and <code>wait</code> as immediate control commands in inbound debounce paths so stop requests are not delayed behind pending message coalescing. (#83348) Thanks @IWhatsskill.</li>
<li>Channels/message tool: resolve configured external channel plugins during in-agent channel selection, so <code>openclaw agent --local</code> message-tool sends no longer report an available channel as unavailable. (#85022) Thanks @Kaspre.</li>
<li>Agents/heartbeat: honor group/channel <code>message_tool</code> visible-reply policy and model-specific Codex runtime config for scheduled heartbeat runs, so failed internal tool output stays private. Fixes #85310. (#85357) Thanks @neeravmakwana.</li>
<li>Gateway/ACP: close child ACP sessions spawned via <code>sessions_spawn</code> when their parent session is reset or deleted, instead of leaving orphaned <code>claude-agent-acp</code> processes that accumulate and exhaust memory. Fixes #68916. (#85190) Thanks @openperf.</li>
<li>Codex app-server: block native execution paths when OpenClaw exec resolves to a node host while preserving the first-party CLI node binding path. Fixes #85012. (#85534) Thanks @joshavant.</li>
<li>Diagnostics: bound cleanup timeout detail logs, emit drop summaries when async diagnostic bursts exceed the queue cap, and surface async queue drops through diagnostic telemetry.</li>
<li>Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.</li>
<li>Context engines: fail closed with a descriptive error when the selected agent runtime cannot satisfy declared context-engine host requirements.</li>
<li>Agents/Pi: treat accepted embedded <code>sessions_spawn</code> child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.</li>
<li>CLI/models: resolve <code>openclaw models set</code> aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill.</li>
<li>Doctor: show personal Codex CLI asset notices as info instead of warnings. Fixes #84859.</li>
<li>WhatsApp: update Baileys to <code>7.0.0-rc13</code> and drop the obsolete logger type patch.</li>
<li>CLI/update: pre-pack GitHub/git package update targets before the staged npm install, restoring <code>openclaw update --tag main</code> for one-off package updates. (#81296) Thanks @fuller-stack-dev.</li>
<li>Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.</li>
<li>Media generation: keep image, music, and video completion delivery from duplicating or losing task ownership when generated media finishes through active session replies. (#84006) Thanks @fuller-stack-dev.</li>
<li>Infra/json: retry transient <code>File changed during read</code> races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)</li>
<li>Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.</li>
<li>Providers/Ollama: resolve configured Ollama Cloud <code>OLLAMA_API_KEY</code> markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)</li>
<li>Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.</li>
<li>Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.</li>
<li>Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in <code>openclaw status --all</code>. Fixes #49577. (#72724)</li>
<li>Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.</li>
<li>Providers/Ollama: treat Docker/OrbStack host aliases as local Ollama endpoints so <code>ollama-local</code> marker auth works when OpenClaw runs inside a VM/container and Ollama runs on the host. Fixes #84875.</li>
<li>QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.</li>
<li>Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.</li>
<li>Installer: require a real controlling terminal before launching onboarding so headless <code>curl | bash</code> installs finish cleanly after installing the CLI.</li>
<li>Agents/Codex: promote a completed final assistant response when a prompt timeout races Codex app-server completion instead of returning an empty timeout envelope. Refs #84516.</li>
<li>Codex app-server: keep interrupted turn statuses from being treated as OpenClaw aborts by themselves, so tool-only turns remain eligible for no-visible-answer recovery. Fixes #84492.</li>
<li>Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.</li>
<li>Control UI/Web Push: use <code>https://openclaw.ai</code> as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when <code>OPENCLAW_VAPID_SUBJECT</code> is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.</li>
<li>Control UI: distinguish inherited thinking-off settings from explicit Off selections so the thinking selector no longer shows two identical Off rows. (#85223) Thanks @amknight.</li>
<li>Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.</li>
<li>Codex/TUI: surface Codex-native post-turn compaction failures instead of continuing uncompacted, and keep successful native compaction serialized before local idle/next-turn handling. Fixes #84305. (#85160) Thanks @joshavant.</li>
<li>Memory/search: stop recall tracking from writing dreaming side-effect artifacts when <code>dreaming.enabled=false</code>, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.</li>
<li>Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.</li>
<li>QA: keep <code>pnpm qa:e2e</code> self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.</li>
<li>fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.</li>
<li>doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.</li>
<li>Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.</li>
<li>Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.</li>
<li>Discord: keep session recovery and <code>/stop</code> abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.</li>
<li>Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.</li>
<li>Codex app-server: give visible <code>message</code> dynamic tool sends a longer timeout budget so slow channel delivery can return its own result or error instead of hitting the 30-second Codex wrapper. (#85216) Thanks @amknight.</li>
<li>Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.</li>
<li>Matrix: keep explicitly configured two-person rooms on the room route before stale <code>m.direct</code> or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.</li>
<li>Agents/subagents: require explicit subagent allowlist targets to be configured agents so stale deleted-agent ids are omitted from <code>agents_list</code> and rejected by <code>sessions_spawn</code>. Fixes #84811. (#85154) Thanks @joshavant.</li>
<li>PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.</li>
<li>Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.</li>
<li>Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.</li>
<li>Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.</li>
<li>Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.</li>
<li>Node/Linux: keep <code>OPENCLAW_GATEWAY_TOKEN</code> out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)</li>
<li>Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale <code>dreaming-narrative-*</code> sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.</li>
<li>Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.</li>
<li>TUI: coalesce repeated idle Esc abort notices into a single <code>no active run xN</code> system row instead of appending duplicate rows.</li>
<li>Telegram: honor <code>channels.telegram.pollingStallThresholdMs</code> in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.</li>
<li>Telegram: dedupe replayed message dispatches by Telegram chat/message identity so isolated-ingress replays do not trigger duplicate model dispatches. Fixes #84886. (#85208) Thanks @joshavant.</li>
<li>Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.</li>
<li>Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.</li>
<li>Slack: keep native plugin approval prompts in the originating app conversation thread when the live Slack turn source is a <code>D...</code> conversation.</li>
<li>Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.</li>
<li>CLI/perf: keep <code>setup --help</code>, <code>onboard --help</code>, and <code>configure --help</code> out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.</li>
<li>CLI/perf: keep <code>agents --help</code> out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.</li>
<li>CLI/perf: keep <code>secrets --help</code> and <code>nodes --help</code> on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn.</li>
<li>CLI/perf: serve <code>doctor</code>, <code>gateway</code>, <code>models</code>, and <code>plugins</code> parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.</li>
<li>Codex/Lossless: keep context-engine history on the canonical run session when Telegram DMs use per-peer runtime policy keys. Fixes #84936. (#84954) Thanks @neeravmakwana.</li>
<li>Codex: keep heartbeat response tool schemas durable without exposing dynamic tools disabled by turn policy, so heartbeat wakeups can reuse threads while scoped tool allowlists stay enforced. (#84681) Thanks @jalehman.</li>
<li>Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda.</li>
<li>Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with <code>No API key found for provider "openai-codex"</code> until the user runs <code>openclaw doctor</code>. Thanks @Totalsolutionsync and @romneyda.</li>
<li>Codex/failover: classify <code>deactivated_workspace</code> as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.</li>
<li>Exec: keep configured <code>tools.exec.pathPrepend</code> entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.</li>
<li>Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.</li>
<li>Agents/embedded runner: classify HTML auth provider responses as <code>auth_html</code> and return a re-authentication hint instead of the CDN-blocked copy that <code>upstream_html</code> returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.</li>
<li>TUI/streaming watchdog: dismiss the <code>This response is taking longer than expected</code> notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.</li>
<li>Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.</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.22/OpenClaw-2026.5.22.zip" length="54409357" type="application/octet-stream" sparkle:edSignature="am1mwLOmUHor9QuQWtxSsKoBOCySUBo4fB+0Qdcrz0E3wf6ESIMTfOC0k+dKJSh9gtLZw5jzpWVqTBzEdU36Aw=="/>
</item>
</channel>
</rss>

View File

@@ -5,6 +5,8 @@
Maintenance update for the current OpenClaw release.
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
## 2026.5.28 - 2026-05-28

View File

@@ -29,6 +29,14 @@ def clear_empty_env_var(key)
ENV.delete(key) unless env_present?(ENV[key])
end
def screenshot_upload_requested?
ENV["DELIVER_SCREENSHOTS"] == "1"
end
def screenshot_paths
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
end
def maybe_decode_hex_keychain_secret(value)
return value unless env_present?(value)
@@ -314,6 +322,7 @@ platform :ios do
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = asc_api_key
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
app_identifier = ENV["ASC_APP_IDENTIFIER"]
@@ -321,11 +330,21 @@ platform :ios do
app_identifier = nil unless env_present?(app_identifier)
app_id = nil unless env_present?(app_id)
if screenshot_upload_requested? && screenshot_paths.empty?
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
end
deliver_options = {
api_key: api_key,
force: true,
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
app_version: version_metadata[:short_version],
copyright: "2026 OpenClaw",
primary_category: "PRODUCTIVITY",
secondary_category: "UTILITIES",
skip_screenshots: !screenshot_upload_requested?,
skip_metadata: ENV["DELIVER_METADATA"] != "1",
skip_binary_upload: true,
overwrite_screenshots: screenshot_upload_requested?,
run_precheck_before_submit: false
}
deliver_options[:app_identifier] = app_identifier if app_identifier

View File

@@ -1,18 +1,19 @@
OpenClaw is a personal AI assistant you run on your own devices.
Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation.
Pair this iPhone app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, sharing, and device-aware automation.
What you can do:
- Pair with your private OpenClaw Gateway by QR code or setup code
- Chat with your assistant from iPhone
- Use voice wake and push-to-talk
- Capture photos and short clips on request
- Record screen snippets for troubleshooting and workflows
- Use realtime Talk mode and push-to-talk
- Review Gateway action approvals from your phone
- Share text, links, and media directly from iOS into OpenClaw
- Run location-aware and device-aware automations
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
- Receive push wakes and node status updates for connected workflows
OpenClaw is local-first: you control your gateway, keys, and configuration.
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by iOS permissions and can be enabled only for the capabilities you want to use.
Getting started:
1) Set up your OpenClaw Gateway
2) Open the iOS app and pair with your gateway
3) Start using commands and automations from your phone
3) Start using chat, Talk mode, approvals, and automations from your phone

View File

@@ -1 +1 @@
openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node
openclaw,ai assistant,local ai,iphone ai,voice assistant,automation,gateway,chat,agent

View File

@@ -1 +1 @@
Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions.
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.

View File

@@ -1,3 +1,5 @@
Maintenance update for the current OpenClaw release.
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.

View File

@@ -389,6 +389,15 @@
"plan.0.step"
]
},
"skill_workshop": {
"emoji": "🧰",
"title": "Skill Workshop",
"detailKeys": [
"action",
"name",
"proposal_id"
]
},
"gateway": {
"emoji": "🔌",
"title": "Gateway",

View File

@@ -5276,6 +5276,334 @@ public struct SkillsDetailResult: Codable, Sendable {
}
}
public struct SkillsProposalsListParams: Codable, Sendable {
public let agentid: String?
public init(
agentid: String? = nil)
{
self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
}
}
public struct SkillsProposalsListResult: Codable, Sendable {
public let schema: String
public let updatedat: String
public let proposals: [[String: AnyCodable]]
public init(
schema: String,
updatedat: String,
proposals: [[String: AnyCodable]])
{
self.schema = schema
self.updatedat = updatedat
self.proposals = proposals
}
private enum CodingKeys: String, CodingKey {
case schema
case updatedat = "updatedAt"
case proposals
}
}
public struct SkillsProposalInspectParams: Codable, Sendable {
public let agentid: String?
public let proposalid: String
public init(
agentid: String? = nil,
proposalid: String)
{
self.agentid = agentid
self.proposalid = proposalid
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case proposalid = "proposalId"
}
}
public struct SkillsProposalInspectResult: Codable, Sendable {
public let record: SkillsProposalRecordResult
public let content: String
public let supportfiles: [[String: AnyCodable]]?
public init(
record: SkillsProposalRecordResult,
content: String,
supportfiles: [[String: AnyCodable]]?)
{
self.record = record
self.content = content
self.supportfiles = supportfiles
}
private enum CodingKeys: String, CodingKey {
case record
case content
case supportfiles = "supportFiles"
}
}
public struct SkillsProposalCreateParams: Codable, Sendable {
public let agentid: String?
public let name: String
public let description: String
public let content: String
public let supportfiles: [[String: AnyCodable]]?
public let goal: String?
public let evidence: String?
public init(
agentid: String? = nil,
name: String,
description: String,
content: String,
supportfiles: [[String: AnyCodable]]?,
goal: String?,
evidence: String?)
{
self.agentid = agentid
self.name = name
self.description = description
self.content = content
self.supportfiles = supportfiles
self.goal = goal
self.evidence = evidence
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case name
case description
case content
case supportfiles = "supportFiles"
case goal
case evidence
}
}
public struct SkillsProposalUpdateParams: Codable, Sendable {
public let agentid: String?
public let skillname: String
public let description: String?
public let content: String
public let supportfiles: [[String: AnyCodable]]?
public let goal: String?
public let evidence: String?
public init(
agentid: String? = nil,
skillname: String,
description: String?,
content: String,
supportfiles: [[String: AnyCodable]]?,
goal: String?,
evidence: String?)
{
self.agentid = agentid
self.skillname = skillname
self.description = description
self.content = content
self.supportfiles = supportfiles
self.goal = goal
self.evidence = evidence
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case skillname = "skillName"
case description
case content
case supportfiles = "supportFiles"
case goal
case evidence
}
}
public struct SkillsProposalReviseParams: Codable, Sendable {
public let agentid: String?
public let proposalid: String
public let content: String
public let supportfiles: [[String: AnyCodable]]?
public let description: String?
public let goal: String?
public let evidence: String?
public init(
agentid: String? = nil,
proposalid: String,
content: String,
supportfiles: [[String: AnyCodable]]?,
description: String?,
goal: String?,
evidence: String?)
{
self.agentid = agentid
self.proposalid = proposalid
self.content = content
self.supportfiles = supportfiles
self.description = description
self.goal = goal
self.evidence = evidence
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case proposalid = "proposalId"
case content
case supportfiles = "supportFiles"
case description
case goal
case evidence
}
}
public struct SkillsProposalActionParams: Codable, Sendable {
public let agentid: String?
public let proposalid: String
public let reason: String?
public init(
agentid: String? = nil,
proposalid: String,
reason: String?)
{
self.agentid = agentid
self.proposalid = proposalid
self.reason = reason
}
private enum CodingKeys: String, CodingKey {
case agentid = "agentId"
case proposalid = "proposalId"
case reason
}
}
public struct SkillsProposalApplyResult: Codable, Sendable {
public let record: SkillsProposalRecordResult
public let targetskillfile: String
public init(
record: SkillsProposalRecordResult,
targetskillfile: String)
{
self.record = record
self.targetskillfile = targetskillfile
}
private enum CodingKeys: String, CodingKey {
case record
case targetskillfile = "targetSkillFile"
}
}
public struct SkillsProposalRecordResult: Codable, Sendable {
public let schema: String
public let id: String
public let kind: AnyCodable
public let status: AnyCodable
public let title: String
public let description: String
public let createdat: String
public let updatedat: String
public let createdby: AnyCodable
public let proposedversion: String
public let draftfile: String
public let drafthash: String
public let supportfiles: [[String: AnyCodable]]?
public let target: [String: AnyCodable]
public let scan: [String: AnyCodable]
public let goal: String?
public let evidence: String?
public let appliedat: String?
public let rejectedat: String?
public let quarantinedat: String?
public let staleat: String?
public let statusreason: String?
public init(
schema: String,
id: String,
kind: AnyCodable,
status: AnyCodable,
title: String,
description: String,
createdat: String,
updatedat: String,
createdby: AnyCodable,
proposedversion: String,
draftfile: String,
drafthash: String,
supportfiles: [[String: AnyCodable]]?,
target: [String: AnyCodable],
scan: [String: AnyCodable],
goal: String?,
evidence: String?,
appliedat: String?,
rejectedat: String?,
quarantinedat: String?,
staleat: String?,
statusreason: String?)
{
self.schema = schema
self.id = id
self.kind = kind
self.status = status
self.title = title
self.description = description
self.createdat = createdat
self.updatedat = updatedat
self.createdby = createdby
self.proposedversion = proposedversion
self.draftfile = draftfile
self.drafthash = drafthash
self.supportfiles = supportfiles
self.target = target
self.scan = scan
self.goal = goal
self.evidence = evidence
self.appliedat = appliedat
self.rejectedat = rejectedat
self.quarantinedat = quarantinedat
self.staleat = staleat
self.statusreason = statusreason
}
private enum CodingKeys: String, CodingKey {
case schema
case id
case kind
case status
case title
case description
case createdat = "createdAt"
case updatedat = "updatedAt"
case createdby = "createdBy"
case proposedversion = "proposedVersion"
case draftfile = "draftFile"
case drafthash = "draftHash"
case supportfiles = "supportFiles"
case target
case scan
case goal
case evidence
case appliedat = "appliedAt"
case rejectedat = "rejectedAt"
case quarantinedat = "quarantinedAt"
case staleat = "staleAt"
case statusreason = "statusReason"
}
}
public struct SkillsSecurityVerdictsParams: Codable, Sendable {
public let agentid: String?

View File

@@ -1,4 +1,4 @@
289c1bae4b9574d219fe61931be6b3ce42d4efb37d0a2edc570a521016394db5 config-baseline.json
5bcb22d1506d82e59caa3bbc97931213299e3a2c0d45dbc549386b254661094a config-baseline.core.json
370da2e3a4253f00c3963a3ad8b57707ea3f67a8d0d394b7d2b96db4f3413d32 config-baseline.json
6a66c70d36dacf5fd1a8b7e157d1ff4812e97f518c13ebc3190509df4c269f29 config-baseline.core.json
a9102c0611b8170fac37853cc31771810f31757a9e3b2c6796bbd9625f9b9206 config-baseline.channel.json
0a8e088f8dc7b12341075ce019281d5fe45827ae802f60c71a490022ba5867cf config-baseline.plugin.json
923a8cac695c752e51751cc2dea185a3fbe19d0015722f7ea1909f897dfbb898 config-baseline.plugin.json

View File

@@ -40,11 +40,9 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
## How cron works
- Cron runs **inside the Gateway** process (not inside the model).
- Job definitions persist at `~/.openclaw/cron/jobs.json` so restarts do not lose schedules.
- Runtime execution state persists next to it in `~/.openclaw/cron/jobs-state.json`. If you track cron definitions in git, track `jobs.json` and gitignore `jobs-state.json`.
- If `jobs.json` contains malformed rows, the Gateway keeps valid jobs running, removes the malformed rows from the active store, and saves the raw rows beside it in `jobs-quarantine.json` for later repair or review.
- After the split, older OpenClaw versions can read `jobs.json` but may treat jobs as fresh because runtime fields now live in `jobs-state.json`.
- When `jobs.json` is edited while the Gateway is running or stopped, OpenClaw compares the changed schedule fields with pending runtime slot metadata and clears stale `nextRunAtMs` values. Pure formatting or key-order-only rewrites preserve the pending slot.
- Job definitions, runtime state, and run history persist in OpenClaw's shared SQLite state database so restarts do not lose schedules.
- On upgrade, legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
- `cron.store` still names the logical cron store key and legacy import path. After import, editing that JSON file no longer changes active cron jobs; use `openclaw cron add|edit|remove` or the Gateway cron RPC methods instead.
- All cron executions create [background task](/automation/tasks) records.
- On Gateway startup, overdue isolated agent-turn jobs are rescheduled out of the channel-connect window instead of replaying immediately, so Discord/Telegram startup and native-command setup stay responsive after restarts.
- One-shot jobs (`--at`) auto-delete after success by default.
@@ -462,9 +460,7 @@ Model override note:
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
The runtime state sidecar is derived from `cron.store`: a `.json` store such as `~/clawd/cron/jobs.json` uses `~/clawd/cron/jobs-state.json`, while a store path without a `.json` suffix appends `-state.json`.
If you hand-edit `jobs.json`, leave `jobs-state.json` out of source control. OpenClaw uses that sidecar for pending slots, active markers, last-run metadata, and the schedule identity that tells the scheduler when an externally edited job needs a fresh `nextRunAtMs`.
`cron.store` is a logical store key and legacy import path. Existing stores are imported into SQLite on first load and archived; future cron changes should go through the CLI or Gateway API.
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
@@ -476,7 +472,7 @@ Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
</Accordion>
<Accordion title="Maintenance">
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.maxBytes` / `cron.runLog.keepLines` auto-prune run-log files.
`cron.sessionRetention` (default `24h`) prunes isolated run-session entries. `cron.runLog.keepLines` limits retained SQLite run-history rows per job; `maxBytes` is retained for config compatibility with older file-backed run logs.
</Accordion>
</AccordionGroup>

View File

@@ -346,7 +346,7 @@ A sweeper runs every **60 seconds** and handles four things:
</Accordion>
<Accordion title="Tasks and cron">
A cron job **definition** lives in `~/.openclaw/cron/jobs.json`; runtime execution state lives beside it in `~/.openclaw/cron/jobs-state.json`. **Every** cron execution creates a task record - both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
Cron job definitions, runtime execution state, and run history live in OpenClaw's shared SQLite state database. **Every** cron execution creates a task record - both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications.
See [Cron Jobs](/automation/cron-jobs).

View File

@@ -915,9 +915,10 @@ Uploaded files are stored in a `/OpenClawShared/` folder in the configured Share
OpenClaw sends Teams polls as Adaptive Cards (there is no native Teams poll API).
- CLI: `openclaw message poll --channel msteams --target conversation:<id> ...`
- Votes are recorded by the gateway in `~/.openclaw/msteams-polls.json`.
- Votes are recorded by the gateway in OpenClaw plugin-state SQLite under `state/openclaw.sqlite`.
- Existing `msteams-polls.json` files are imported once when the MSTeams plugin starts.
- The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed).
- Polls do not auto-post result summaries yet, and there is no supported poll-results CLI yet.
## Presentation cards

View File

@@ -118,7 +118,7 @@ Skipped runs are tracked separately from execution errors. They do not affect re
For isolated jobs that target a local configured model provider, cron runs a lightweight provider preflight before starting the agent turn. Loopback, private-network, and `.local` `api: "ollama"` providers are probed at `/api/tags`; local OpenAI-compatible providers such as vLLM, SGLang, and LM Studio are probed at `/models`. If the endpoint is unreachable, the run is recorded as `skipped` and retried on a later schedule; matching dead endpoints are cached for 5 minutes to avoid many jobs hammering the same local server.
Note: cron job definitions live in `jobs.json`, while pending runtime state lives in `jobs-state.json`. If `jobs.json` is edited externally, the Gateway reloads changed schedules and clears stale pending slots; formatting-only rewrites do not clear the pending slot. Malformed job rows are removed from active `jobs.json` at load time after their raw contents are copied to `jobs-quarantine.json`.
Note: cron jobs, pending runtime state, and run history live in the shared SQLite state database. Legacy `jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files are imported once and renamed with a `.migrated` suffix. After import, edit schedules with `openclaw cron add|edit|remove` instead of editing JSON files.
### Manual runs
@@ -199,7 +199,7 @@ Cron does not classify final-output prose or approval-looking refusal phrases as
Retention and pruning are controlled in config:
- `cron.sessionRetention` (default `24h`) prunes completed isolated run sessions.
- `cron.runLog.maxBytes` and `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl`.
- `cron.runLog.keepLines` prunes retained SQLite run-history rows per job. `cron.runLog.maxBytes` remains accepted for compatibility with older file-backed run logs.
## Migrating older jobs

View File

@@ -348,7 +348,8 @@ For broader testing context, see [Testing](/help/testing).
## OpenClaw as an MCP client registry
This is the `openclaw mcp list`, `show`, `set`, and `unset` path.
This is the `openclaw mcp list`, `show`, `status`, `probe`, `set`, `tools`,
and `unset` path.
These commands do not expose OpenClaw over MCP. They manage OpenClaw-owned MCP server definitions under `mcp.servers` in OpenClaw config.
@@ -357,10 +358,15 @@ Those saved definitions are for runtimes that OpenClaw launches or configures la
<AccordionGroup>
<Accordion title="Important behavior">
- these commands only read or write OpenClaw config
- they do not connect to the target MCP server
- `status`, `list`, `show`, `set`, `tools`, and `unset` do not connect to the target MCP server
- `probe` connects to the selected server or all configured servers, lists tools, and reports capabilities/diagnostics
- they do not validate whether the command, URL, or remote transport is reachable right now
- runtime adapters decide which transport shapes they actually support at execution time
- embedded OpenClaw exposes configured MCP tools in normal `coding` and `messaging` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` disables them explicitly
- per-server `toolFilter.include` and `toolFilter.exclude` filter discovered MCP tools before they become OpenClaw tools
- servers that advertise resources or prompts also expose utility tools for listing/reading resources and listing/fetching prompts; those generated utility names (`resources_list`, `resources_read`, `prompts_list`, `prompts_get`) use the same include/exclude filter
- dynamic MCP tool-list changes invalidate the cached catalog for that session; the next discovery/use refreshes from the server
- repeated MCP tool request/protocol failures pause that server briefly so one broken server does not consume the whole turn
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end
</Accordion>
@@ -387,14 +393,20 @@ Commands:
- `openclaw mcp list`
- `openclaw mcp show [name]`
- `openclaw mcp status`
- `openclaw mcp probe [name]`
- `openclaw mcp set <name> <json>`
- `openclaw mcp tools <name> [--include csv] [--exclude csv] [--clear]`
- `openclaw mcp unset <name>`
Notes:
- `list` sorts server names.
- `show` without a name prints the full configured MCP server object.
- `status` classifies configured transports without connecting.
- `probe` connects and reports tool counts, resources/prompts support, list-change support, and diagnostics.
- `set` expects one JSON object value on the command line.
- `tools` updates per-server tool filters. Include/exclude entries are MCP tool names and simple `*` globs.
- Use `transport: "streamable-http"` for Streamable HTTP MCP servers. `openclaw mcp set` also normalizes CLI-native `type: "http"` to the same canonical config shape for compatibility.
- `unset` fails if the named server does not exist.
@@ -403,7 +415,10 @@ Examples:
```bash
openclaw mcp list
openclaw mcp show context7 --json
openclaw mcp status
openclaw mcp probe context7 --json
openclaw mcp set context7 '{"command":"uvx","args":["context7-mcp"]}'
openclaw mcp tools context7 --include 'resolve-library-id,get-library-docs'
openclaw mcp set docs '{"url":"https://mcp.example.com","transport":"streamable-http"}'
openclaw mcp unset context7
```
@@ -420,7 +435,11 @@ Example config shape:
},
"docs": {
"url": "https://mcp.example.com",
"transport": "streamable-http"
"transport": "streamable-http",
"toolFilter": {
"include": ["search_*"],
"exclude": ["admin_*"]
}
}
}
}

View File

@@ -326,6 +326,8 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
openclaw plugins install -l ./my-plugin
```
Standalone plugin files must be listed in `plugins.load.paths` rather than placed directly in `~/.openclaw/extensions` or `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin package or bundle directories, while top-level script files are treated as local helpers and skipped.
<Note>
`--force` is not supported with `--link` because linked installs reuse the source path instead of copying over a managed install target.

View File

@@ -47,6 +47,20 @@ Scope selection:
- `--store <path>`: explicit store path (cannot be combined with `--agent` or `--all-agents`)
- `--limit <n|all>`: max rows to output (default `100`; `all` restores full output)
Tail human-readable trajectory progress for stored sessions:
```bash
openclaw sessions tail
openclaw sessions tail --follow
openclaw sessions tail --session-key "agent:main:telegram:direct:123" --tail 25
openclaw sessions --agent work tail --follow
openclaw sessions --all-agents tail --follow
```
`openclaw sessions tail` renders recent trajectory JSONL events as compact progress lines. Without `--session-key`, it tails running sessions first, then the latest stored session. `--tail <count>` controls how many existing events print before follow mode; the default is `80`, and `0` starts at the current end. `--follow` keeps watching the selected trajectory files, including relocated files referenced by `<session>.trajectory-path.json`.
The progress view is intentionally conservative: prompt text, tool arguments, and tool result bodies are not printed. Tool calls show the tool name with `{...redacted...}`; tool results show status such as `ok`, `error`, or `done`; model completion lines show provider/model and terminal status.
Export a trajectory bundle for a stored session:
```bash
@@ -104,7 +118,7 @@ openclaw sessions cleanup --json
`openclaw sessions cleanup` uses `session.maintenance` settings from config:
- Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run logs (`cron/runs/<jobId>.jsonl`), which are managed by `cron.runLog.maxBytes` and `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance).
- Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run history, which is managed by `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance).
- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved.
- `--dry-run`: preview how many entries would be pruned/capped without writing.

View File

@@ -1,5 +1,5 @@
---
summary: "CLI reference for `openclaw skills` (search/install/update/verify/list/info/check)"
summary: "CLI reference for `openclaw skills` (search/install/update/verify/list/info/check/workshop)"
read_when:
- You want to see which skills are available and ready to run
- You want to search ClawHub or install skills from ClawHub, Git, or local directories
@@ -53,6 +53,14 @@ openclaw skills info <name> --agent <id>
openclaw skills check
openclaw skills check --agent <id>
openclaw skills check --json
openclaw skills workshop propose-create --name "qa-check" --description "QA checklist" --proposal ./PROPOSAL.md
openclaw skills workshop propose-update qa-check --proposal ./PROPOSAL.md
openclaw skills workshop list
openclaw skills workshop inspect <proposal-id>
openclaw skills workshop revise <proposal-id> --proposal ./PROPOSAL.md
openclaw skills workshop apply <proposal-id>
openclaw skills workshop reject <proposal-id> --reason "Not reusable"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
`search`, `update`, and `verify` use ClawHub directly. `install <slug>` installs
@@ -116,6 +124,76 @@ Notes:
`--json`, that means the machine-readable payload stays on stdout for pipes
and scripts.
## Skill Workshop proposals
`openclaw skills workshop` manages pending skill proposals in the selected
workspace. Proposals are durable OpenClaw state under
`<OPENCLAW_STATE_DIR>/skill-workshop/proposals/`; they are not active skills
until applied. The default state directory is `~/.openclaw`. Proposal bodies
honor `skills.workshop.maxSkillBytes`, and proposal descriptions are capped at
160 bytes because they can appear in discovery and listing output.
Create a proposal from a draft markdown file:
```bash
openclaw skills workshop propose-create \
--name "qa-check" \
--description "Repeatable QA checklist" \
--proposal ./PROPOSAL.md
```
Or create a proposal from a full draft skill directory:
```bash
openclaw skills workshop propose-create \
--name "qa-check" \
--description "Repeatable QA checklist" \
--proposal-dir ./qa-check-proposal
```
Update an existing workspace skill through the same pending path:
```bash
openclaw skills workshop propose-update qa-check --proposal ./PROPOSAL.md
```
Revise a pending proposal before approval:
```bash
openclaw skills workshop revise <proposal-id> --proposal ./PROPOSAL.md
```
The supplied draft is stored as `PROPOSAL.md` with proposal-only frontmatter:
```markdown
---
name: qa-check
description: Repeatable QA checklist
status: proposal
version: v1
date: "2026-05-30T00:00:00.000Z"
---
```
Applying a proposal writes the active `SKILL.md` into the workspace `skills/`
root, strips `status`, proposal `version`, and proposal `date` from the
frontmatter, scans the draft, writes rollback metadata, and refuses stale
updates when the target skill changed after the proposal was created.
When `--proposal-dir` is used, the directory must contain `PROPOSAL.md`.
Support files can be included under `assets/`, `examples/`, `references/`,
`scripts/`, or `templates/`. OpenClaw stores support files with the proposal,
scans them, verifies their hashes before apply, and writes them beside the
active `SKILL.md` only after the proposal is applied.
Agents can create, revise, list, and inspect pending proposals through the
`skill_workshop` tool when the user asks for reusable work to be captured.
Autonomous proposal capture from durable conversation signals is off by
default and is enabled with `skills.workshop.autonomous.enabled`. If the user
explicitly asks to approve/use/apply, reject, or quarantine a specific
proposal, `skill_workshop` can also perform that proposal lifecycle action
through the same Skill Workshop safeguards.
## Related
- [CLI reference](/cli)

View File

@@ -127,7 +127,7 @@ See [Sandboxing](/gateway/sandboxing) and [Multi-Agent Sandbox & Tools](/tools/m
Configure logging before the delegate handles any real data:
- Cron run history: `~/.openclaw/cron/runs/<jobId>.jsonl`
- Cron run history: OpenClaw shared SQLite state database
- Session transcripts: `~/.openclaw/agents/delegate/sessions`
- Identity provider audit logs (Exchange, Google Workspace)

View File

@@ -1235,7 +1235,6 @@
"plugins/memory-wiki",
"plugins/memory-lancedb",
"plugins/oc-path",
"plugins/skill-workshop",
"plugins/zalouser"
]
},

View File

@@ -109,6 +109,10 @@ target server during config edits.
headers: {
Authorization: "Bearer ${MCP_REMOTE_TOKEN}",
},
toolFilter: {
include: ["search_*"],
exclude: ["admin_*"],
},
// Optional Codex app-server projection controls.
codex: {
agents: ["main"],
@@ -125,6 +129,12 @@ target server during config edits.
Remote entries use `transport: "streamable-http"` or `transport: "sse"`;
`type: "http"` is a CLI-native alias that `openclaw mcp set` and
`openclaw doctor --fix` normalize into the canonical `transport` field.
- `mcp.servers.<name>.toolFilter`: optional per-server tool selection. `include`
limits the discovered MCP tools to matching names; `exclude` hides matching
names. Entries are exact MCP tool names or simple `*` globs. Servers with
resources or prompts also generate utility tool names (`resources_list`,
`resources_read`, `prompts_list`, `prompts_get`), and those names use the
same filter.
- `mcp.servers.<name>.codex`: optional Codex app-server projection controls.
This block is OpenClaw metadata for Codex app-server threads only; it does not
affect ACP sessions, generic Codex harness config, or other runtime adapters.
@@ -142,6 +152,11 @@ target server during config edits.
- Changes under `mcp.*` hot-apply by disposing cached session MCP runtimes.
The next tool discovery/use recreates them from the new config, so removed
`mcp.servers` entries are reaped immediately instead of waiting for idle TTL.
- Runtime discovery also honors MCP tool-list change notifications by dropping
the cached catalog for that session. Servers that advertise resources or
prompts get utility tools for listing/reading resources and listing/fetching
prompts. Repeated tool-call failures pause the affected server briefly before
another call is attempted.
See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
[CLI backends](/gateway/cli-backends#bundle-mcp-overlays) for runtime behavior.
@@ -214,7 +229,8 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
}
```
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
- Loaded from package or bundle directories under `~/.openclaw/extensions` and `<workspace>/.openclaw/extensions`, plus files or directories listed in `plugins.load.paths`.
- Put standalone plugin files in `plugins.load.paths`; auto-discovered extension roots ignore top-level `.js`, `.mjs`, and `.ts` files so helper scripts in those roots do not block startup.
- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles.
- **Config changes require a gateway restart.**
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
@@ -1249,8 +1265,8 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
```
- `sessionRetention`: how long to keep completed isolated cron run sessions before pruning from `sessions.json`. Also controls cleanup of archived deleted cron transcripts. Default: `24h`; set `false` to disable.
- `runLog.maxBytes`: max size per run log file (`cron/runs/<jobId>.jsonl`) before pruning. Default: `2_000_000` bytes.
- `runLog.keepLines`: newest lines retained when run-log pruning is triggered. Default: `2000`.
- `runLog.maxBytes`: accepted for compatibility with older file-backed cron run logs. Default: `2_000_000` bytes.
- `runLog.keepLines`: newest SQLite run-history rows retained per job. Default: `2000`.
- `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth header is sent.
- `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have `notify: true`.

View File

@@ -431,7 +431,7 @@ candidate contains redacted secret placeholders such as `***`.
```
- `sessionRetention`: prune completed isolated run sessions from `sessions.json` (default `24h`; set `false` to disable).
- `runLog`: prune `cron/runs/<jobId>.jsonl` by size and retained lines.
- `runLog`: prune retained cron run-history rows per job. `maxBytes` remains accepted for older file-backed run logs.
- See [Cron jobs](/automation/cron-jobs) for feature overview and CLI examples.
</Accordion>

View File

@@ -222,8 +222,9 @@ Set `stream: true` to receive Server-Sent Events (SSE):
- `frequency_penalty`: number; best-effort frequency penalty forwarded to the upstream provider via the agent stream-param channel. Validated range: -2.0 to 2.0. Returns `400 invalid_request_error` for out-of-range values.
- `presence_penalty`: number; best-effort presence penalty forwarded to the upstream provider via the agent stream-param channel. Validated range: -2.0 to 2.0. Returns `400 invalid_request_error` for out-of-range values.
- `seed`: number (integer); best-effort seed forwarded to the upstream provider via the agent stream-param channel. Returns `400 invalid_request_error` for non-integer values.
- `stop`: string or array of up to 4 strings; best-effort stop sequences forwarded to the upstream provider via the agent stream-param channel. Returns `400 invalid_request_error` for more than 4 sequences or non-string/empty entries.
When either token-cap field is set, the value is forwarded to the upstream provider via the agent stream-param channel. The actual wire field name sent to the upstream provider is chosen by the provider transport: `max_completion_tokens` for OpenAI-family endpoints, and `max_tokens` for providers that only accept the legacy name (such as Mistral and Chutes). Sampling fields (`temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `seed`) follow the same stream-param channel; the ChatGPT-based Codex Responses backend strips them server-side since it uses fixed sampling.
When either token-cap field is set, the value is forwarded to the upstream provider via the agent stream-param channel. The actual wire field name sent to the upstream provider is chosen by the provider transport: `max_completion_tokens` for OpenAI-family endpoints, and `max_tokens` for providers that only accept the legacy name (such as Mistral and Chutes). Sampling fields (`temperature`, `top_p`, `frequency_penalty`, `presence_penalty`, `seed`) follow the same stream-param channel; the ChatGPT-based Codex Responses backend strips them server-side since it uses fixed sampling. `stop` also rides the stream-param channel and maps to the transport's stop field (`stop` for Chat Completions backends, `stop_sequences` for Anthropic); the OpenAI Responses API has no stop parameter, so `stop` is not applied on Responses-backed models.
### Unsupported variants

View File

@@ -348,7 +348,8 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `usage.status` returns provider usage windows/remaining quota summaries.
- `usage.cost` returns aggregated cost usage summaries for a date range.
Pass `agentId` for one agent, or `agentScope: "all"` to aggregate configured agents.
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only when the caller explicitly wants a live embedding provider ping.
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only when the caller explicitly wants a live embedding provider ping. Dreaming-aware clients may also pass `{ "agentId": "agent-id" }` to scope Dreaming store stats to a selected agent workspace; omitting `agentId` keeps the default-agent fallback and aggregates configured Dreaming workspaces.
- `doctor.memory.dreamDiary`, `doctor.memory.backfillDreamDiary`, `doctor.memory.resetDreamDiary`, `doctor.memory.resetGroundedShortTerm`, `doctor.memory.repairDreamingArtifacts`, and `doctor.memory.dedupeDreamDiary` accept optional `{ "agentId": "agent-id" }` params for selected-agent Dreaming views/actions. When `agentId` is omitted, they operate on the configured default agent workspace.
- `doctor.memory.remHarness` returns a bounded, read-only REM harness preview for remote control-plane clients. It can include workspace paths, memory snippets, rendered grounded markdown, and deep promotion candidates, so callers need `operator.read`.
- `sessions.usage` returns per-session usage summaries. Pass `agentId` for one
agent, or `agentScope: "all"` to list configured agents together.

View File

@@ -296,8 +296,8 @@ replacement. Gateway startup does not generate bundled-plugin dependency trees.
For full persistence details on VM deployments, see
[Docker VM Runtime - What persists where](/install/docker-vm-runtime#what-persists-where).
**Disk growth hotspots:** watch `media/`, session JSONL files,
`cron/runs/*.jsonl`, installed plugin package roots, and rolling file logs
**Disk growth hotspots:** watch `media/`, session JSONL files, the shared
SQLite state database, installed plugin package roots, and rolling file logs
under `/tmp/openclaw/`.
### Shell helpers (optional)

View File

@@ -7,7 +7,7 @@ read_when:
title: "iOS app"
---
Availability: internal preview. The iOS app is not publicly distributed yet.
Availability: iPhone app builds are distributed through Apple channels when enabled for a release. Local development builds can also run from source.
## What it does

View File

@@ -85,25 +85,25 @@ For an already-running app-server, use WebSocket transport:
Supported `appServer` fields:
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle guard used after a tool handoff when Codex emits raw assistant completion or progress but does not send `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, or post-tool raw assistant progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start, resume, and turn. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start and resume. Active OpenClaw sandboxes narrow `danger-full-access` turns to Codex `workspace-write`; the turn network flag follows OpenClaw sandbox egress. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
The plugin blocks older or unversioned app-server handshakes. Codex app-server
must report stable version `0.125.0` or newer.
@@ -329,23 +329,29 @@ OpenClaw session lane so follow-up chat messages are not queued behind a stale
native turn.
Most non-terminal notifications for the same turn disarm that short watchdog
because Codex has proven the turn is still alive. Raw `custom_tool_call_output`
completions keep the short post-tool watchdog armed because they are the
turn-scoped tool-result handoff. Completed `agentMessage` items and pre-tool raw
assistant `rawResponseItem/completed` items arm the assistant-output release: if
Codex then goes quiet without `turn/completed`, OpenClaw best-effort interrupts
the native turn and releases the session lane. Post-tool raw assistant progress
keeps waiting for `turn/completed` while a completion-idle guard stays armed; the
guard uses `appServer.postToolRawAssistantCompletionIdleTimeoutMs` when
configured and defaults to five minutes otherwise. Replay-safe stdio app-server
failures, including turn-completion idle timeouts without assistant, tool,
active-item, or side-effect evidence, are retried once on a fresh app-server
attempt. Unsafe timeouts still retire the stuck app-server client and release
the OpenClaw session lane. They also clear the stale native thread binding and
surface a recoverable timeout message for user or maintainer judgment instead of
being replayed automatically. Timeout diagnostics include the last
app-server notification method and, for raw assistant response items, the item
type, role, id, and a bounded assistant text preview.
because Codex has proven the turn is still alive. Tool handoffs use a longer
post-tool idle budget: after OpenClaw returns an `item/tool/call` response, after
native tool items such as `commandExecution` complete, after raw
`custom_tool_call_output` completions, and after post-tool raw assistant
progress. The guard uses `appServer.postToolRawAssistantCompletionIdleTimeoutMs`
when configured and defaults to five minutes otherwise. That same post-tool
budget also extends the progress watchdog for the silent synthesis window before
Codex emits the next current-turn event. Reasoning completions, commentary
`agentMessage` completions, and pre-tool raw reasoning or assistant progress can
be followed by an automatic final reply, so they use the post-progress reply
guard instead of releasing the session lane immediately. Only
final/non-commentary completed `agentMessage` items and pre-tool raw assistant
completions arm the assistant-output release: if Codex then goes quiet without
`turn/completed`, OpenClaw best-effort interrupts the native turn and releases
the session lane. Replay-safe stdio app-server failures, including
turn-completion idle timeouts without assistant, tool, active-item, or
side-effect evidence, are retried once on a fresh app-server attempt. Unsafe
timeouts still retire the stuck app-server client and release the OpenClaw
session lane. They also clear the stale native thread binding and surface a
recoverable timeout message for user or maintainer judgment instead of being
replayed automatically. Timeout diagnostics include the last app-server
notification method and, for raw assistant response items, the item type, role,
id, and a bounded assistant text preview.
## Model discovery

View File

@@ -526,25 +526,25 @@ Supported top-level Codex plugin fields:
Supported `appServer` fields:
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. Raise this for slow post-tool or status-only synthesis phases. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle guard used after a tool handoff when Codex emits raw assistant completion or progress but does not send `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
| Field | Default | Meaning |
| --------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | managed Codex binary | Executable for stdio transport. Leave unset to use the managed binary; set it only for an explicit override. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
| `codeModeOnly` | `false` | Opt into Codex's code-mode-only tool surface. OpenClaw dynamic tools remain registered with Codex so nested `tools.*` calls return through the app-server `item/tool/call` bridge. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after Codex accepts a turn or after a turn-scoped app-server request while OpenClaw waits for `turn/completed`. |
| `postToolRawAssistantCompletionIdleTimeoutMs` | `300000` | Completion-idle and progress guard used after a tool handoff, native tool completion, or post-tool raw assistant progress while OpenClaw waits for `turn/completed`. Use this for trusted or heavy workloads where post-tool synthesis can legitimately stay quiet longer than the final assistant release budget. |
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
| `approvalPolicy` | `"never"` or an allowed guardian approval policy | Native Codex approval policy sent to thread start/resume/turn. Guardian defaults prefer `"on-request"` when allowed. |
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
OpenClaw-owned dynamic tool calls are bounded independently from
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
@@ -565,18 +565,23 @@ quiet for `appServer.turnCompletionIdleTimeoutMs`, OpenClaw best-effort
interrupts the Codex turn, records a diagnostic timeout, and releases the
OpenClaw session lane so follow-up chat messages are not queued behind a stale
native turn. Most non-terminal notifications for the same turn disarm that short
watchdog because Codex has proven the turn is still alive; raw
`custom_tool_call_output` completions keep the short post-tool watchdog armed
because they are the turn-scoped tool-result handoff. Global app-server
notifications, such as rate-limit updates, do not reset turn-idle progress.
Completed `agentMessage` items and pre-tool raw assistant
`rawResponseItem/completed` items arm the assistant-output release: if Codex then
goes quiet without `turn/completed`, OpenClaw best-effort interrupts the native
turn and releases the session lane. Post-tool raw assistant progress keeps
waiting for `turn/completed` while a completion-idle guard stays armed; the guard
uses `appServer.postToolRawAssistantCompletionIdleTimeoutMs` when configured and
defaults to five minutes otherwise. Replay-safe stdio app-server failures,
including turn-completion idle timeouts without assistant, tool, active-item, or
watchdog because Codex has proven the turn is still alive. Tool handoffs use a
longer post-tool idle budget: after OpenClaw returns an `item/tool/call`
response, after native tool items such as `commandExecution` complete, after raw
`custom_tool_call_output` completions, and after post-tool raw assistant
progress. The guard uses `appServer.postToolRawAssistantCompletionIdleTimeoutMs`
when configured and defaults to five minutes otherwise. That same post-tool
budget also extends the progress watchdog for the silent synthesis window before
Codex emits the next current-turn event. Global app-server notifications, such
as rate-limit updates, do not reset turn-idle progress. Reasoning completions,
commentary `agentMessage` completions, and pre-tool raw reasoning or assistant
progress can be followed by an automatic final reply, so they use the
post-progress reply guard instead of releasing the session lane immediately.
Only final/non-commentary completed `agentMessage` items and pre-tool raw
assistant completions arm the assistant-output release: if Codex then goes quiet
without `turn/completed`, OpenClaw best-effort interrupts the native turn and
releases the session lane. Replay-safe stdio app-server failures, including
turn-completion idle timeouts without assistant, tool, active-item, or
side-effect evidence, are retried once on a fresh app-server attempt. Unsafe
timeouts still retire the stuck app-server client and release the OpenClaw
session lane. They also clear the stale native thread binding and surface a

View File

@@ -141,7 +141,10 @@ observation-only.
**Subagents**
- `subagent_spawning` / `subagent_delivery_target` / `subagent_spawned` / `subagent_ended` - coordinate subagent routing and completion delivery
- `subagent_spawned` / `subagent_ended` - observe subagent launch and completion.
- `subagent_delivery_target` - compatibility hook for completion delivery when no core session binding can project a route.
- `subagent_spawning` - deprecated compatibility hook. Core now prepares `thread: true` subagent bindings through channel session-binding adapters before `subagent_spawned` fires.
- `subagent_spawned` includes `resolvedModel` and `resolvedProvider` when OpenClaw has resolved the child session's native model before launch.
**Lifecycle**
@@ -463,6 +466,10 @@ before the next major release:
- **`before_agent_start`** remains for compatibility. New plugins should use
`before_model_resolve` and `before_prompt_build` instead of the combined
phase.
- **`subagent_spawning`** remains for compatibility with older plugins, but
new plugins should not return thread routing from it. Core prepares
`thread: true` subagent bindings through channel session-binding adapters
before `subagent_spawned` fires.
- **`deactivate`** remains as a deprecated cleanup compatibility alias until
after 2026-08-16. New plugins should use `gateway_stop`.
- **`onResolution` in `before_tool_call`** now uses the typed

View File

@@ -120,7 +120,6 @@ commands.
| [senseaudio](/plugins/reference/senseaudio) | Adds media understanding provider support. | `@openclaw/senseaudio-provider`<br />included in OpenClaw | contracts: mediaUnderstandingProviders |
| [sglang](/plugins/reference/sglang) | Adds SGLang model provider support to OpenClaw. | `@openclaw/sglang-provider`<br />included in OpenClaw | providers: sglang |
| [signal](/plugins/reference/signal) | Adds the Signal channel surface for sending and receiving OpenClaw messages. | `@openclaw/signal`<br />included in OpenClaw | channels: signal |
| [skill-workshop](/plugins/reference/skill-workshop) | Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh. | `@openclaw/skill-workshop`<br />included in OpenClaw | contracts: tools |
| [stepfun](/plugins/reference/stepfun) | Adds StepFun, StepFun Plan model provider support to OpenClaw. | `@openclaw/stepfun-provider`<br />included in OpenClaw | providers: stepfun, stepfun-plan |
| [synthetic](/plugins/reference/synthetic) | Adds Synthetic model provider support to OpenClaw. | `@openclaw/synthetic-provider`<br />included in OpenClaw | providers: synthetic |
| [tavily](/plugins/reference/tavily) | Adds agent-callable tools. Adds web search provider support. | `@openclaw/tavily-plugin`<br />included in OpenClaw | contracts: tools, webSearchProviders; skills |

View File

@@ -114,7 +114,6 @@ pnpm plugins:inventory:gen
| [senseaudio](/plugins/reference/senseaudio) | Adds media understanding provider support. | `@openclaw/senseaudio-provider`<br />included in OpenClaw | contracts: mediaUnderstandingProviders |
| [sglang](/plugins/reference/sglang) | Adds SGLang model provider support to OpenClaw. | `@openclaw/sglang-provider`<br />included in OpenClaw | providers: sglang |
| [signal](/plugins/reference/signal) | Adds the Signal channel surface for sending and receiving OpenClaw messages. | `@openclaw/signal`<br />included in OpenClaw | channels: signal |
| [skill-workshop](/plugins/reference/skill-workshop) | Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh. | `@openclaw/skill-workshop`<br />included in OpenClaw | contracts: tools |
| [slack](/plugins/reference/slack) | OpenClaw Slack channel plugin for channels, DMs, commands, and app events. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
| [stepfun](/plugins/reference/stepfun) | Adds StepFun, StepFun Plan model provider support to OpenClaw. | `@openclaw/stepfun-provider`<br />included in OpenClaw | providers: stepfun, stepfun-plan |
| [synology-chat](/plugins/reference/synology-chat) | Synology Chat channel plugin for OpenClaw channels and direct messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |

View File

@@ -1,23 +0,0 @@
---
summary: "Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh."
read_when:
- You are installing, configuring, or auditing the skill-workshop plugin
title: "Skill Workshop plugin"
---
# Skill Workshop plugin
Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh.
## Distribution
- Package: `@openclaw/skill-workshop`
- Install route: included in OpenClaw
## Surface
contracts: tools
## Related docs
- [skill-workshop](/plugins/skill-workshop)

View File

@@ -792,6 +792,35 @@ canonical replacement.
</Accordion>
<Accordion title="subagent_spawning hook → core thread binding">
**Old**: `api.on("subagent_spawning", handler)` returning
`threadBindingReady` or `deliveryOrigin`.
**New**: let core prepare `thread: true` subagent bindings through the
channel session-binding adapter. Use `api.on("subagent_spawned", handler)`
only for post-launch observation.
```typescript
// Before
api.on("subagent_spawning", async () => ({
status: "ok",
threadBindingReady: true,
deliveryOrigin: { channel: "discord", to: "channel:123", threadId: "456" },
}));
// After
api.on("subagent_spawned", async (event) => {
await observeSubagentLaunch(event);
});
```
`subagent_spawning`, `PluginHookSubagentSpawningEvent`,
`PluginHookSubagentSpawningResult`, and
`SubagentLifecycleHookRunner.runSubagentSpawning(...)` remain only as
deprecated compatibility surfaces while external plugins migrate.
</Accordion>
<Accordion title="Provider discovery types → provider catalog types">
Four discovery type aliases are now thin wrappers over the
catalog-era types:

View File

@@ -1,713 +0,0 @@
---
summary: "Experimental capture of reusable procedures as workspace skills with review, approval, quarantine, and hot skill refresh"
title: "Skill workshop plugin"
read_when:
- You want agents to turn corrections or reusable procedures into workspace skills
- You are configuring procedural skill memory
- You are debugging skill_workshop tool behavior
- You are deciding whether to enable automatic skill creation
---
Skill Workshop is **experimental**. It is disabled by default, its capture
heuristics and reviewer prompts may change between releases, and automatic
writes should be used only in trusted workspaces after reviewing pending-mode
output first.
Skill Workshop is procedural memory for workspace skills. It lets an agent turn
reusable workflows, user corrections, hard-won fixes, and recurring pitfalls
into `SKILL.md` files under:
```text
<workspace>/skills/<skill-name>/SKILL.md
```
This is different from long-term memory:
- **Memory** stores facts, preferences, entities, and past context.
- **Skills** store reusable procedures the agent should follow on future tasks.
- **Skill Workshop** is the bridge from a useful turn to a durable workspace
skill, with safety checks and optional approval.
Skill Workshop is useful when the agent learns a procedure such as:
- how to validate externally sourced animated GIF assets
- how to replace screenshot assets and verify dimensions
- how to run a repo-specific QA scenario
- how to debug a recurring provider failure
- how to repair a stale local workflow note
It is not intended for:
- facts like "the user likes blue"
- broad autobiographical memory
- raw transcript archiving
- secrets, credentials, or hidden prompt text
- one-off instructions that will not repeat
## Default state
The bundled plugin is **experimental** and **disabled by default** unless it is
explicitly enabled in `plugins.entries.skill-workshop`.
The plugin manifest does not set `enabledByDefault: true`. The `enabled: true`
default inside the plugin config schema applies only after the plugin entry has
already been selected and loaded.
Experimental means:
- the plugin is supported enough for opt-in testing and dogfooding
- proposal storage, reviewer thresholds, and capture heuristics can evolve
- pending approval is the recommended starting mode
- auto apply is for trusted personal/workspace setups, not shared or hostile
input-heavy environments
## Enable
Minimal safe config:
```json5
{
plugins: {
entries: {
"skill-workshop": {
enabled: true,
config: {
autoCapture: true,
approvalPolicy: "pending",
reviewMode: "hybrid",
},
},
},
},
}
```
With this config:
- the `skill_workshop` tool is available
- explicit reusable corrections are queued as pending proposals
- threshold-based reviewer passes can propose skill updates
- no skill file is written until a pending proposal is applied
Use automatic writes only in trusted workspaces:
```json5
{
plugins: {
entries: {
"skill-workshop": {
enabled: true,
config: {
autoCapture: true,
approvalPolicy: "auto",
reviewMode: "hybrid",
},
},
},
},
}
```
`approvalPolicy: "auto"` still uses the same scanner and quarantine path. It
does not apply proposals with critical findings.
## Configuration
| Key | Default | Range / values | Meaning |
| -------------------- | ----------- | ------------------------------------------- | -------------------------------------------------------------------- |
| `enabled` | `true` | boolean | Enables the plugin after the plugin entry is loaded. |
| `autoCapture` | `true` | boolean | Enables post-turn capture/review on successful agent turns. |
| `approvalPolicy` | `"pending"` | `"pending"`, `"auto"` | Queue proposals or write safe proposals automatically. |
| `reviewMode` | `"hybrid"` | `"off"`, `"heuristic"`, `"llm"`, `"hybrid"` | Chooses explicit correction capture, LLM reviewer, both, or neither. |
| `reviewInterval` | `15` | `1..200` | Run reviewer after this many successful turns. |
| `reviewMinToolCalls` | `8` | `1..500` | Run reviewer after this many observed tool calls. |
| `reviewTimeoutMs` | `45000` | `5000..180000` | Timeout for the embedded reviewer run. |
| `maxPending` | `50` | `1..200` | Max pending/quarantined proposals kept per workspace. |
| `maxSkillBytes` | `40000` | `1024..200000` | Max generated skill/support file size. |
Recommended profiles:
```json5
// Conservative: explicit tool use only, no automatic capture.
{
autoCapture: false,
approvalPolicy: "pending",
reviewMode: "off",
}
```
```json5
// Review-first: capture automatically, but require approval.
{
autoCapture: true,
approvalPolicy: "pending",
reviewMode: "hybrid",
}
```
```json5
// Trusted automation: write safe proposals immediately.
{
autoCapture: true,
approvalPolicy: "auto",
reviewMode: "hybrid",
}
```
```json5
// Low-cost: no reviewer LLM call, only explicit correction phrases.
{
autoCapture: true,
approvalPolicy: "pending",
reviewMode: "heuristic",
}
```
## Capture paths
Skill Workshop has three capture paths.
### Tool suggestions
The model can call `skill_workshop` directly when it sees a reusable procedure
or when the user asks it to save/update a skill.
This is the most explicit path and works even with `autoCapture: false`.
### Heuristic capture
When `autoCapture` is enabled and `reviewMode` is `heuristic` or `hybrid`, the
plugin scans successful turns for explicit user correction phrases:
- `next time`
- `from now on`
- `remember to`
- `make sure to`
- `always ... use/check/verify/record/save/prefer`
- `prefer ... when/for/instead/use`
- `when asked`
The heuristic creates a proposal from the latest matching user instruction. It
uses topic hints to choose skill names for common workflows:
- animated GIF tasks -> `animated-gif-workflow`
- screenshot or asset tasks -> `screenshot-asset-workflow`
- QA or scenario tasks -> `qa-scenario-workflow`
- GitHub PR tasks -> `github-pr-workflow`
- fallback -> `learned-workflows`
Heuristic capture is intentionally narrow. It is for clear corrections and
repeatable process notes, not for general transcript summarization.
### LLM reviewer
When `autoCapture` is enabled and `reviewMode` is `llm` or `hybrid`, the plugin
runs a compact embedded reviewer after thresholds are reached.
The reviewer receives:
- the recent transcript text, capped to the last 12,000 characters
- up to 12 existing workspace skills
- up to 2,000 characters from each existing skill
- JSON-only instructions
The reviewer has no tools:
- `disableTools: true`
- `toolsAllow: []`
- `disableMessageTool: true`
The reviewer returns either `{ "action": "none" }` or one proposal. The `action` field is `create`, `append`, or `replace` - prefer `append`/`replace` when a relevant skill already exists; use `create` only when no existing skill fits.
Example `create`:
```json
{
"action": "create",
"skillName": "media-asset-qa",
"title": "Media Asset QA",
"reason": "Reusable animated media acceptance workflow",
"description": "Validate externally sourced animated media before product use.",
"body": "## Workflow\n\n- Verify true animation.\n- Record attribution.\n- Store a local approved copy.\n- Verify in product UI before final reply."
}
```
`append` adds `section` + `body`. `replace` swaps `oldText` for `newText` in the named skill.
## Proposal lifecycle
Every generated update becomes a proposal with:
- `id`
- `createdAt`
- `updatedAt`
- `workspaceDir`
- optional `agentId`
- optional `sessionId`
- `skillName`
- `title`
- `reason`
- `source`: `tool`, `agent_end`, or `reviewer`
- `status`
- `change`
- optional `scanFindings`
- optional `quarantineReason`
Proposal statuses:
- `pending` - waiting for approval
- `applied` - written to `<workspace>/skills`
- `rejected` - rejected by operator/model
- `quarantined` - blocked by critical scanner findings
State is stored per workspace under the Gateway state directory:
```text
<stateDir>/skill-workshop/<workspace-hash>.json
```
Pending and quarantined proposals are deduplicated by skill name and change
payload. The store keeps the newest pending/quarantined proposals up to
`maxPending`.
## Tool reference
The plugin registers one agent tool:
```text
skill_workshop
```
### `status`
Count proposals by state for the active workspace.
```json
{ "action": "status" }
```
Result shape:
```json
{
"workspaceDir": "/path/to/workspace",
"pending": 1,
"quarantined": 0,
"applied": 3,
"rejected": 0
}
```
### `list_pending`
List pending proposals.
```json
{ "action": "list_pending" }
```
To list another status:
```json
{ "action": "list_pending", "status": "applied" }
```
Valid `status` values:
- `pending`
- `applied`
- `rejected`
- `quarantined`
### `list_quarantine`
List quarantined proposals.
```json
{ "action": "list_quarantine" }
```
Use this when automatic capture appears to do nothing and the logs mention
`skill-workshop: quarantined <skill>`.
### `inspect`
Fetch a proposal by id.
```json
{
"action": "inspect",
"id": "proposal-id"
}
```
### `suggest`
Create a proposal. With `approvalPolicy: "pending"` (default), this queues instead of writing.
```json
{
"action": "suggest",
"skillName": "animated-gif-workflow",
"title": "Animated GIF Workflow",
"reason": "User established reusable GIF validation rules.",
"description": "Validate animated GIF assets before using them.",
"body": "## Workflow\n\n- Verify the URL resolves to image/gif.\n- Confirm it has multiple frames.\n- Record attribution and license.\n- Avoid hotlinking when a local asset is needed."
}
```
<AccordionGroup>
<Accordion title="Request immediate write in auto mode (apply: true)">
```json
{
"action": "suggest",
"apply": true,
"skillName": "animated-gif-workflow",
"description": "Validate animated GIF assets before using them.",
"body": "## Workflow\n\n- Verify true animation.\n- Record attribution."
}
```
With `approvalPolicy: "pending"`, `apply: true` still queues the proposal. Review it, then use
the `apply` action after approval.
</Accordion>
<Accordion title="Force pending under auto policy (apply: false)">
```json
{
"action": "suggest",
"apply": false,
"skillName": "screenshot-asset-workflow",
"description": "Screenshot replacement workflow.",
"body": "## Workflow\n\n- Verify dimensions.\n- Optimize the PNG.\n- Run the relevant gate."
}
```
</Accordion>
<Accordion title="Append to a named section">
```json
{
"action": "suggest",
"skillName": "qa-scenario-workflow",
"section": "Workflow",
"description": "QA scenario workflow.",
"body": "- For media QA, verify generated assets render and pass final assertions."
}
```
</Accordion>
<Accordion title="Replace exact text">
```json
{
"action": "suggest",
"skillName": "github-pr-workflow",
"oldText": "- Check the PR.",
"newText": "- Check unresolved review threads, CI status, linked issues, and changed files before deciding."
}
```
</Accordion>
</AccordionGroup>
### `apply`
Apply a pending proposal.
With `approvalPolicy: "pending"`, this action asks for operator approval before writing the
workspace skill.
```json
{
"action": "apply",
"id": "proposal-id"
}
```
`apply` refuses quarantined proposals:
```text
quarantined proposal cannot be applied
```
### `reject`
Mark a proposal rejected.
```json
{
"action": "reject",
"id": "proposal-id"
}
```
### `write_support_file`
Write a supporting file inside an existing or proposed skill directory.
Allowed top-level support directories:
- `references/`
- `templates/`
- `scripts/`
- `assets/`
Example:
```json
{
"action": "write_support_file",
"skillName": "release-workflow",
"relativePath": "references/checklist.md",
"body": "# Release Checklist\n\n- Run release docs.\n- Verify changelog.\n"
}
```
Support files are workspace-scoped, path-checked, byte-limited by
`maxSkillBytes`, scanned, and written atomically.
## Skill writes
Skill Workshop writes only under:
```text
<workspace>/skills/<normalized-skill-name>/
```
Skill names are normalized:
- lowercased
- non `[a-z0-9_-]` runs become `-`
- leading/trailing non-alphanumerics are removed
- max length is 80 characters
- final name must match `[a-z0-9][a-z0-9_-]{1,79}`
For `create`:
- if the skill does not exist, Skill Workshop writes a new `SKILL.md`
- if it already exists, Skill Workshop appends the body to `## Workflow`
For `append`:
- if the skill exists, Skill Workshop appends to the requested section
- if it does not exist, Skill Workshop creates a minimal skill then appends
For `replace`:
- the skill must already exist
- `oldText` must be present exactly
- only the first exact match is replaced
All writes are atomic and refresh the in-memory skills snapshot immediately, so
the new or updated skill can become visible without a Gateway restart.
## Safety model
Skill Workshop has a safety scanner on generated `SKILL.md` content and support
files.
Critical findings quarantine proposals:
| Rule id | Blocks content that... |
| -------------------------------------- | --------------------------------------------------------------------- |
| `prompt-injection-ignore-instructions` | tells the agent to ignore prior/higher instructions |
| `prompt-injection-system` | references system prompts, developer messages, or hidden instructions |
| `prompt-injection-tool` | encourages bypassing tool permission/approval |
| `shell-pipe-to-shell` | includes `curl`/`wget` piped into `sh`, `bash`, or `zsh` |
| `secret-exfiltration` | appears to send env/process env data over the network |
Warn findings are retained but do not block by themselves:
| Rule id | Warns on... |
| -------------------- | -------------------------------- |
| `destructive-delete` | broad `rm -rf` style commands |
| `unsafe-permissions` | `chmod 777` style permission use |
Quarantined proposals:
- keep `scanFindings`
- keep `quarantineReason`
- appear in `list_quarantine`
- cannot be applied through `apply`
To recover from a quarantined proposal, create a new safe proposal with the
unsafe content removed. Do not edit the store JSON by hand.
## Prompt guidance
When enabled, Skill Workshop injects a short prompt section that tells the agent
to use `skill_workshop` for durable procedural memory.
The guidance emphasizes:
- procedures, not facts/preferences
- user corrections
- non-obvious successful procedures
- recurring pitfalls
- stale/thin/wrong skill repair through append/replace
- saving reusable procedure after long tool loops or hard fixes
- short imperative skill text
- no transcript dumps
The write mode text changes with `approvalPolicy`:
- pending mode: queue suggestions; use `apply` after explicit approval
- auto mode: apply safe workspace-skill updates unless `apply: false` queues instead
## Costs and runtime behavior
Heuristic capture does not call a model.
LLM review uses an embedded run on the active/default agent model. It is
threshold-based so it does not run on every turn by default.
The reviewer:
- uses the same configured provider/model context when available
- falls back to runtime agent defaults
- has `reviewTimeoutMs`
- uses lightweight bootstrap context
- has no tools
- writes nothing directly
- can only emit a proposal that goes through the normal scanner and
approval/quarantine path
If the reviewer fails, times out, or returns invalid JSON, the plugin logs a
warning/debug message and skips that review pass.
## Operating patterns
Use Skill Workshop when the user says:
- "next time, do X"
- "from now on, prefer Y"
- "make sure to verify Z"
- "save this as a workflow"
- "this took a while; remember the process"
- "update the local skill for this"
Good skill text:
```markdown
## Workflow
- Verify the GIF URL resolves to `image/gif`.
- Confirm the file has multiple frames.
- Record source URL, license, and attribution.
- Store a local copy when the asset will ship with the product.
- Verify the local asset renders in the target UI before final reply.
```
Poor skill text:
```markdown
The user asked about a GIF and I searched two websites. Then one was blocked by
Cloudflare. The final answer said to check attribution.
```
Reasons the poor version should not be saved:
- transcript-shaped
- not imperative
- includes noisy one-off details
- does not tell the next agent what to do
## Debugging
Check whether the plugin is loaded:
```bash
openclaw plugins list --enabled
```
Check proposal counts from an agent/tool context:
```json
{ "action": "status" }
```
Inspect pending proposals:
```json
{ "action": "list_pending" }
```
Inspect quarantined proposals:
```json
{ "action": "list_quarantine" }
```
Common symptoms:
| Symptom | Likely cause | Check |
| ------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Tool is unavailable | Plugin entry is not enabled | `plugins.entries.skill-workshop.enabled` and `openclaw plugins list` |
| No automatic proposal appears | `autoCapture: false`, `reviewMode: "off"`, or thresholds not met | Config, proposal status, Gateway logs |
| Heuristic did not capture | User wording did not match correction patterns | Use explicit `skill_workshop.suggest` or enable LLM reviewer |
| Reviewer did not create a proposal | Reviewer returned `none`, invalid JSON, or timed out | Gateway logs, `reviewTimeoutMs`, thresholds |
| Proposal is not applied | `approvalPolicy: "pending"` | `list_pending`, then `apply` |
| Proposal disappeared from pending | Duplicate proposal reused, max pending pruning, or was applied/rejected/quarantined | `status`, `list_pending` with status filters, `list_quarantine` |
| Skill file exists but model misses it | Skill snapshot not refreshed or skill gating excludes it | `openclaw skills` status and workspace skill eligibility |
Relevant logs:
- `skill-workshop: queued <skill>`
- `skill-workshop: applied <skill>`
- `skill-workshop: quarantined <skill>`
- `skill-workshop: heuristic capture skipped: ...`
- `skill-workshop: reviewer skipped: ...`
- `skill-workshop: reviewer found no update`
## QA scenarios
Repo-backed QA scenarios:
- `qa/scenarios/plugins/skill-workshop-animated-gif-autocreate.md`
- `qa/scenarios/plugins/skill-workshop-pending-approval.md`
- `qa/scenarios/plugins/skill-workshop-reviewer-autonomous.md`
Run the deterministic coverage:
```bash
pnpm openclaw qa suite \
--scenario skill-workshop-animated-gif-autocreate \
--scenario skill-workshop-pending-approval \
--concurrency 1
```
Run reviewer coverage:
```bash
pnpm openclaw qa suite \
--scenario skill-workshop-reviewer-autonomous \
--concurrency 1
```
The reviewer scenario is intentionally separate because it enables
`reviewMode: "llm"` and exercises the embedded reviewer pass.
## When not to enable auto apply
Avoid `approvalPolicy: "auto"` when:
- the workspace contains sensitive procedures
- the agent is working on untrusted input
- skills are shared across a broad team
- you are still tuning prompts or scanner rules
- the model frequently handles hostile web/email content
Use pending mode first. Switch to auto mode only after reviewing the kind of
skills the agent proposes in that workspace.
## Related docs
- [Skills](/tools/skills)
- [Plugins](/tools/plugin)
- [Testing](/reference/test)

View File

@@ -50,8 +50,8 @@ Each card stores:
- optional linked session, run, task, or source URL
- optional execution metadata for a Codex or Claude session started from the card
- compact metadata for attempts, comments, links, proof, artifacts, automation,
claims, diagnostics, notifications, templates, archive state, and
stale-session detection
attachments, worker logs, worker protocol state, claims, diagnostics,
notifications, templates, archive state, and stale-session detection
- recent card events such as created, moved, linked, claimed, heartbeat,
attempt, proof, artifact, diagnostic, notification, dispatch, archive, stale,
or agent-updated changes
@@ -108,6 +108,27 @@ Workboard also exposes optional agent tools for board-aware workflows:
final summaries, proof, artifacts, created-card manifests, and blocker
reasons. Created-card manifests must reference cards linked back to the
completed card, which keeps phantom children out of summaries.
- `workboard_attachment_add`, `workboard_attachment_read`, and
`workboard_attachment_delete` store small card attachments in plugin SQLite
state, index them on the card, and expose them in worker context.
- `workboard_worker_log` and `workboard_protocol_violation` record worker log
lines and block cards when an automated worker stops without calling
`workboard_complete` or `workboard_block`.
- `workboard_board_create`, `workboard_board_archive`, and
`workboard_board_delete` manage persisted board metadata such as display name,
description, archive state, and default workspace.
- `workboard_runs` returns the persisted run-attempt history stored on a card.
- `workboard_specify` turns a rough triage or backlog card into a clarified
`todo` card and records the specification summary on the card.
- `workboard_decompose` fans a parent orchestration card into linked children,
inherits board and tenant metadata, and can complete the parent with a
created-card manifest.
- `workboard_notify_subscribe`, `workboard_notify_list`,
`workboard_notify_events`, `workboard_notify_advance`, and
`workboard_notify_unsubscribe` manage notification subscriptions in plugin
state. Event reads are replay-safe; the advance tool moves the durable cursor
so callers can resume without losing or double-reading completed, failed, or
stale card events.
- `workboard_boards`, `workboard_stats`, `workboard_promote`,
`workboard_reassign`, `workboard_reclaim`, `workboard_comment`,
`workboard_proof`, `workboard_unblock`, and `workboard_dispatch` let an agent
@@ -119,6 +140,15 @@ Claimed cards reject agent-tool mutations from other agents unless the caller
has the claim token returned by `workboard_claim`. Dashboard operators still use
the normal Gateway RPC surface and can recover or reassign cards.
Workboard stores all durable board data through the plugin SQLite key-value
store. Cards live in `workboard.cards`, board metadata in `workboard.boards`,
notification subscriptions in `workboard.notify`, and attachment blobs in
`workboard.attachments`. Run history, comments, proof, artifact references,
attachment indexes, diagnostics, dependencies, lifecycle events, worker logs,
protocol state, and automation metadata stay on the card record so a card export
preserves the board narrative without inlining attachment blob contents. Each
attachment blob must fit one 64 KiB plugin state value after JSON serialization.
Workboard diagnostics are computed from local card metadata. The built-in checks
flag assigned cards that wait too long, running cards without recent heartbeat,
blocked cards that need attention, repeated failures, done cards without proof,
@@ -126,9 +156,16 @@ and running cards that only have a loose session link.
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
system processes; normal OpenClaw sessions still own execution. A dispatch nudge
promotes dependency-ready cards, records dispatch metadata on ready cards, and
blocks expired claims or timed-out runs so operators can recover them from the
board.
promotes dependency-ready cards, records dispatch metadata on ready cards,
blocks expired claims or timed-out runs, marks board-configured triage cards as
orchestration candidates, and leaves durable notification subscriptions for the
caller that delivers notifications.
Board metadata can include orchestration settings such as `autoDecompose`,
`autoDecomposePerDispatch`, `defaultAssignee`, and `orchestratorProfile`.
OpenClaw records the orchestration intent and exposes it in worker context; the
actual specification, decomposition, or session start still happens through the
normal Workboard tools and dashboard session flow.
## Session lifecycle sync
@@ -188,9 +225,12 @@ The plugin registers Gateway RPC methods under the `workboard.*` namespace:
- `workboard.cards.export` requires `operator.read`
- `workboard.cards.diagnostics` requires `operator.read`
- `workboard.cards.diagnostics.refresh` requires `operator.write`
- attachment list/get and notification event reads require `operator.read`
- notification cursor advancement requires `operator.write`
- create, update, move, delete, comment, link, dependency link, proof, artifact,
claim, heartbeat, release, complete, block, unblock, dispatch, bulk, and
archive methods require `operator.write`
attachment add/delete, worker log, protocol violation, claim, heartbeat,
release, complete, block, unblock, dispatch, bulk, and archive methods require
`operator.write`
Browsers connected with read-only operator access can inspect the board but
cannot mutate cards.

View File

@@ -6,6 +6,7 @@ read_when:
- You want to enable OpenClaw code mode for an agent run
- You need to explain why code mode is different from Codex Code mode
- You are reviewing the exec/wait contract, QuickJS-WASI sandbox, TypeScript transform, or hidden tool-catalog bridge
- You are adding or reviewing an internal code-mode namespace registry integration
---
Code mode is an experimental OpenClaw agent-runtime feature. It is off by
@@ -380,6 +381,7 @@ The guest runtime exposes a small global API:
```typescript
declare const ALL_TOOLS: ToolCatalogEntry[];
declare const tools: ToolCatalog;
declare const namespaces: Record<string, unknown>;
declare function text(value: unknown): void;
declare function json(value: unknown): void;
@@ -433,6 +435,189 @@ const hits = await tools.web_search({ query: "OpenClaw code mode" });
The guest runtime must not expose host objects directly. Inputs and outputs cross
the bridge as JSON-compatible values with explicit size caps.
## Internal namespaces
Internal namespaces give code mode a concise domain API without adding more
model-visible tools. A loader-owned integration can register a namespace such
as `Issues`, `Fictions`, or `Calendar`; guest code then calls that namespace
inside the QuickJS program while OpenClaw still shows only `exec` and `wait` to
the model.
Namespaces are internal for now. There is no public plugin SDK namespace API:
external plugin namespaces need a loader-owned contract so plugin identity,
installed manifests, auth state, and cached catalog descriptors cannot drift
from the plugin tools that back the namespace. Core code mode owns only the
sandbox, serialization, catalog gating, and bridge dispatch.
Guest code can then use either the direct global or the `namespaces` map:
```javascript
const open = await Issues.list({ state: "open" });
const alsoOpen = await namespaces.Issues.list({ state: "open" });
return { count: open.length, alsoCount: alsoOpen.length };
```
### Registry lifecycle
The namespace registry is process-local and keyed by namespace id. A typical
run follows this path:
1. A trusted loader calls `registerCodeModeNamespaceForPlugin(pluginId, registration)`.
2. Code mode creates the hidden `ToolSearchRuntime` for the run and reads its
run-scoped catalog.
3. `createCodeModeNamespaceRuntime(ctx, catalog)` keeps only registrations
whose `requiredToolNames` are all visible and owned by the same `pluginId`.
4. Each visible namespace calls `createScope(ctx)` for the current run. The
scope receives run context such as `agentId`, `sessionKey`, `sessionId`,
`runId`, config, and abort state.
5. Scope data is serialized into a plain descriptor and injected into QuickJS as
direct globals and `namespaces.<globalName>`.
6. Guest calls suspend through the worker bridge, resolve the namespace path on
the host, map the call to a declared plugin-owned catalog tool, and execute
that tool through `ToolSearchRuntime.call`.
7. `wait` resumes the same namespace runtime when a code-mode run suspended on
nested tool work.
8. Plugin rollback or uninstall calls `clearCodeModeNamespacesForPlugin(pluginId)`
so stale globals do not survive a failed plugin load.
The important invariant: namespace calls are catalog tool calls. They use the
same policy hooks, approvals, abort handling, telemetry, transcript projection,
and suspend/resume behavior as `tools.call(...)`.
### Registration shape
Register namespaces from the integration that owns the backing tools. Keep the
scope small and only expose domain verbs that map to declared catalog tools.
```typescript
import {
createCodeModeNamespaceTool,
registerCodeModeNamespaceForPlugin,
} from "../agents/code-mode-namespaces.js";
const pluginId = "github";
registerCodeModeNamespaceForPlugin(pluginId, {
id: "github-issues",
globalName: "Issues",
description: "GitHub issue helpers for the current repository.",
requiredToolNames: ["github_list_issues", "github_update_issue"],
prompt: "Use Issues.list(params) and Issues.update(number, patch).",
createScope: (ctx) => ({
repository: ctx.config,
list: createCodeModeNamespaceTool("github_list_issues", ([params]) => params ?? {}),
update: createCodeModeNamespaceTool("github_update_issue", ([number, patch]) => ({
number,
patch,
})),
}),
});
```
`createCodeModeNamespaceTool(toolName, inputMapper)` marks a scope member as a
callable namespace function. The optional `inputMapper` receives the guest
arguments and returns the input object for the backing catalog tool. Without an
input mapper, the first guest argument is used, or `{}` when omitted.
Raw host functions are rejected before guest code runs:
```typescript
createScope: () => ({
// Wrong: this bypasses the catalog tool lifecycle and will be rejected.
list: async () => githubClient.listIssues(),
});
```
### Ownership and visibility
Namespace ownership is bound to the registration caller's `pluginId`.
`requiredToolNames` is both a visibility gate and an ownership check:
- every required tool must exist in the run catalog
- every required tool must have `sourceName === pluginId`
- the namespace is hidden when any required tool is absent or owned by another
plugin
- each callable path may target only a tool named in `requiredToolNames`
This prevents another plugin from exposing a namespace by registering a
same-named tool. It also keeps namespaces aligned with ordinary agent policy:
if the run cannot see the backing tools, it cannot see the namespace.
For example, a GitHub namespace should live behind a GitHub-owned extension that
owns GitHub auth, REST or GraphQL clients, rate limits, write approvals, and
tests. Core code mode should not embed GitHub-specific APIs, token handling, or
provider policy.
### Scope serialization rules
`createScope(ctx)` may return a plain object containing JSON-compatible values,
arrays, nested objects, and `createCodeModeNamespaceTool(...)` call markers.
Host objects never enter QuickJS directly.
The serializer rejects:
- raw functions
- circular object graphs
- unsafe path segments: `__proto__`, `constructor`, `prototype`, empty keys, or
keys containing the internal path separator
- `globalName` values that are not JavaScript identifiers
- `globalName` collisions with built-in code-mode globals such as `tools`,
`namespaces`, `text`, `json`, `yield_control`, or `__openclaw*`
Values that cannot be JSON-serialized are converted to JSON-safe fallback
values before crossing the bridge. Binary data, handles, sockets, clients, and
class instances should stay behind ordinary catalog tools.
### Prompts
The namespace `description` and optional `prompt` are appended to the model
visible `exec` schema only when the namespace is visible for that run. Use them
to teach the smallest useful surface:
```typescript
{
description: "Fiction production service helpers.",
prompt:
"Use Fictions.riskAudit(), Fictions.promoteIfReady(id, status), and Fictions.unpaidOver(amount).",
}
```
Keep prompts about the namespace contract, not auth setup, implementation
history, or unrelated plugin behavior.
### Cleanup
Namespaces are process-local registrations. Remove them when the owning plugin
is disabled, uninstalled, or rolled back:
```typescript
clearCodeModeNamespacesForPlugin(pluginId);
```
Use `unregisterCodeModeNamespace(namespaceId)` only when removing one known
namespace. Tests can call `clearCodeModeNamespacesForTest()` to avoid leaking
registrations across cases.
### Test checklist
Namespace changes should cover the security boundary and the guest behavior:
- namespace prompt text appears only when backing tools are visible
- same-named tools from another `sourceName` do not expose the namespace
- raw scope functions are rejected
- forged namespace ids and forged paths are rejected
- callable paths cannot target undeclared tools
- nested objects and shared references serialize correctly
- namespace calls execute through catalog tools and return JSON-safe details
- failures can be caught by guest code
- suspended namespace calls resume through `wait`
- plugin rollback clears the owning namespace registrations
Namespaces complement the generic `tools.search` / `tools.call` catalog. Use
the catalog for arbitrary enabled tools; use namespaces for plugin-owned,
documented domain APIs where concise code is more reliable than repeated schema
lookups.
## Output API
`text(value)` appends human-readable output to the `output` array.

View File

@@ -13,9 +13,11 @@ to the public blog post.
Two audits are combined here:
- **Release performance sweep:** GitHub Releases from `v2026.5.27` back through
- **Release performance sweep:** GitHub Releases from `v2026.5.28` back through
stable `v2026.4.23`, using the `OpenClaw Performance` workflow,
`profile=smoke`, `repeat=1`, mock-provider lane.
`profile=smoke`, mock-provider lane. Most tag rows are one sample; the
`v2026.5.27` and `v2026.5.28` rows use the latest repeat-3 release-branch
artifacts.
- **Earlier April context:** published `clawgrit-reports` mock-provider
baselines from `v2026.4.1` through `v2026.5.2`, used only to avoid treating
the broken late-April releases as the public performance baseline.
@@ -27,42 +29,44 @@ Two audits are combined here:
file count.
<Warning>
The main performance sweep uses one smoke sample per tag. Earlier April context
uses published repeat-3 medians from `clawgrit-reports`. Treat the numbers as
trend evidence and regression-hunting signal, not as release-gate statistics.
The main performance sweep uses one smoke sample per tag, except the
`v2026.5.27` and `v2026.5.28` rows, which use the latest repeat-3
release-branch artifacts. Earlier April context uses published repeat-3
medians from `clawgrit-reports`. Treat the numbers as trend evidence and
regression-hunting signal, not as release-gate statistics.
</Warning>
## Snapshot
Performance coverage: **76 requested releases**, **73 artifact-backed points**,
and **3 unavailable CI runs**. Latest stable measured point: `v2026.5.27`.
Performance coverage: **77 requested releases**, **74 artifact-backed points**,
and **3 unavailable CI runs**. Latest stable measured point: `v2026.5.28`.
<CardGroup cols={2}>
<Card title="Stable agent turn" icon="gauge">
**2.9x faster cold turn**
**5.1x faster cold turn**
- `v2026.4.14`: 9.8s
- `v2026.5.27`: 3.4s
- `v2026.5.28`: 1.9s
</Card>
<Card title="Published package" icon="package">
**17.8MB tarball**
**17.9MB tarball**
Latest stable package, down from the 43.3MB March package-size peak.
</Card>
<Card title="Latest stable install" icon="hard-drive">
**786.9MB fresh install**
**361.7MiB fresh install**
`v2026.5.27` still contains the nested OpenClaw dependency tree. The
next-release state on `main` is 407.4MB.
`v2026.5.28` cuts the nested OpenClaw dependency tree sharply, but a
smaller 259.7MiB nested tree still remains in the local install audit.
</Card>
<Card title="Dependency graph" icon="boxes">
**371 installed packages**
**300 installed packages**
Latest stable release. Current `main` is down to 314 after the follow-up
dependency cleanup.
Latest stable release, measured as unique package name/version roots in a
fresh install with scripts disabled.
</Card>
</CardGroup>
@@ -84,45 +88,44 @@ and **3 unavailable CI runs**. Latest stable measured point: `v2026.5.27`.
</Card>
<Card title="Latest stable" icon="tag">
**786.9MB install**
**361.7MiB install**
`2026.5.27` reduced the peak but still installed a 675.9MB nested
OpenClaw tree.
`2026.5.28` cuts fresh install size by 52.8% from `2026.5.27`, but still
installs a 259.7MiB nested OpenClaw tree.
</Card>
<Card title="Next-release state" icon="scissors">
**407.4MB install**
<Card title="Dependency graph" icon="scissors">
**300 package roots**
Current `main` keeps shrinkwrap, removes the nested tree, and installs
314 packages.
`2026.5.28` installs 71 fewer unique package name/version roots than
`2026.5.27`.
</Card>
</CardGroup>
<Tip>
Shrinkwrap was not the problem by itself. The bad package shape was. Current
`main` still ships shrinkwrap, but npm no longer materializes a second
OpenClaw dependency tree during install.
Shrinkwrap was not the problem by itself. The bad package shape was.
`v2026.5.28` still ships shrinkwrap, but the nested dependency tree is much
smaller and the all-platform canvas fanout is gone in the local audit.
</Tip>
## What Changed After 5.27
## What Changed In 5.28
The cleanup between `v2026.5.27` and current `main` removed the duplicate
default-install graph instead of removing the capabilities themselves.
The cleanup between `v2026.5.27` and `v2026.5.28` reduced the default-install
graph instead of removing the capabilities themselves.
<CardGroup cols={2}>
<Card title="Root default graph" icon="git-branch">
Root shrinkwrap package paths fell from **372** to **331**. Unique package
names fell from **357** to **318**.
Unique package name/version roots fell from **371** to **300**. Package
instances fell from **372** to **301**.
</Card>
<Card title="Direct root dependencies" icon="unplug">
`@earendil-works/pi-agent-core`, `@earendil-works/pi-ai`,
`@earendil-works/pi-coding-agent`, and `pdfjs-dist` left the default root
dependency path.
<Card title="Nested tree" icon="unplug">
Nested `openclaw/node_modules` fell from **656.1MiB** to **259.7MiB** in
the same local install audit.
</Card>
<Card title="Native optional cones" icon="cpu">
The all-platform `@napi-rs/canvas` and `@mariozechner/clipboard` native
package cones stopped landing in the default install.
The all-platform `@napi-rs/canvas` native package cone stopped landing in
the default install.
</Card>
<Card title="Supply-chain surface" icon="shield">
Fewer default packages means fewer tarballs, maintainers, native binaries,
@@ -138,11 +141,11 @@ Do not use the late-April broken rows as public performance baselines.
For the blog narrative, use the earlier April published baseline as scale:
| Metric | Earlier April baseline | `v2026.5.27` | Delta |
| Metric | Earlier April baseline | `v2026.5.28` | Delta |
| --------------- | ---------------------: | -----------: | -----------------------: |
| Cold agent turn | 9,819ms | 3,378ms | 65.6% lower, 2.9x faster |
| Warm agent turn | 7,458ms | 2,973ms | 60.1% lower, 2.5x faster |
| Agent peak RSS | 686.2MB | 635.5MB | 7.4% lower |
| Cold agent turn | 9,819ms | 1,908ms | 80.6% lower, 5.1x faster |
| Warm agent turn | 7,458ms | 1,870ms | 74.9% lower, 4.0x faster |
| Agent peak RSS | 686.2MB | 581.0MB | 15.3% lower |
The earlier April baseline is `v2026.4.14` from the published
`clawgrit-reports` mock-provider run. That run used repeat 3 and failed only
@@ -150,32 +153,33 @@ because the diagnostic timeline was not emitted; the cold, warm, and RSS
medians are still useful as rough scale. Treat this as narrative context, not a
release-gate statistic.
Within the single-sample stable May sweep, the line moved more modestly:
Within the May sweep, the latest release-branch row moved materially from
`v2026.5.2`:
| Metric | `v2026.5.2` | `v2026.5.27` | Delta |
| Metric | `v2026.5.2` | `v2026.5.28` | Delta |
| --------------- | ----------: | -----------: | ----------: |
| Cold agent turn | 3,897ms | 3,378ms | 13.3% lower |
| Warm agent turn | 3,610ms | 2,973ms | 17.6% lower |
| Agent peak RSS | 613.7MB | 635.5MB | 3.6% higher |
| Cold agent turn | 3,897ms | 1,908ms | 51.0% lower |
| Warm agent turn | 3,610ms | 1,870ms | 48.2% lower |
| Agent peak RSS | 613.7MB | 581.0MB | 5.3% lower |
Best prerelease point in the single-sample sweep:
Compared with the previous stable release:
| Metric | `v2026.5.27` | `v2026.5.27-beta.1` | Delta |
| --------------- | -----------: | ------------------: | ----------: |
| Cold agent turn | 3,378ms | 2,575ms | 23.8% lower |
| Warm agent turn | 2,973ms | 2,217ms | 25.4% lower |
| Agent peak RSS | 635.5MB | 635.3MB | flat |
| Metric | `v2026.5.27` | `v2026.5.28` | Delta |
| --------------- | -----------: | -----------: | ----------: |
| Cold agent turn | 2,231ms | 1,908ms | 14.5% lower |
| Warm agent turn | 2,226ms | 1,870ms | 16.0% lower |
| Agent peak RSS | 649.0MB | 581.0MB | 10.5% lower |
### Install footprint
| Metric | Baseline | Current main | Delta |
| Metric | Baseline | `v2026.5.28` | Delta |
| ----------------------------------------------- | --------: | -----------: | ----------: |
| Install size from `2026.5.22` peak | 1,020.6MB | 407.4MB | 60.1% lower |
| Install size from latest release `2026.5.27` | 786.9MB | 407.4MB | 48.2% lower |
| Dependencies from monthly high `2026.2.26` | 645 | 314 | 51.3% lower |
| Dependencies from latest release `2026.5.27` | 371 | 314 | 15.4% lower |
| Nested `openclaw/node_modules` from `2026.5.22` | 911.8MB | 0MB | removed |
| Nested `openclaw/node_modules` from `2026.5.27` | 675.9MB | 0MB | removed |
| Install size from `2026.5.22` peak | 1,020.6MB | 361.7MiB | 64.6% lower |
| Install size from latest release `2026.5.27` | 767.1MiB | 361.7MiB | 52.8% lower |
| Dependencies from monthly high `2026.2.26` | 645 | 300 | 53.5% lower |
| Dependencies from latest release `2026.5.27` | 371 | 300 | 19.1% lower |
| Nested `openclaw/node_modules` from `2026.5.22` | 911.8MB | 259.7MiB | 71.5% lower |
| Nested `openclaw/node_modules` from `2026.5.27` | 656.1MiB | 259.7MiB | 60.4% lower |
### npm package size
@@ -187,7 +191,8 @@ Best prerelease point in the single-sample sweep:
| `2026.4.29` | 22.9MB | 74.6MB | 9,309 | package pruning visible |
| `2026.5.12` | 23.4MB | 80.1MB | 12,035 | major external-plugin split |
| `2026.5.22` | 17.2MB | 76.9MB | 12,386 | docs/assets excluded from package |
| `2026.5.27` | 17.8MB | 79.0MB | 12,509 | latest stable package |
| `2026.5.27` | 17.8MB | 79.0MB | 12,509 | previous stable package |
| `2026.5.28` | 17.9MB | 81.0MB | 9,082 | latest stable package |
`2026.5.12` is the visible plugin-extraction milestone in the changelog:
Amazon Bedrock, Bedrock Mantle, Slack, OpenShell sandbox, Anthropic Vertex,
@@ -211,7 +216,7 @@ Earlier published context:
| `v2026.4.20` | FAIL | 22,314ms | 18,811ms | 810.8MB |
| `v2026.4.22` | FAIL | 9,630ms | 7,459ms | 743.0MB |
Supplied single-sample sweep:
Supplied sweep:
| Release | Kova | Cold turn | Warm turn | Agent peak RSS |
| ------------------- | ---- | --------: | --------: | -------------: |
@@ -229,7 +234,8 @@ Supplied single-sample sweep:
| `v2026.5.22` | PASS | 4,494ms | 4,093ms | 654.3MB |
| `v2026.5.26` | PASS | 2,626ms | 2,282ms | 660.4MB |
| `v2026.5.27-beta.1` | PASS | 2,575ms | 2,217ms | 635.3MB |
| `v2026.5.27` | PASS | 3,378ms | 2,973ms | 635.5MB |
| `v2026.5.27` | PASS | 2,231ms | 2,226ms | 649.0MB |
| `v2026.5.28` | PASS | 1,908ms | 1,870ms | 581.0MB |
## Source probes
@@ -249,7 +255,8 @@ Representative source-probe points:
| `v2026.5.22` | 2,081ms | 1,884ms | 5,095ms | 444.2MB |
| `v2026.5.26` | 1,546ms | 1,634ms | 656ms | 400.4MB |
| `v2026.5.27-beta.1` | 1,462ms | 1,548ms | 548ms | 394.0MB |
| `v2026.5.27` | 1,874ms | 1,925ms | 660ms | 398.0MB |
| `v2026.5.27` | 1,491ms | 1,571ms | 553ms | 401.5MB |
| `v2026.5.28` | 1,457ms | 1,474ms | 623ms | 386.1MB |
The `v2026.5.22` CLI health spike is visible in this table even though the
agent-turn lane still passed. Keep the source probes when investigating
@@ -258,8 +265,7 @@ targeted CLI or gateway regressions.
## Install footprint audit
Dependency samples use one stable release per month, plus the
`2026.5.22` shrinkwrap-introduction event, latest `2026.5.27`, and current
`main`.
`2026.5.22` shrinkwrap-introduction event and the latest `2026.5.28` release.
| Point | Installed deps | Fresh install | OpenClaw package | Nested `openclaw/node_modules` | Root shrinkwrap | Canvas install behavior |
| ------------------ | -------------: | ------------: | ---------------: | -----------------------------: | --------------- | ----------------------------------------- |
@@ -269,8 +275,8 @@ Dependency samples use one stable release per month, plus the
| Apr `2026.4.29` | 392 | 335.0MB | 97.4MB | 0MB | no | none installed |
| `2026.5.22` | 401 | 1,020.6MB | 1,020.4MB | 911.8MB | yes | nested: all 12 `@napi-rs/canvas` packages |
| May `2026.5.26` | 371 | 767.5MB | 767.4MB | 656.4MB | yes | nested: all 12 `@napi-rs/canvas` packages |
| Latest `2026.5.27` | 371 | 786.9MB | 786.7MB | 675.9MB | yes | nested: all 12 `@napi-rs/canvas` packages |
| Current `main` | 314 | 407.4MB | 101.0MB | 0MB | yes | top-level wrapper + `darwin-arm64` |
| `2026.5.27` | 371 | 767.1MiB | 766.9MiB | 656.1MiB | yes | nested: all 12 `@napi-rs/canvas` packages |
| Latest `2026.5.28` | 300 | 361.7MiB | 361.6MiB | 259.7MiB | yes | none installed |
### Shrinkwrap boundary
@@ -284,11 +290,12 @@ Dependency samples use one stable release per month, plus the
`openclaw/node_modules`.
</Card>
<Card title="Latest stable" icon="tag">
`2026.5.27` keeps shrinkwrap and still installs 675.9MB under nested
`2026.5.28` keeps shrinkwrap and still installs 259.7MiB under nested
`openclaw/node_modules`.
</Card>
<Card title="Current main" icon="check">
`main` keeps shrinkwrap and removes the nested OpenClaw dependency tree.
<Card title="Canvas fanout fixed" icon="check">
`2026.5.28` no longer installs any `@napi-rs/canvas` packages in the local
fresh install audit.
</Card>
</CardGroup>
@@ -304,12 +311,13 @@ Published tarball inspection verifies the boundary:
| `2026.5.25` | no | n/a | no stable npm release |
| `2026.5.26` | yes | yes | nested dependency tree still present |
| `2026.5.27` | yes | yes | nested dependency tree still present |
| `main` | n/a | yes | nested dependency tree removed |
| `2026.5.28` | yes | yes | nested dependency tree much smaller |
The important distinction: **shrinkwrap itself is not the problem**. Current
`main` still ships root shrinkwrap. The problem was the package shape that made
npm materialize a large nested OpenClaw dependency tree and all 12
`@napi-rs/canvas` platform packages.
The important distinction: **shrinkwrap itself is not the problem**.
`v2026.5.28` still ships root shrinkwrap. The problem was the package shape
that made npm materialize a large nested OpenClaw dependency tree and all 12
`@napi-rs/canvas` platform packages. The nested tree is smaller in `v2026.5.28`,
and the canvas platform fanout no longer lands in the local audit.
For a plain-English explanation of shrinkwrap and the maintainer-level package
checks, see [npm shrinkwrap](/gateway/security/shrinkwrap).
@@ -337,24 +345,3 @@ Related docs:
- [Plugin dependency resolution](/plugins/dependency-resolution)
- [Plugin inventory](/plugins/plugin-inventory)
- [Full release validation](/reference/full-release-validation)
## Unavailable performance runs
| Release | Run | Result | Reason |
| ------------------- | ---------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
| `v2026.5.3-1` | [26561664645](https://github.com/openclaw/openclaw/actions/runs/26561664645) | failure | mock-provider job failed: CLI startup timed out waiting for qa-channel ready; no qa-channel accounts reported |
| `v2026.5.3` | [26561666722](https://github.com/openclaw/openclaw/actions/runs/26561666722) | failure | mock-provider job failed: CLI startup timed out waiting for qa-channel ready; no qa-channel accounts reported |
| `v2026.4.29-beta.2` | [26561683635](https://github.com/openclaw/openclaw/actions/runs/26561683635) | cancelled | optional baseline fetch hung before artifact upload |
## Follow-up gates
Recommended release checks from this sweep:
1. Run the mock-provider performance smoke for release candidates and retain
artifacts.
2. Track cold turn, warm turn, agent RSS, Gateway `readyz`, and CLI health.
3. Fresh-install the packed tarball with scripts disabled.
4. Record installed dependency count, install size, package size, nested
`openclaw/node_modules` size, and native optional package shape.
5. Fail or hold release review when nested dependency trees or all-platform
native packages appear unexpectedly.

View File

@@ -125,7 +125,7 @@ openclaw sessions cleanup --enforce
Isolated cron runs also create session entries/transcripts, and they have dedicated retention controls:
- `cron.sessionRetention` (default `24h`) prunes old isolated cron run sessions from the session store (`false` disables).
- `cron.runLog.maxBytes` + `cron.runLog.keepLines` prune `~/.openclaw/cron/runs/<jobId>.jsonl` files (defaults: `2_000_000` bytes and `2000` lines).
- `cron.runLog.keepLines` prunes retained SQLite run-history rows per cron job (default: `2000`). `cron.runLog.maxBytes` remains accepted for older file-backed run logs.
When cron force-creates a new isolated run session, it sanitizes the previous
`cron:<jobId>` session entry before writing the new row. It carries safe

View File

@@ -94,6 +94,45 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
</Step>
</Steps>
## Propose before applying
For agent-generated procedures, use a Skill Workshop proposal instead of
writing `SKILL.md` directly:
```bash
openclaw skills workshop propose-create \
--name "hello-world" \
--description "A simple skill that says hello." \
--proposal ./PROPOSAL.md
```
Use `--proposal-dir` when the proposal also has support files:
```bash
openclaw skills workshop propose-create \
--name "hello-world" \
--description "A simple skill that says hello." \
--proposal-dir ./hello-world-proposal
```
The draft is stored under
`<OPENCLAW_STATE_DIR>/skill-workshop/proposals/<proposal-id>/PROPOSAL.md` and
stays inactive until an operator reviews and applies it. The default state
directory is `~/.openclaw`. Proposal directories must contain `PROPOSAL.md`.
Support files can be included under `assets/`, `examples/`, `references/`,
`scripts/`, or `templates/`; OpenClaw stores and scans them with the proposal:
```bash
openclaw skills workshop inspect <proposal-id>
openclaw skills workshop revise <proposal-id> --proposal ./PROPOSAL.md
openclaw skills workshop apply <proposal-id>
```
When applied, OpenClaw writes the final `SKILL.md` into the workspace `skills/`
root, writes approved support files beside it, and removes proposal-only
frontmatter such as `status: proposal`, proposal `version`, and proposal
`date`.
## Skill metadata reference
The YAML frontmatter supports these fields:

View File

@@ -162,11 +162,16 @@ Approval-backed interpreter/runtime runs are intentionally conservative:
When approvals are required, the exec tool returns immediately with an approval id. Use that id to
correlate later approved-run system events (`Exec finished`, and `Exec running` when configured).
If no decision arrives before the timeout, the request is treated as an approval timeout and
surfaced as a terminal denial rather than an agent-waking system event.
surfaced as a terminal host-command denial. For main-agent async approvals with an originating
session, OpenClaw also resumes that session with an internal followup so the agent observes that
the command did not run instead of later repairing a missing result.
### Followup delivery behavior
After an approved async exec finishes, OpenClaw sends a followup `agent` turn to the same session.
Denied async approvals use the same main-session followup path for the denial status, but they do
not register elevated runtime handoffs and they do not run the command. Denials without a resumable
main session are either suppressed or reported through a safe direct route when one exists.
- If a valid external delivery target exists (deliverable channel plus target `to`), followup delivery uses that channel.
- In webchat-only or internal-session flows with no external target, followup delivery stays session-only (`deliver: false`).

View File

@@ -443,9 +443,13 @@ Exec lifecycle is surfaced as system messages:
- `Exec finished`.
These are posted to the agent's session after the node reports the event.
Denied exec approvals are terminal: OpenClaw can report the denial to the
operator or direct chat route, but it does not post `Exec denied` back into the
agent session or wake agent work.
Denied exec approvals are terminal for the host command itself: the command
does not run. For main-agent async approvals with an originating session,
OpenClaw posts the denial back into that session as an internal followup so the
agent can stop waiting on the async command and avoid a missing-result repair.
If there is no session or the session cannot be resumed, OpenClaw can still
report a concise denial to the operator or direct chat route. Denials for
subagent sessions are not posted back into the subagent.
Gateway-host exec approvals emit the same lifecycle events when the
command finishes (and optionally when running longer than the threshold).
Approval-gated execs reuse the approval id as the `runId` in these
@@ -453,11 +457,12 @@ messages for easy correlation.
## Denied approval behavior
When an async exec approval is denied, OpenClaw treats the request as terminal.
It can show a concise denial to the operator or direct chat route, but it does
not send denial guidance back through the agent session. That keeps a denied
command from becoming another model turn and prevents the agent from reusing
output from an earlier run of the same command.
When an async exec approval is denied, OpenClaw treats the host command as
terminal and fail-closed. For main-agent sessions, the denial is delivered as an
internal session followup that tells the agent the async command did not run.
That preserves transcript continuity without exposing stale command output. If
session delivery is unavailable, OpenClaw falls back to a concise operator or
direct-chat denial when a safe route exists.
## Implications

View File

@@ -143,9 +143,6 @@ Choose the extension path by the job you need OpenClaw to do:
[Build plugins](/plugins/building-plugins).
- Add or tune reusable agent instructions with [Skills](/tools/skills) and
[Creating skills](/tools/creating-skills).
- Package reusable workflow material with
[Skill workshop](/plugins/skill-workshop) when the workflow belongs in a
plugin-distributed skill bundle.
- Use [Plugin SDK](/plugins/sdk-overview) and [Plugin manifest](/plugins/manifest) when you need implementation contracts.
## Troubleshoot missing tools

View File

@@ -25,6 +25,14 @@ Most skills loader/install configuration lives under `skills` in
nodeManager: "npm", // npm | pnpm | yarn | bun (Gateway runtime still Node; bun not recommended)
allowUploadedArchives: false,
},
workshop: {
autonomous: {
enabled: false,
},
approvalPolicy: "pending", // pending | auto
maxPending: 50,
maxSkillBytes: 40000,
},
entries: {
"image-lab": {
enabled: true,
@@ -110,6 +118,18 @@ Rules:
clients to install private zip archives staged through `skills.upload.*`
(default: false). This only enables the uploaded-archive path; normal ClawHub
installs do not require it.
- `workshop.autonomous.enabled`: allow agents to create pending Skill Workshop
proposals from durable conversation signals after successful turns (default:
false). User-prompted skill creation still goes through Skill Workshop.
- `workshop.approvalPolicy`: proposal lifecycle policy. `pending` requires
approval before agent-initiated apply/reject/quarantine actions; `auto`
allows those actions without approval.
- `workshop.maxPending`: maximum pending/quarantined proposals retained per
workspace (default: 50).
- `workshop.maxSkillBytes`: maximum generated proposal body size in bytes
(default: 40000).
Proposal descriptions are also hard-capped at 160 bytes because they can be
shown in skill discovery and proposal listings.
- `entries.<skillKey>`: per-skill overrides.
- `agents.defaults.skills`: optional default skill allowlist inherited by agents
that omit `agents.list[].skills`.

View File

@@ -118,22 +118,66 @@ workspace skill overrides them. You can gate them via
See [Plugins](/tools/plugin) for discovery/config and [Tools](/tools) for
the tool surface those skills teach.
## Skill Workshop
## Skill Workshop proposals
The optional, experimental **Skill Workshop** plugin can create or update
workspace skills from reusable procedures observed during agent work. It
is disabled by default and must be explicitly enabled via
`plugins.entries.skill-workshop`.
Skill Workshop proposals are durable drafts for creating or updating workspace
skills without silently mutating active `SKILL.md` files. OpenClaw stores them
under:
Skill Workshop writes only to `<workspace>/skills`, scans generated
content, supports pending approval or automatic safe writes, quarantines
unsafe proposals, and refreshes the skill snapshot after successful
writes so new skills become available without a Gateway restart.
```text
<OPENCLAW_STATE_DIR>/skill-workshop/
proposals.json
proposals/<proposal-id>/
proposal.json
PROPOSAL.md
references/
scripts/
rollback.json
```
Use it for corrections such as _"next time, verify GIF attribution"_ or
hard-won workflows such as media QA checklists. Start with pending
approval; use automatic writes only in trusted workspaces after reviewing
its proposals. Full guide: [Skill Workshop plugin](/plugins/skill-workshop).
The default state directory is `~/.openclaw`.
`proposal.json` is the canonical proposal record. `proposals.json` is the fast
listing manifest and can be rebuilt from proposal folders when missing or stale.
`PROPOSAL.md` marks draft content explicitly with `status: proposal`,
`version: v1`, and `date`; those proposal-only fields are stripped when the
proposal is applied as an active `SKILL.md`.
Proposal bodies honor `skills.workshop.maxSkillBytes`, and proposal
descriptions are capped at 160 bytes because they can appear in discovery and
listing output.
Proposal folders can also carry support files under `assets/`, `examples/`,
`references/`, `scripts/`, or `templates/`. OpenClaw records support file
metadata in `proposal.json`, stores the file contents beside `PROPOSAL.md`,
scans them with the proposal, and verifies their hashes before apply. Approved
support files are written into the active skill directory beside `SKILL.md`.
Only pending proposals can be revised or applied. Revision keeps the same
proposal id, increments the proposal version, refreshes the proposal date,
reruns scanner metadata, and preserves existing support files unless a new
support-file list is supplied. Apply writes to the selected workspace `skills/`
root, runs the skill scanner, writes rollback metadata, refuses to overwrite an
existing create target, and marks update proposals stale when the target skill
changed since proposal creation. Reject and quarantine update only proposal
metadata; they do not touch active skills.
Use the CLI for operator review:
```bash
openclaw skills workshop list
openclaw skills workshop inspect <proposal-id>
openclaw skills workshop revise <proposal-id> --proposal ./PROPOSAL.md
openclaw skills workshop apply <proposal-id>
openclaw skills workshop reject <proposal-id>
openclaw skills workshop quarantine <proposal-id>
```
Agents can draft proposals through the `skill_workshop` tool when they identify
work worth reusing and can revise pending proposals during review. When the
user explicitly asks to approve/use/apply, reject, or quarantine a specific
proposal, the tool can perform that lifecycle action through Skill Workshop
instead of shell or direct filesystem changes.
## ClawHub (install and sync)
@@ -564,6 +608,5 @@ schema: [Skills config](/tools/skills-config).
- [ClawHub](/clawhub) - public skills registry
- [Creating skills](/tools/creating-skills) - building custom skills
- [Plugins](/tools/plugin) - plugin system overview
- [Skill Workshop plugin](/plugins/skill-workshop) - generate skills from agent work
- [Skills config](/tools/skills-config) - skill configuration reference
- [Slash commands](/tools/slash-commands) - all available slash commands

View File

@@ -144,6 +144,10 @@ session to confirm the effective tool list.
- **Run timeout:** if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout).
- **Task delivery:** native sub-agents receive the delegated task in their first visible `[Subagent Task]` message. The sub-agent system prompt carries runtime rules and routing context, not a hidden duplicate of the task.
Accepted native sub-agent spawns include the resolved child model metadata in
the tool result: `resolvedModel` contains the applied model ref and
`resolvedProvider` contains the provider prefix when the ref has one.
### Delegation prompt mode
`agents.defaults.subagents.delegationMode` controls prompt guidance only; it does not change tool policy or enforce delegation.
@@ -287,14 +291,12 @@ same sub-agent session.
### Thread supporting channels
**Discord** is currently the only supported channel. It supports
persistent thread-bound subagent sessions (`sessions_spawn` with
`thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`,
`/session idle`, `/session max-age`), and adapter keys
`channels.discord.threadBindings.enabled`,
`channels.discord.threadBindings.idleHours`,
`channels.discord.threadBindings.maxAgeHours`, and
`channels.discord.threadBindings.spawnSessions`.
Any channel with a session-binding adapter can support persistent
thread-bound subagent sessions (`sessions_spawn` with `thread: true`).
Bundled adapters currently include Discord threads, Matrix threads,
Telegram forum topics, and current-conversation bindings for Feishu.
Use the per-channel `threadBindings` config keys for enablement,
timeouts, and `spawnSessions`.
### Quick flow

View File

@@ -4130,6 +4130,50 @@ describe("active-memory plugin", () => {
expect(cached?.summary).toBe("memory 1");
});
it("drops cached active-memory results when the current clock is not a valid date timestamp", () => {
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
const cacheKey = testing.buildCacheKey({
agentId: "main",
sessionKey: "agent:main:invalid-clock-cache",
query: "cache invalid clock prompt",
});
testing.setCachedResult(
cacheKey,
{
status: "ok",
elapsedMs: 1,
rawReply: "memory",
summary: "memory",
},
15_000,
);
nowSpy.mockReturnValue(Number.NaN);
expect(testing.getCachedResult(cacheKey)).toBeUndefined();
});
it("does not cache active-memory results when the expiry timestamp would exceed the valid date range", () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const cacheKey = testing.buildCacheKey({
agentId: "main",
sessionKey: "agent:main:overflow-cache",
query: "cache overflow prompt",
});
testing.setCachedResult(
cacheKey,
{
status: "ok",
elapsedMs: 1,
rawReply: "memory",
summary: "memory",
},
15_000,
);
expect(testing.getCachedResult(cacheKey)).toBeUndefined();
});
it("skips recall after consecutive timeouts when circuit breaker trips (#74054)", async () => {
const CONFIGURED_TIMEOUT_MS = 25;
testing.setMinimumTimeoutMsForTests(1);

View File

@@ -13,7 +13,11 @@ import {
} from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { closeActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import {
asDateTimestampMs,
parseStrictPositiveInteger,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
resolveLivePluginConfigObject,
resolvePluginConfigObject,
@@ -1360,7 +1364,12 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
if (!cached) {
return undefined;
}
if (cached.expiresAt <= Date.now()) {
const now = asDateTimestampMs(Date.now());
if (
now === undefined ||
asDateTimestampMs(cached.expiresAt) === undefined ||
cached.expiresAt <= now
) {
activeRecallCache.delete(cacheKey);
return undefined;
}
@@ -1368,19 +1377,27 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
}
function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: number): void {
const now = Date.now();
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
if (
activeRecallCache.size >= DEFAULT_MAX_CACHE_ENTRIES ||
now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS
(now !== undefined && now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS)
) {
sweepExpiredCacheEntries(now);
lastActiveRecallCacheSweepAt = now;
if (now !== undefined) {
lastActiveRecallCacheSweepAt = now;
}
}
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNow });
if (expiresAt === undefined) {
activeRecallCache.delete(cacheKey);
return;
}
if (activeRecallCache.has(cacheKey)) {
activeRecallCache.delete(cacheKey);
}
activeRecallCache.set(cacheKey, {
expiresAt: now + ttlMs,
expiresAt,
result,
});
while (activeRecallCache.size > DEFAULT_MAX_CACHE_ENTRIES) {
@@ -1392,9 +1409,13 @@ function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: nu
}
}
function sweepExpiredCacheEntries(now = Date.now()): void {
function sweepExpiredCacheEntries(now = asDateTimestampMs(Date.now())): void {
if (now === undefined) {
activeRecallCache.clear();
return;
}
for (const [cacheKey, cached] of activeRecallCache.entries()) {
if (cached.expiresAt <= now) {
if (asDateTimestampMs(cached.expiresAt) === undefined || cached.expiresAt <= now) {
activeRecallCache.delete(cacheKey);
}
}

View File

@@ -230,6 +230,32 @@ describe("bedrock mantle discovery", () => {
expect(getCachedIamToken("us-east-1")).toBeUndefined();
});
it("does not cache generated IAM tokens when ttl expiry overflows", async () => {
const tokenProvider = vi
.fn<() => Promise<string>>()
.mockResolvedValueOnce("bedrock-overflow-token-1") // pragma: allowlist secret
.mockResolvedValueOnce("bedrock-overflow-token-2"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
await expect(
generateBearerTokenFromIam({
region: "us-east-1",
now: () => 8_640_000_000_000_000,
tokenProviderFactory,
}),
).resolves.toBe("bedrock-overflow-token-1");
expect(getCachedIamToken("us-east-1")).toBeUndefined();
await expect(
generateBearerTokenFromIam({
region: "us-east-1",
now: () => 8_640_000_000_000_000,
tokenProviderFactory,
}),
).resolves.toBe("bedrock-overflow-token-2");
expect(tokenProvider).toHaveBeenCalledTimes(2);
});
// ---------------------------------------------------------------------------
// Model discovery
// ---------------------------------------------------------------------------
@@ -537,6 +563,24 @@ describe("bedrock mantle discovery", () => {
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("omits Mantle runtime IAM token expiry when the process clock is invalid", async () => {
const tokenProvider = vi.fn(async () => "bedrock-api-key-invalid-clock"); // pragma: allowlist secret
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
const resolved = await resolveMantleRuntimeBearerToken({
apiKey: MANTLE_IAM_TOKEN_MARKER,
env: {
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
now: () => Number.NaN,
tokenProviderFactory,
});
expect(resolved).toEqual({
apiKey: "bedrock-api-key-invalid-clock",
});
expect(tokenProvider).toHaveBeenCalledTimes(1);
});
it("returns null for unsupported regions", async () => {
const provider = await resolveImplicitMantleProvider({
env: {

View File

@@ -1,5 +1,9 @@
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import type {
ModelDefinitionConfig,
ModelProviderConfig,
@@ -92,9 +96,10 @@ function getCachedIamTokenEntry(
now: number = Date.now(),
): { token: string; expiresAt: number } | undefined {
const cached = iamTokenCache.get(region);
if (cached && cached.expiresAt > now) {
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
return cached;
}
iamTokenCache.delete(region);
return undefined;
}
@@ -123,7 +128,10 @@ export async function generateBearerTokenFromIam(params: {
region: params.region,
expiresInSeconds: 7200, // 2 hours
})();
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
const expiresAt = resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
if (expiresAt !== undefined) {
iamTokenCache.set(params.region, { token, expiresAt });
}
return token;
} catch (error) {
log.debug?.("Mantle IAM token generation unavailable", {
@@ -171,9 +179,11 @@ export async function resolveMantleRuntimeBearerToken(params: {
return undefined;
}
const refreshed = getCachedIamTokenEntry(region, now);
const expiresAt =
refreshed?.expiresAt ?? resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
return {
apiKey: refreshed?.token ?? token,
expiresAt: refreshed?.expiresAt ?? now + IAM_TOKEN_TTL_MS,
...(expiresAt === undefined ? {} : { expiresAt }),
};
}
/** Reset the IAM token cache (for testing). */

View File

@@ -256,6 +256,28 @@ describe("bedrock discovery", () => {
expect(sendMock).toHaveBeenCalledTimes(2);
});
it("skips cache when refreshInterval expiry overflows", async () => {
sendMock
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 1 },
now: () => 8_640_000_000_000_000,
clientFactory,
});
await discoverBedrockModels({
region: "us-east-1",
config: { refreshInterval: 1 },
now: () => 8_640_000_000_000_000,
clientFactory,
});
expect(sendMock).toHaveBeenCalledTimes(4);
});
it("skips cache when refreshInterval is 0", async () => {
sendMock
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })

View File

@@ -5,6 +5,10 @@ import {
} from "@aws-sdk/client-bedrock";
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
isFutureDateTimestampMs,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import type {
BedrockDiscoveryConfig,
ModelDefinitionConfig,
@@ -503,11 +507,16 @@ export async function discoverBedrockModels(params: {
if (refreshIntervalSeconds > 0) {
const cached = discoveryCache.get(cacheKey);
if (cached?.value && cached.expiresAt > now) {
return cached.value;
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
if (cached.value) {
return cached.value;
}
if (cached.inFlight) {
return cached.inFlight;
}
}
if (cached?.inFlight) {
return cached.inFlight;
if (cached) {
discoveryCache.delete(cacheKey);
}
}
@@ -581,19 +590,27 @@ export async function discoverBedrockModels(params: {
})();
if (refreshIntervalSeconds > 0) {
discoveryCache.set(cacheKey, {
expiresAt: now + refreshIntervalSeconds * 1000,
inFlight: discoveryPromise,
});
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, { nowMs: now });
if (expiresAt !== undefined) {
discoveryCache.set(cacheKey, {
expiresAt,
inFlight: discoveryPromise,
});
}
}
try {
const value = await discoveryPromise;
if (refreshIntervalSeconds > 0) {
discoveryCache.set(cacheKey, {
expiresAt: now + refreshIntervalSeconds * 1000,
value,
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, {
nowMs: now,
});
if (expiresAt !== undefined) {
discoveryCache.set(cacheKey, {
expiresAt,
value,
});
}
}
return value;
} catch (error) {

View File

@@ -72,6 +72,8 @@ function levelIds(profile: unknown): Array<unknown> {
return (levels as Array<{ id?: unknown }>).map((level) => level.id);
}
const ANTHROPIC_SETUP_TOKEN = `sk-ant-oat01-${"a".repeat(80)}`;
describe("anthropic provider replay hooks", () => {
it("registers the claude-cli backend", () => {
const captured = capturePluginRegistration({ register: anthropicPlugin.register });
@@ -684,6 +686,61 @@ describe("anthropic provider replay hooks", () => {
expect(normalized).toBeUndefined();
});
it("stores setup-token expiry from a bounded duration", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
try {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
if (!setupTokenAuth) {
throw new Error("expected Anthropic setup-token auth method");
}
const result = await setupTokenAuth.run({
opts: {
token: ANTHROPIC_SETUP_TOKEN,
tokenExpiresIn: "1h",
},
} as never);
expect(result?.profiles[0]?.credential).toMatchObject({
type: "token",
provider: "anthropic",
token: ANTHROPIC_SETUP_TOKEN,
expires: 3_601_000,
});
} finally {
vi.useRealTimers();
}
});
it("omits setup-token expiry when duration overflows the Date range", async () => {
vi.useFakeTimers();
vi.setSystemTime(8_640_000_000_000_000);
try {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
if (!setupTokenAuth) {
throw new Error("expected Anthropic setup-token auth method");
}
const result = await setupTokenAuth.run({
opts: {
token: ANTHROPIC_SETUP_TOKEN,
tokenExpiresIn: "1h",
},
} as never);
expect(result?.profiles[0]?.credential).toEqual({
type: "token",
provider: "anthropic",
token: ANTHROPIC_SETUP_TOKEN,
});
} finally {
vi.useRealTimers();
}
});
it("resolves claude-cli synthetic oauth auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({

View File

@@ -1,4 +1,5 @@
import { formatCliCommand, parseDurationMs } from "openclaw/plugin-sdk/cli-runtime";
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
import type {
OpenClawPluginApi,
ProviderAuthContext,
@@ -128,7 +129,9 @@ function resolveAnthropicSetupTokenExpiry(rawExpiresIn?: unknown): number | unde
if (typeof rawExpiresIn !== "string" || rawExpiresIn.trim().length === 0) {
return undefined;
}
return Date.now() + parseDurationMs(rawExpiresIn.trim(), { defaultUnit: "d" });
return resolveExpiresAtMsFromDurationMs(
parseDurationMs(rawExpiresIn.trim(), { defaultUnit: "d" }),
);
}
async function runAnthropicSetupTokenAuth(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {

View File

@@ -1,3 +1,4 @@
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
import type { Dialog, Page } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -121,6 +122,46 @@ describe("observed browser dialogs", () => {
observed.cleanup();
});
it("does not arm next-dialog responses while the process clock is invalid", () => {
const nowSpy = vi.spyOn(Date, "now");
try {
nowSpy.mockReturnValue(Number.NaN);
const { page, emit } = createPageHarness();
ensurePageState(page);
const dialog = createDialog({ type: "alert", message: "Still pending" });
armObservedDialogResponseOnPage({ page, accept: false, timeoutMs: 1000 });
emit("dialog", dialog);
expect(dialog.dismiss).not.toHaveBeenCalled();
expect(getObservedBrowserStateForPage(page).dialogs.pending).toMatchObject([
{ id: "d1", type: "alert", message: "Still pending" },
]);
} finally {
nowSpy.mockRestore();
}
});
it("does not arm next-dialog responses when the expiry would overflow Date bounds", () => {
const nowSpy = vi.spyOn(Date, "now");
try {
nowSpy.mockReturnValue(MAX_DATE_TIMESTAMP_MS);
const { page, emit } = createPageHarness();
ensurePageState(page);
const dialog = createDialog({ type: "alert", message: "Still pending" });
armObservedDialogResponseOnPage({ page, accept: false, timeoutMs: 1000 });
emit("dialog", dialog);
expect(dialog.dismiss).not.toHaveBeenCalled();
expect(getObservedBrowserStateForPage(page).dialogs.pending).toMatchObject([
{ id: "d1", type: "alert", message: "Still pending" },
]);
} finally {
nowSpy.mockRestore();
}
});
it("aborts in-flight actions while keeping unarmed dialogs pending", async () => {
const { page, emit } = createPageHarness();
ensurePageState(page);

View File

@@ -1,6 +1,10 @@
import crypto from "node:crypto";
import path from "node:path";
import { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
import {
isFutureDateTimestampMs,
parseFiniteNumber,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
Browser,
@@ -354,7 +358,7 @@ function observeDialog(pageState: PageState, dialog: Dialog): void {
pageState.pendingDialogs.push(pending);
const armed = pageState.armedDialogResponse;
if (armed && armed.expiresAt >= Date.now()) {
if (armed && isFutureDateTimestampMs(armed.expiresAt)) {
clearArmedDialogResponse(pageState);
void settleObservedDialog({
state: pageState,
@@ -791,9 +795,13 @@ export function armObservedDialogResponseOnPage(opts: {
const state = ensurePageState(opts.page);
clearArmedDialogResponse(state);
const timeoutMs = resolveObservedDialogTimeoutMs(opts.timeoutMs);
const expiresAt = resolveExpiresAtMsFromDurationMs(timeoutMs);
if (expiresAt === undefined) {
return;
}
const response: ArmedDialogResponse = {
accept: opts.accept,
expiresAt: Date.now() + timeoutMs,
expiresAt,
...(opts.promptText !== undefined ? { promptText: opts.promptText } : {}),
};
response.timer = setTimeout(() => {

View File

@@ -812,10 +812,10 @@ describe("connectCodexAppServerEndpoint", () => {
typeof data === "string"
? data
: Array.isArray(data)
? Buffer.concat(data).toString()
? Buffer.concat(data).toString("utf8")
: data instanceof ArrayBuffer
? Buffer.from(new Uint8Array(data)).toString()
: Buffer.from(data).toString();
? Buffer.from(new Uint8Array(data)).toString("utf8")
: Buffer.from(data).toString("utf8");
const request = JSON.parse(messageText) as Record<string, unknown>;
if (request.method === "initialize") {
socket.send(JSON.stringify({ id: request.id, result: {} }));

View File

@@ -360,8 +360,8 @@
"advanced": true
},
"appServer.postToolRawAssistantCompletionIdleTimeoutMs": {
"label": "Post-Tool Raw Assistant Completion Idle Timeout",
"help": "Completion-idle guard after a tool handoff when Codex emits raw assistant completion or progress without turn/completed. Defaults to 300000 ms when unset.",
"label": "Post-Tool Continuation Idle Timeout",
"help": "Completion-idle and progress guard after a tool handoff, native tool completion, or post-tool raw assistant progress while waiting for turn/completed. Defaults to 300000 ms when unset.",
"advanced": true
},
"appServer.approvalPolicy": {

View File

@@ -1,3 +1,4 @@
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import {
CodexAppInventoryCache,
@@ -71,6 +72,31 @@ describe("Codex app inventory cache", () => {
expect(refreshed.apps.map((item) => item.id)).toEqual(["app-2"]);
});
it("marks inventory stale when the expiry would exceed the Date range", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async () => {
return {
data: [app("app-overflow")],
nextCursor: null,
} satisfies v2.AppsListResponse;
});
const key = "runtime";
const snapshot = await cache.refreshNow({
key,
request,
nowMs: MAX_DATE_TIMESTAMP_MS,
});
expect(snapshot.expiresAtMs).toBe(0);
const read = cache.read({
key,
request,
nowMs: Date.parse("2026-05-29T12:00:00.000Z"),
});
expect(read.state).toBe("stale");
expect(read.snapshot?.apps.map((item) => item.id)).toEqual(["app-overflow"]);
});
it("records refresh errors without discarding the last successful snapshot", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 1 });
const key = "runtime";

View File

@@ -1,4 +1,9 @@
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
isFutureDateTimestampMs,
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { JsonValue, v2 } from "./protocol.js";
@@ -71,7 +76,7 @@ export class CodexAppInventoryCache {
}
read(params: RefreshParams): CodexAppInventoryCacheRead {
const nowMs = params.nowMs ?? Date.now();
const nowMs = resolveDateTimestampMs(params.nowMs);
const entry = this.entries.get(params.key);
if (!entry) {
const refreshScheduled = params.suppressRefresh ? false : this.scheduleRefresh(params);
@@ -87,7 +92,9 @@ export class CodexAppInventoryCache {
}
const state: CodexAppInventoryReadState =
entry.invalidated || entry.expiresAtMs <= nowMs ? "stale" : "fresh";
entry.invalidated || !isFutureDateTimestampMs(entry.expiresAtMs, { nowMs })
? "stale"
: "fresh";
const refreshScheduled =
state === "fresh" && !params.forceRefetch ? false : this.scheduleRefresh(params);
return {
@@ -163,15 +170,16 @@ export class CodexAppInventoryCache {
params: RefreshParams,
refreshToken: number,
): Promise<CodexAppInventorySnapshot> {
const nowMs = params.nowMs ?? Date.now();
const nowMs = resolveDateTimestampMs(params.nowMs);
try {
const apps = await listAllApps(params.request, params.forceRefetch ?? false);
this.revision += 1;
const expiresAtMs = resolveExpiresAtMsFromDurationMs(this.ttlMs, { nowMs }) ?? 0;
const snapshot: CodexAppInventorySnapshot = {
key: params.key,
apps,
fetchedAtMs: nowMs,
expiresAtMs: nowMs + this.ttlMs,
expiresAtMs,
revision: this.revision,
};
// Only publish this snapshot if no newer refresh started for the same key

View File

@@ -4,6 +4,7 @@ import {
isAssistantCompletionReleaseNotification,
isCodexTurnAbortMarkerNotification,
isFileChangePatchUpdatedNotification,
isAssistantCommentaryCompletionNotification,
isNativeToolProgressNotification,
isNativeResponseStreamDeltaNotification,
isPendingOpenClawDynamicToolCompletionNotification,
@@ -18,7 +19,7 @@ import {
shouldDisarmAssistantCompletionIdleWatch,
updateActiveTurnItemIds,
} from "./attempt-notifications.js";
import { CODEX_POST_REASONING_SOURCE_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
import type { CodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
import type { CodexServerNotification } from "./protocol.js";
@@ -79,7 +80,6 @@ export function applyCodexTurnNotificationState(params: {
threadId: string;
turnId: string;
currentPromptTexts: string[];
sourceReplyDeliveryMode: string | undefined;
turnWatches: CodexAttemptTurnWatchController;
activeTurnItemIds: Set<string>;
activeAppServerTurnRequests: number;
@@ -151,23 +151,45 @@ export function applyCodexTurnNotificationState(params: {
params.activeTurnItemIds.size === 0 &&
params.activeAppServerTurnRequests === 0 &&
!assistantCompletionCanRelease &&
!postToolRawAssistantCompletionNeedsTerminalGuard;
const shouldArmPostReasoningSourceReplyWatch =
!postToolRawAssistantCompletionNeedsTerminalGuard &&
!rawToolOutputCompletion;
const shouldArmNoToolPostProgressReplyWatch =
isCurrentTurnNotification &&
isReasoningItemCompletionNotification(notification) &&
!turnCrossedToolHandoff &&
params.activeTurnItemIds.size === 0 &&
params.sourceReplyDeliveryMode === "message_tool_only";
const shouldArmPostRawReasoningSourceReplyWatch =
(isReasoningItemCompletionNotification(notification) ||
isAssistantCommentaryCompletionNotification(notification));
const shouldArmNoToolPostRawProgressReplyWatch =
!turnCrossedToolHandoff &&
rawResponseItemCompletedWithNoActiveItems &&
isRawReasoningCompletionNotification(notification) &&
params.sourceReplyDeliveryMode === "message_tool_only";
(isRawReasoningCompletionNotification(notification) ||
isRawAssistantProgressNotification(notification));
const shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem =
isCurrentTurnNotification &&
notification.method === "item/completed" &&
params.activeTurnItemIds.size === 0 &&
!trackedDynamicToolCompletion &&
!assistantCompletionCanRelease &&
!shouldArmPostReasoningSourceReplyWatch;
!shouldArmNoToolPostProgressReplyWatch;
const shouldUsePostToolContinuationWatch =
turnCrossedToolHandoff &&
(postToolRawAssistantCompletionNeedsTerminalGuard ||
postToolPatchUpdateNeedsTerminalGuard ||
rawToolOutputCompletion ||
trackedDynamicToolCompletion ||
shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem);
const armPostToolContinuationWatch = () => {
turnWatches.armCompletionIdleWatch({
timeoutMs: params.postToolRawAssistantCompletionIdleTimeoutMs,
});
turnWatches.extendAttemptIdleWatch(params.postToolRawAssistantCompletionIdleTimeoutMs);
};
const armPostProgressReplyWatch = () => {
turnWatches.armCompletionIdleWatch({
timeoutMs: CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS,
});
turnWatches.extendAttemptIdleWatch(CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS);
};
if (isCurrentTurnNotification && notification.method === "error") {
if (isRetryableErrorNotification(notification.params)) {
@@ -187,26 +209,28 @@ export function applyCodexTurnNotificationState(params: {
// Post-tool assistant status and patch snapshots can be followed by more
// native edit streaming. Keep the short guard alive until Codex reports a
// terminal turn state instead of falling back to the long terminal watch.
turnWatches.armCompletionIdleWatch({
timeoutMs: params.postToolRawAssistantCompletionIdleTimeoutMs,
});
} else if (shouldArmPostReasoningSourceReplyWatch || shouldArmPostRawReasoningSourceReplyWatch) {
turnWatches.armCompletionIdleWatch({
timeoutMs: CODEX_POST_REASONING_SOURCE_REPLY_IDLE_TIMEOUT_MS,
});
armPostToolContinuationWatch();
} else if (shouldArmNoToolPostProgressReplyWatch || shouldArmNoToolPostRawProgressReplyWatch) {
armPostProgressReplyWatch();
} else if (trackedDynamicToolCompletion) {
armPostToolContinuationWatch();
} else if (unblockedAssistantCompletionRelease) {
turnWatches.armAssistantCompletionIdleWatch(describeNotificationActivity(notification));
} else if (shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem) {
// If a non-assistant current-turn item is the last active item and the
// bridge then goes quiet, reset the short completion-idle guard from that
// final completion so the remaining silent-turn gap fails fast.
turnWatches.armCompletionIdleWatch();
if (shouldUsePostToolContinuationWatch) {
armPostToolContinuationWatch();
} else {
turnWatches.armCompletionIdleWatch();
}
} else if (rawResponseItemCompletedWithNoActiveItems) {
turnWatches.armCompletionIdleWatch();
} else if (isCurrentTurnNotification && rawToolOutputCompletion) {
// Raw OpenAI response streams can report the tool-output handoff without
// a matching app-server `item/completed`; keep the post-tool guard alive.
turnWatches.armCompletionIdleWatch();
armPostToolContinuationWatch();
} else if (isCurrentTurnNotification && shouldDisarmAssistantCompletionIdleWatch(notification)) {
turnWatches.disarmAssistantCompletionIdleWatch();
}
@@ -222,8 +246,8 @@ export function applyCodexTurnNotificationState(params: {
!postToolRawAssistantCompletionNeedsTerminalGuard &&
!postToolPatchUpdateNeedsTerminalGuard &&
!rawResponseItemCompletedWithNoActiveItems &&
!shouldArmPostReasoningSourceReplyWatch &&
!shouldArmPostRawReasoningSourceReplyWatch &&
!shouldArmNoToolPostProgressReplyWatch &&
!shouldArmNoToolPostRawProgressReplyWatch &&
!shouldRearmCompletionIdleWatchAfterLastCurrentTurnItem
) {
// The short completion-idle watchdog guards blind gaps after Codex

View File

@@ -83,6 +83,20 @@ export function isReasoningItemCompletionNotification(
return item ? readString(item, "type") === "reasoning" : false;
}
export function isAssistantCommentaryCompletionNotification(
notification: CodexServerNotification,
): boolean {
if (!isJsonObject(notification.params) || notification.method !== "item/completed") {
return false;
}
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
return Boolean(
item &&
readString(item, "type") === "agentMessage" &&
readString(item, "phase") === "commentary",
);
}
export function isRawReasoningCompletionNotification(
notification: CodexServerNotification,
): boolean {

View File

@@ -7,7 +7,7 @@ export const CODEX_TURN_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS = 10_000;
// progress item. Forwarded deltas count as activity, but older native paths may
// not surface them, so keep this terminal guard conservative.
export const CODEX_POST_TOOL_RAW_ASSISTANT_COMPLETION_IDLE_TIMEOUT_MS = 5 * 60_000;
export const CODEX_POST_REASONING_SOURCE_REPLY_IDLE_TIMEOUT_MS = 5 * 60_000;
export const CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS = 5 * 60_000;
export const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
function resolvePositiveIntegerTimeoutMs(value: number | undefined, fallbackMs: number): number {

View File

@@ -54,6 +54,7 @@ export function createCodexAttemptTurnWatchController(params: {
let completionLastActivityAt = Date.now();
let completionLastActivityReason = "startup";
let completionLastActivityDetails: Record<string, unknown> | undefined;
let attemptIdleTimeoutOverrideMs: number | undefined;
let attemptLastProgressAt = Date.now();
let attemptLastProgressReason = "startup";
let attemptLastProgressDetails: Record<string, unknown> | undefined;
@@ -127,7 +128,8 @@ export function createCodexAttemptTurnWatchController(params: {
return;
}
const elapsedMs = Math.max(0, Date.now() - attemptLastProgressAt);
const delayMs = Math.max(1, params.turnAttemptIdleTimeoutMs - elapsedMs);
const timeoutMs = attemptIdleTimeoutOverrideMs ?? params.turnAttemptIdleTimeoutMs;
const delayMs = Math.max(1, timeoutMs - elapsedMs);
attemptIdleTimer = setTimeout(fireAttemptIdleTimeout, delayMs);
attemptIdleTimer.unref?.();
}
@@ -154,6 +156,21 @@ export function createCodexAttemptTurnWatchController(params: {
scheduleTerminalIdleWatch();
}
function recordAttemptProgress(
reason: string,
options?: { details?: Record<string, unknown>; attemptTimeoutMs?: number },
) {
attemptIdleTimeoutOverrideMs =
options?.attemptTimeoutMs !== undefined
? Math.max(1, Math.floor(options.attemptTimeoutMs))
: undefined;
attemptLastProgressAt = completionLastActivityAt;
attemptLastProgressReason = reason;
attemptLastProgressDetails = options?.details;
params.onAttemptProgress(reason, options?.details);
scheduleAttemptIdleWatch();
}
function fireAssistantCompletionIdleRelease() {
if (params.isCompleted() || params.signal.aborted || !assistantCompletionIdleWatchArmed) {
return;
@@ -204,14 +221,15 @@ export function createCodexAttemptTurnWatchController(params: {
return;
}
const idleMs = Math.max(0, Date.now() - attemptLastProgressAt);
if (idleMs < params.turnAttemptIdleTimeoutMs) {
const timeoutMs = attemptIdleTimeoutOverrideMs ?? params.turnAttemptIdleTimeoutMs;
if (idleMs < timeoutMs) {
scheduleAttemptIdleWatch();
return;
}
const timeout = {
kind: "progress" as const,
idleMs,
timeoutMs: params.turnAttemptIdleTimeoutMs,
timeoutMs,
lastActivityReason: attemptLastProgressReason,
details: attemptLastProgressDetails,
};
@@ -361,17 +379,19 @@ export function createCodexAttemptTurnWatchController(params: {
},
touchActivity: (
reason: string,
options?: { arm?: boolean; details?: Record<string, unknown>; attemptProgress?: boolean },
options?: {
arm?: boolean;
details?: Record<string, unknown>;
attemptProgress?: boolean;
attemptTimeoutMs?: number;
},
) => {
completionLastActivityAt = Date.now();
completionLastActivityReason = reason;
completionLastActivityDetails = options?.details;
completionIdleTimeoutOverrideMs = undefined;
if (options?.attemptProgress) {
attemptLastProgressAt = completionLastActivityAt;
attemptLastProgressReason = reason;
attemptLastProgressDetails = options.details;
params.onAttemptProgress(reason, options.details);
recordAttemptProgress(reason, options);
}
params.onProgressDiagnostic(reason);
if (options?.arm) {
@@ -382,7 +402,11 @@ export function createCodexAttemptTurnWatchController(params: {
},
noteNotificationReceived: (
method: string,
options?: { details?: Record<string, unknown>; attemptProgress?: boolean },
options?: {
details?: Record<string, unknown>;
attemptProgress?: boolean;
attemptTimeoutMs?: number;
},
) => {
completionLastActivityAt = Date.now();
completionLastActivityReason = `notification:${method}`;
@@ -390,12 +414,13 @@ export function createCodexAttemptTurnWatchController(params: {
completionLastActivityDetails = options.details;
}
if (options?.attemptProgress) {
attemptLastProgressAt = completionLastActivityAt;
attemptLastProgressReason = completionLastActivityReason;
attemptLastProgressDetails = options.details;
params.onAttemptProgress(completionLastActivityReason, options.details);
recordAttemptProgress(completionLastActivityReason, options);
}
},
extendAttemptIdleWatch: (timeoutMs: number) => {
attemptIdleTimeoutOverrideMs = Math.max(1, Math.floor(timeoutMs));
scheduleAttemptIdleWatch();
},
scheduleProgressWatches,
clearCompletionIdleTimer,
clearAssistantCompletionIdleTimer,

View File

@@ -189,7 +189,7 @@ function resolveEffectiveExecHost(params: {
function readRuntimeSessionEntryBestEffort(sessionKey: string): SessionEntry | undefined {
try {
return getSessionEntry({ sessionKey });
return getSessionEntry({ sessionKey, hydrateSkillPromptRefs: false });
} catch {
return undefined;
}

View File

@@ -1255,7 +1255,6 @@ export async function runCodexAppServerAttempt(
threadId: thread.threadId,
turnId,
currentPromptTexts: [codexTurnPromptText],
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
turnWatches,
activeTurnItemIds,
activeAppServerTurnRequests,
@@ -1347,6 +1346,9 @@ export async function runCodexAppServerAttempt(
isNativeResponseStreamDelta
? {
attemptProgress: true,
...(turnCrossedToolHandoff
? { attemptTimeoutMs: postToolRawAssistantCompletionIdleTimeoutMs }
: {}),
details: { lastNotificationMethod: notification.method },
}
: undefined,
@@ -1610,10 +1612,20 @@ export async function runCodexAppServerAttempt(
} finally {
if (requestCountsAsTurnActivity) {
activeAppServerTurnRequests = Math.max(0, activeAppServerTurnRequests - 1);
const postToolContinuationTimeoutMs =
request.method === "item/tool/call" && turnCrossedToolHandoff
? postToolRawAssistantCompletionIdleTimeoutMs
: undefined;
turnWatches.touchActivity(`request:${request.method}:response`, {
arm: armCompletionWatchOnResponse,
attemptProgress: true,
...(postToolContinuationTimeoutMs !== undefined
? { attemptTimeoutMs: postToolContinuationTimeoutMs }
: {}),
});
if (armCompletionWatchOnResponse && postToolContinuationTimeoutMs !== undefined) {
turnWatches.armCompletionIdleWatch({ timeoutMs: postToolContinuationTimeoutMs });
}
scheduleTerminalDynamicToolReleaseCheck();
} else {
turnWatches.scheduleProgressWatches();
@@ -1924,6 +1936,8 @@ export async function runCodexAppServerAttempt(
const activeProjector = projector;
turnWatches.armTerminalIdleWatch();
turnWatches.touchActivity("turn:start", { arm: true });
turnWatches.armAttemptIdleWatch();
turnWatches.touchActivity("turn:start", { attemptProgress: true });
for (const notification of pendingNotifications.splice(0)) {
await enqueueNotification(notification);
}
@@ -1967,9 +1981,6 @@ export async function runCodexAppServerAttempt(
threadId: thread.threadId,
turnId: activeTurnId,
});
turnWatches.armAttemptIdleWatch();
turnWatches.armTerminalIdleWatch();
turnWatches.touchActivity("turn:start", { attemptProgress: true });
const abortListener = () => {
const shouldRetireClient = timedOut;

View File

@@ -10,6 +10,7 @@ import {
type DiagnosticEventPayload,
} from "openclaw/plugin-sdk/diagnostic-runtime";
import { describe, expect, it, vi } from "vitest";
import { createCodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
import * as authBridge from "./auth-bridge.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import * as elicitationBridge from "./elicitation-bridge.js";
@@ -39,6 +40,60 @@ setupRunAttemptTestHooks();
const tinyPngBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
describe("createCodexAttemptTurnWatchController", () => {
it("reschedules the attempt watch when notification progress shortens its timeout", async () => {
const onTimeout = vi.fn();
const onAbort = vi.fn();
const controller = createCodexAttemptTurnWatchController({
threadId: "thread-1",
signal: new AbortController().signal,
getTurnId: () => "turn-1",
isCompleted: () => false,
isTerminalTurnNotificationQueued: () => false,
getActiveAppServerTurnRequests: () => 0,
getActiveTurnItemCount: () => 0,
turnCompletionIdleTimeoutMs: 500,
turnAssistantCompletionIdleTimeoutMs: 500,
turnAttemptIdleTimeoutMs: 200,
turnTerminalIdleTimeoutMs: 500,
interruptTimeoutMs: 5_000,
onInterruptTurn: vi.fn(),
onTimeout,
onMarkTimedOut: vi.fn(),
onAbort,
onCompleted: vi.fn(),
onResolveCompletion: vi.fn(),
onRecordEvent: vi.fn(),
onAttemptProgress: vi.fn(),
onProgressDiagnostic: vi.fn(),
});
try {
controller.armAttemptIdleWatch();
controller.touchActivity("turn:start", { attemptProgress: true });
await new Promise((resolve) => setTimeout(resolve, 20));
controller.noteNotificationReceived("response.output_text.delta", {
attemptProgress: true,
attemptTimeoutMs: 40,
});
await vi.waitFor(() => expect(onAbort).toHaveBeenCalledWith("turn_progress_idle_timeout"), {
interval: 5,
timeout: 120,
});
expect(onTimeout).toHaveBeenCalledWith(
expect.objectContaining({
kind: "progress",
timeoutMs: 40,
lastActivityReason: "notification:response.output_text.delta",
}),
);
} finally {
controller.clearAllTimers();
}
});
});
describe("runCodexAppServerAttempt turn watches", () => {
it("releases the session when Codex never completes after a dynamic tool response", async () => {
let handleRequest:
@@ -79,6 +134,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
const run = runCodexAppServerAttempt(params, {
pluginConfig: { appServer: { turnCompletionIdleTimeoutMs: 5 } },
postToolRawAssistantCompletionIdleTimeoutMs: 5,
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
@@ -136,6 +192,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
const run = runCodexAppServerAttempt(params, {
pluginConfig: { appServer: { turnCompletionIdleTimeoutMs: 5 } },
turnAssistantCompletionIdleTimeoutMs: 1_000,
postToolRawAssistantCompletionIdleTimeoutMs: 5,
});
await harness.waitForMethod("turn/start");
await harness.notify({
@@ -864,6 +921,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 5,
postToolRawAssistantCompletionIdleTimeoutMs: 5,
turnTerminalIdleTimeoutMs: 60_000,
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
@@ -890,7 +948,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
"codex app-server turn idle timed out waiting for turn/completed",
);
const warnCall = warn.mock.calls.find(
([message]) => message === "codex app-server turn idle timed out waiting for completion",
([message]) =>
message === "codex app-server turn idle timed out waiting for completion" ||
message === "codex app-server turn idle timed out waiting for progress",
);
const warnData = warnCall?.[1] as
| { lastActivityReason?: string; timeoutMs?: number }
@@ -940,9 +1000,13 @@ describe("runCodexAppServerAttempt turn watches", () => {
);
params.timeoutMs = 60_000;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 5,
postToolRawAssistantCompletionIdleTimeoutMs: 80,
turnTerminalIdleTimeoutMs: 200,
}).finally(() => {
settled = true;
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
@@ -972,6 +1036,10 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
const result = await run;
expect(result.aborted).toBe(true);
expect(result.timedOut).toBe(true);
@@ -983,6 +1051,11 @@ describe("runCodexAppServerAttempt turn watches", () => {
([message]) => message === "codex app-server turn idle timed out waiting for completion",
),
).toBe(true);
const completionWarnCall = warn.mock.calls.find(
([message]) => message === "codex app-server turn idle timed out waiting for completion",
);
const completionWarnData = completionWarnCall?.[1] as { timeoutMs?: number } | undefined;
expect(completionWarnData?.timeoutMs).toBe(80);
expect(
warn.mock.calls.some(
([message]) =>
@@ -1032,9 +1105,13 @@ describe("runCodexAppServerAttempt turn watches", () => {
);
params.timeoutMs = 60_000;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 5,
postToolRawAssistantCompletionIdleTimeoutMs: 80,
turnTerminalIdleTimeoutMs: 200,
}).finally(() => {
settled = true;
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
@@ -1065,6 +1142,10 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(settled).toBe(false);
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
const result = await run;
expect(result.aborted).toBe(true);
expect(result.timedOut).toBe(true);
@@ -1077,7 +1158,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
const completionWarnData = completionWarnCall?.[1] as
| { lastActivityReason?: string; lastNotificationItemType?: string; timeoutMs?: number }
| undefined;
expect(completionWarnData?.timeoutMs).toBe(5);
expect(completionWarnData?.timeoutMs).toBe(80);
expect(completionWarnData?.lastActivityReason).toBe("notification:rawResponseItem/completed");
expect(completionWarnData?.lastNotificationItemType).toBe("custom_tool_call_output");
expect(
@@ -1180,6 +1261,234 @@ describe("runCodexAppServerAttempt turn watches", () => {
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
});
it("keeps waiting after an OpenClaw dynamic tool response before final synthesis", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleRequest:
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
| undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
return turnStartResult("turn-1", "inProgress");
}
return {};
});
setCodexAppServerClientFactoryForTest(
async () =>
({
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: (
handler: (request: {
id: string;
method: string;
params?: unknown;
}) => Promise<unknown>,
) => {
handleRequest = handler;
return () => undefined;
},
}) as never,
);
const params = createParams(
path.join(tempDir, "session-post-tool-silent.jsonl"),
path.join(tempDir, "workspace-post-tool-silent"),
);
params.timeoutMs = 100;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 20,
turnAssistantCompletionIdleTimeoutMs: 20,
postToolRawAssistantCompletionIdleTimeoutMs: 180,
turnTerminalIdleTimeoutMs: 500,
}).finally(() => {
settled = true;
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
const toolResult = (await handleRequest?.({
id: "request-tool-1",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "message",
arguments: { action: "send", text: "already sent" },
},
})) as { success?: boolean };
expect(toolResult.success).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 130));
expect(settled).toBe(false);
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
const result = await run;
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
});
it("keeps waiting after native tool completion before final synthesis", async () => {
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session-native-tool-silent.jsonl"),
path.join(tempDir, "workspace-native-tool-silent"),
);
params.timeoutMs = 100;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 20,
turnAssistantCompletionIdleTimeoutMs: 20,
postToolRawAssistantCompletionIdleTimeoutMs: 180,
turnTerminalIdleTimeoutMs: 500,
}).finally(() => {
settled = true;
});
await harness.waitForMethod("turn/start");
await harness.notify({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "cmd-1",
type: "commandExecution",
command: "git status -sb",
status: "inProgress",
},
},
});
await harness.notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "cmd-1",
type: "commandExecution",
command: "git status -sb",
status: "completed",
},
},
});
await new Promise((resolve) => setTimeout(resolve, 130));
expect(settled).toBe(false);
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
});
it("preserves post-tool budget for native tool completion buffered during turn start", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
await notify({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "cmd-1",
type: "commandExecution",
command: "git status -sb",
status: "inProgress",
},
},
});
await notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "cmd-1",
type: "commandExecution",
command: "git status -sb",
status: "completed",
},
},
});
return turnStartResult("turn-1", "inProgress");
}
return {};
});
setCodexAppServerClientFactoryForTest(
async () =>
({
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
}) as never,
);
const params = createParams(
path.join(tempDir, "session-buffered-native-tool-silent.jsonl"),
path.join(tempDir, "workspace-buffered-native-tool-silent"),
);
params.timeoutMs = 100;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 20,
turnAssistantCompletionIdleTimeoutMs: 20,
postToolRawAssistantCompletionIdleTimeoutMs: 180,
turnTerminalIdleTimeoutMs: 500,
}).finally(() => {
settled = true;
});
await vi.waitFor(
() =>
expect(request).toHaveBeenCalledWith("turn/start", expect.anything(), expect.anything()),
fastWait,
);
await new Promise((resolve) => setTimeout(resolve, 130));
expect(settled).toBe(false);
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
const result = await run;
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
});
it("times out post-tool raw assistant progress after the post-tool timeout", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleRequest:
@@ -2336,17 +2645,56 @@ describe("runCodexAppServerAttempt turn watches", () => {
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
});
it("keeps the normal completion idle guard after non-source reasoning completes", async () => {
it("keeps waiting after raw reasoning completes before automatic assistant reply", async () => {
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 60_000;
params.timeoutMs = 80;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 15,
turnTerminalIdleTimeoutMs: 500,
}).finally(() => {
settled = true;
});
await harness.waitForMethod("turn/start");
await harness.notify({
method: "rawResponseItem/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { id: "raw-reasoning-1", type: "reasoning" },
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(settled).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
});
it("keeps waiting after commentary assistant progress before automatic final reply", async () => {
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 80;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 15,
turnTerminalIdleTimeoutMs: 500,
}).finally(() => {
settled = true;
});
await harness.waitForMethod("turn/start");
await harness.notify({
@@ -2354,7 +2702,12 @@ describe("runCodexAppServerAttempt turn watches", () => {
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { id: "reasoning-1", type: "reasoning" },
item: {
id: "commentary-1",
type: "agentMessage",
phase: "commentary",
text: "Working on it.",
},
},
});
await harness.notify({
@@ -2362,16 +2715,24 @@ describe("runCodexAppServerAttempt turn watches", () => {
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { id: "reasoning-1", type: "reasoning" },
item: {
id: "commentary-1",
type: "agentMessage",
phase: "commentary",
text: "Working on it.",
},
},
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(settled).toBe(false);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
const result = await run;
expect(result.aborted).toBe(true);
expect(result.timedOut).toBe(true);
expect(result.promptError).toBe(
"codex app-server turn idle timed out waiting for turn/completed",
);
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
});
it("does not treat global rate-limit notifications as turn progress", async () => {

View File

@@ -77,6 +77,31 @@ describe("Codex app-server startup binding", () => {
expect(savedBinding?.threadId).toBe("thread-existing");
});
it("reuses the session record cache while sessions.json is unchanged", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
const sessionsJson = path.join(path.dirname(sessionFile), "sessions.json");
const readFileSpy = vi.spyOn(fs, "readFile");
for (let i = 0; i < 2; i += 1) {
const binding = await rotateOversizedCodexAppServerStartupBinding({
binding: await readCodexAppServerBinding(sessionFile),
sessionFile,
agentDir,
config: undefined,
});
expect(binding?.threadId).toBe("thread-existing");
}
const sessionStoreReads = readFileSpy.mock.calls.filter(
([file]) => typeof file === "string" && file === sessionsJson,
);
expect(sessionStoreReads).toHaveLength(1);
});
it("checks native rollout token pressure under default compaction config", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -30,6 +30,14 @@ const CODEX_APP_SERVER_BYTE_UNITS: Record<string, number> = {
tb: 1024 * 1024 * 1024 * 1024,
tib: 1024 * 1024 * 1024 * 1024,
};
type CodexSessionRecordCacheEntry = {
sessionsFile: string;
mtimeMs: number;
size: number;
record: (Record<string, unknown> & { sessionKey: string }) | undefined;
};
const codexSessionRecordCache = new Map<string, CodexSessionRecordCacheEntry>();
function parseCodexAppServerByteLimit(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
@@ -112,16 +120,34 @@ async function readCodexSessionRecordForSessionFile(
sessionFile: string,
): Promise<(Record<string, unknown> & { sessionKey: string }) | undefined> {
const sessionsFile = path.join(path.dirname(sessionFile), "sessions.json");
const resolvedSessionFile = path.resolve(sessionFile);
let stat: Awaited<ReturnType<typeof fs.stat>>;
try {
stat = await fs.stat(sessionsFile);
} catch {
codexSessionRecordCache.delete(resolvedSessionFile);
return undefined;
}
const cached = codexSessionRecordCache.get(resolvedSessionFile);
if (
cached?.sessionsFile === sessionsFile &&
cached.mtimeMs === stat.mtimeMs &&
cached.size === stat.size
) {
return cached.record;
}
let store: JsonValue | undefined;
try {
store = JSON.parse(await fs.readFile(sessionsFile, "utf8")) as JsonValue;
} catch {
codexSessionRecordCache.delete(resolvedSessionFile);
return undefined;
}
if (!isJsonObject(store)) {
codexSessionRecordCache.delete(resolvedSessionFile);
return undefined;
}
const resolvedSessionFile = path.resolve(sessionFile);
let found: (Record<string, unknown> & { sessionKey: string }) | undefined;
for (const [sessionKey, record] of Object.entries(store)) {
if (!isJsonObject(record) || typeof record.sessionFile !== "string") {
continue;
@@ -129,9 +155,16 @@ async function readCodexSessionRecordForSessionFile(
if (path.resolve(record.sessionFile) !== resolvedSessionFile) {
continue;
}
return { sessionKey, ...record };
found = { sessionKey, ...record };
break;
}
return undefined;
codexSessionRecordCache.set(resolvedSessionFile, {
sessionsFile,
mtimeMs: stat.mtimeMs,
size: stat.size,
record: found,
});
return found;
}
type CodexAppServerRolloutTokenSnapshot = {

View File

@@ -1,4 +1,10 @@
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
resolveDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
import { getOptionalDiscordRuntime } from "./runtime.js";
@@ -163,7 +169,7 @@ function getPersistentModalStore(): DiscordRegistryStore<DiscordModalEntry> | un
}
function isExpired(entry: { expiresAt?: number }, now: number) {
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;
return entry.expiresAt !== undefined && !isFutureDateTimestampMs(entry.expiresAt, { nowMs: now });
}
function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: number }>(
@@ -171,11 +177,33 @@ function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: nu
now: number,
ttlMs: number,
): T {
const createdAt = entry.createdAt ?? now;
const expiresAt = entry.expiresAt ?? createdAt + ttlMs;
const createdAt = resolveDateTimestampMs(entry.createdAt, now);
const expiresAt =
asDateTimestampMs(entry.expiresAt) ??
resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: createdAt }) ??
0;
return { ...entry, createdAt, expiresAt };
}
function pruneUndefinedRegistryValues<T>(value: T): T {
if (Array.isArray(value)) {
return value
.filter((entry) => entry !== undefined)
.map((entry) => pruneUndefinedRegistryValues(entry)) as T;
}
if (!value || typeof value !== "object") {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry === undefined) {
continue;
}
result[key] = pruneUndefinedRegistryValues(entry);
}
return result as T;
}
function registerEntries<
T extends { id: string; messageId?: string; createdAt?: number; expiresAt?: number },
>(
@@ -237,8 +265,9 @@ function registerPersistentRegistryEntries<T extends { id: string }>(params: {
return;
}
for (const entry of params.entries) {
const persistedEntry = pruneUndefinedRegistryValues(entry);
void store
.register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs })
.register(entry.id, { version: 1, entry: persistedEntry }, { ttlMs: params.ttlMs })
.catch(disablePersistentComponentRegistry);
}
}

View File

@@ -1,5 +1,7 @@
import { ButtonStyle, MessageFlags } from "discord-api-types/v10";
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
@@ -378,6 +380,36 @@ describe("discord component registry", () => {
second.clearDiscordComponentEntries();
});
it("expires component entries registered while the process clock is invalid", () => {
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
registerDiscordComponentEntries({
entries: [{ id: "btn_invalid_clock", kind: "button", label: "Invalid clock" }],
modals: [],
ttlMs: 1000,
});
expect(resolveDiscordComponentEntry({ id: "btn_invalid_clock", consume: false })).toBeNull();
} finally {
dateNowSpy.mockRestore();
}
});
it("expires component entries whose calculated expiry exceeds the Date range", () => {
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
try {
registerDiscordComponentEntries({
entries: [{ id: "btn_overflow", kind: "button", label: "Overflow" }],
modals: [],
ttlMs: 1000,
});
} finally {
dateNowSpy.mockRestore();
}
expect(resolveDiscordComponentEntry({ id: "btn_overflow", consume: false })).toBeNull();
});
it("persists component and modal entries when runtime state is available", async () => {
const componentRegister = vi.fn().mockResolvedValue(undefined);
const modalRegister = vi.fn().mockResolvedValue(undefined);
@@ -468,6 +500,92 @@ describe("discord component registry", () => {
expect(openKeyedStore).toHaveBeenCalledTimes(4);
});
it("omits undefined component fields before persisting registry state", async () => {
const componentRegister = vi.fn().mockResolvedValue(undefined);
const modalRegister = vi.fn().mockResolvedValue(undefined);
const componentStore = {
register: componentRegister,
lookup: vi.fn(),
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
};
const modalStore = {
register: modalRegister,
lookup: vi.fn(),
consume: vi.fn(),
delete: vi.fn(),
entries: vi.fn(),
clear: vi.fn(),
};
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
opts.namespace === "discord.components" ? componentStore : modalStore,
);
const { setDiscordRuntime } = await import("./runtime.js");
setDiscordRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
} as never);
const componentEntry = Object.assign(
{
id: "btn_undefined",
kind: "button",
label: "Approve",
callbackData: "approve",
} satisfies DiscordComponentEntry,
{ modalId: undefined, sessionKey: undefined },
);
const modalEntry = Object.assign(
{
id: "mdl_undefined",
title: "Details",
fields: [
Object.assign(
{
id: "fld_undefined",
name: "reason",
label: "Reason",
type: "text",
} satisfies DiscordModalEntry["fields"][number],
{ description: undefined, placeholder: undefined },
),
],
} satisfies DiscordModalEntry,
{ sessionKey: undefined },
);
registerDiscordComponentEntries({
entries: [componentEntry],
modals: [modalEntry],
ttlMs: 1000,
});
await vi.waitFor(() => expect(componentRegister).toHaveBeenCalledTimes(1));
expect(modalRegister).toHaveBeenCalledTimes(1);
const persistedComponent = componentRegister.mock.calls[0]?.[1] as
| { entry: Record<string, unknown> }
| undefined;
expect(persistedComponent?.entry.callbackData).toBe("approve");
expect(persistedComponent?.entry).not.toHaveProperty("modalId");
expect(persistedComponent?.entry).not.toHaveProperty("sessionKey");
expect(persistedComponent?.entry).not.toHaveProperty("messageId");
const modalPayload = modalRegister.mock.calls[0]?.[1] as
| { entry: { fields?: Array<Record<string, unknown>> } }
| undefined;
expect(modalPayload?.entry.fields?.[0]).not.toHaveProperty("description");
expect(modalPayload?.entry.fields?.[0]).not.toHaveProperty("placeholder");
expect(modalPayload?.entry).not.toHaveProperty("sessionKey");
expect(modalPayload?.entry).not.toHaveProperty("messageId");
const inMemoryComponent = resolveDiscordComponentEntry({ id: "btn_undefined", consume: false });
expect(inMemoryComponent).toHaveProperty("modalId", undefined);
expect(inMemoryComponent).toHaveProperty("sessionKey", undefined);
});
it("deletes sibling persistent component entries when a group entry is consumed", async () => {
const componentDelete = vi.fn().mockResolvedValue(true);
const componentStore = {

View File

@@ -342,6 +342,47 @@ describe("Client.deployCommands", () => {
await client.fetchChannel("c1");
expect(get).toHaveBeenCalledTimes(2);
});
it("does not reuse cached REST objects while the process clock is invalid", async () => {
const client = createInternalTestClient();
const get = vi
.fn()
.mockResolvedValueOnce({ id: "c1", type: 0, name: "old" })
.mockResolvedValueOnce({ id: "c1", type: 0, name: "fresh" })
.mockResolvedValueOnce({ id: "c1", type: 0, name: "recovered" });
attachRestMock(client, { get });
const first = await client.fetchChannel("c1");
expect(first.name).toBe("old");
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const second = await client.fetchChannel("c1");
expect(second.name).toBe("fresh");
vi.mocked(Date.now).mockReturnValue(1_000);
const third = await client.fetchChannel("c1");
expect(third.name).toBe("recovered");
expect(get).toHaveBeenCalledTimes(3);
});
it("does not cache REST objects when the cache expiry would exceed the Date range", async () => {
const client = createInternalTestClient();
const get = vi
.fn()
.mockResolvedValueOnce({ id: "c1", type: 0, name: "first" })
.mockResolvedValueOnce({ id: "c1", type: 0, name: "second" });
attachRestMock(client, { get });
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const first = await client.fetchChannel("c1");
const second = await client.fetchChannel("c1");
expect(first.name).toBe("first");
expect(second.name).toBe("second");
expect(get).toHaveBeenCalledTimes(2);
});
});
describe("Client gateway event queue", () => {

View File

@@ -1,4 +1,8 @@
import { GatewayDispatchEvents } from "discord-api-types/v10";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { getChannel, getGuild, getGuildMember, getUser } from "./api.js";
import type { RequestClient } from "./rest.js";
import { Guild, GuildMember, User, channelFactory, type StructureClient } from "./structures.js";
@@ -79,15 +83,23 @@ export class DiscordEntityCache {
private async fetchCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const ttl = this.params.ttlMs ?? DEFAULT_REST_CACHE_TTL_MS;
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
if (ttl > 0) {
const cached = this.entries.get(key) as CacheEntry<T> | undefined;
if (cached && cached.expiresAt > Date.now()) {
if (cached && now !== undefined && cached.expiresAt > now) {
return cached.value;
}
if (cached) {
this.entries.delete(key);
}
}
const value = await fetcher();
if (ttl > 0) {
this.entries.set(key, { expiresAt: Date.now() + ttl, value });
const expiresAt = resolveExpiresAtMsFromDurationMs(ttl, { nowMs: rawNow });
if (expiresAt !== undefined) {
this.entries.set(key, { expiresAt, value });
}
}
return value;
}

View File

@@ -1,3 +1,4 @@
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import { readHeaderNumber, readResetAt } from "./rest-routes.js";
@@ -35,4 +36,54 @@ describe("Discord REST rate limit header parsing", () => {
vi.useRealTimers();
}
});
it("rounds fractional millisecond reset-after headers up", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-28T12:00:00.000Z"));
try {
const response = new Response(null, {
headers: { "X-RateLimit-Reset-After": "0.0004" },
});
expect(readResetAt(response)).toBe(Date.now() + 1);
} finally {
vi.useRealTimers();
}
});
it("keeps immediate reset-after headers working", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-28T12:00:00.000Z"));
try {
const response = new Response(null, {
headers: { "X-RateLimit-Reset-After": "0" },
});
expect(readResetAt(response)).toBe(Date.now());
} finally {
vi.useRealTimers();
}
});
it("drops reset-after headers when the expiry would exceed the Date range", () => {
vi.useFakeTimers();
vi.setSystemTime(MAX_DATE_TIMESTAMP_MS);
try {
const response = new Response(null, {
headers: { "X-RateLimit-Reset-After": "1" },
});
expect(readResetAt(response)).toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it("drops absolute reset headers outside the Date range", () => {
const response = new Response(null, {
headers: { "X-RateLimit-Reset": String(MAX_DATE_TIMESTAMP_MS / 1000 + 1) },
});
expect(readResetAt(response)).toBeUndefined();
});
});

View File

@@ -1,3 +1,8 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
type QueryValue = string | number | boolean;
const RATE_LIMIT_HEADER_NUMBER_RE = /^\d+(?:\.\d+)?$/;
@@ -37,13 +42,24 @@ export function readHeaderNumber(headers: Headers, name: string): number | undef
: undefined;
}
export function resolveRateLimitResetAt(delayMs: number): number | undefined {
const clampedDelayMs = Math.ceil(Math.max(0, delayMs));
if (!Number.isSafeInteger(clampedDelayMs)) {
return undefined;
}
if (clampedDelayMs === 0) {
return asDateTimestampMs(Date.now());
}
return resolveExpiresAtMsFromDurationMs(clampedDelayMs);
}
export function readResetAt(response: Response): number | undefined {
const resetAfter = readHeaderNumber(response.headers, "X-RateLimit-Reset-After");
if (resetAfter !== undefined) {
return Date.now() + Math.max(0, resetAfter * 1000);
return resolveRateLimitResetAt(resetAfter * 1000);
}
const reset = readHeaderNumber(response.headers, "X-RateLimit-Reset");
return reset !== undefined ? reset * 1000 : undefined;
return reset !== undefined ? asDateTimestampMs(reset * 1000) : undefined;
}
export function appendQuery(path: string, query?: Record<string, QueryValue>): string {

View File

@@ -1,3 +1,4 @@
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import { RateLimitError } from "./rest-errors.js";
import { RestScheduler, type RestSchedulerOptions } from "./rest-scheduler.js";
@@ -68,4 +69,67 @@ describe("RestScheduler", () => {
expect(executor).toHaveBeenCalledTimes(1);
expect(scheduler.queueSize).toBe(0);
});
it("ignores 429 retry deadlines that exceed the Date range", () => {
vi.useFakeTimers();
vi.setSystemTime(MAX_DATE_TIMESTAMP_MS);
try {
const scheduler = new RestScheduler(createOptions(), vi.fn());
scheduler.recordResponse(
"GET /channels/c1/messages",
"/channels/c1/messages",
createJsonResponse(
{ message: "Rate limited", retry_after: 1, global: true },
{ status: 429 },
),
{ message: "Rate limited", retry_after: 1, global: true },
);
expect(scheduler.getMetrics().globalRateLimitUntil).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("keeps immediate 429 retry deadlines working", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-28T12:00:00.000Z"));
try {
const scheduler = new RestScheduler(createOptions(), vi.fn());
scheduler.recordResponse(
"GET /channels/c1/messages",
"/channels/c1/messages",
createJsonResponse(
{ message: "Rate limited", retry_after: 0, global: true },
{ status: 429 },
),
{ message: "Rate limited", retry_after: 0, global: true },
);
expect(scheduler.getMetrics().globalRateLimitUntil).toBe(Date.now());
} finally {
vi.useRealTimers();
}
});
it("rounds fractional millisecond 429 retry deadlines up", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-28T12:00:00.000Z"));
try {
const scheduler = new RestScheduler(createOptions(), vi.fn());
scheduler.recordResponse(
"GET /channels/c1/messages",
"/channels/c1/messages",
createJsonResponse(
{ message: "Rate limited", retry_after: 0.0004, global: true },
{ status: 429 },
),
{ message: "Rate limited", retry_after: 0.0004, global: true },
);
expect(scheduler.getMetrics().globalRateLimitUntil).toBe(Date.now() + 1);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,6 +1,12 @@
import { resolveIntegerOption } from "openclaw/plugin-sdk/number-runtime";
import { RateLimitError, readRetryAfter } from "./rest-errors.js";
import { createBucketKey, createRouteKey, readHeaderNumber, readResetAt } from "./rest-routes.js";
import {
createBucketKey,
createRouteKey,
readHeaderNumber,
readResetAt,
resolveRateLimitResetAt,
} from "./rest-routes.js";
export type RequestPriority = "critical" | "standard" | "background";
export type RequestQuery = Record<string, string | number | boolean>;
@@ -323,7 +329,10 @@ export class RestScheduler<TData> {
}
bucket.rateLimitHits += 1;
const retryAfterMs = Math.max(0, readRetryAfter(parsed, response, 1) * 1000);
const retryAt = Date.now() + retryAfterMs;
const retryAt = resolveRateLimitResetAt(retryAfterMs);
if (retryAt === undefined) {
return;
}
if (response.headers.get("X-RateLimit-Global") === "true" || isGlobalRateLimit(parsed)) {
this.globalRateLimitUntil = Math.max(this.globalRateLimitUntil, retryAt);
return;

View File

@@ -12,6 +12,9 @@ type DiscordInboundJobRuntimeField =
| "guildHistories"
| "client"
| "threadBindings"
// Function-backed feedback stays runtime-only; payload must remain
// materializable data so queued jobs cannot accidentally serialize it.
| "replyTypingFeedback"
| "discordRestFetch";
type DiscordInboundJobRuntime = Pick<DiscordMessagePreflightContext, DiscordInboundJobRuntimeField>;
@@ -26,6 +29,8 @@ export type DiscordInboundJob = {
};
export function resolveDiscordInboundJobQueueKey(ctx: DiscordMessagePreflightContext): string {
// This key is both the run-queue serialization key and the typing prestart
// dedupe key, so keep it aligned with the eventual session route.
const sessionKey = ctx.route.sessionKey?.trim();
if (sessionKey) {
return sessionKey;
@@ -47,6 +52,7 @@ export function buildDiscordInboundJob(
guildHistories,
client,
threadBindings,
replyTypingFeedback,
discordRestFetch,
message,
data,
@@ -72,6 +78,7 @@ export function buildDiscordInboundJob(
guildHistories,
client,
threadBindings,
replyTypingFeedback,
discordRestFetch,
},
replayKeys: options?.replayKeys ? [...options.replayKeys] : undefined,

View File

@@ -1,3 +1,7 @@
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ChannelType, Message } from "../internal/discord.js";
@@ -30,6 +34,22 @@ export function resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear();
}
function resolveDiscordChannelInfoCacheExpiresAt(ttlMs: number, nowMs: number): number | undefined {
return resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs });
}
function cacheDiscordChannelInfo(
channelId: string,
value: DiscordChannelInfo | null,
ttlMs: number,
nowMs: number,
): void {
const expiresAt = resolveDiscordChannelInfoCacheExpiresAt(ttlMs, nowMs);
if (expiresAt !== undefined) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value, expiresAt });
}
}
function normalizeDiscordChannelId(value: unknown): string {
return normalizeOptionalStringifiedId(value) ?? "";
}
@@ -51,9 +71,11 @@ export async function resolveDiscordChannelInfo(
client: DiscordChannelInfoClient,
channelId: string,
): Promise<DiscordChannelInfo | null> {
const rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (cached.expiresAt > Date.now()) {
if (now !== undefined && cached.expiresAt > now) {
return cached.value;
}
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
@@ -61,10 +83,7 @@ export async function resolveDiscordChannelInfo(
try {
const channel = await client.fetchChannel(channelId);
if (!channel) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
return null;
}
const channelInfo = resolveDiscordChannelInfoSafe(channel);
@@ -80,17 +99,11 @@ export async function resolveDiscordChannelInfo(
parentId: channelInfo.parentId,
ownerId: channelInfo.ownerId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
cacheDiscordChannelInfo(channelId, payload, DISCORD_CHANNEL_INFO_CACHE_TTL_MS, rawNow);
return payload;
} catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
return null;
}
}

View File

@@ -2252,4 +2252,40 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => {
}),
).toBe(true);
});
it("does not suppress unbound thread webhook echoes when echo expiry overflows", async () => {
const manager = createThreadBindingManager({
cfg: DEFAULT_PREFLIGHT_CFG,
accountId: "default",
persist: false,
enableSweeper: false,
});
const binding = await manager.bindTarget({
threadId: "thread-overflow",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child-1",
agentId: "main",
webhookId: "wh-overflow",
webhookToken: "tok-1",
});
expect(binding).not.toBeNull();
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
try {
manager.unbindThread({
threadId: "thread-overflow",
sendFarewell: false,
});
} finally {
nowSpy.mockRestore();
}
expect(
shouldIgnoreBoundThreadWebhookMessage({
accountId: "default",
threadId: "thread-overflow",
webhookId: "wh-overflow",
}),
).toBe(false);
});
});

View File

@@ -8,6 +8,7 @@ import type { ChannelType, Client, User } from "../internal/discord.js";
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
import type { DiscordChannelInfo } from "./message-utils.js";
import type { DiscordThreadBindingLookup } from "./reply-delivery.js";
import type { DiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
import type { DiscordSenderIdentity } from "./sender-identity.js";
export type { DiscordSenderIdentity } from "./sender-identity.js";
@@ -95,6 +96,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
historyEntry?: HistoryEntry;
threadBindings: DiscordThreadBindingLookup;
replyTypingFeedback?: DiscordReplyTypingFeedback;
discordRestFetch?: typeof fetch;
botLoopProtection?: ChannelBotLoopProtectionFacts;
};

View File

@@ -86,6 +86,16 @@ vi.mock("../send.js", () => ({
},
}));
const typingMocks = vi.hoisted(() => ({
sendTyping: vi.fn<(params: { rest: unknown; channelId: string }) => Promise<void>>(
async () => {},
),
}));
vi.mock("./typing.js", () => ({
sendTyping: typingMocks.sendTyping,
}));
const discordTargetMocks = vi.hoisted(() => ({
resolveDiscordTargetChannelId: vi.fn(async (target: string, _opts?: unknown) => ({
channelId: target === "user:u1" ? "dm-u1" : target,
@@ -169,6 +179,7 @@ type DispatchInboundParams = {
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
onTypingCleanup?: () => Promise<void> | void;
};
};
const dispatchInboundMessage = vi.hoisted(() =>
@@ -233,6 +244,7 @@ let createThreadBindingManager: typeof import("./thread-bindings.js").createThre
let processDiscordMessage: typeof import("./message-handler.process.js").processDiscordMessage;
let formatDiscordReplySkip: typeof import("./message-handler.process.js").formatDiscordReplySkip;
let notifyDiscordInboundEventOutboundSuccess: typeof import("../inbound-event-delivery.js").notifyDiscordInboundEventOutboundSuccess;
let createDiscordReplyTypingFeedback: typeof import("./reply-typing-feedback.js").createDiscordReplyTypingFeedback;
vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
dispatchReplyWithBufferedBlockDispatcher: async (params: {
@@ -244,6 +256,14 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
deliver: (payload: unknown, info: { kind: "block" | "final" }) => Promise<void> | void;
onError?: (err: unknown, info: { kind: "block" | "final" }) => void;
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
typingCallbacks?: {
onReplyStart?: () => Promise<void> | void;
onIdle?: () => void;
onCleanup?: () => void;
};
onReplyStart?: () => Promise<void> | void;
onIdle?: () => void;
onCleanup?: () => void;
onSettled?: () => unknown;
onFreshSettledDelivery?: () => unknown;
};
@@ -273,10 +293,16 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
pendingDeliveries.push(delivery);
return true;
};
const typingCallbacks = params.dispatcherOptions.typingCallbacks;
const replyOptions = {
...params.replyOptions,
onReplyStart: params.dispatcherOptions.onReplyStart ?? typingCallbacks?.onReplyStart,
onTypingCleanup: params.dispatcherOptions.onCleanup ?? typingCallbacks?.onCleanup,
};
try {
return await dispatchInboundMessage({
ctx: params.ctx,
replyOptions: params.replyOptions,
replyOptions,
dispatcher: {
sendBlockReply: vi.fn((payload: ReplyPayload) =>
queueDelivery(payload, { kind: "block" }),
@@ -292,6 +318,8 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
} finally {
await params.dispatcherOptions.onSettled?.();
await params.dispatcherOptions.onFreshSettledDelivery?.();
params.dispatcherOptions.onIdle?.();
typingCallbacks?.onIdle?.();
}
},
dispatchInboundMessage: (params: DispatchInboundParams) => dispatchInboundMessage(params),
@@ -456,12 +484,15 @@ beforeAll(async () => {
({ processDiscordMessage, formatDiscordReplySkip } =
await import("./message-handler.process.js"));
({ notifyDiscordInboundEventOutboundSuccess } = await import("../inbound-event-delivery.js"));
({ createDiscordReplyTypingFeedback } = await import("./reply-typing-feedback.js"));
});
beforeEach(() => {
vi.useRealTimers();
sendMocks.reactMessageDiscord.mockClear();
sendMocks.removeReactionDiscord.mockClear();
typingMocks.sendTyping.mockClear();
typingMocks.sendTyping.mockResolvedValue(undefined);
discordTargetMocks.resolveDiscordTargetChannelId.mockClear();
editMessageDiscord.mockClear();
deliverDiscordReply.mockClear();
@@ -873,6 +904,70 @@ describe("processDiscordMessage ack reactions", () => {
expect(feedbackRest).not.toBe(deliveryRest);
});
it("reuses accepted typing feedback through reply dispatch", async () => {
const replyTypingFeedback = {
onReplyStart: vi.fn(async () => {}),
onIdle: vi.fn(),
onCleanup: vi.fn(),
updateChannelId: vi.fn(),
getChannelId: vi.fn(() => "c1"),
restartForDispatch: vi.fn(),
};
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReplyStart?.();
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
replyTypingFeedback,
});
await runProcessDiscordMessage(ctx);
expect(replyTypingFeedback.updateChannelId).not.toHaveBeenCalled();
expect(replyTypingFeedback.restartForDispatch).toHaveBeenCalledWith("c1");
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
expect(replyTypingFeedback.onIdle).toHaveBeenCalledTimes(1);
expect(replyTypingFeedback.onCleanup).toHaveBeenCalledTimes(1);
expect(typingMocks.sendTyping).not.toHaveBeenCalled();
});
it("restarts stale carried typing feedback before dispatch", async () => {
vi.useFakeTimers();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const rest = { kind: "feedback-rest" };
try {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReplyStart?.();
await vi.advanceTimersByTimeAsync(3_500);
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext();
ctx.replyTypingFeedback = createDiscordReplyTypingFeedback({
cfg: ctx.cfg,
token: ctx.token,
accountId: ctx.accountId,
channelId: "c1",
rest: rest as never,
log: vi.fn(),
maxDurationMs: 5_000,
});
await ctx.replyTypingFeedback.onReplyStart();
await vi.advanceTimersByTimeAsync(5_100);
typingMocks.sendTyping.mockClear();
await runProcessDiscordMessage(ctx);
expect(typingMocks.sendTyping.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(
typingMocks.sendTyping.mock.calls.every(
([params]) => params.channelId === "c1" && params.rest === rest,
),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
it("debounces intermediate phase reactions and jumps to done for short runs", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.();

View File

@@ -9,7 +9,6 @@ import {
createStatusReactionController,
DEFAULT_TIMING,
logAckFailure,
logTypingFailure,
shouldAckReaction as shouldAckReactionGate,
} from "openclaw/plugin-sdk/channel-feedback";
import {
@@ -66,11 +65,11 @@ import { createDiscordDraftPreviewController } from "./message-handler.draft-pre
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
import {
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
} from "./timeouts.js";
import { sendTyping } from "./typing.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
@@ -78,7 +77,6 @@ function sleep(ms: number): Promise<void> {
});
}
const DISCORD_TYPING_MAX_DURATION_MS = 20 * 60_000;
let replyRuntimePromise: Promise<typeof import("openclaw/plugin-sdk/reply-runtime")> | undefined;
async function loadReplyRuntime() {
@@ -154,6 +152,17 @@ function readToolBooleanArg(args: Record<string, unknown>, key: string): boolean
export async function processDiscordMessage(
ctx: DiscordMessagePreflightContext,
observer?: DiscordMessageProcessObserver,
) {
try {
await processDiscordMessageInner(ctx, observer);
} finally {
ctx.replyTypingFeedback?.onCleanup?.();
}
}
async function processDiscordMessageInner(
ctx: DiscordMessagePreflightContext,
observer?: DiscordMessageProcessObserver,
) {
const dispatchStartedAt = Date.now();
const {
@@ -184,6 +193,7 @@ export async function processDiscordMessage(
discordRestFetch,
abortSignal,
botLoopProtection,
replyTypingFeedback,
} = ctx;
if (isProcessAborted(abortSignal)) {
return;
@@ -432,25 +442,32 @@ export async function processDiscordMessage(
const typingChannelId = deliverTarget.startsWith("channel:")
? deliverTarget.slice("channel:".length)
: messageChannelId;
// Deliver target can move into a thread after preflight accepted the message.
// The typing owner follows the final target before reply dispatch starts.
const typingFeedback =
replyTypingFeedback ??
createDiscordReplyTypingFeedback({
cfg,
token,
accountId,
channelId: typingChannelId,
rest: feedbackRest,
log: logVerbose,
});
if (replyTypingFeedback) {
// A carried prestart only covers queue wait time; dispatch needs a fresh
// controller after retargeting so an expired TTL cannot silence the run.
replyTypingFeedback.restartForDispatch(typingChannelId);
} else {
typingFeedback.updateChannelId(typingChannelId);
}
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg,
agentId: route.agentId,
channel: "discord",
accountId: route.accountId,
typing: {
start: () => sendTyping({ rest: feedbackRest, channelId: typingChannelId }),
onStartError: (err) => {
logTypingFailure({
log: logVerbose,
channel: "discord",
target: typingChannelId,
error: err,
});
},
// Long tool-heavy runs are expected on Discord; keep heartbeats alive.
maxDurationMs: DISCORD_TYPING_MAX_DURATION_MS,
},
typingCallbacks: typingFeedback,
});
const tableMode = resolveMarkdownTableMode({
cfg,

View File

@@ -11,25 +11,16 @@ import {
createDiscordPreflightContext,
} from "./message-handler.test-helpers.js";
const earlyTypingMocks = vi.hoisted(() => ({
createDiscordRestClient: vi.fn(() => ({
token: "test-token",
rest: { kind: "discord-rest" },
account: { accountId: "default", config: {} },
})),
sendTyping: vi.fn(async () => {}),
}));
vi.mock("../client.js", () => ({
createDiscordRestClient: earlyTypingMocks.createDiscordRestClient,
}));
vi.mock("./typing.js", () => ({
sendTyping: earlyTypingMocks.sendTyping,
}));
type SetStatusFn = (patch: Record<string, unknown>) => void;
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
type ReplyTypingFeedbackMock = {
onReplyStart: ReturnType<typeof vi.fn<() => Promise<void>>>;
onIdle: ReturnType<typeof vi.fn<() => void>>;
onCleanup: ReturnType<typeof vi.fn<() => void>>;
updateChannelId: ReturnType<typeof vi.fn<(channelId: string) => void>>;
getChannelId: ReturnType<typeof vi.fn<() => string>>;
restartForDispatch: ReturnType<typeof vi.fn<(channelId: string) => void>>;
};
function mockCall(source: MockCallSource, label: string, callIndex = 0): Array<unknown> {
const call = source.mock.calls[callIndex];
@@ -104,9 +95,22 @@ function createPreflightContext(channelId = "ch-1") {
cfg,
accountId: "default",
token: "test-token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
textLimit: 2_000,
replyToMode: "off" as const,
discordConfig,
messageText: "hello",
isDirectMessage: false,
isGuildMessage: true,
isGroupDm: false,
inboundEventKind: "message" as const,
effectiveWasMentioned: false,
};
}
@@ -121,6 +125,17 @@ function createAcceptedDmPreflightContext(overrides: Record<string, unknown> = {
};
}
function createReplyTypingFeedbackMock(channelId = "ch-1"): ReplyTypingFeedbackMock {
return {
onReplyStart: vi.fn(async () => {}),
onIdle: vi.fn(),
onCleanup: vi.fn(),
updateChannelId: vi.fn(),
getChannelId: vi.fn(() => channelId),
restartForDispatch: vi.fn(),
};
}
function createHandlerWithDefaultPreflight(overrides?: { setStatus?: SetStatusFn }) {
preflightDiscordMessageMock.mockImplementation(async (params: { data: { channel_id: string } }) =>
createPreflightContext(params.data.channel_id),
@@ -172,106 +187,118 @@ async function createLifecycleStopScenario(params: {
describe("createDiscordMessageHandler queue behavior", () => {
beforeEach(() => {
earlyTypingMocks.createDiscordRestClient.mockReset().mockReturnValue({
token: "test-token",
rest: { kind: "discord-rest" },
account: { accountId: "default", config: {} },
});
earlyTypingMocks.sendTyping.mockReset().mockResolvedValue(undefined);
vi.useRealTimers();
});
it("sends an accepted DM typing cue before queued processing starts", async () => {
it("starts accepted DM typing feedback before queued processing starts", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
preflightDiscordMessageMock.mockResolvedValue(createAcceptedDmPreflightContext());
preflightDiscordMessageMock.mockImplementation(async () => createAcceptedDmPreflightContext());
processDiscordMessageMock.mockResolvedValue(undefined);
const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1");
const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback);
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
const handler = createDiscordMessageHandler({
...createDiscordHandlerParams(),
testing: { createReplyTypingFeedback },
});
await expect(
handler(createMessageData("m-typing", "dm-1") as never, {} as never),
).resolves.toBeUndefined();
await flushQueueWork();
expect(earlyTypingMocks.createDiscordRestClient).toHaveBeenCalledTimes(1);
const [restClientParams] = mockCall(
earlyTypingMocks.createDiscordRestClient,
"createDiscordRestClient",
expect(createReplyTypingFeedback).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
token: "test-token",
channelId: "dm-1",
}),
);
expect((restClientParams as { accountId?: unknown } | undefined)?.accountId).toBe("default");
expect((restClientParams as { token?: unknown } | undefined)?.token).toBe("test-token");
expect(earlyTypingMocks.sendTyping).toHaveBeenCalledWith({
rest: { kind: "discord-rest" },
channelId: "dm-1",
});
expect(earlyTypingMocks.sendTyping.mock.invocationCallOrder[0]).toBeLessThan(
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
expect(replyTypingFeedback.onReplyStart.mock.invocationCallOrder[0]).toBeLessThan(
processDiscordMessageMock.mock.invocationCallOrder[0],
);
});
it("keeps accepted DM dispatch running when the early typing cue fails", async () => {
it("keeps accepted DM dispatch running when accepted typing feedback fails", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
earlyTypingMocks.sendTyping.mockRejectedValueOnce(new Error("typing failed"));
preflightDiscordMessageMock.mockResolvedValue(createAcceptedDmPreflightContext());
preflightDiscordMessageMock.mockImplementation(async () => createAcceptedDmPreflightContext());
processDiscordMessageMock.mockResolvedValue(undefined);
const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1");
replyTypingFeedback.onReplyStart.mockRejectedValueOnce(new Error("typing failed"));
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
const handler = createDiscordMessageHandler({
...createDiscordHandlerParams(),
testing: { createReplyTypingFeedback: vi.fn(() => replyTypingFeedback) },
});
await expect(
handler(createMessageData("m-typing-fails", "dm-1") as never, {} as never),
).resolves.toBeUndefined();
await flushQueueWork();
expect(earlyTypingMocks.sendTyping).toHaveBeenCalledTimes(1);
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
});
it("does not send early typing when preflight rejects the message", async () => {
it("does not start accepted typing feedback when preflight rejects the message", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
preflightDiscordMessageMock.mockResolvedValue(null);
const createReplyTypingFeedback = vi.fn();
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
const handler = createDiscordMessageHandler({
...createDiscordHandlerParams(),
testing: { createReplyTypingFeedback },
});
await expect(
handler(createMessageData("m-rejected", "dm-1") as never, {} as never),
).resolves.toBeUndefined();
await flushQueueWork();
expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled();
expect(createReplyTypingFeedback).not.toHaveBeenCalled();
expect(processDiscordMessageMock).not.toHaveBeenCalled();
});
it("does not send early typing when typing mode is not instant", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
preflightDiscordMessageMock.mockResolvedValue(
createAcceptedDmPreflightContext({
cfg: {
...createPreflightContext().cfg,
agents: {
defaults: {
typingMode: "message",
it.each(["message", "thinking", "never"] as const)(
"does not start accepted typing feedback when typing mode is %s",
async (typingMode) => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
preflightDiscordMessageMock.mockResolvedValue(
createAcceptedDmPreflightContext({
cfg: {
...createPreflightContext().cfg,
agents: {
defaults: {
typingMode,
},
},
},
},
}),
);
processDiscordMessageMock.mockResolvedValue(undefined);
}),
);
processDiscordMessageMock.mockResolvedValue(undefined);
const createReplyTypingFeedback = vi.fn();
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
await expect(
handler(createMessageData("m-message-mode", "dm-1") as never, {} as never),
).resolves.toBeUndefined();
const handler = createDiscordMessageHandler({
...createDiscordHandlerParams(),
testing: { createReplyTypingFeedback },
});
await expect(
handler(createMessageData(`m-${typingMode}-mode`, "dm-1") as never, {} as never),
).resolves.toBeUndefined();
await flushQueueWork();
await flushQueueWork();
expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled();
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
});
expect(createReplyTypingFeedback).not.toHaveBeenCalled();
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
},
);
it("does not send early typing for guild messages", async () => {
it("does not start default accepted typing feedback for unmentioned guild replies", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
preflightDiscordMessageMock.mockResolvedValue(
@@ -279,21 +306,107 @@ describe("createDiscordMessageHandler queue behavior", () => {
isDirectMessage: false,
isGuildMessage: true,
messageChannelId: "guild-channel",
effectiveWasMentioned: false,
}),
);
processDiscordMessageMock.mockResolvedValue(undefined);
const createReplyTypingFeedback = vi.fn();
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
const handler = createDiscordMessageHandler({
...createDiscordHandlerParams(),
testing: { createReplyTypingFeedback },
});
await expect(
handler(createMessageData("m-guild", "guild-channel") as never, {} as never),
).resolves.toBeUndefined();
await flushQueueWork();
expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled();
expect(createReplyTypingFeedback).not.toHaveBeenCalled();
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
});
it("starts accepted typing feedback for message-tool-only guild replies", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
preflightDiscordMessageMock.mockResolvedValue(
createAcceptedDmPreflightContext({
cfg: {
...createPreflightContext().cfg,
messages: {
inbound: { debounceMs: 0 },
groupChat: { visibleReplies: "message_tool" },
},
},
isDirectMessage: false,
isGuildMessage: true,
messageChannelId: "guild-channel",
effectiveWasMentioned: false,
}),
);
processDiscordMessageMock.mockResolvedValue(undefined);
const replyTypingFeedback = createReplyTypingFeedbackMock("guild-channel");
const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback);
const handler = createDiscordMessageHandler({
...createDiscordHandlerParams(),
testing: { createReplyTypingFeedback },
});
await expect(
handler(createMessageData("m-guild-tool", "guild-channel") as never, {} as never),
).resolves.toBeUndefined();
await flushQueueWork();
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
});
it("deduplicates accepted typing feedback while same-session runs are queued", async () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
const firstRun = createDeferred();
const processedContexts: Array<Record<string, unknown>> = [];
processDiscordMessageMock
.mockImplementationOnce(async (ctx: Record<string, unknown>) => {
processedContexts.push(ctx);
await firstRun.promise;
})
.mockImplementationOnce(async (ctx: Record<string, unknown>) => {
processedContexts.push(ctx);
});
preflightDiscordMessageMock.mockImplementation(async () => createAcceptedDmPreflightContext());
const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1");
const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback);
const handler = createDiscordMessageHandler({
...createDiscordHandlerParams(),
testing: { createReplyTypingFeedback },
});
await expect(
handler(createMessageData("m-1", "dm-1") as never, {} as never),
).resolves.toBeUndefined();
await flushQueueWork();
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
await expect(
handler(createMessageData("m-2", "dm-1") as never, {} as never),
).resolves.toBeUndefined();
await flushQueueWork();
expect(createReplyTypingFeedback).toHaveBeenCalledTimes(1);
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
expect(processedContexts[0]?.replyTypingFeedback).toBe(replyTypingFeedback);
firstRun.resolve();
await firstRun.promise;
await flushQueueWork();
expect(processDiscordMessageMock).toHaveBeenCalledTimes(2);
expect(processedContexts[1]?.replyTypingFeedback).toBeUndefined();
});
it("resets busy counters when the handler is created", () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();

View File

@@ -0,0 +1,123 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { resolveDiscordAcceptedTypingPrestart } from "./message-handler.reply-typing-policy.js";
import { createDiscordPreflightContext } from "./message-handler.test-helpers.js";
function createPolicyContext(
overrides: Partial<DiscordMessagePreflightContext> = {},
): DiscordMessagePreflightContext {
const cfg: OpenClawConfig = {
channels: {
discord: {
enabled: true,
token: "test-token",
groupPolicy: "allowlist",
},
},
messages: {
inbound: {
debounceMs: 0,
},
},
};
return {
...createDiscordPreflightContext("c1"),
cfg,
accountId: "default",
token: "test-token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
discordConfig: cfg.channels?.discord,
messageText: "hello",
isDirectMessage: true,
isGuildMessage: false,
isGroupDm: false,
inboundEventKind: "message",
effectiveWasMentioned: false,
...overrides,
} as DiscordMessagePreflightContext;
}
describe("resolveDiscordAcceptedTypingPrestart", () => {
it.each([
["default direct message", createPolicyContext(), true, "direct"],
[
"default mentioned guild message",
createPolicyContext({
isDirectMessage: false,
isGuildMessage: true,
effectiveWasMentioned: true,
}),
true,
"mentioned-group",
],
[
"default unmentioned guild message",
createPolicyContext({
isDirectMessage: false,
isGuildMessage: true,
effectiveWasMentioned: false,
}),
false,
"defer-to-message",
],
[
"message-tool-only guild message",
createPolicyContext({
cfg: {
...createPolicyContext().cfg,
messages: {
inbound: { debounceMs: 0 },
groupChat: { visibleReplies: "message_tool" },
},
},
isDirectMessage: false,
isGuildMessage: true,
effectiveWasMentioned: false,
}),
true,
"tool-only",
],
[
"room event",
createPolicyContext({
inboundEventKind: "room_event",
}),
false,
"room-event",
],
[
"configured instant",
createPolicyContext({
cfg: {
...createPolicyContext().cfg,
agents: { defaults: { typingMode: "instant" } },
},
}),
true,
"configured-instant",
],
[
"configured message",
createPolicyContext({
cfg: {
...createPolicyContext().cfg,
agents: { defaults: { typingMode: "message" } },
},
}),
false,
"configured-not-instant",
],
] as const)("%s", (_label, ctx, shouldPrestart, reason) => {
expect(resolveDiscordAcceptedTypingPrestart(ctx)).toMatchObject({
shouldPrestart,
reason,
});
});
});

View File

@@ -0,0 +1,76 @@
import { resolveChannelMessageSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-outbound";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js";
type SourceReplyDeliveryMode = ReturnType<typeof resolveChannelMessageSourceReplyDeliveryMode>;
export type DiscordAcceptedTypingPrestartDecision = {
sourceReplyDeliveryMode: SourceReplyDeliveryMode;
shouldPrestart: boolean;
reason:
| "aborted"
| "empty"
| "room-event"
| "configured-instant"
| "configured-not-instant"
| "tool-only"
| "direct"
| "mentioned-group"
| "defer-to-message";
};
export function resolveDiscordSourceReplyDeliveryMode(
ctx: DiscordMessagePreflightContext,
): SourceReplyDeliveryMode {
// Keep prestart policy keyed to the same source-reply mode as dispatch.
// Otherwise message-tool-only group replies would wait behind "message" mode.
return resolveChannelMessageSourceReplyDeliveryMode({
cfg: ctx.cfg,
ctx: {
ChatType: ctx.isDirectMessage
? "direct"
: ctx.isGroupDm
? "group"
: ctx.isGuildMessage
? "channel"
: undefined,
InboundEventKind: ctx.inboundEventKind,
},
});
}
export function resolveDiscordAcceptedTypingPrestart(
ctx: DiscordMessagePreflightContext,
): DiscordAcceptedTypingPrestartDecision {
const sourceReplyDeliveryMode = resolveDiscordSourceReplyDeliveryMode(ctx);
if (ctx.abortSignal?.aborted) {
return { sourceReplyDeliveryMode, shouldPrestart: false, reason: "aborted" };
}
if (!ctx.messageText.trim()) {
return { sourceReplyDeliveryMode, shouldPrestart: false, reason: "empty" };
}
if (ctx.inboundEventKind === "room_event") {
return { sourceReplyDeliveryMode, shouldPrestart: false, reason: "room-event" };
}
const configuredTypingMode = ctx.cfg.session?.typingMode ?? ctx.cfg.agents?.defaults?.typingMode;
if (configuredTypingMode !== undefined) {
// Explicit operator config wins over Discord heuristics.
// Non-instant modes intentionally defer to the normal reply pipeline.
return {
sourceReplyDeliveryMode,
shouldPrestart: configuredTypingMode === "instant",
reason: configuredTypingMode === "instant" ? "configured-instant" : "configured-not-instant",
};
}
if (sourceReplyDeliveryMode === "message_tool_only") {
// Message-tool-only replies have no visible default response path.
// Prestart preserves user feedback while the tool-delivered reply waits.
return { sourceReplyDeliveryMode, shouldPrestart: true, reason: "tool-only" };
}
if (!ctx.isGuildMessage && !ctx.isGroupDm) {
return { sourceReplyDeliveryMode, shouldPrestart: true, reason: "direct" };
}
if (ctx.effectiveWasMentioned) {
return { sourceReplyDeliveryMode, shouldPrestart: true, reason: "mentioned-group" };
}
return { sourceReplyDeliveryMode, shouldPrestart: false, reason: "defer-to-message" };
}

View File

@@ -4,7 +4,6 @@ import {
} from "openclaw/plugin-sdk/channel-inbound";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import { createDiscordRestClient } from "../client.js";
import type { Client } from "../internal/discord.js";
import {
buildDiscordInboundReplayKey,
@@ -14,11 +13,14 @@ import {
DiscordRetryableInboundError,
releaseDiscordInboundReplay,
} from "./inbound-dedupe.js";
import { buildDiscordInboundJob } from "./inbound-job.js";
import { buildDiscordInboundJob, resolveDiscordInboundJobQueueKey } from "./inbound-job.js";
import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js";
import { applyImplicitReplyBatchGate } from "./message-handler.batch-gate.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js";
import type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
import { resolveDiscordAcceptedTypingPrestart } from "./message-handler.reply-typing-policy.js";
import {
createDiscordMessageRunQueue,
type DiscordMessageRunQueueTestingHooks,
@@ -28,11 +30,15 @@ import {
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
} from "./message-utils.js";
import {
createDiscordReplyTypingFeedback,
type DiscordReplyTypingFeedback,
} from "./reply-typing-feedback.js";
import type { DiscordMonitorStatusSink } from "./status.js";
import { sendTyping } from "./typing.js";
type PreflightDiscordMessage =
typeof import("./message-handler.preflight.js").preflightDiscordMessage;
type CreateDiscordReplyTypingFeedback = typeof createDiscordReplyTypingFeedback;
type DiscordMessageHandlerParams = Omit<
DiscordMessagePreflightParams,
@@ -45,6 +51,12 @@ type DiscordMessageHandlerParams = Omit<
type DiscordMessageHandlerTestingHooks = DiscordMessageRunQueueTestingHooks & {
preflightDiscordMessage?: PreflightDiscordMessage;
createReplyTypingFeedback?: CreateDiscordReplyTypingFeedback;
};
type PrestartedTypingFeedbackEntry = {
channelId: string;
feedback: DiscordReplyTypingFeedback;
};
let messagePreflightRuntimePromise:
@@ -64,34 +76,47 @@ function isNonEmptyString(value: string | undefined): value is string {
return typeof value === "string" && value.length > 0;
}
function shouldSendAcceptedDiscordTypingCue(ctx: DiscordMessagePreflightContext): boolean {
if (ctx.abortSignal?.aborted) {
return false;
function startAcceptedTypingFeedback(params: {
ctx: DiscordMessagePreflightContext;
createFeedback?: CreateDiscordReplyTypingFeedback;
dedupeKey: string;
activeFeedback: Map<string, PrestartedTypingFeedbackEntry>;
}): DiscordReplyTypingFeedback | undefined {
const { ctx, createFeedback, dedupeKey, activeFeedback } = params;
if (!resolveDiscordAcceptedTypingPrestart(ctx).shouldPrestart) {
return undefined;
}
if (!ctx.isDirectMessage || ctx.isGuildMessage || ctx.isGroupDm) {
return false;
const channelId = ctx.messageChannelId.trim();
const existing = activeFeedback.get(dedupeKey);
if (existing) {
// One pre-dispatch keepalive owns each serialized Discord queue key.
// Later queued jobs get fresh typing when their dispatch turn starts.
return undefined;
}
if (!ctx.messageText.trim()) {
return false;
}
const configuredTypingMode = ctx.cfg.session?.typingMode ?? ctx.cfg.agents?.defaults?.typingMode;
return configuredTypingMode === undefined || configuredTypingMode === "instant";
}
function queueAcceptedDiscordTypingCue(ctx: DiscordMessagePreflightContext): void {
if (!shouldSendAcceptedDiscordTypingCue(ctx)) {
return;
}
const { rest } = createDiscordRestClient({
cfg: ctx.cfg,
token: ctx.token,
accountId: ctx.accountId,
});
void sendTyping({ rest, channelId: ctx.messageChannelId }).catch((err) => {
logVerbose(
`discord early typing cue failed for channel ${ctx.messageChannelId}: ${String(err)}`,
);
const replyTypingFeedback =
ctx.replyTypingFeedback ??
(createFeedback ?? createDiscordReplyTypingFeedback)({
cfg: ctx.cfg,
token: ctx.token,
accountId: ctx.accountId,
channelId: ctx.messageChannelId,
log: logVerbose,
});
const cleanup = replyTypingFeedback.onCleanup;
replyTypingFeedback.onCleanup = () => {
cleanup?.();
// Cleanup is the lease release for both normal dispatch and skipped jobs.
// Without this, a stale queue key would suppress future accepted typing.
if (activeFeedback.get(dedupeKey)?.feedback === replyTypingFeedback) {
activeFeedback.delete(dedupeKey);
}
};
activeFeedback.set(dedupeKey, { channelId, feedback: replyTypingFeedback });
ctx.replyTypingFeedback = replyTypingFeedback;
void replyTypingFeedback.onReplyStart().catch((err) => {
logVerbose(`discord accepted typing feedback failed: ${String(err)}`);
});
return replyTypingFeedback;
}
export function createDiscordMessageHandler(
@@ -108,6 +133,9 @@ export function createDiscordMessageHandler(
"group-mentions";
const preflightDiscordMessageImpl = params.testing?.preflightDiscordMessage;
const replayGuard = createDiscordInboundReplayGuard();
// The map owns pre-dispatch typing leases, not queued work itself.
// Each lease is released by the feedback cleanup hook installed below.
const prestartedTypingFeedback = new Map<string, PrestartedTypingFeedbackEntry>();
const messageRunQueue = createDiscordMessageRunQueue({
runtime: params.runtime,
setStatus: params.setStatus,
@@ -185,8 +213,14 @@ export function createDiscordMessageHandler(
await commitDiscordInboundReplay({ replayKeys, replayGuard });
return;
}
const queueKey = resolveDiscordInboundJobQueueKey(ctx);
startAcceptedTypingFeedback({
ctx,
createFeedback: params.testing?.createReplyTypingFeedback,
dedupeKey: queueKey,
activeFeedback: prestartedTypingFeedback,
});
applyImplicitReplyBatchGate(ctx, params.replyToMode, false);
queueAcceptedDiscordTypingCue(ctx);
messageRunQueue.enqueue(buildDiscordInboundJob(ctx, { replayKeys }));
return;
}
@@ -235,6 +269,13 @@ export function createDiscordMessageHandler(
await commitDiscordInboundReplay({ replayKeys, replayGuard });
return;
}
const queueKey = resolveDiscordInboundJobQueueKey(ctx);
startAcceptedTypingFeedback({
ctx,
createFeedback: params.testing?.createReplyTypingFeedback,
dedupeKey: queueKey,
activeFeedback: prestartedTypingFeedback,
});
applyImplicitReplyBatchGate(ctx, params.replyToMode, true);
if (entries.length > 1) {
const ids = entries.map((entry) => entry.data.message?.id).filter(isNonEmptyString);
@@ -249,7 +290,6 @@ export function createDiscordMessageHandler(
ctxBatch.MessageSidLast = ids[ids.length - 1];
}
}
queueAcceptedDiscordTypingCue(ctx);
messageRunQueue.enqueue(buildDiscordInboundJob(ctx, { replayKeys }));
} catch (error) {
if (error instanceof DiscordRetryableInboundError) {

View File

@@ -31,6 +31,8 @@ export type DiscordMessageRunQueueTestingHooks = {
processDiscordMessage?: ProcessDiscordMessage;
};
type SkippedQueuedMessageCleanup = () => void;
let messageProcessRuntimePromise:
| Promise<typeof import("./message-handler.process.js")>
| undefined;
@@ -73,10 +75,28 @@ async function processDiscordQueuedMessage(params: {
}
}
function cleanupSkippedDiscordQueuedMessage(params: {
job: DiscordInboundJob;
replayGuard: ClaimableDedupe;
}) {
try {
// Skipped jobs never reach processDiscordMessage's finally block.
// Clean carried typing here before reopening the replay key for retry.
params.job.runtime.replyTypingFeedback?.onCleanup?.();
} finally {
releaseDiscordInboundReplay({
replayKeys: params.job.replayKeys,
error: new DiscordRetryableInboundError("discord queued run skipped before processing"),
replayGuard: params.replayGuard,
});
}
}
export function createDiscordMessageRunQueue(
params: DiscordMessageRunQueueParams,
): DiscordMessageRunQueue {
const replayGuard = params.replayGuard ?? createDiscordInboundReplayGuard();
const skippedCleanup = new Set<SkippedQueuedMessageCleanup>();
const runQueue = createChannelRunQueue({
setStatus: params.setStatus,
abortSignal: params.abortSignal,
@@ -84,10 +104,42 @@ export function createDiscordMessageRunQueue(
params.runtime.error(danger(`discord message run failed: ${String(error)}`));
},
});
let lifecycleActive = !params.abortSignal?.aborted;
const cleanupSkippedQueuedMessages = () => {
// These callbacks represent jobs accepted into the queue but not started.
// Running jobs remove their callback before processDiscordMessage owns cleanup.
if (!lifecycleActive && skippedCleanup.size === 0) {
return;
}
lifecycleActive = false;
const cleanups = [...skippedCleanup];
skippedCleanup.clear();
for (const cleanup of cleanups) {
cleanup();
}
};
if (params.abortSignal?.aborted) {
cleanupSkippedQueuedMessages();
} else {
params.abortSignal?.addEventListener("abort", cleanupSkippedQueuedMessages, { once: true });
}
return {
enqueue(job) {
const cleanupSkipped = () => {
cleanupSkippedDiscordQueuedMessage({ job, replayGuard });
};
if (!lifecycleActive) {
cleanupSkipped();
return;
}
skippedCleanup.add(cleanupSkipped);
runQueue.enqueue(job.queueKey, async ({ lifecycleSignal }) => {
// Once the task starts, normal process/commit handling owns cleanup.
// Leaving it in skippedCleanup would double-release replay/typing state.
skippedCleanup.delete(cleanupSkipped);
await processDiscordQueuedMessage({
job,
lifecycleSignal,
@@ -96,6 +148,9 @@ export function createDiscordMessageRunQueue(
});
});
},
deactivate: runQueue.deactivate,
deactivate() {
runQueue.deactivate();
cleanupSkippedQueuedMessages();
},
};
}

View File

@@ -4,7 +4,7 @@ import {
MessageReferenceType,
StickerFormatType,
} from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType, type Client, type Message } from "../internal/discord.js";
const readRemoteMediaBuffer = vi.fn();
@@ -65,6 +65,10 @@ beforeAll(async () => {
} = await import("./message-utils.js"));
});
afterEach(() => {
vi.restoreAllMocks();
});
function asMessage(payload: Record<string, unknown>): Message {
return payload as unknown as Message;
}
@@ -1231,4 +1235,37 @@ describe("resolveDiscordChannelInfo", () => {
expect(second).toBeNull();
expect(fetchChannel).toHaveBeenCalledTimes(1);
});
it("does not reuse cached channel info while the process clock is invalid", async () => {
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "old" })
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "fresh" });
const client = { fetchChannel } as unknown as Client;
const first = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
expect(first?.name).toBe("old");
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
const second = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
expect(second?.name).toBe("fresh");
expect(fetchChannel).toHaveBeenCalledTimes(2);
});
it("does not cache channel info when the cache expiry would exceed the Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "first" })
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "second" });
const client = { fetchChannel } as unknown as Client;
const first = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
const second = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
expect(first?.name).toBe("first");
expect(second?.name).toBe("second");
expect(fetchChannel).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,71 @@
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-outbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createDiscordRestClient } from "../client.js";
import type { RequestClient } from "../internal/discord.js";
import { sendTyping } from "./typing.js";
export const DISCORD_REPLY_TYPING_MAX_DURATION_MS = 20 * 60_000;
// Discord can keep long tool-heavy replies alive, but not forever.
// The dispatch restart path refreshes this TTL after queue wait time.
export type DiscordReplyTypingFeedback = ReturnType<typeof createTypingCallbacks> & {
updateChannelId: (channelId: string) => void;
getChannelId: () => string;
restartForDispatch: (channelId: string) => void;
};
export function createDiscordReplyTypingFeedback(params: {
cfg: OpenClawConfig;
token: string;
accountId: string;
channelId: string;
rest?: RequestClient;
log: (message: string) => void;
maxDurationMs?: number;
}): DiscordReplyTypingFeedback {
let channelId = params.channelId;
const rest =
params.rest ??
createDiscordRestClient({
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
}).rest;
const createCallbacks = () =>
createTypingCallbacks({
start: () => sendTyping({ rest, channelId }),
onStartError: (err) => {
logTypingFailure({
log: params.log,
channel: "discord",
target: channelId,
error: err,
});
},
maxDurationMs: params.maxDurationMs ?? DISCORD_REPLY_TYPING_MAX_DURATION_MS,
});
const updateChannelId = (nextChannelId: string) => {
const trimmed = nextChannelId.trim();
if (trimmed) {
channelId = trimmed;
}
};
let callbacks = createCallbacks();
return {
// Expose one stable owner while allowing the inner typing controller to
// rotate between prequeue feedback and the actual dispatch lifecycle.
onReplyStart: () => callbacks.onReplyStart(),
onIdle: () => callbacks.onIdle?.(),
onCleanup: () => callbacks.onCleanup?.(),
updateChannelId,
restartForDispatch: (nextChannelId) => {
updateChannelId(nextChannelId);
// Prequeue typing may have hit its TTL before the job starts.
// Rotate the inner controller so dispatch always owns a live heartbeat.
callbacks.onCleanup?.();
callbacks = createCallbacks();
},
getChannelId: () => channelId,
};
}

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