* fix(ui): fork active sessions from stable history
* fix(protocol): align active fork types
* test(ui): match main-session fork routing
* fix(sessions): unify stable fork admission
* fix(native): fork active sessions from stable history
* fix(android): fork active sessions from stable history
* style(android): simplify active fork result handling
* fix(native): preserve legacy fork transport API
* test(native): complete legacy fork transport stub
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(ui): complete pairing setup lifecycle
Redeemed and expired mobile setup codes stayed on screen as usable QR
codes, so a successful pairing had no visible outcome and expired bearer
material still looked live.
The Gateway now mints an opaque, non-authorizing setupId beside each
setup credential, returns its authoritative expiresAtMs, records the
terminal outcome of the exact redemption, and broadcasts
device.pair.setup.completed. Because that broadcast is dropped for
buffered operator sockets, the completion is persisted first and can be
reconciled through the new device.pair.setupStatus method: the Control UI
asks for the recorded outcome before it may present a credential as
expired, so a pairing that succeeds is never shown as a failure.
The Control UI models one closed lifecycle (selection, loading, waiting,
success, expired, error) correlated only by the active setupId, and
Pairing help now carries the external-link affordance.
* fix(ui): preserve unknown pairing outcomes
* test(ui): target pairing terminal headings
* test(ui): align pairing completion fixture
* fix(gateway-protocol): decode setup-code results from older gateways
Older protocol-v4 gateways omit the new setupId and expiresAtMs
lifecycle fields, so requiring them in the generated native model broke
decoding an existing device.pair.setupCode response. Keep both optional
at the wire boundary, require lifecycle metadata before the Control UI
enters its waiting state so a missing outcome stays visible, and cover
the legacy payload with Swift and schema regressions.
* fix(ui): surface rejected pairing dialog loads
The lazy pairing chunk could reject while its overlay was already open,
leaving the shell rendering nothing at all. Record the rejection on the
shell and render a recoverable modal with a reason and a retry so the
open action always ends in a visible outcome.
* fix(state): preserve pairing setup schema compatibility
* test(gateway): cover pairing setup release train
* fix(gateway): commit pairing setup completion atomically
* refactor(state): distinguish setup transaction helpers
* refactor(state): remove obsolete bootstrap restore path
* fix(gateway): preserve setup handoff type safety
* fix(gateway): keep pairing completion terminal after consume
* fix(gateway): validate Watch binding during setup commit
* fix(gateway): revalidate setup credential expiry at commit
* chore: refresh Plugin SDK API contracts after rebase
* fix(pairing): prune expired setup completions
* chore: retrigger CI
* fix(protocol): deduplicate setup expiry field
* fix(protocol): refresh pairing setup clients
* fix(gateway): make pairing setup completion durable
* fix(ui): retire expired pairing credentials immediately
* fix(ui): keep pairing dialog visible while loading
* fix(macos): align setup result initializer order
* fix(gateway): restore generic bootstrap retries
* chore(ui): record pairing startup budget
* chore(ui): refresh pairing startup budget
* style(gateway): format maintenance imports
* test(gateway): cover session-sharing mock
* fix(state): defer setup correlation schema
---------
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* refactor(delivery): collapse failed-row lifecycle
Replace the unshipped failure-operations platform with payload-free terminal receipts owned by existing queue boundaries. Keep bounded/permanent idempotency only for reusable or crash-ambiguous producers, move physical expiry to queue maintenance, and preserve migration and media-cleanup safety.\n\nTogether with #123410, production code is net negative by 11 lines; tests, docs, and generated protocol mirrors are accounted separately.
* fix(delivery): break state DB import cycle
* fix(delivery): classify SQLite boundary uses
* test(gateway): mark retained health fixture
* feat(computer-use): computer.act v2 action contract with capability-filtered tool
* fix(computer-use): break contract import cycle, bound model-visible elements, regen swift protocol
* test(computer-use): satisfy curly rule in schema-cap helpers
* fix(computer-use): satisfy type-aware lint on contract and tool
* test(gateway-protocol): keep connect-params suite under the line cap
* feat(secrets): add authenticated egress substitution proxy
* feat(secrets): bind egress substitution to hosts
* ci(codeql): classify egress proxy bypass tunnel in network boundary query
* refactor(proxy-capture): use the canonical IP parser instead of node:net
* fix(secrets): compare proxy tokens with a process-keyed MAC
* fix(ui): report the outcome of a cross-device token rotation
Rotating another device's token ended with nothing on screen. The Gateway
returns the replacement only to a device rotating its own token
(shouldReturnRotatedDeviceToken; docs/cli/devices.md), so every rotation of
another device answered without a token and the Control UI, which only rendered
when one came back, showed no dialog, no message, and no error while the target
device was invalidated and disconnected.
device.token.rotate now records how it delivered the replacement in a
tokenDelivery discriminator ("in-band" | "withheld-cross-device"), additive and
optional so gateways that predate it still decode. The Devices page reports the
recorded fact rather than inferring one from a missing token: the show-once
reveal for an in-band rotation, and an outcome dialog for a withheld one that
states the token is not shown here, that the device was disconnected, that it
picks up the replacement on its next connect with a Gateway token or password,
and that a device which can only sign in with its device token has to be paired
again.
The reveal component takes an optional secret: with one, dismissal stays refused
because the value is unrecoverable; without one there is nothing to lose, so
Escape and backdrop close it like any dialog.
* fix(ui): lead the rotation outcome with what happened, not why
The withheld-rotation dialog opened on a negation, stacked three clauses of
protocol reasoning, and left "do I have to do anything?" to the last sentence.
It now reads outcome, then next step, then the one exception, with the security
rationale demoted to a muted trailing line: the device is named, told to be
signed out, and the common case says nothing else is needed. The rotate callback
carries the row's resolved label so the dialog names the same device the operator
clicked instead of rederiving the precedence.
The reveal component takes paragraphs plus an optional muted note, so an outcome
and its exception stay separate instead of collapsing into one block.
* fix(ui): give the rotation outcome a visual hierarchy
Four near-equal paragraphs in a plain box read as one undifferentiated block:
the reassurance, the conditional branch, and the footnote all carried the same
weight, and the calm text was rendered in muted grey because it reused
.exec-approval-sub.
The dialog now has zones. A success mark in the shared icon-tile geometry sits
beside the title, the two reassurance sentences carry full --text weight, the
one conditional branch becomes a house .callout.info so it cannot be skimmed
past, and the security rationale is a 12px muted footnote. Spacing follows the
card's 4px rhythm.
The acknowledge button follows the confirm-dialog convention: the accent button
is the action that commits something, so a show-once reveal keeps it and a
report of work already done uses the neutral button.
* fix(ui): collapse the rotation outcome to a single reassurance
The body zigzagged: it announced a new token, then raised a disconnect, then
spent the next line walking that alarm back. The middle beat existed only to be
contradicted, so it is gone -- the disconnect is transient and self-healing, and
naming it bought the reader nothing.
The title now carries the announcement and the device name, the body is the one
reassurance, and the callout drops its "this device" echo. The neutral dismiss
button gets --border-strong: .btn's resting border is within ~4/255 of --card in
dark, so it read as unpainted on this surface. The callout wraps balanced.
Both dialogs now pass a single message, so the paragraph-array support added for
the previous structure is removed rather than left speculative.
* fix(ui): key the rotation callout to an observable symptom
"If it can only sign in with its device token" asked the operator to know which
credential the device holds, which is internal state they cannot see. The
condition is now the symptom that state produces: the device does not come back
on its own. Same behavior, same technical truth, decidable from the Devices page.
* fix(ui): reject contradictory device-token rotation results
The Devices page inferred the outcome from an unchecked RPC payload, so an
explicit `tokenDelivery: "in-band"` with no token, a withheld result that
carried one, or a delivery mode this client predates all rendered as a
successful withheld rotation - telling the operator the device re-credentials
itself while their previous token was already invalid.
rotateDeviceToken now parses the response into the closed outcome: the two
current pairs, the two legacy omission states from gateways released before
tokenDelivery, and every other explicit pair through the existing error path,
which surfaces as the page's danger callout carrying the recovery step.
* chore(plugin-sdk): refresh the API contract for the rotation result schema
Registering DeviceTokenRotateResultSchema in PluginLifecycleProtocolSchemas is
what makes the generators emit the Swift model, and that registry sits in the
type closure of 18 plugin-sdk exports, so their closureHash entries move. This
is generator output rather than a hand edit: the export set and every declaration
are unchanged, only the hashes.
* fix(ui): reject malformed device-token rotation envelopes
The parser trusted the envelope around the fields it read. A null, scalar, or
empty payload carries neither tokenDelivery nor token, so it matched the legacy
omission state and produced the reassuring completion dialog after the previous
credential had already been invalidated. A blank token did the same, though the
result schema bounds token to a non-empty string.
Only DeviceTokenRotateResultSchema's shapes are accepted now: the payload must be
a record that identifies the grant it rotated - every Gateway answering this
method returns deviceId and role, before and after tokenDelivery existed - and
token must be either absent or a non-empty string. This has to happen here
because the browser Gateway client resolves frame.payload directly, so the
registered result schema never runs on the client.
The mid-flight-reconnect reveal test asserted on a two-field stub no Gateway
sends; it now uses the real response shape.
* fix(ui): reject rotation results that do not answer the request
The rotation parser accepted any envelope naming some device and role. It
never checked scopes or rotatedAtMs, both required by
DeviceTokenRotateResultSchema, and never compared the returned grant with the
one that was requested -- so an incomplete or unrelated reply opened a success
dialog claiming a credential the operator may still hold had been replaced.
Require the full result shape and bind it to the requested grant, comparing on
the same trim normalization the device-auth store applies. The page fixture
now echoes the requested grant instead of hardcoding device-1, which is what
let a cross-device test accept a success dialog for the wrong device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(ui): complete the rotation fixture the epoch test resolves
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(ui): update token rotation E2E envelope
* fix(protocol): correlate rotated token delivery
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
* feat(gateway): provision paired node worker installs
* docs: record node local-install PR chain
* chore(protocol): regenerate node worker manifest models
* fix(gateway): resolve node worker build through the device runtime
Repairs a rebase artifact: startup referenced a stale deviceNodeRegistry
binding; the device runtime now owns the connected-node lookup via
resolveWorkerBuild.
* feat(protocol): add portal methods and event
Bump the reviewed protocol owner-module count from 55 to 56.
* feat(gateway): add portal service and reverse proxy
* feat(agents): add portal tool
* fix(gateway): refine portal URL and proxy auth
* refactor(gateway): keep portal helper types private
* fix(gateway): declare portal transport service
* test(gateway): satisfy portal proxy lint
* test(gateway): narrow websocket payload types
* refactor(protocol): compact portal schema exports
* fix(gateway): export portal protocol types
* feat(ui): add portals page
* docs(gateway): add portals guide
* fix(gateway): dial portal targets via localhost dual-stack
Vite and other Node >=17 dev servers bind ::1 only for localhost, so a
fixed 127.0.0.1 dial 502s on the default path. Use hostname localhost
with family autoselection and rewrite Host to match.
* fix(gateway): type portal dual-stack connection
* fix: satisfy portal integration gates
* fix(gateway): isolate portal cookie jars per target
Cookies are hostname-scoped, not port-scoped, so the per-port origin
split alone let Gateway plugin-auth cookies reach agent-run targets.
Forward only cookies carrying this portal's own name prefix (stripped),
rewrite target Set-Cookie names to the prefixed form incl. the WS 101
handshake, and drop Domain attributes.
* fix(ui): detect unreachable portals behind proxied gateways
Probe the portal origin from the browser (no-cors, 4s timeout) and show
a recovery notice with the gateway-host URL instead of a dead iframe
when only the gateway port is exposed (Serve/Funnel/reverse proxy).
Docs: cookie isolation + reachability; zh-CN glossary entry.
* test(ui): satisfy portal reachability lint
* test(gateway): provide control UI request hosts
* chore(protocol): regenerate after rebase
* fix(gateway): namespace portal auth cookies by listener
* fix(gateway): scope portal token URLs to write-capable clients
The portal bearer token rides in the summary url/tokenQuery; portal.list
is operator.read and portal.changed fans out to read subscribers, so a
read-only client could harvest an openable URL. Make those fields
optional, redact them from read-scope list responses, and drop them from
every portal.changed broadcast; write/admin clients still receive them
and the UI refetches the list on change.
* docs(web): list the portals route
* fix(gateway): type portal open credentials
* docs(gateway): clarify portals PORT/PUBLIC_URL are agent-set
Opening a portal creates only the proxy listener; the agent sets PORT
and PUBLIC_URL in its own exec command, matching the portal tool
contract. Removes the implication of an automatic env handoff.
* chore(protocol): regenerate portal models
* style(gateway): format portal method-order assertions
Rebase union-merge left the portal.list assertion wrapped; oxfmt fits it
on one line.
* chore(plugin-sdk): refresh API baseline after rebase
* chore(plugin-sdk): refresh API baseline after rebase
* chore(protocol): refresh portal event order after rebase
* chore(plugin-sdk): refresh API baseline after rebase
* fix(gateway): pin portal referrer policy to no-referrer
The portal URL carries its bearer token in the query, and upstream
response headers are copied verbatim, so a target answering with
Referrer-Policy: unsafe-url could leak that URL to every third-party
origin it references. Force no-referrer after the copy and drop any
inbound Referer that still carries the token before forwarding.
* feat(gateway): add gateway-host desktop source behind desktop.host lab
Introduce the host as a first-class desktop source so operators can view
the machine OpenClaw runs on, not just cloud-worker environments:
- protocol: desktop.observe / desktop.launch with a discriminated
DesktopSource union (host | environment) plus an additive auth hint;
EnvironmentSummary gains a top-level desktop flag
- config: desktop.host { enabled, port?, passwordFile? }, Labs-gated
- rfb-probe: pure RFB version/security-type parser used to detect an
already-running loopback VNC server and classify its auth
- host-source: attaches to 127.0.0.1:<port>, refuses unauthenticated
(None) and unsupported (VeNCrypt) servers, and refuses ARD with the
supported alternative until the macOS milestone
- host-guidance: per-OS enablement text so no path dead-ends
- doctor + status report host desktop availability and auth type only
worker.desktop.observe/launch stay as delegating aliases with identical
behavior. Also drops the now-unused WorkerDesktopTunnels type export.
Live-verified against macOS Screen Sharing: probe reads RFB 003.889,
returns security types [30,33,36,35], classifies ard-account.
* test(gateway): probe RFB handshakes through the socket boundary
The probe's banner and security-offer parsers were exported solely so
unit tests could call them, which the dead-export gate rejects and which
tests internals rather than behavior. Keep them module-local and drive
the probe through a scripted loopback server instead.
The boundary tests also cover what pure-function vectors could not:
handshakes split across packets, legacy RFB 3.3 single-word security,
server-rejected handshakes, early hangups, and connect timeouts.
* feat(ui): let the Desktop panel view this machine, not just cloud workers
The Desktop panel was gated on a cloud-worker session placement, so an
operator running OpenClaw locally had no way to see the machine hosting
their main session even with a VNC server running on it.
Availability now follows the advertised desktop.observe method plus
operator.admin instead of session placement, and the picker lists every
environment whose summary reports a desktop, with the gateway row shown
as "This machine". Sources are passed to the generic desktop.observe /
desktop.launch RPCs; the app launcher stays worker-only. When a host
attach needs a password the gateway did not supply, the panel prompts and
keeps the value in memory for that connection only.
Adds the hostDesktop Labs toggle for desktop.host.enabled.
* fix(scripts): keep the env-var ratchet usable in shallow checkouts
The env-var budget check resolved its base ref, then hard-failed when
`git merge-base` found no shared ancestor. Shallow clones and grafted
agent checkouts resolve origin/main but truncate the history behind it,
so an advisory growth ratchet took down the whole check:changed gate
with "Could not resolve env-var count merge base for: origin/main".
Only the growth comparison needs a baseline, and the script already has
a no-baseline path. Treat git's exit 1 with empty output (no shared
ancestor) as that case and say so on stderr; a genuine failure still
exits 128 and still throws, and the absolute count-vs-budget check runs
either way.
* test(ui): measure the inline-code chip against its line box
The inline-code spacing test compared the chip's height to a prose text
rect, so it silently measured the monospace font's default line spacing.
That is ~17px on macOS and several px shorter on Linux, so the assertion
passed on CI and failed locally at 4.5 against a 3.75 bound -- after the
bound had already been widened once to chase browser font metrics.
Compare the chip to the paragraph's CSS line box instead, which is what
"the chip must not disrupt the line" actually means and is platform
independent. The horizontal gap stays as-is: it is em-derived padding
plus border, and it is the assertion that catches detached punctuation.
Verified both directions on macOS: the file is fully green, and
restoring the pre-fix 0.15em/0.35em padding still fails the gap
assertion at 5.41.
* feat(gateway): view macOS Screen Sharing from the Desktop panel
Modern macOS only offers ARD account authentication for Screen Sharing,
so the host desktop source refused every Mac. The Gateway now performs
the ARD handshake itself against the loopback server and hands the
browser a plain RFB 003.008 no-auth handshake, so the operator's macOS
account password authenticates the desktop without ever reaching the
browser, the observe result, a URL, or a log.
- rfb-preauth: ARD (type 30) Diffie-Hellman with MD5-derived AES-128-ECB
credentials, and VncAuth (type 2) bit-reversed DES, both under a single
10s negotiation deadline; Apple's RFB 003.889 maps to 3.8
- observe-bridge: runs pre-auth before splicing and starts the view-only
filter at clientInit, since the browser handshake is consumed here;
worker tokens keep the original version start phase
- host-source: attaches ARD, requiring per-observation credentials that
live only in the one-shot observer token and are dropped after use
- doctor: offers an explicitly confirmed sudo launchctl repair when
Screen Sharing is off, and prints the System Settings path otherwise
Live-verified against this Mac's Screen Sharing: the DH exchange and
credential framing are accepted and the server returns SecurityResult.
The VncAuth DES vector is confirmed against OpenSSL independently.
* feat(ui): commit-scoped session diff viewer with file, sync, and view menus
The Control UI session diff panel becomes a dense Amp-style viewer: per-file
menus (copy path, open file, reveal in file tree, open in editor), a Sync
Locally popover with a copyable git fetch command, view options (collapse all,
wrapping, split/unified layout), and a sticky footer that reports how far the
branch is ahead of its merge base and switches between all changes,
uncommitted work, and individual commits.
sessions.diff gains an additive scope param (all | uncommitted | commit) plus
commits, aheadCount, and mergeBase metadata. Commit-scope diffs read only the
object database, skip untracked collection, and bypass session-start baseline
filtering; unknown commits surface a typed unavailableReason. Offscreen file
bodies use content-visibility so large diffs stay responsive.
* fix(ui): satisfy session diff CI gates
* fix(gateway): fence commit-scoped session diffs to the advertised branch history
ClawSweeper found that commit-scoped sessions.diff accepted any commit resolvable in the checkout. Fence operator.read commit reads to the advertised merge-base..HEAD history and cover sibling-branch and base-history commits.
* feat(gateway): projects.list groups known checkouts by repo identity
Implements docs/plan/runners.md milestone 4 derived projects read model.
* feat(ui): regroup the Where picker by gateway, devices, and cloud
* feat(ui): placement chip shows where a session runs with reclaim
Implements docs/plan/runners.md milestone 4 placement display and reclaim.
* fix(ui): align effort picker after model switches
Publish ordered per-model effort profiles from the Gateway and reconcile new-session effort overrides so labels, slider positions, and create payloads stay consistent.
* fix(ci): align model profile contracts
* fix(ui): hide unusable models from picker
* refactor(ui): remove stale model availability helper
* refactor(ui): simplify catalog state guards
* style: format provider catalog imports
* chore: refresh plugin SDK API baseline
* refactor(core): break provider catalog type cycle
* chore(protocol): refresh models list Swift output
* chore: refresh plugin SDK API baseline after rebase
* fix(gateway): preserve full catalog preload semantics
* fix(ui): keep model status within startup budget
* fix(ui): preserve provider status within startup budget
* fix(models): scope live catalog outcomes
* test(ui): expect agent-scoped model refresh
* test(ui): align model refresh e2e fixtures
* fix(workers): persist placement terminal failures
* fix(workers): refresh placement protocol clients
* refactor(workers): isolate error formatting
* fix: integrate cloud terminal state with current main
* chore(plugin-sdk): refresh API baseline
* refactor(ui): inline one-use cloud terminal-reason banner helper
Keeps the Control UI startup JS bundle inside its 317 KiB gzip budget
(the helper + type-only import tipped it by 16 bytes).
* refactor(ui): trim terminal-reason lookup to type-erased optional access
Recovers the last gzip byte of the Control UI startup budget
(324609 B vs the 324608 B limit).
* feat(gateway): task-suggestion acceptance modes with compact split-button card
- taskSuggestions.accept gains additive mode (worktree default | local | cloud | session) + cloudProfileId; operator.admin unchanged; worktree requests stay {taskId}-only for older gateways
- local reuses the plain-cwd create; cloud mirrors the Control UI create-empty -> sessions.dispatch -> send sequence with pre-claim profile validation and full rollback; session delivers the prompt into the source session (steer single active run, plain turn when idle, ambiguous refused) and can only cancel the chip on failure, never touch the source session
- Control UI card redesigned per owner reference: repo-basename eyebrow, clamped summary, instructions expander, split button with per-profile cloud items or a disabled "No cloud environment configured" hint
- TUI gains the same modes as selector rows with capability gating and double-Enter arming
Release-note context: operators now choose where an accepted follow-up runs — isolated worktree (default), the project checkout, a configured cloud worker, or as the next instruction in the current session — from a compact suggestion card.
* fix(gateway): negotiate task-suggestion acceptance modes and enforce steerable-run invariant
- Gate non-worktree task-suggestion actions on the negotiated Gateway capability.
- Require exactly one steerable run ID before delivering into an active source session.
* feat(gateway): live desktop observer for cloud workers
Adds live observation for cloud worker desktops through the gateway and Crabbox plugin, including desktop provisioning, persisted desktop metadata, tunneled WebSocket proxying, and the worker.desktop.observe protocol method.
The gateway, Crabbox plugin, and gateway protocol surfaces remain off by default behind the cloudWorkers.desktop Labs flag.
* feat(ui): Desktop panel for cloud worker observation
* docs(gateway): document cloud worker desktop lab
* fix(ci): regenerate contract baselines after rebase
* fix(protocol): regenerate Android gateway methods
* fix(ci): align rebased SDK and lint baselines
* fix(gateway): enforce view-only RFB boundary and fence desktop teardown
* fix(gateway): tighten RFB filter surface
* fix(state): keep pre-desktop databases readable and harden view-only RFB
* fix(gateway): fence desktop observer upgrades behind work admission
* fix(gateway): bind desktop observer tokens to their owner epoch
* fix(ci): regenerate config and SDK baselines after rebase
* fix(ci): regenerate native protocol and SDK baselines
* fix(ci): regenerate contracts after main rebase
* fix(state): register desktop metadata as lazy additive
* fix(ci): regenerate SDK baseline after final direct-merge rebase
* feat(agents): record run-end worktree cleanup outcome
Persist removed, retained, and failed run-end cleanup outcomes on managed worktree records. Operators and QA can inspect the durable fact through worktrees.list and openclaw worktrees list --json.
Release note: Managed worktree run-end cleanup now records why a checkout was removed or retained in worktree list JSON.
* test(qa): prove dirty worktree retention outcome
* chore(protocol): regenerate swift gateway models
* fix(agents): harden worktree cleanup recovery
Register run_end_cleanup_json as a lazy compatible column so same-version v6 index repair and read-only doctor migration can recover databases created before the column existed.
Type removal contention at the registry boundary; unexpected claim failures now best-effort record a bounded failed outcome and rethrow the original error.
* fix(ci): clear repo-wide lint debt blocking merge gates
The red-main landing rule requires this PR to repair repository-wide merge-gate debt instead of bypassing it. Apply the current lint contracts mechanically and split turn-transition coverage into a concept-named sibling with per-file-safe test state.
Exact line delta: +676/-574 (net +102) across 44 test/support files.
* fix(ci): preserve cached health refresh proof
Require the public refresh call to exist before accepting that sensitive fields were omitted, so the boundary proof cannot pass on a missing call.
* fix(ci): correct test typing left by the lint sweep
Literal-widened totalTokensVersion fixtures, a WebSocket RawData overload
mismatch, and the protocol schema document cast broke check-test-types
after the repo-wide lint repair. Aligns the fixtures with SessionEntry,
narrows Buffer handling per RawData, and keeps the JSON-shaped undefined
omission under structuredClone.
* test(agents): reuse upstream resource-loader test support
The session-loop split and #120463's helper extraction landed the same
createResourceLoader/createCompactionHandlers twice; the rebase kept both,
orphaning main's agent-session-loop-resource-loader.test-support.ts and
failing the dead-code gate. Import the upstream helpers and delete the
duplicates.
* fix(agents): reject finalized rows at the worktree removal claim
Address the accepted ClawSweeper late-claim finding by rereading and rejecting missing or finalized worktree rows inside the synchronous removal-claim transaction.
Preserve the authoritative cleanup invariant: finalized contenders record nothing, while retained-busy is written only while the row remains live.
* refactor(agents): reuse registry update for busy outcomes
Keep the live-row conditional write in the canonical registry update path so the finalized-claim repair stays below the registry max-lines ratchet without weakening the authoritative-outcome invariant.
* test(agents): drop session test duplicates after rebase
Keep current main as the canonical owner of next-turn lifecycle coverage and correctness test support after replaying the older lint-debt split.
* fix(agents): guard post-abort cleanup outcomes against finalization
After abortWorktreeRemoval releases a stale remover's claim, its retained or
failed write raced a finalizing remover and could overwrite the authoritative
removed-lossless fact. Route every retained/failed write through the live-row
condition; only the finalizing remover's own removed-lossless write stays
unconditional.
* fix(agents): persist the removal outcome atomically with finalization
A delayed removed-lossless write after remove() finalized could race a
restore plus newer cleanup and overwrite the newer operator-visible fact.
The run-end outcome now rides remove()'s finalization update; every other
cleanup write stays live-row conditional, so no post-finalize write path
remains.
* test(qa): restore strict cached-health contract assertions
The lint sweep's Boolean() coercions let truthy non-booleans satisfy the
wire-typed cached-meta contract. Assert the literal boolean for unknown-typed
fields and use nullish-coalesced strict equivalents for boolean chains.
* fix(agents): clear the stale cleanup outcome when restoring a worktree
A restored checkout begins a new lifecycle; leaving the removed-lossless
fact on the live row showed operators a stale result until the next
cleanup. Restore clears the recorded outcome and the regression asserts
the cleared state before the next cleanup records fresh truth.
* fix(agents): scope stale cleanup outcomes to their observed lifecycle
A stale remover's retained/failed write raced a concurrent remove-plus-
restore: the revived row is live again, so the live-row condition alone
could stamp a prior-lifecycle outcome. Condition those writes on the
activity stamp the remover observed; restore bumps lastActiveAt, making
any prior-lifecycle write a no-op.
* fix(agents): advance the restore activity stamp within one millisecond
Stale cleanup writes fence on the activity stamp they observed; a restore
completing in the same millisecond could revive the row with an identical
stamp and let the fence match. Restore now always advances past the
stored value, and the ABA regression pins the clock to prove the
same-millisecond case.
* feat(update): scheduled update campaigns with countdown, dev-channel auto-update, and Updates settings page
* fix(ui): satisfy update campaign deadcode checks
* feat(update): extend scheduled update campaigns
* test(ui): include Updates page in settings sidebar search expectations
* fix(update): end campaigns when apply fails
* fix(update): pin dev campaign applies to the announced commit
* test(ui): align update e2e fixtures
* fix(update): pin adopted dev updates to the announced commit
* docs(update): document automatic dev update campaigns
* fix(ui): gate automatic dev updates on git installs
* fix(gateway): scope detailed update metadata to read-capable clients
* fix(gateway): break post-attach madge cycle with a leaf client-lookup type
* chore(protocol): regenerate clients after rebase
* test(gateway): update method tail after rebase
* fix(update): preserve campaign target and hold state
* test(update): align adoption mocks and split handoff suite
* test(update): route split handoff suites on Windows