* refactor(ui): sidebar cleanups — shared tooltips, one idle-import helper, cross-tab outbox bridge
Three bounded cleanups following the offline-state work:
- Tooltip unification: every sidebar-family tooltip (session-row badges,
offline status, agent card, attention actions, brand icons) now uses
the shared <openclaw-tooltip> component instead of raw title attrs,
completing the direction #112639 started. Aria labels unchanged.
- One idle-import helper (ui/src/lib/idle-import.ts): the duplicated
idle-load/retry logic from app-sidebar chrome and app-host's outbox
loader collapses into createIdleImport (cached promise clears on
failure, one idle retry while online, online re-arm, dispose). The
helper migration is net negative at its call sites.
- Cross-tab outbox bridge: subscribeStoredChatOutboxChanges now also
notifies on storage events for the composer outbox keys, installed on
first subscribe and removed with the last subscriber, so a message
queued in another tab refreshes badges here.
* fix(ui): keep idle-import scheduling statement-form for narrow-safe types and consistent-return
* fix(ui): give idle-import a strictly void schedule and promise-only load
* fix(memory-core): keep live reindex of reset/deleted session archives
The session transcript listener dropped every archive artifact before
scheduling, so .jsonl.reset and .jsonl.deleted archives written by /reset
and session delete were no longer indexed on the live path and only
surfaced in memory search after the next gateway restart. Remove the
archive guard so in-agent archives fall through to scheduleSessionDirty,
which reaches the usage-counted-archive branch in processSessionDeltaBatch.
Regression from #89912; restores the incremental archive indexing added in
* fix(memory-core): constrain live archive indexing
* test(memory-core): align migrated search fixtures
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(google-meet): reject malformed audio base64
* refactor(meeting-bot): validate node audio centrally
Make the shared meeting-bot owner reject malformed push and pull audio for Google Meet, Teams, and Zoom without plugin-specific callbacks or permissive fallbacks.
Co-authored-by: sunlit-deng <yang.jiajun1@xydigit.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(agents): give bash stdout and stderr independent decode lanes
stdout and stderr are independent pipes, but the local bash execution path
fed both into one onData callback sharing a single TextDecoder and one
streaming ANSI/OSC sanitizer. A multibyte UTF-8 character split across a
stdout read boundary was corrupted when stderr wrote between its bytes, and
an unterminated OSC on stdout swallowed subsequent stderr output. This
contradicts the documented invariant in shell-utils.ts ('Keep one ANSI
parser per process stream so control sequences can span callbacks').
Tag onData with an optional stream identifier and give each lane its own
TextDecoder and text-transform state; finish() flushes every lane. Untagged
callers keep the single shared lane for backward compatibility.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(agents): harden bash stream isolation
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(discord): fall back to raw bot mentions
* fix(discord): gate raw mention fallback on hydration failure
* fix(discord): reject non-pinging raw mentions
* fix(discord): model mention hydration authority
Co-authored-by: luyifan <al3060388206@gmail.com>
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
normalizeLabel truncated external member display names (nick / global_name /
username) with a raw .slice(0, 100). When the cut landed on a UTF-16 surrogate
pair, the label kept a lone high surrogate that corrupted the JSON-quoted
roster line fed into the agent's voice-roster prompt. Use truncateUtf16Safe
so the boundary backs off the split pair, matching the same surrogate-safe
truncation already applied to other discord truncation sites.
Co-authored-by: ben.li <li.yang6@xydigit.com>
Co-authored-by: 刘伟勤0668000710 <liu.weiqin@xydigit.com>
* feat(cron): system-owned heartbeat monitor jobs replace the interval scheduler
- new internal cron payload kind {kind:"heartbeat"}: execution pokes
requestHeartbeat({source:"interval"}); reported in the protocol job
schema, not accepted from client create/patch
- gateway converges one declaration-keyed monitor job per heartbeat-enabled
agent (schedule every+deterministic phase anchor) at startup and on
config reload; removes monitors for unconfigured agents
- heartbeat runner loses its interval setTimeout machinery; nextDueMs
stays as the cooldown gate, event wakes unchanged
* test(cron): heartbeat monitor regressions; docs for cron-owned cadence
- converge/prune/failure-containment tests for heartbeat monitor jobs
- heartbeat payload run fires an interval wake, no system event
- scheduler tests converted from timer self-fire to wake-queue pokes;
timer-mechanics-only tests deleted with the timer
- persisted-shape accepts the heartbeat payload kind
- docs: heartbeat cadence ownership + system payload kind
* fix(cron): heartbeat monitor review round 1
- targeted cron-monitor interval ticks use the full per-agent path so
due-commitment sessions still deliver
- cron-disabled gateways keep a local fallback interval timer (shipped
cron.enabled=false contract; removed when heartbeat config folds into
cron in #110950)
- heartbeat job reconciliations serialize with latest-wins epochs and a
bounded 30s retry after a failed convergence pass
* fix(cron): chain clamped fallback heartbeat timers past the setTimeout cap
* fix(cron): heartbeat monitor review round 3
- targeted monitor redirect skips wakes carrying heartbeat overrides and
surfaces the per-agent terminal skip reason instead of not-due
- cron-disabled fallback timer re-arms with a 1s floor after each firing
so a dropped wake cannot end the chain
- heartbeat payloads are system-owned at the service boundary: add requires
the gateway opt-in, patches to the kind are rejected
* fix(cron): heartbeat monitor review round 4 — full ownership enforcement
- prune only jobs proven to be monitors (prefix AND heartbeat payload)
- existing monitors reject every update patch; declarative upserts on the
monitor key require the gateway opt-in even with a different payload
* fix(cron): complete heartbeat monitor ownership boundary
- converge scopes declarative matching to real monitors so a colliding
user job with the same key is never adopted or overwritten
- monitor removal requires the gateway systemOwned opt-in; ad-hoc
API/CLI deletion is rejected, reconciliation cleanup still prunes
* docs(cron): record intentional enrollment-snapshot semantics for monitor ticks
* fix(cron): repair heartbeat monitor CI gates
* fix(pid): treat EPERM as alive in isPidAlive
Match isPidDefinitelyDead: process.kill(pid, 0) throwing EPERM means the
PID exists but cannot be signaled, so it should not look dead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(pid): check Linux zombies after EPERM existence probe
EPERM means the PID exists but cannot be signaled; still run the zombie check so Linux zombies are not reported as alive.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(pid): isolate EPERM liveness probe
Co-authored-by: stantheman0128 <stanshih888@gmail.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Claude Code worktrees under .claude/worktrees/ are created without their
own node_modules (git worktrees don't materialize gitignored dirs), so
tooling silently resolves up to the parent checkout's node_modules. That
breaks when the parent sits on a divergent branch missing a newer
dependency (observed: tsgo failing on libphonenumber-js in a fresh
worktree).
Track a minimal .claude/settings.json with a SessionStart(startup) hook
that runs an idempotent `pnpm install --frozen-lockfile --ignore-scripts`
in the worktree root on first entry, so each worktree is self-contained.
Hardening:
- resolves the worktree root via `git rev-parse --show-toplevel` (not cwd),
so it works when Claude launches from a subdirectory;
- --ignore-scripts: a branch-controlled package lifecycle script cannot gain
code execution merely because Claude starts a session in that worktree;
- --frozen-lockfile: no lockfile mutation; skips cleanly when pnpm or the
lockfile is absent; warns instead of blocking the session on failure.
Only .claude/settings.json is un-ignored; .claude/worktrees, skills, and
settings.local.json stay ignored. Claude Code only — CI and Codex worktrees
do not read this file.
* fix(ui): keep composer sends on one stable bubble through the history handoff and add a subtle entry animation
* fix(ui): keep userTurnSendIdentity module-local
maybeRepairLegacyFlatAuthProfileStores rewrote the per-agent SQLite auth
profile store with a store built solely from the legacy flat
auth-profiles.json, and backed up only that flat JSON. Any credential
present in SQLite but absent from the flat file (for example an OAuth
refresh token from a login after the SQLite migration) was destroyed and
was not in the backup, so a routine openclaw doctor caused unrecoverable
credential loss.
Load the existing SQLite store and merge the legacy flat profiles into
it, preserving credentials already present, then verify the imported
profiles persisted before removing the flat file, mirroring the SQLite
migration path.
* docs(plugins): link setup-input deprecations to the migration pattern and document reader sweeps
* ci: retrigger queued run
* docs: regenerate docs map
* feat(ui): surface queued-outbox counts in composer hint, session rows, and offline footer
Follow-up to the offline-state unification: queued sends were mechanically
safe (durable outbox with reconnect replay) but only visible inside the
open session's thread.
- Composer offline hint now includes the visible session's queued count.
- Session rows show a clock badge with the per-scope outbox count,
independent of connection state (covers waiting-idle and failed too),
with alias-safe scope resolution so agent-main never double-counts.
- The offline footer button appends the aggregate ("· N queued"); the
connected state stays completely silent.
- One narrow subscription seam (subscribeStoredChatOutboxChanges) added
in composer-persistence; no send/drain logic touched.
* perf(ui): keep the startup bundle under budget with a lean outbox read module
The queued-count feature statically imported composer-persistence from
startup modules, hoisting the chat page's persistence machinery into the
startup chunk and breaking the Control UI startup JS gzip budget
(319.6 KiB > 314.0 KiB limit).
Split ownership instead of gaming the budget: a lean read/subscribe
module (ui/src/lib/chat/outbox-store.ts + codec/draft-state) serves
startup consumers (app-host, sidebar), while writes, migrations, and
drain stay in the lazy chat chunk (composer-outbox-store/composer-storage);
composer-persistence keeps its export surface for chat callers. Startup
is back to 313.8 KiB gzip at 12 requests with no chunking-config changes.
Also fixes an autoreview finding in the new summary: legacy bare-main
outbox rows now resolve through session defaults (online) or the
persisted mainAlias (offline reload) instead of trusting the row's stale
embedded agent id, so badge counts key to the same scope the sidebar
resolves. The shared footer status renderer is deduplicated into
session-row-badges.
* fix(ui): correct type-only import and const tuple in outbox split
* perf(ui): idle-load the outbox summary so startup carries no outbox code
The lean outbox read module still cost ~2.8 KiB of startup gzip against
1.5 KiB of budget headroom. Follow the sidebar chrome pattern
(lobster-pet/facepile): app-host idle-loads outbox-store, subscribes on
arrival, and passes the sidebar a resolver callback instead of letting
startup modules import scope resolution. Badges and counts hydrate
moments after load; before that the summary is empty by design.
Failed chunk loads recover on browser online events and, because chunks
are usually served by the gateway itself, on gateway reconnect — the
exact moment the offline badges become relevant again.
Raise the initial-graph packing ceiling 448->512 KiB: the grown core
graph split at the old boundary into an extra chunk, costing ~1.9 KiB of
startup gzip to compression-context resets (same documented tradeoff as
the earlier 400->448 bump). Startup lands at 313.5 KiB gzip / 9 requests,
matching the origin/main baseline, limit 315.0.