* refactor(state): retire six dead shared-state tables at schema v10
agent_model_catalogs, android_notification_recent_packages,
command_log_entries, diagnostic_stability_bundles, media_blobs, and
model_capability_cache landed with the database-first squash but their
runtime writers never reached main; every stable since v2026.6.10 created
them empty (agent_model_catalogs held only rebuildable catalog cache rows
until #111173 removed its writer). State schema 10 drops all six tables
and seven indexes through both the runtime-open and doctor migration
paths, records the retirements, bumps the native reader ceiling, and
corrects stale database-first doc claims that still named these tables
as canonical stores.
* test: move cross-lane schema-version pins to v10
The v10 retirement missed current-version pins outside src/state: the
native guard vitest wrapper, placement-move and node-worker-launch
same-version assertions, and the audit outbound-progress tripwire. The
pinned pre-C04 audit reader is a v9-era build that now refuses v10
databases by the version contract, so the test projects the file back to
the exact v9 shape with the documented 10-to-9 downgrade fixture before
the reader proof; the shared fixture also seeds the v10 retirement
regression.
* test: keep only the used downgrade fixture export
* fix(gateway): refresh provider usage asynchronously
* fix(ui): report a stalled provider-usage refresh on Model Providers
The page observed the incomplete-usage marker but discarded the exhausted
outcome, so once the retry budget was spent it rendered ordinary provider
cards with no usage and no explanation — indistinguishable from providers
that report no usage at all. Keep the outcome and render the warning the
Usage page already owns, reusing usage.providerUsage.stalled rather than
minting a Model Providers key so no locale baseline churns.
A user-initiated refresh now restarts the retry budget. The notice tells the
operator to refresh, so the button has to hand back attempts to spend; only
the forced path resets it, or the budget could never exhaust.
Also fixes tsgo:core:test on the current head: createStore's inferred literal
had no usageStats, so the run-bookkeeping case could not stamp it, and
view.test.ts needed the new prop.
Closes the ClawSweeper P2 at model-providers-page.ts:169-175.
* fix(ui): keep the stalled usage notice when usage.status starts rejecting
loadModelProvidersData turned a rejected usage.status into providerUsage:
null, which the page read as a completed load. observe(false) then reset the
retry budget and cleared the stalled callout, so a permanently broken usage
endpoint rendered as ordinary cards with no usage and no explanation — the
same silent failure the callout was added to prevent. The reset also fired
mid-cycle: one incomplete response followed by one rejection restarted the
budget, so the notice could be deferred indefinitely.
Record the failure at its producer instead of inferring it downstream. A null
providerUsage also means "not loaded yet", and no caller can tell the two
apart, so load.ts now reports providerUsageFailed explicitly and the page
treats a failed read as unresolved rather than resolved-empty.
Found by a Codex review of 417d43b65d.
* revert(gateway): drop the opportunistic model-catalog fast path
It broke two chat.history tests on main — both assert the cold catalog loader
runs exactly once, and reading the prepared snapshot first means it never does.
checks-node-compact-small-10 was red for that reason.
The change was a separate-surface latency fix that this PR picked up in passing,
and the body already offered to split it. Dropping it is the honest resolution:
rewriting main's assertions to accommodate a drive-by optimization would trade
one concern's proof for another's convenience. optional-model-catalog.ts,
server-model-catalog-auth.ts and their test return to the merge-base.
This PR is now only the usage.status non-blocking contract and its clients.
* fix(usage): preserve incomplete retry state
* perf(ui): keep usage capability startup-neutral
* fix(ui): restore provider usage retry convergence
* fix(usage): restore retry and cache invariants
* fix(usage): stabilize provider convergence
* test(ui): exercise provider recovery path
* test(ui): remove stale usage route fixture field
* fix(macos): show provider usage errors
* fix(macos): bound usage retries per menu open
* fix(macos): end usage retries on menu close
---------
Co-authored-by: Josh Lehman <550978+jalehman@users.noreply.github.com>
* fix(macos): honor user accent precedence in config snapshot and live-update chat window
The Control UI user accent (ui.prefs.accent) landed in #128432/#128577 with
precedence user accent -> operator ui.seamColor -> theme default, and the
gateway's talk.config payload already applies it. The macOS app had two gaps:
- ChannelsStore.applyUIConfig read raw ui.seamColor from the config.get
snapshot and clobbered the user accent set from talk.config depending on
arrival order. It now resolves ui.prefs.accent ?? ui.seamColor via a
testable helper mirroring the gateway precedence.
- The native chat window read AppStateStore.seamColorHex once at window
construction, so accent changes never live-updated. MacChatSurface now
reads the @Observable store in body, deleting the one-shot userAccent
plumbing.
Docs: configuration-reference.md documents the precedence for native-app
chrome. Regression test fails pre-fix (snapshot returned the operator seam
color instead of the user accent).
* fix(macos): refresh config from gateway config.changed events
Addresses the review finding that no macOS consumer turned the gateway's
hash-only config.changed broadcast into refreshed shared state, so a
Control UI accent change never reached an open native chat window while
the app ran. ChannelsStore now subscribes to gateway pushes and re-fetches
config.get on config.changed, reconnect snapshots, and sequence gaps.
The refresh applies non-force so an in-progress local settings draft wins
(the gateway rejects stale-hash writes anyway). The in-flight reload queue
gains a closed pending level (none/refresh/force) so a refresh arriving
during a load is coalesced instead of dropped, and a requeued refresh
cannot clobber a dirty draft the way the old boolean force-pending did.
* fix(nodes): report camera positions the hardware actually reached
`camera.ptz.control` returned a position it never verified, and
`camera.snap`/`camera.clip` could capture from a camera the caller did
not ask for. Both told the agent an action succeeded when it had not.
PTZ read its post-write status from the same UVC connection that issued
the write. Gimbal cameras echo a pending setpoint back on the writing
connection, so the check confirmed its own write. Those cameras also
service camera-terminal controls only while a video stream is active, and
no capture session was held, so writes could be discarded entirely while
reads returned phantom values.
Hold a frame-discarding capture session across every PTZ operation, close
the writing controller, and verify through a fresh connection against each
axis's advertised resolution. An axis that misses now reports through the
existing CAMERA_PTZ_PARTIAL outcome with observed versus requested values
and what to check next.
Apple camera selection accepted an explicit deviceId and silently fell
back to the default camera when nothing matched. Linux already rejected
this, and CameraPTZService already rejected it in the same app. Centralize
exact selection in OpenClawKit so macOS and iOS both fail with a
device-not-found error; the facing/default fallback stays only for
requests that supply no deviceId.
camera.ptz.status now activates the camera and its privacy indicator for
the duration of the read. That is the cost of returning real positions.
* fix(nodes): tell callers how to recover from an unknown camera ID
Device IDs change when cameras are reconnected, so a bare
device-not-found error dead-ends the caller. Both Apple errors and the
docs now point at camera.list for current IDs.
Addresses the ClawSweeper P2 finding on #128595.
Fixes#128194.
Local-mode onboarding has two rightful actors on the CLI page: the page's
own install flow, which may open an install-target prompt on unreleased
builds, and the connection-mode commit, which starts the gateway through
ConnectionModeCoordinator the moment the user picks This Mac. When the
gateway comes up on its own (dev-root builds, or an externally attached
gateway), AI setup auto-connects and finish() runs — but close() was
silently ineffective because AppKit ignores NSWindow.close() while a sheet
is attached. The result, reproduced live with os_log instrumentation: a
completed onboarding (dashboard open, onboardingSeen set) with a zombie
onboarding window showing a dead CLI page and a stale channel-choice sheet.
Two coupled fixes. OnboardingController.close() now ends any attached sheet
before closing, so completion always tears the window down. And a running
local gateway now resolves a pending install prompt directly: a new tested
static (shouldResolveInstallPromptForRunningGateway) extends the existing
gateway-status revise path to the choosingTarget phase, marking the step
installed and dismissing the moot sheet; runCLIInstall returns quietly in
that case instead of writing a cancellation over a resolved step. Genuine
declines keep the cancelled status.
Writing to a pipe whose reader has already exited raises SIGPIPE, which
kills the whole process instead of throwing. MacNodeHostWorker already
guarded its stdin pipe with F_SETNOSIGPIPE; the codex App Server client
and the MLX TTS helper transport did not, so a child exiting mid-write
could take down the app. Suppressing the signal exposed that an
undelivered App Server request write was reported as appServerUnavailable
even though the frame was provably never sent, so it now requeues once
onto a fresh child instead of failing the caller.
Test-side pipe write ends whose readers are spawned children (or a
readability handler that can close the pipe mid-test) get the same
suppression so a racing reader exit fails the assertion instead of
killing swiftpm-testing-helper with signal 13, which is what caused the
macos-swift CI lane's intermittent unrelated-test crashes (e.g. PR
#126559, run 32341197738 job 96340683947).
* fix(macos): surface concrete Gateway start failure reason in onboarding
GatewayProcessManager already retains the specific registration/readiness
failure (e.g. "launchd disabled", a launchd enable error, a readiness
timeout) in lastFailureReason, and Settings/menu bar UI already read it.
Onboarding discarded it: LocalGatewayActivation.failed collapses every
cause to the same generic "Retry setup" message, so a missing LaunchAgent
registration is indistinguishable from any other startup failure.
Surface the retained reason in the onboarding status text so the failure
is diagnosable without going through Settings.
* fix(macos): record command-resolution failures in lastFailureReason
GatewayProcessManager set status but not lastFailureReason when
GatewayEnvironment.resolveGatewayCommand() returns no command (missing
runtime/CLI), unlike the launchd-disabled and launchd-enable-error
branches a few lines below. Onboarding's new failure message therefore
rendered the generic text or a stale reason from an earlier attempt
for this failure class. Mirror the sibling branches and record
resolution.status.message.
Also fixes the macos-swift SwiftFormat lint failure: the comment block
directly above gatewayStartFailureMessage needed to be a doc comment
(///), matching the repo's existing convention for declaration-adjacent
comments.
* fix(macos): bind Gateway start failure reason to its activation attempt
LocalGatewayActivation.failed carried no data, so both onboarding call
sites reread the mutable GatewayProcessManager.shared.lastFailureReason
singleton after activateLocalGateway() returned. A later gateway-start
attempt can overwrite that singleton before the caller gets around to
reading it, so a stale wait could surface a newer attempt's reason (or
vice versa) attributed to the wrong onboarding attempt.
Widen LocalGatewayActivation.failed to carry reason: String?, captured
inside activateLocalGateway() the instant waitUntilReady() resolves to
false, and have both onboarding call sites map that bound value instead
of rereading the singleton. CLIInstallPrompter's two `!= .failed`
comparisons become `if case .failed = activation` pattern matches since
`.failed` is no longer a payload-free value; its existing `case .failed:`
message switch is unaffected, since bare-case patterns still match
regardless of associated data.
* fix(macos): satisfy SwiftFormat lint on CLIInstaller.swift
Converts the LocalGatewayActivation.failed declaration comment to a
doc comment and wraps activateLocalGateway's closing signature per
config/swiftformat, matching the same docComments convention already
applied elsewhere in this PR. No behavior change.
* fix(setup): refresh Codex registry with staged install
* fix(macos): verify inference before onboarding handoff
* fix(setup): use native Codex home for subscription auth
* fix(codex): honor attempt-scoped setup config
* fix(macos): align onboarding handoff with reopen
* fix(setup): await prepared model convergence
* fix(ui): avoid false auth state for empty catalog
* fix(setup): scope catalog convergence to Codex gateway
* fix(setup): publish the committed runtime catalog
* fix(models): project configured static runtime models
* fix(codex): expose app-server model catalog
* fix(models): preserve Codex auth across reloads
* fix(ci): align Codex onboarding checks
* test(ui): stabilize dock suppression environment
* fix(codex): honor discovery config in app-server model catalog
The manifest documents discovery.enabled (bundled fallback list) and
discovery.timeoutMs (default 2500ms) for model discovery; the new catalog
path used the generic 60s request timeout and ignored the enable gate.
Also drop the test-only listModels injection seam in favor of vi.mock.
* fix(setup): refuse prepared Codex auth over an explicit remote transport
configureCodexCliPreparedAuth silently rewrote an explicitly configured
websocket/unix app-server to local stdio (keeping a dangling url), moving
the credential boundary onto this host. Fail setup with actionable
guidance instead; also surface the root cause when the prepared model
catalog refresh fails after activation.
* refactor(agents): one canonical model-catalog identity key
Three near-identical key helpers existed (models-list-result,
models-list-configured-static, harness/model-catalog). Export
resolveModelCatalogIdentityKey from the route-policy owner, collapse the
duplicate dedupe loops into dedupeByKey, make donor enrichment Map-based,
and inline the one-off harness-augment wrapper.
* fix(macos): restore custodian handoff for fresh activations
Landing every finish on the plain dashboard stranded the custodian
first-run flow (memory import, channels, permissions, hatch). Fresh
activations now hand off to custodian onboarding; live-verified
pre-existing setups reopen the normal dashboard, matching the removed
already-configured shortcut. Tests pin the destination per path.
Also isolate the post-startup Codex login test from developer machines:
ambient OPENAI_API_KEY and a real Codex login made it assert-fail.
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
* fix(state): store in-root agent database registry paths relative to the state dir (schema v9)
Copied state directories retained stale absolute agent database registry rows. The combined gateway store then merged old and new copies, causing every sessions.list request to fail with SessionCanonicalKeyMigrationRequiredError.
Schema v9 stores in-root registry paths relative to the state directory; migration rewrites eligible rows, deletes stale duplicates, and preserves external paths.
* fix(voice-call): describe state-relative registry migration
* fix(state): preserve registry locator traversal
* test(macos): move PortGuardian schema-version boundary to v9
The store opens any state database up to maximumSupportedSchemaVersion,
which the schema v9 bump moved from 8 to 9. Shift the supported/newer
partition in the boundary test accordingly.
* fix(state): re-anchor copied default-layout registry rows instead of deleting them
Resolve the ClawSweeper P1 by preserving genuine external default-layout registrations, re-anchoring copied rows when an in-root counterpart exists, and deleting only dual default-layout conflicts.