* feat(geolocation): resolve client addresses to a coarse city via a bundled plugin
The Activity identity card could show a client's IP address but not where it
was, so an operator still had to look the address up by hand.
Add a bundled `geolocation` plugin that owns address-to-place resolution behind
one authenticated route, `GET /plugins/geolocation/lookup?ip=`. It downloads a
MaxMind-format database on first lookup into the state directory, answers from
that local copy, and refreshes it monthly, so a lookup never sends an address
to a third party. The Control UI renders the resolved city on the device row
next to the address and the client-reported time zone.
The default source is DB-IP City Lite under CC BY 4.0. That license requires
attribution, so every response carries the credit and the UI renders it next to
the value; the database is downloaded at runtime and never redistributed.
Plugin code and the `maxmind` reader are MIT. No free city-level IP database is
MIT-licensed, so the obligation lives with the data rather than the code, and
`databaseUrl` plus the attribution fields make the source swappable.
No new core provider kind: with one implementation the plugin owns everything
through the existing HTTP-route seam, keeping core plugin-agnostic. A second
provider is what would justify promoting this to a registry contract.
Availability and lookup failure stay distinguishable: a missing or still
downloading database answers 503, never `found: false`. A failed refresh serves
the cached copy, and a body that does not parse as an MMDB is discarded without
replacing a working database.
* fix(docs): correct geolocation config examples and add zh-CN glossary entries
The config examples used `plugins.<id>` instead of the real
`plugins.entries.<id>.config` shape, which the docs config-example
validator and src/config/docs-config-examples.test.ts both reject.
New doc labels also need zh-CN glossary entries.
* chore(labeler): cover the geolocation extension directory
AGENTS.md requires a labeler entry plus a GitHub label for every new
plugin surface; test/scripts/labeler-extension-coverage.test.ts enforces
the labeler half.
* fix(geolocation): address review findings on caching, download bounds, and scope
Cold-start lookups were permanently suppressed. The loader cached one promise
per address including failures, so the 15s browser deadline expiring against a
first download that takes ~46s cached a blank forever, and a mounted row only
looks up again when its IP changes. Lookups now return a discriminated
located/absent/unavailable result: only definitive answers are cached, and the
element retries an unavailable one on a widening 5s/15s/45s schedule.
Download limits ran after allocation. The size check happened only after
`response.arrayBuffer()` had buffered the whole body, and gunzip had no output
ceiling, so a replaced source or a compression bomb could exhaust Gateway memory
before rejection. The body now streams against a compressed ceiling enforced
per chunk, and inflation uses zlib's maxOutputLength.
Cached placements were not scoped to the Gateway. The cache keyed only by
address while endpoint and credentials come from the shared Gateway context, so
a switch could render the previous Gateway's answer. The shared reset hook now
supports multiple subscribers - a single slot silently dropped whichever
registered first - and the geolocation cache subscribes.
Unresolvable ranges no longer trigger a download. Only loopback suppresses `ip`
at connect, so Tailscale carrier-grade-NAT and LAN addresses are recorded and
displayed. No geolocation database contains them, so a tailnet-only or LAN-only
Gateway was downloading 125 MB to answer nothing. The route now answers those
ranges without loading the database, using the already-public
`isPrivateOrLoopbackHost` seam so the SDK surface budget is unchanged.
The quickstart queried a reserved documentation range while showing a located
response, which cannot happen; it now uses a routable address and documents the
not-found case.
* fix(deps): resync the lockfile after dropping the net-policy dependency
The geolocation plugin briefly depended on @openclaw/net-policy before
switching to the already-public isPrivateOrLoopbackHost SDK seam. The
package.json entry was removed without regenerating the lockfile, so the
frozen-lockfile install failed and every downstream CI job failed with it.
* perf(ui): consolidate Control UI boot chunk graph for HTTP/1.1 gateways
The Control UI boot flow (app shell + sidebar + chat route) lazily loaded
~124 automatic chunks in one burst after the gateway handshake, which the
gateway's HTTP/1.1 transport serializes into ~24 six-connection round-trips
on high-latency links (Tailscale, remote gateways).
Add a measured boot-module manifest (ui/config/control-ui-boot-modules.json,
regenerated via pnpm ui:boot-manifest:gen) and a control-ui-boot codeSplitting
group that merges exactly that module set into a handful of chunks with
recursive dependency inclusion. Lazy islands (locales, ghostty-web, novnc,
non-default routes) keep their own chunks; stale manifest entries degrade
gracefully back to automatic chunking.
Measured on the built dist with the mocked gateway (chat route, 3 runs):
unique boot JS requests 140 -> 45, raw boot JS 3751 -> 3717 KiB, chat
composer interactive at simulated 50 ms RTT ~1600 ms -> ~575 ms.
Largest-CSS budget rises 45 -> 47 KiB for the merged boot CSS; startup JS
gzip baseline ratchets down (345049 -> 339214 B) as consolidation shrinks
the startup graph.
* chore(ui): refresh boot module manifest after rebase onto current main
* fix(ui): stop the pending lazy shell action replay loop starving boot
When a pending lazy shell action (command palette open, panel toggle)
replayed while the shell was still splash-gated, the dispatched event had
no rendered element to consume it and re-entered requestLazyElement in a
microtask cycle: request -> load -> replay -> dispatch -> request. The
cycle starved tasks (Gateway WebSocket messages included), so the boot
never finished and the recovery e2e froze on the splash screen.
Gate replay on the element actually being rendered: the controller skips
the action after load until the host's render root contains the tag, and
restorePendingLazyAction skips dispatch while a defined element is still
render-gated. The host retries after every completed update, so the replay
fires on the update that first renders the element. Regression test fails
on the pre-fix controller.
* fix(ui): re-anchor the scope-upgrade details popover before opening
wa-popover resolves its `for` target once per property change and never
re-resolves a missing or replaced anchor. The trigger with the shared id
can render after the popover's first update (the header trigger ships with
the lazy chat chunk), leaving the opened popover permanently invisible:
active popup with a native [popover] part stuck at UA display:none because
showPopover() never ran without an anchor. Re-arm the watcher when opening
while the anchor is missing or disconnected.
* test(ui): compare settled layouts in device-scope stability assertions
The 0.5px no-move assertions sampled geometry that later reflowed when the
details surface's first render fetched glyph subsets, reporting sub-pixel
drift the open never caused. Burn in the one-time open per context and
sample the baseline adjacent to the click.
* fix(ui): map the keyboard shortcuts dialog in lazy replay gating
Current main added the keyboard-shortcuts lazy shell event; the replay
gate's exhaustive event-to-element record needs its entry.
* chore(ui): refresh startup budget baseline after rebase onto current main
The Linux desktop companion could complete a CLI install and still claim
'Installation did not finish' with circular update advice, discarding the
real failure. Verified end-to-end in a clean Ubuntu VM across all three
release channels:
- cli.rs: failed CLI commands now surface their stderr tail (deduped, last
12 lines) instead of being mislabeled as JSON parse failures.
- gateway.rs: missing dashboard --json support maps to an honest curated
message pointing at Beta/Development channels, not a circular npm-update
hint.
- main.rs: run 'doctor --fix --non-interactive' right after install so the
CLI repairs config/state before Gateway readiness checks; wrap
post-install failures as 'installed, but connecting failed: <reason>'.
- installer.rs: keep structured step events out of the prose failure tail.
- ui/main.js: humanize streamed install steps, render real errors on the
failure screen, and always offer Reinstall from connection failures.
- scripts/install-cli.sh: service refresh uses 'gateway status --json' with
the bundled node runtime, corepack failure falls back to npm, dev channel
clones with --filter=blob:none.
* refactor(anthropic): explore official Claude Agent SDK runtime
* refactor(anthropic): replace handwritten Claude sessions with SDK
* refactor(anthropic): collapse SDK live-session ownership
* refactor(anthropic): simplify SDK ownership and preserve live skills
* fix(anthropic): fence cancelled SDK runs before process startup
* fix(anthropic): harden SDK approvals, lifecycle, and packaging
* refactor(anthropic): own SDK process trees and streamline runtime
* fix(anthropic): repair rebased packaging and legacy test fixtures
* feat(ui): show disk space in diagnostics overlay
* test(ui): cover unavailable disk diagnostics
* fix(ui): preserve status when disk lookup fails
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Restore the /new context-window selector from the selected catalog model and carry the choice into session creation.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Remove the bundled OpenProse plugin and /prose command now that upstream owns the maintained Agent Skill. Preserve /prose as migration documentation and let Doctor clean stale plugin configuration.
BREAKING CHANGE: The bundled OpenProse plugin and /prose command are removed.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
* fix(scripts): size the tsdown heap from the build's own cgroup budget
The build heap probe only read the cgroup root (/sys/fs/cgroup/memory.max and
the v1 equivalent). Those files exist only when the process runs in a
namespaced container cgroup; under systemd the budget lives on the process's
own slice, and the v2 root carries no limit at all. So every systemd-managed
build found no limit, fell back to /proc/meminfo MemTotal, and took the full
12288 MB default heap regardless of its actual budget.
Observed on a 15.4 GiB host: openclaw-main-update.service ran tsdown with
NODE_OPTIONS=--max-old-space-size=12288 while its user@999.service slice was
bounded at 5 GiB, reaching 3.2 GB RSS and 6.25 GB peak before the host began
OOM-killing unrelated services.
Resolve the limit from /proc/self/cgroup and walk that chain instead, reading
memory.high alongside memory.max (memory.high throttles reclaim rather than
failing allocation, so a heap above it stalls the build instead of OOM-ing),
and take the tightest bound found. Root paths stay as the container fallback,
and an explicitly injected path list still disables detection.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): resolve the build heap budget from the v1 memory controller too
The slice walk only accepted the unified 0:: record, so a legacy or hybrid
systemd host fell back to the root probe and kept taking host memory. One
resolver now walks both hierarchies leaf-to-root, which makes the static root
list its own depth-0 case and removes it.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): read cgroup controller mounts instead of assuming their paths
v1 controllers can be co-mounted at the cgroup root, where memory.limit_in_bytes
sits under the slice with no per-controller directory, so the hardcoded
/sys/fs/cgroup/memory probe missed the budget and the build took the full
12288MB default. Mount points now come from mountinfo.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): translate cgroup records through the mount root
mountinfo field 4 is the subtree a cgroupfs mount exposes. Under a container
mount the /proc/self/cgroup record stays host-absolute, so walking it verbatim
probed paths below the visible mount and the build fell back to host memory.
Records now translate through the mount root before the walk.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): skip cgroup mounts that cannot represent this process
Falling back to the mount root for a record outside the mount's subtree sized
the build from an unrelated cgroup: an inherited namespace clamped the heap to
the 2048MB floor from a foreign 1GiB limit. Non-representable mounts are now
skipped, and the blind root probe only runs when no memory record exists.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): keep every cgroup mount view, not just the last one seen
One hierarchy can be visible through several mounts and only some expose a
subtree containing this process. Retaining only the last view dropped the
budget whenever a non-representable bind view came later, sending the build
back to host MemTotal.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): decode octal-escaped mountinfo paths before matching cgroups
ClawSweeper P2 on 7e64ad61f7: the cgroup resolver compared mountinfo's mount
root and mount point verbatim. The kernel escapes space, tab, newline, and
backslash in those two fields, so any cgroup mounted under such a path never
matched, the bounded slice was missed, and heap sizing silently fell back to
host memory.
Decode both fields before matching. The decoder lives in scripts/lib beside the
other shared script helpers rather than inline, so the scripts program has one
copy rather than a new ad hoc one.
Regression test fails pre-fix: a v2 mount at "/sys/fs/cgroup\040dir" with a
5 GiB memory.high yields --max-old-space-size=12288 (host fallback) before the
fix and 4352 after.
Follow-up, deliberately not bundled here: src/infra/sqlite-wal.ts,
src/commands/doctor-state-integrity.ts, and src/plugins/bundled-source-overlays.ts
each carry their own private copy of this same decoder. Consolidating all four
into @openclaw/normalization-core is the right end state, but it touches a
shared package plus three core modules and belongs in its own reviewable change.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): resolve cgroup-namespace-relative records to their mount
ClawSweeper P1 on d6fe49dd3f: inside a cgroup namespace /proc/self/cgroup
reports the namespace root ("0::/") while mountinfo field 4 stays the host
subtree the cgroupfs was mounted from ("/docker/<id>"). relativeCgroupPath then
found no prefix match and returned null; because a memory record had already
been seen, the root probe was skipped and the build fell back to host MemTotal.
A constrained container therefore missed its own budget entirely.
That namespace root is exactly what the mount exposes at its mount point, so it
resolves to "/" rather than failing closed.
Regression test fails pre-fix: a "0::/" record against a /docker/2f1a9c mount
root with a 5 GiB memory.max yields --max-old-space-size=12288 before the fix
and 4352 after.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): reject inherited cgroup mount views instead of guessing
ClawSweeper P1 on b4d200c5d2: the previous commit resolved a namespace-relative
record against any mount root, including the inherited views cgroup_namespaces(7)
documents, whose field-4 root reads "/..". Which cgroup such a view exposes is not
derivable from mountinfo, so probing it can size the build from an unrelated
cgroup's limit.
Reject non-canonical mount roots outright. An undecidable view now falls back to
host sizing, which is current main's behavior, rather than silently adopting the
wrong budget.
Regression test covers the "/.." inherited mount: it must yield host MemTotal
sizing, not the 5 GiB limit sitting behind that mount.
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(scripts): fail closed on namespace-root records against non-root mounts
ClawSweeper P1 on 731d3bbc8e: a "0::/" record does not prove that a mount
rooted at some other subtree exposes this process's cgroup. Resolving that pair
could cap the build heap from an unrelated cgroup's limit.
Return no mapping for it. An undecidable pair now falls back to host sizing,
which is current main's behavior, so the failure mode is a missed optimisation
rather than a wrong budget. The "/.." inherited-mount rejection stays; this
covers the broader ambiguous mapping it did not.
The namespace-relative test is repointed accordingly: an unrelated mounted
subtree must yield host sizing, not that subtree's limit.
Net production change: none (4 lines swapped).
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
* fix(build): cap tsdown heap to the real budget and refuse hosts that cannot build
The 2048MB floor was applied on top of a discovered cgroup limit, so a small
container was handed a heap larger than it could honour. Measured in real
cgroups, that does not OOM-kill, it thrashes: a 1500MiB container sat pinned at
its ceiling for 10 minutes with oom_kill at 0, never finished the second of
eleven invocations, and starved every other process on the host.
Cap to the discovered budget, then refuse up front when that budget cannot hold
the build. The threshold is the whole-build peak, not a single pass: a full
eleven-invocation build peaks at 4730MiB, so a 5GiB slice completes while 4GiB
and 2816MiB slices are both killed partway through the third invocation.
The refusal runs before any output is cleaned, so a host that cannot rebuild
does not also lose the build it has.
* fix(build): harden tsdown heap admission
* fix(build): guard the default tsdown plan
* fix(build): preserve runtime-only Docker builds
* fix(build): admit only declaration cache misses
* fix(build): scope heap admission to real budgets
* fix(build): guard direct unified declarations
* fix(build): guard the canonical tsdown config
* fix(build): satisfy cache planning lint
* fix(gateway): release empty orphan leases
* fix(build): cap cgroup budget by host memory
* fix(build): serialize the canonical tsdown config
* test(build): freeze host memory fixtures
* fix(build): honor cgroup v1 soft limits
* fix(build): respect cgroup v1 hierarchy mode
* fix(build): admit unified runtime plans
* fix(build): admit every unified runtime path
* fix(build): collect repeated tsdown filters
* fix(build): ignore cgroup v1 soft limits
* fix(build): use explicit heap override as opt-in
* refactor(build): simplify memory admission
* fix(build): harden constrained build recovery
* fix(ci): prebuild runtime before real CLI shards
* fix(build): honor runtime-only runner environment
* fix(ci): satisfy tooling shard lint
* fix(scripts): bound tsgo runs with the managed-command watchdog
run-tsgo bypassed the repo's managed-command seam and called spawnSync
directly, so a wedged tsgo blocked its caller indefinitely: no timeout, no
process-group cleanup, and no SIGKILL escalation.
Observed in the wild as a tsgo holding 2.85 GB for 90+ minutes on 41s of
total CPU with RSS frozen to the byte, ignoring SIGTERM, with its wrapper
reparented to init. Because shouldReclaimLock() treats a live PID as a valid
lock owner, that orphan also held the heavy-check lock until every other
invocation hit the 10-minute lock timeout and threw.
Route the run through runManagedCommand, which already owns process-group
termination and SIGKILL escalation on timeout, and bound it with
OPENCLAW_TSGO_TIMEOUT_MS (default 45m) through the shared readPositiveEnvInt
helper, mirroring OPENCLAW_CLI_STARTUP_BUILD_TIMEOUT_MS in
ensure-cli-startup-build.mts.
* fix(scripts): saturate the tsgo watchdog at Node's timer ceiling
An OPENCLAW_TSGO_TIMEOUT_MS above 2147483647 reached setTimeout unchanged,
where Node collapses it to a 1ms delay, so raising the override killed
healthy typechecks immediately instead of loosening the bound.
* fix(scripts): make the tsgo watchdog opt-in and stop the harness leaking
ClawSweeper review on 6ba02c9d0a raised two findings.
[P1] The 45-minute default applied an unproven deadline to every tsgo
invocation. No supported duration contract covers every host and project, and
CI already bounds its own tsgo jobs at 15-20 minutes, so the default could only
ever fire outside CI where it was least validated. Drop it: an unset
OPENCLAW_TSGO_TIMEOUT_MS keeps the pre-existing unbounded wait, so no existing
run changes behavior, and operators opt in per host. Documented in
docs/help/testing.md beside the sibling Vitest watchdog.
[P2] The regression harness could leak its wedged child. The fake compiler
ignores SIGTERM by design, so a pre-fix or otherwise failing run left the tree
running after spawnSync gave up. Bound the fixture's loop as a backstop.
* fix(scripts): set the tsgo watchdog default from measured lane duration
ClawSweeper on c7a699ee82 reversed its earlier guidance: the opt-in default
adopted last iteration "deliberately preserves the indefinite tsgo hang that
this PR is meant to fix". Its objection was never that a default existed, only
that 45 minutes was unmeasured.
Measured instead of guessed: hosted tsgo lanes (check-test-types, and its core
stripes) complete in 1-2 minutes across recent successful main runs, against CI
job caps of 15-20 minutes. 30 minutes is 15-30x the observed duration, leaves
room for a far slower local host, and still bounds the 90-minute and multi-hour
wedges that motivated this PR. OPENCLAW_TSGO_TIMEOUT_MS remains the documented
override for hosts that need longer.
* test(scripts): reap the wedged fake tsgo tree on the harness outer timeout
ClawSweeper on 0d9f3604e8 flagged that the harness can still leave a detached
pre-fix process tree alive after its outer timeout. The bounded fixture loop
added earlier only capped the leak; it did not terminate the tree.
spawnSync's killSignal reaches the direct child only. runManagedCommand spawns
the compiler detached into its own process group, so the fake tsgo is a
grandchild that never receives that signal. The fixture now records its pid and
the harness reaps that group in a finally, with the bounded loop kept as a
last-resort backstop.
Verified: pid file written with the live pid, and killing that group terminates
the tree; focused suite 16/16 with no surviving fake-tsgo processes.
* fix(scripts): harden the tsgo watchdog after two-phase code review
Review fixes on top of the watchdog change, from one native pass and six cold
passes:
- A rejected OPENCLAW_TSGO_TIMEOUT_MS escaped main() as a raw module rejection.
It now reports one actionable line and exits 1. Strict validation was kept
rather than switching to coercion, so a typo cannot silently fall back to the
30-minute default.
- The rejection message named a numeric range while the parser enforces plain
decimal digits, so 1e5 and 007 were refused by a message saying they
qualified. It now names the real format and states that the watchdog cannot
be disabled.
- The timer ceiling is declared locally rather than imported from packages/.
A static import there resolves before the sparse-checkout guard runs, which
turned a clean sparse skip into ERR_MODULE_NOT_FOUND and flipped
check-changed's typecheck lane from exit 0 to exit 1.
- The wedge test asserted the kill message but not the outcome; it now captures
the wedged pid and asserts the process group is gone.
- Three near-duplicate "not killed" cases are table-driven.
- Doc bullet corrected: values ABOVE the ceiling saturate at it, and the
rejected-value list now includes non-decimal input.
Deferred follow-up, not fixed here: scripts/lib/tsx-cli-shim.mjs shares a
5000ms force-kill delay with managed-child-process, so Ctrl-C can still orphan
a wedged compiler about one run in three. Measured base 4/4 orphaned versus
4/10 here, so this change improves it; the fix is out of diff and shared with
four other wrappers.
* fix(scripts): close tsgo signal cleanup race
* fix(scripts): make tsgo watchdog opt-in
---------
Co-authored-by: ClawSweeper <steipete+clawsweeper@gmail.com>
* fix(mantis): enforce verdict-expectation coherence and publish agent analysis files
Mantis run 32619081130 on #127989 published an overall `pass` while its own
manifest recorded that the candidate expectation was not observed: per-lane
`status` was mechanical capture success and the agent's judgment lived only
in `expected` prose, so nothing reconciled the two before publication.
- `mantis-evidence.json` schemaVersion 2: each comparison lane carries a
required boolean `expectationMet`. The desktop agent sets it in the same
manifest edit as `expected`; mechanical producers (Telegram live, web UI,
Slack, Discord) derive it from lane status.
- `scripts/mantis/publish-pr-evidence.mjs` is the single enforcement owner:
it requires the booleans, recomputes `pass`/`outcome`, downgrades a
contradictory pass claim to `fail`, and renders a visible "verdict
downgraded" note. The desktop workflow invokes it with `--validate-only`
before upload or comment.
- Agent top-level `*.json`/`*.md` analysis files (assertions, comparisons,
recipe suggestion) now survive the quarantine rebuild and upload, so cited
evidence actually exists in the artifact.
* fix(mantis): derive expectations from trusted lane facts
* feat(mantis): add exec and restart lane primitives
Give the proof agent a developer shell inside each SUT container and an
in-container gateway restart so it can design scenarios like a local
developer: patch openclaw.json and restart, stage plugins and fixtures,
run node/tsx against the read-only repo root, inspect SQLite state.
- container script: exec (docker exec as mantis-sut, bounded by timeout),
restart (request file + TERM), sut_command becomes a relaunch supervisor
- lane CLI: exec returns bounded stdout/stderr/exitCode and records a
redacted invocation; restart waits for a fresh [gateway] ready marker
- MAX_SENDS 12 -> 40 (shared-QA-bot flood safety, not a scenario bound)
- runtime root chown root:mantis-proof, mode 1770 so the agent can stage
files while root-owned attestation stays unreplaceable
* docs(mantis): let the proof agent design scenarios like a local developer
Lead with developer-shell parity, allow reading whatever code the scenario
needs (PR text still untrusted, PR code only inside SUT lanes), document
exec/restart shapes, and reserve block for hard impossibilities.
* fix(mantis): keep the SUT exec result type local
* fix(mantis): resume the agent when it ends without a manifest
Run 32615428295 (exec branch on #127950) hit Codex context compaction at
03:52:11 and the model answered with a confabulated "handoff" message instead
of continuing; codex exec exited 0 with no mantis-evidence.json and the
trusted-evidence step failed the run with no verdict.
The agent step now checks for the manifest after codex exits and, when it is
missing, resumes the same thread (`codex exec ... resume --last -`, verified
against codex-rs/exec/src/lib.rs at rust-v0.149.0: cwd-matched latest thread,
`-` reads the prompt from stdin) with a short correction prompt, bounded to
three resumes. The main prompt states that a handoff/summary is never an
acceptable final message.
Pass verdicts rested on agent prose only. The evidence builder now digests
each lane's trusted mantis-lane-facts.json (sends, bot messages, edits,
deletes, provider requests, injected Bot API faults, observed seconds,
attempt, sanitized user inputs) into an additive per-lane digest field and
a comparison.differential line listing the counts that changed between
baseline and candidate; the PR comment renders both.
The proof gateways execute runtime JS only; declarations forced a ~177s
unified rebuild per lane because the declaration cache key is an
aggregate source hash. OPENCLAW_RUN_NODE_SKIP_DTS_BUILD=1 on profile
full now selects the runtime artifact surface via the uncached generic
tsdown graph, both Mantis lanes pass it through, and the baseline
archive moves to an isolated mantis-runtime-v1 namespace. Measured
candidate build: 177.1s -> 33.05s.
The Telegram Desktop proof previously built the candidate from the raw PR
head, so a head behind main failed for reasons main already fixed
(observed on #127770: 59 commits behind, hitting the Unknown-model defect
fixed by #127952). GitHub's cached PR test merge cannot be the candidate
either: it was observed 50 commits / 12 hours stale and never refreshed.
The workflow now resolves the live refs/heads/main tip and merge base at
dispatch, requires a main-targeting PR, and builds a deterministic local
merge (merge-tree --merge-base + commit-tree with pinned identity/date) as
the candidate; conflicts fail with a direct rebase message and there is no
fallback to the raw head. Lane labels and docs now say the candidate is
the PR merged onto main.