* Revert "fix(terminal): a file link opens in the workspace it was clicked in (#32320)"
This reverts commit 359fec1a09.
* test: drop redundant openFiles cast restored by the revert
The Windows boundary step's managed-hook stdin test bounds each hook at 10s
to catch launchers that block reading stdin. Its first encoded-PowerShell
launcher was also the worker's first Windows PowerShell start, which took
5.7s on a passing run and 15.3s on a failing one before exiting 0 by itself.
Run that launcher once before timing so the bound measures hook behaviour,
and name the command when the bound trips.
* fix(editor): add bounded Zig syntax highlighting
Recognize .zig files and load the existing pinned Shiki Zig grammar through
the lazy TextMate provider. Map its scopes to stock Monaco theme categories
while preserving the shared tokenizer state and per-line bounds. Keep .zon
and formatting outside this lexical-only change.
Reuse the optional scope-mapping hook reviewed in Haskell PR #25411 and
its refreshed candidate 12908d0341,
retaining the original contribution credit from #25315. Ship the existing
grammar package MIT notice in renderer public assets for desktop and web.
Requested by @thinkAfCod in issue #11313. This addresses highlighting only;
it does not close the formatting portion of the issue title.
Co-authored-by: davidwis <davidwis000@gmail.com>
* fix(build): serve Zig grammar notices through allowed assets
Move the unchanged Shiki notices beneath assets/ and reference both files
with relative HTML metadata links. Preserve reverse-proxy prefixes and let
the existing projection and serving allowlists accept the browser client.
Do not expand either allowlist.
Exercise real Vite output, projection, manifest validation, and static HTTP
serving at root and /orca paths. Follow the relative-assets notice strategy
used by the coauthored Haskell adaptation and retain its contribution credit.
Co-authored-by: davidwis <davidwis000@gmail.com>
* test(editor): verify Zig source and diff highlighting
* chore(editor): drop Zig grammar notice assets
Zig grammar comes from the existing shiki dependency (already shipped via
syntax-highlighter's bundled-language loader), so no separate notice is needed.
---------
Co-authored-by: davidwis <davidwis000@gmail.com>
Antigravity usage: when agy's read times out or fails while showing its interactive browser sign-in prompt, Orca marks it signed out and skips automated re-reads for 1h (manual refresh still bypasses), instead of opening a new sign-in page on every poll. A valid reading always wins; fast-exit signed-out reads poll as before. Fixes#26484.
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->
| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 1 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$279 | 0 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$279 |
| Prod | 1 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$58 | $\color{#cf222e}{\Huge{\mathbf{−}}}$13 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$45 |
<!-- /orca-pr-loc -->
## ELI5
When we restart a relay server during a routine rollout, computers that were offline during the rollout still have that server written down as "their" server. If one of them comes back during the ~45 seconds while the replacement server is starting up, the director currently tells it "your server is unavailable, try again" over and over, instead of just sending it to another server. This change lets the director send it elsewhere, but only when it has proof that the old server process is really gone.
## What Changed
**Before (production, 2026-10-24 23:43Z, cell production-gce-c15):** during a same-capacity roll the cell is roll-isolated, drained, then its single VM is replaced. A desktop host that was offline during the drain reconnected in the gap between the old process stopping and the new one being ready. The director refused it for ~26 s (`assignment rejected route=assign lane=placement hinted=true reason=relay_home_cell_unavailable cause=not_ready`, x5, plus `route=resolve ... cause=unheard`, x2). It got a grant somewhere else only once the new process was live.
**After:** while the replacement process is starting up (`not_ready`), the same host is moved to another cell on its first try. A host on a roll-isolated cell can be moved off even though the cell is not live, as long as the process heartbeating for the cell *started after the roll isolated it* (`relay_cell_runtime.started_at > relay_cell_admission.roll_isolated_at`), its heartbeat is fresh, and (for connection-capped cells) its latest heartbeat reports zero connections. Both lanes that move an isolated host apply it: the narrowed isolated-replacement path (`isolatedIncumbentMayMove`) and the all-rows path it falls back to. Together these serve assign and resolve, because resolve hands a non-live cell to `assign`. "Roll-isolated" is the existing predicate: `migration-only` with a roll stamp younger than 2 h.
**Still refused (residual):** the `unheard` window, when the old process's heartbeat is past the 45 s TTL and the new process has not been heard yet. On c15 that was about 5-10 s. Nothing proves the drained sockets are gone there, so the refusal stays, and the client's retry recovers once the new process heartbeats. Also unchanged: an ordinary readiness dip, a `migration-only` cell without a roll stamp, a stale (over 2 h) stamp, and a new process that later goes unheard. Fence handling is untouched.
## Why
The refusal exists because moving a host off a cell we cannot fully trust could leave the host's old socket served by the old process while its clients go to the new cell. Until now, the only proof the code accepted was an operator-written fence. This adds a second proof that costs no operator step:
- **A new incarnation means the drained process is gone.** Each process mints a fresh incarnation UUID and `startedAt` at boot (`cell-heartbeat-client.ts`), and the director rejects a heartbeat with an older `startedAt` once a newer one is recorded. Each cell VM runs exactly one relay container (`docker run --name orca-relay --restart always`, with stale containers force-removed at boot). The cell MIG uses `replacement_method = RECREATE` with `max_surge = 0` (pinned by a Terraform assert), so the old VM is deleted before the new one is created. A same-VM container restart also produces a new incarnation and is just as safe: Docker restarts only after the old process exits, and that exit closes all of its sockets.
- **Whatever the new process holds, it can enforce.** It is heartbeating (fresh), and on capped cells it reports no connections at all. A host that attaches between two heartbeats carries the old epoch, so the epoch bump this move writes makes the cell refuse it or close it with 4409. That check is the same live `verifyCellAssignment` / control-renewal read the existing live-isolated path relies on.
- **Clock skew** between the cell's `startedAt` and the director's stamp is covered by the zero-connections check on capped cells only: a drained process that only *looks* post-stamp still holds its sockets, so it is refused. Uncapped cells report no counts and get no such guard; they already move off without a fence on the dead-cell path today, so this adds no new exposure there.
Alternatives considered: (a) adding an incarnation column to `relay_assignments`: a schema change for no extra proof. (b) Honouring any not-live roll-isolated cell: that drops the proof. (c) Having the roll write a fence for the `unheard` window: the only fence driver refuses enabled cells, a fence is a MIG resize to 0 that would fight the roll's own apply, and the window is a few seconds the client retry already absorbs.
## Linked Issue
N/A (internal production incident, 2026-10-24 c15 roll).
## Visual Proof
N/A: director placement logic only, no UI.
## Testing
- [x] Automated tests added: `cloud/apps/relay/src/assignment-roll-replaced-cell.test.ts` (18 cases; the redial cases run for both hinted assign and resolve):
- not_ready with an incarnation started after isolation: re-placed, epoch bumped.
- Refused as before: not_ready with the pre-isolation incarnation, with reported connections, after the new process goes unheard, with a stale stamp, on an ordinary unstamped readiness dip, and on an unstamped migration-only cell. Also refused: the unheard window between the old and new process.
- One case per lane. The narrowed path's cross-region move fails if only the narrowed-path change is reverted. The all-rows path's move, forced by units no lease backs, fails if only the all-rows change is reverted.
- [x] Full relay vitest suite, `--maxWorkers=2`: 130 files / 1344 tests passed, 39 files (201 tests) skipped. The Postgres-backed suites skip locally (no `ORCA_RELAY_TEST_POSTGRES_URL`); CI runs them.
- [x] `pnpm typecheck` in `cloud/apps/relay`.
## Review
## Agent skill upstream boundary
- [x] Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.
## Notes
Backwards compatibility: no schema, wire or RPC change; old directors keep refusing as today. Performance: the new query runs only when a host's home cell is roll-isolated and not live; the general sticky hot path is unchanged.
## Checklist
- [x] This PR is small and focused
- [x] I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
- [x] Before/after screenshots or videos attached for UI changes, or `N/A` with reason
- [x] Self-reviewed for correctness, security, and performance
- [x] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
- [x] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)
* fix(terminal): keep Mac trackpad flicks continuous in fullscreen TUIs
Chromium reports wheelDelta = -3 x deltaY for continuous devices, so a fast
trackpad event crossed the 100-unit 'notched wheel' threshold and switched to
the compressed, burst-boosted wheel curve mid-flick (2 -> 6 -> 2 reports per
event). Classify the gesture instead: an off-120-grid wheelDelta marks a
continuous device and the verdict sticks until the gesture goes idle.
* fix(terminal): end a trackpad gesture when a Mac wheel notch arrives
An on-grid event now continues the continuous gesture only with the trackpad's
3x wheelDelta shape and only shortly after off-grid input, so a notched wheel
used right after the trackpad keeps its discrete feel.
* fix(terminal): match wheel notches on the zoomed grid and require the trackpad ratio
Chromium divides both deltaY and wheelDelta by page zoom, so a notch reports
120/zoom; derive the grid from the UI zoom. An on-grid event now continues a
trackpad gesture only when 3 x |deltaY| is close to |wheelDelta|, so a
Windows/Linux notch (100 px per 120) right after trackpad input stays discrete.
* refactor(terminal): classify trackpad gestures without zoom or a notch grid
Keep the base rule (a small wheelDelta is continuous) and extend it across the
gesture: a large event within 300 ms of continuous input stays continuous only
while it keeps Chromium's continuous delta shape (wheelDelta about 3x deltaY on
macOS, 1x on Windows/Linux). Both deltas scale with zoom, so the shape check
needs no zoom factor; notches never match it.
* fix(terminal): only small wheel events extend the trackpad gesture window
A notch that happens to match the continuous delta ratio (40 px per 120 on
macOS) is now captured for at most 300 ms after trackpad input instead of
renewing the window indefinitely.
* fix(terminal): keep the whole trackpad gesture continuous through long momentum
Restore the approved rule where every continuous event extends the gesture
window, so a hard flick whose momentum stays above notch size for over 300 ms
keeps its 1:1 row distance. Pins that with a long-momentum fixture.
Codex native chat: follow-ups to a running goal turn are steered into it (turn/steer) instead of waiting, when the follow-up's model/effort match what the turn actually runs (newest handed-over submission, incl. ones rejected after their turn opened). Still waits while an earlier send is pending. Fixes#33056. Supersedes #33057.
Co-authored-by: Nico Hu <296744606+niconicohu@users.noreply.github.com>
* fix(runtime): a workspace sleep no longer fails when another workspace sleeps at the same time
Sleeping two or more workspaces at once failed every one but the last with
terminal_liveness_unavailable, so their terminals kept running. Each sleep asks the terminal
host which PTYs are live; when a newer listing starts while one is in flight, the older answer
is discarded as stale and retried once. Two sleeps leapfrog each other: each retry starts a
listing that makes the other's stale, and the second retry gives up.
A targeted read that a newer listing overtook now waits for that listing and uses its answer,
which is at least as fresh as the read itself, instead of starting another listing. Lifecycle
changes (a PTY spawning or exiting mid-listing) keep the single retry they had.
The listing step moves into its own layer to keep the refresh within the file-size limit.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(runtime): an overtaken PTY listing joins only a newer one that asked the same question
A targeted read that a newer listing overtook adopts that listing's answer. That newer listing
could have asked without foreground-process evidence, or waited less for slow hosts, so the read
could get rows without the evidence it asked for, or fewer hosts than it would have waited for.
It now joins only a newer listing with the same evidence option and a deadline at least as
late as its own; otherwise it keeps its single re-list.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(runtime): a PTY read with no deadline stops after one listing budget, however many it joins
Each listing an overtaken read joined got a fresh 3 s when its caller set no deadline, so
pollers starting listings faster than a slow SSH census finished could keep such a read waiting
without end; before joining it gave up after two listings. The read now fixes one deadline up
front (the caller's, or one listing budget from now) and every listing it joins shares it.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Pi: a finished answer followed by Pi's own threshold compaction is announced (done/unread) when the compaction starts instead of waiting for it to end. Skipped when follow-ups are queued; the settle post still ends the row. Refs #33267 (idle-compaction case unchanged).
Codex native chat shows the existing approval card for MCP tool-call approvals (form-mode elicitations with no fields) instead of auto-declining them; Deny sends Codex's user-cancel reply so the turn continues. Other elicitations and turn-less requests are still declined. Fixes#25045. Supersedes #25988, #25046.
Co-authored-by: u-ichi <82369+u-ichi@users.noreply.github.com>
A hook-reported agent under an older shell title (e.g. zsh before Codex paints its title) keeps its agent status and session id, so mobile can open a fresh Codex chat; PTY titles now record when they were observed. Fixes#32030. Supersedes #32046.
Co-authored-by: dorian-kwon <61652817+dorian-kwon@users.noreply.github.com>
Pi's titlebar extension paints the idle title at session_start (plain Pi only, not OMP) so Pi-based harnesses that brand their own title pass worker-start agent_readiness. Fixes#28273.
Hermes 0.22's shared gateway: the status plugin reads pane identity from each session's own client env (scrub-safe aliases via terminal.client_env_passthrough) instead of the gateway's os.environ, so every pane reports its own status. Unsafe-to-edit configs fall back to main's plugin-only edit; SSH panes now get the scrub-safe aliases. Fixes#32709. Supersedes #33240, #33222.
Co-authored-by: <+@users.noreply.github.com>
Co-authored-by: <+@users.noreply.github.com>
* fix(agent-status): keep a running Codex turn on paired and mobile views while /side is shown
Codex's terminal title follows the thread it displays, so opening /side idles the title while the main turn runs. The paired/mobile session projection read that idle title, newer than the main turn's last hook, as the turn ending and published a title-only done row with no prompt. A Codex row whose hooks report the main turn running now owns the title interval; the turn's end still arrives by hook or rollout.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(agent-status): keep a Codex main turn waiting on approval through /side on paired views
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(agent-status): leave restored Codex rows to the existing title rules
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(agent-status): stop a running Codex turn at a PTY process replacement
A replacement process on the same PTY id invalidates its predecessor's title-interval evidence; record when that happened so a predecessor's running Codex hook cannot outlive it.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(runtime): a pane's new process does not wear its previous run's Done (STA-12043)
* fix(runtime): keep the superseded turn's segment and date process start at spawn request
* fix(runtime): take the identity row's segment from the turn clients were shown
Hermes remote install only rewrites config.yaml when its content changed, so a stale snapshot can no longer overwrite a concurrent edit (matches local install).
A CSI scan now stops at ESC/CAN/SUB (as xterm's parser does), so a hidden-startup carry ending mid-CSI no longer swallows the next OSC's introducer and leaks `104;255` onto the prompt. Fixes#31410.
Co-authored-by: Neil <neil@stably.ai>
OpenCode model selection accepts per-major floors (1.x >= 1.18.30, 2.x >= 2.0.14; prereleases and 3.x refused) instead of exact version allowlists, so current releases launch with a picked model again. Fixes#26611. Supersedes #26683, #31985.
Co-authored-by: ggbdpq <67929842+ggbdpq@users.noreply.github.com>
Claude tool events from agent-hook runs (`hook-agent-<uuid>` ids, which never send SubagentStop) no longer mint phantom "Working - Agent" subagent rows. Fixes#27775. Supersedes #27776, #15326.
Co-authored-by: DamHV <251416543+DamHV@users.noreply.github.com>
First-work branch/workspace-name generation backs off after a failed attempt (30s, 2m, then every 30m) instead of re-running on every working event, bounding cost to ~2 sessions/hour/workspace and recovering after a limit resets. Fixes#9705.
* fix(terminal): answer a background pane's startup queries while its paired view is paused
A paired desktop pauses the output of a terminal it is not showing. The host
drops that output, but it still counted the paused view as the one answering
terminal queries, so main's query responder stayed silent too. A fish started
in such a pane (for example by `orca terminal create`) sent DA1, got no
answer, and stalled 10 s before warning.
A multiplex stream now registers with its pause state, and main answers the
queries of output a paused view drops, exactly as it does for output the
hidden-pane gate drops. A view that receives the output still answers it, so
each query is answered once.
Fixes#32944
Co-authored-by: Ben Grynhaus <ben.grynhaus@wiz.io>
* fix(terminal): answer queries in output a pausing remote view discards unsent
A chunk ingested while the view was still receiving yields its queries to
that view. If the view then pauses, the host drops the chunk from the
output batcher, or clears it from the ack backlog, before the view ever
parses it. Nothing answered those queries.
The pause now carves the query bytes out of what it discards, with the
same extraction the hidden-pane gate uses, and hands them to main's
existing responder. The responder answers only while no other view is
receiving that terminal's output.
Refs #32944
Co-authored-by: Ben Grynhaus <ben.grynhaus@wiz.io>
* revert: answer queries in output a pausing remote view discards unsent
This reverts commit 3fc500fe09. Answering those queries later through
main's emulator injects bytes into its live parser. That can cancel a
partial escape sequence, it answers cursor and mode queries against
state that has since moved on, and it can answer twice when two views
pause one after the other. An exact fix has to capture the replies when
the bytes are first parsed and track delivery per view, so it is left as
a follow-up.
Also renames a test helper to say what it reads.
---------
Co-authored-by: Ben Grynhaus <ben.grynhaus@wiz.io>
OpenCode Go credential status: a late initial read can no longer overwrite a save/clear result, and pending/failed reads show Checking…/Status unavailable instead of Not saved. Fixes#33068.
Co-authored-by: Pr1p <48266347+Pr1p@users.noreply.github.com>
Fold a standalone copy of the leading spinner frame in agent titles (Codex 0.155–0.163 `⠙ renaming... ⠙ | x`) so the title tracker stops republishing session tabs every 100ms. Fixes#27598. Supersedes #27599.
Co-authored-by: outof-place <69466055+outof-place@users.noreply.github.com>
Claude's hook script refills ORCA_PANE_KEY / ORCA_AGENT_LAUNCH_TOKEN from the scrub-safe aliases when CLAUDE_CODE_SUBPROCESS_ENV_SCRUB strips them, so scrubbed Claude panes report status and resume again. Fixes#31832. Supersedes #31834.
Co-authored-by: David De Smet <2607383+daviddesmet@users.noreply.github.com>
* fix(orcad): a liveness probe must not mistake its own shell for orcad
After a host or distro restart the recorded orcad PID is free, and the probe's own
'sh -c' (whose argv carries the slot paths) can be given it. The argv identity
check matched and kill -0 succeeded, so the probe answered LIVE: a wake skipped
the start and the next probe found nothing. The probe now treats its own PID, or
any shell process, as a reused PID.
* fix(orcad): only the probe's own shells count as reused, never another shell
Review: an older launcher (before exec) recorded a shell that waits on a live
orcad, and the setsid launch path holds an sh between the PID write and exec;
calling every shell reused turned those into DEAD, which permits GC and relaunch.
Only the probe's own shell and the shell wrapping it are now excluded.
* test(orcad): drop an unused parameter
* test(orcad): reap the waiting shell's node child after the liveness test
* test(orcad): keep the wrapping shell alive so the parent-PID case is exercised on dash
* fix(agent-status): keep Codex /side threads from taking over the pane after a restart
The /side guard keyed on transient caches (the parsed rollout and the
listener's own row), so after an Orca or relay restart a side thread's
hooks claimed the pane and its Stop showed the pane finished while the
main turn was still running. Decide by the stored row's thread instead,
on the execution host and again when a relayed hook arrives.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(agent-status): recognise Codex side threads on the execution host only
Record ephemeral forks from their own SessionStart and fall back to the stored row's thread, so the host keeps dropping /side hooks across restarts without a desktop rule that cannot tell a fork from a new conversation.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(agent-status): keep a running Codex turn when a relay restarts mid side chat
A relay that restarts during a /side chat has lost the fork's SessionStart, so the desktop keeps a running main turn against the rest of that thread's relayed hooks. Any relayed SessionStart still passes, so a new conversation can replace the row.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(daemon): report input to a live terminal whose PTY stopped taking it instead of claiming delivery
* fix(daemon): treat master-retiring errors as closed input on every platform; keep same-incarnation close across spawn reply
* fix(daemon): track closed input per incarnation so a replacement's early report survives its spawn reply
* fix(ssh): move a managed tunnel off an unbindable saved local port
When the saved local port of a managed SSH server's tunnel can no longer
be bound (EADDRINUSE from another listener, or EACCES from a Windows
excluded port range), pick a free port, save it with the pairing and the
deployment link together, and build the tunnel again. The pairing, server
identity, remote port and projects are unchanged.
A move interrupted between its sidecar and store writes reads the moved
port from the stored endpoint, so the deployment link is never dropped.
Fixes#33182
Co-authored-by: SahilZ0810 <sahil@zamp.ai>
* test(e2e): drop squatter connections so teardown never waits on them
* fix(ssh): move the tunnel port before pairing repair and retire old transports
Move outside buildWithPairingRepair so a refusal on the moved port is
repaired against the revision the move saved, and retire transports and
status owners still dialing the old port, as a repair does.
* fix(ssh): refresh maintenance pairings on the current tunnel port
Update, rollback and restart reread the deployment's local port when they
save the new pairing, since the census before them may have moved it.
* fix(ssh): let concurrent tunnel builds follow a port another caller moved
---------
Co-authored-by: SahilZ0810 <sahil@zamp.ai>
A workspace created from the composer on a paired host launched its terminal
agent with the desktop's command, arguments and environment. Hosts that
negotiate agent.launch.v2 now get startupAgent (and startupPrompt) and build
the launch through their own planner and settings. Older hosts, failed probes,
hosts that predate the agent's name, linked-item drafts and VM recipes keep the
desktop-built command. When the host planned the launch, the window never runs
the desktop plan or its own agent recovery, and says it couldn't confirm the
agent started if the host reported no agent terminal.
* fix(new-workspace): read Linear status on the host the search runs on
Create worktree searched Linear on the selected repo's host but offered the
Linear tab from the focused host's status. With Linear connected only on the
desktop and a project on a paired server, the tab appeared and every search
silently came back empty. The repo-derived Linear source now reads its own
host's status, as explicit sources already did, so the tab and the selected
workspace follow the host that will answer.
* fix(new-workspace): keep Linear status per host while it re-reads, and treat loading as unknown
The host status read is keyed by host, not project scope, and a same-host
re-read (repo switch, workspace change, reconnect) keeps the last status
instead of reading as disconnected, so the Linear tab and the chosen mode no
longer reset. While the first read is pending, a chosen Linear tab stays
selected and a pasted Linear URL keeps blocking Enter. The credential-error
test now stubs the repo host's status read it depends on.
* fix(cli): say a headless terminal create found no workspace, not that the window is missing (#33044)
On a windowless `orca serve` host, `orca terminal create` from a folder
outside any workspace failed with "No renderer window available". The
create falls through to the window-only path because no workspace was
resolved, so name that and point at --worktree instead. Behavior is
unchanged; only the error message for this already-failing create.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(runtime): pass the no-workspace message to the window lookup instead of relabeling every error
A catch-all would rename any other window-lookup failure as a missing workspace.
---------
Co-authored-by: tnfhrnsss <tnfhrns@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(runtime): a status publication that answers a pending probe leaves the capability readable (STA-11921)
* fix(runtime): keep negative-capability re-probe across a published replacement; split the test
* refactor(runtime): one compatibility-entry factory keeps the cache under max-lines