formatFileOperations in agent-core joined every accumulated read/
modified path unbounded — and the lists ratchet: each compaction merges
the prior summary's file operations into the next accumulation, so a
long default-mode session (or branch summarization, which runs in every
mode) re-emitted an ever-growing path dump into each model-visible
summary with no cap, violating the context-budget invariant.
The safeguard extension already solved this with a bounded formatter
(900 chars/list, 2000 chars/section, '...and N more' overflow). Move
that implementation into the agent-core owner so compact(),
generateBranchSummary, and the safeguard all share one bounded
formatter, and delete the safeguard-local duplicate plus its constant
copies.
* fix(cli): render gateway connect failures as an actionable message
Gateway-required commands (e.g. `agents delete` with the gateway down)
printed a bare `Error: connect ECONNREFUSED 127.0.0.1:19801` with no next
step. Wrap raw socket-level connect failures at the callGateway owner into a
GatewayTransportError that names the target URL and points at
`openclaw gateway run` / `openclaw gateway status`; every gateway CLI
call site inherits the fix.
* fix(cli): point unknown-channel errors at channels list
`message send --channel qa-channel` and agent delivery failed with a bare
`Unknown channel: qa-channel`. Route both throw sites through the existing
formatUnknownChannelMessage helper so the error names
`openclaw channels list --all` as the next step.
* fix(cli): stop console redaction from garbling models status auth counts
The auth overview line used `(oauth=1, token=1, api_key=0)`; the console
secret redactor matched `token=`/`api_key=` as credential key=value pairs
and printed `token=*** api_key=*** |` with unbalanced parens. Switch to
count-first wording (`1 oauth, 1 token, 0 api-key`) via a shared formatter
and add a regression test that runs the line through redactSensitiveText.
* fix(cli): show recorded session totals in the sessions table
`openclaw sessions` rendered `unknown/200k (?%)` right after turns while
`openclaw status` showed real numbers: the table dropped any recorded
total without freshness provenance. Align with status semantics — always
show the recorded total, withhold only the percentage when the
freshness fact is missing.
* fix(cli): middle-truncate long plugin source paths in the list table
Out-of-root plugin source paths wrapped a single `plugins list` row across
5+ terminal lines. Middle-truncate the table fallback at 48 chars so both
path ends stay identifiable; verbose and JSON output keep the full
pasteable path.
Three silent-failure sinks in the plugin lifecycle, all violating
'every action ends in a visible outcome or a recorded reason':
- cleanupReplacedPluginHostRegistry collects per-hook cleanup failures
instead of throwing (it must finish every plugin), but both callers
discarded the returned failures array entirely — broken session-
extension/scheduler teardown vanished. The shared funnel now warns
per failure with plugin and hook ids.
- setup-registry dropped broken setup entries with catch{return null}
and threw-away registration errors with catch{return false}, silently
removing a plugin's providers/CLI backends/migrations from
onboarding. Both now push typed diagnostics (setup-entry-load-failed,
setup-registration-failed) — and since the diagnostics array had no
operator surface at all, the registry build now warns once per
uncached build for every diagnostic (including the previously
invisible descriptor-drift ones).
- loadPluginCliDescriptors swallowed total load failures behind a muted
per-plugin logger; a failure removes every plugin command from
help/dispatch and must not vanish with it. One warn on the catch.
* refactor(plugin-sdk): dedupe session-catalog cursor paging into session-catalog-runtime
The acpx Pi and opencode session catalogs carried byte-identical
boundedLimit/encodeCursor/decodeCursor/optionalRawCursor/transcriptPage
scaffolding (~114 duplicated lines each). Move one canonical copy into the
private-local session-catalog-runtime SDK subpath both plugins already may
import, and keep the plugin-named cursor guards as direct aliases.
Net -87 production LOC; no behavior change (error strings, cursor canonical
form, and byte budgets are unchanged).
* refactor(core): replace private sleep() clones with canonical sleep helpers
managed-linux readiness polling now uses @openclaw/retry sleepWithAbort with
ref:false (preserving the clone's timer.unref behavior), and the embedded
agent runner's async-task wait uses src/utils/sleep.ts. Both clones matched
canonical semantics on real inputs (positive integer poll intervals).
The update-managed-service-handoff copy stays: it lives inside a serialized
standalone handoff script (String.raw template) that cannot import repo
modules.
sendMessageSlack special-cased core's silent-reply token before any API
call, returning a fabricated 'suppressed' messageId. Silent-reply
stripping is owned by core auto-reply normalization before payloads
reach outbound — no sibling channel transport has this check, so a
literal NO_REPLY sent through the message tool delivered everywhere
except Slack. The check predates the extension extraction (it moved
verbatim in 8746362f5e) and is duplicate policy.
Delete the check, its sentinel mint (the only one in the repo), and the
receipt filter for it; tests now pin sibling-parity delivery instead of
the suppression.
The active-hours guard returned skipped/quiet-hours without emitting a
heartbeat event, while every sibling skip in the same stage emits one.
'openclaw system heartbeat last' and the troubleshooting docs promise
reason=quiet-hours is observable; the silent return hid the window from
operators. Emit the same skipped event the siblings do.
build-artifacts is the wall's pole in 4 of the last 5 main runs (171-186s,
~15-20s ahead of the next lane), so its serial steps are the wall. The
Doctor plugin-index proof, singleton smoke, and startup-memory check ran
as their own 13s step even though they are independent dist readers that
the 47s artifact-check wave could absorb.
They now run inside that wave: on Blacksmith all seven start together, so
the verifiers cost the wave's max instead of 13s of serial time; hosted
runners still serialize the three through run_verifier so the RSS ceiling
measures an unloaded process. The step drops its selection gate because
the verifiers always run -- each artifact check already self-gates on its
own RUN_* flag, so a run with no checks selected still verifies.
Proof: extracted the step body and ran it with stubbed pnpm/node. Both
modes behave (Blacksmith 7 checks started, hosted-with-nothing-selected
still runs the 3 verifiers), and a failing verifier exits 1 with its
::error annotation in both -- the wave cannot swallow it.
checks-node-core-tooling-2 failed on main (run 31943910358) with
'expected 1786879225197 to be >= 1786879225197.2246': the repaired output
mtime landed a fraction of a millisecond below the input it had to clear.
isArtifactSetFresh repairs output mtimes to exactly ceil(newestInput), so
it leaves zero headroom for sub-millisecond write rounding or lagging
metadata on CI filesystems. When the repair lands at or below its input
the mtime fast path never engages, and every later invocation in that
checkout falls back to re-hashing every input byte -- the expensive path
this repair exists to avoid, gating the d.ts emit that is the slowest
build-all phase (25.9s of a 61s build).
Neither macOS APFS nor an idle Linux ext4 Testbox reproduces the
shortfall in 300 runs, so the repair now clears the newest input by a
whole millisecond instead of matching it. The assertion pins that
headroom, making the test deterministic where it was previously
load-dependent (fails 615 vs 616 without the fix).
* fix(computer-use): unblock the macOS live-rig proof flow
The rig ran its operator CLI and its proof runner from one state dir, so both
shared one device identity. A paired operator device is pinned to the scopes of
its first connect, and `nodes list` connects first for `node.pair.list`
(operator.pairing); the proof runner then needs operator.write, which is a scope
upgrade the gateway never approves silently and which no rig client can approve
for itself. The proof runner is a GATEWAY_CLIENT/BACKEND client, so on a
loopback auth-none gateway it is admitted unpaired with the scopes it asks for:
giving the CLI its own `cli-state` identity is enough, and `agent-state` now
never accumulates a pairing row.
`nodes list` also read `node.list` through the plain CLI client while
`nodes status`/`describe` used the diagnostics ladder. On any gateway where the
CLI must pair, the unfiltered list silently dropped connected/commands/
computerUse and `--connected` failed outright, so the documented rig gate could
not confirm the node. Both call sites now use `callNodeDiagnosticsGatewayCli`.
Docs drop the `devices approve <requestId>` instruction, which was circular:
that invocation is its own new device identity.
* test(cli): share the runtime-log formatter across nodes CLI e2e files
The extracted diagnostics-auth file stringified captured log arguments directly, which the type-aware core lint stripe rejects (no-base-to-string). Move the existing formatter into the shared node test helpers instead of duplicating it.
* fix(cron): keep error class names out of run history
* fix(cron): preserve lifecycle abort reason
* fix(cron): preserve coded abort reasons
* docs: record cron abort follow-up
* fix(ui): always surface terminal chat send failures
Terminal chat send failures were recorded only on the queue item when the
owning pane was not visible (reconnect, alias drift, split-pane routing),
so the operator saw nothing at all — an action ending in silence.
Root cause: every error surface in the send/drain path was gated on
visibleSessionMatches(...), and the FIFO outbox drain treated every
chat.history reconcile rejection as a silent retryable "blocked", so a
non-retryable rejection (e.g. auth loss) wedged the head — and everything
behind it — permanently with no visible outcome.
Fix, at the owner:
- New surfaceChatDeliveryFailure() in steer-lifecycle.ts (the shared
error-text owner): visible pane keeps the inline chat error; otherwise
the failure routes through the existing global toast host, naming the
session. All terminal failure sites in chat-send-delivery,
chat-outbox-drain, chat-send-queue-state, and steer-lifecycle now use it.
- reconcileStoredChatOutboxHead: a non-retryable GatewayRequestError on the
head now terminally fails a never-attempted head (unblocking the lane)
or parks an attempted head as unconfirmed — both with a visible outcome —
instead of blocking the lane forever.
- Composer disabled-reason: the reason now also renders while a draft hides
the placeholder, and the cloud-startup-pending gate gets a reason instead
of a silently disabled composer.
Regression tests: hidden-pane terminal failure surfaces via toast; wedged
head fails visibly and the lane drains the next message; attempted head
parks unconfirmed; disabled reason visible with draft text present.
* fix(ci): raise startup JS baseline for global failure surfacing and pin workspace-sync test clock
The chat send path now imports session-display naming for the global
failure toast, adding ~1 KiB gzip to startup JS (335452 B on CI's Linux
builder, still well under the 358400 B committed cap).
workspace-sync "never commands" asserted the exact dispatch timeoutMs
(777) but the impl derives it from a Date.now() deadline, so any elapsed
ms between admission and dispatch failed the exact-equality assertion on
a loaded runner. Pin the clock like the sibling timeout tests do.
* fix(ui): surface route-switched command failures and agent-scope global toast naming
ClawSweeper review findings on #124473:
- A queued local command failing after the operator navigated away hit
failCommand(error) with expose=false; the dispatcher's stale-scope
guard had already withheld the inline error, so a successful state
write recorded the failure invisibly — the silent class this PR
removes. Expose it globally when the scope is stale and the owning
pane is hidden; a stale scope with the pane still visible keeps the
failed queue chip (the new connection owns the inline surface).
- Global session rows are agent-scoped behind one shared "global" key,
so the toast row lookup could borrow another agent's label. Match the
row's agentId to the failed outbox's agent for global keys.
Both regression tests fail pre-fix (stash-verified).
* fix(doctor): never report unpersisted config fixes
doctor --fix printed "Doctor changes" panels while computing candidate
mutations, then crashed with a raw Error and persisted nothing when the
repaired candidate still failed write validation (e.g. an unknown root
key repaired alongside an unrepairable schema type error).
Root cause: the compute->print->validate->persist ordering was wrong.
Panels were printed at mutation time, but validation only ran inside the
atomic writer, after all panels were visible.
Fix at the owner boundaries:
- doctor-config-flow queues repair-mode "Doctor changes" panels in a
sink instead of printing them; preview panels still print immediately.
Committed side-effect repair notes (SQLite/filesystem) keep printing
at repair time; candidate-config notes from the repair sequence are
routed through the same deferred sink.
- io.write throws a typed CONFIG_VALIDATION_FAILED error (with the full
issue list) via a new createConfigValidationFailedError owner in
io.write-errors.
- runWriteConfigHealth prints queued panels only after the atomic write
commits, and renders a validation refusal as a "Doctor warnings" panel
stating no config changes were written plus the exact paths to fix by
hand. The contribution loop stops after refusal (same invariant as the
cron-ownership deferral) and doctor exits 1 without a raw Error leak.
Regression tests: pre-fix, the new validation-refusal e2e test fails on
the lying "Doctor changes" panel; contribution-level tests cover the
refusal note, held panels printing exactly once after commit, and no
retry of the identical candidate.
* fix(doctor): report partial persistence accurately after a later write refusal
Post-rebase CI and ClawSweeper follow-ups:
- repair-sequencing tests now assert the deferred configChangeNotes contract
(candidate-only mutation notes moved out of changeNotes by the parent
commit); committed side-effect notes stay in changeNotes.
- formatConfigValidationFailure is module-private; its guidance formatting is
covered through createConfigValidationFailedError, fixing the knip
unused-export gate.
- When the initial write pass committed and only the later post-repair write
is refused, the warnings panel says earlier fixes were saved instead of
claiming no config changes were written, and the outro says "some config
fixes were not applied". New regression test covers commit-then-refusal.
A failed message send (e.g. missing channel credentials on a fresh
install) durably rewrote the agent's folded main session route before
delivery was even attempted: prepareOutboundMirrorRoute called
ensureOutboundSessionEntry pre-send, stamping delivery.route/origin and
minting a conversations-registry row for the never-reached target. The
Control UI then showed the phantom channel identity and the composer
bound to a dead conversation.
Route resolution stays read-only in prepareOutboundMirrorRoute; the
durable write commits once per send at the first success proof:
identified platform evidence (onDeliveryResult) or plugin action
acceptance (onPluginSendAccepted), both before the in-delivery
transcript mirror so first-contact routes still create their session
row, with a post-return safety net for adapters whose results carry no
platform identity. The gateway send RPC sibling had the same pre-send
persistence and gets the same commit-on-evidence ordering.
Regression test proves a failed send leaves the seeded main-session
origin and conversation identity untouched while a successful send
still persists the mirror route; fails on pre-fix code.
Gateway agents.delete closed the agent database (unlinking -wal/-shm)
during preparation, then awaited config/cron/session-purge work that
reopened the database and recreated the sidecars. The sweep classified
those recreated files as foreign replacements ("cleanup path appeared
after deletion preparation"), preserved them, cascaded ancestor
protection over the whole agent directory, and finished the deletion
journal with exit 0 while ~157 files survived under agents/<id>/.
The deletion journal fence already blocks legitimate claims beneath
prepared paths, so a file that appears at a prepared-absent path can
only be leaked deleted-agent state: adopt its identity and sweep it
instead of preserving it. Prepared-present paths keep the existing
identity-mismatch protection for genuine operator replacements.
Regression tests: recreated WAL sidecars between preparation and
cleanup are trashed with the agent directory, and recovery sweeps a
file that appeared at a journaled prepared-absent path.
Hide model fallback and recovery notices in group and channel conversations while preserving direct-chat notices, persisted state, and lifecycle events.
Co-authored-by: NehoraiHadad <nehorai.hadad.projects@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Preserve RFC 5322 angle-address emails without weakening namespaced tag stripping, and keep the iMessage security projection aligned.
Co-authored-by: Aria Ghasedi <drariaghasedi@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>