Archived sessions keep their stale pending requests in agentState. The
sidebar still treated them as live: the collapsed folder preview forced
them visible and trimmed active sessions to make room, so a folder could
look like it had no active sessions. They also kept the permission/input
attention dot and the "Pending N" badge.
Only count pending requests on active sessions for preview pinning,
attention, and the badge. The state itself is unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): stop session toolbar search/clear overlapping neighbors
Collapsed search used min-w-0 and crushed under shrink-0 icons, so clear
painted over filter/BlockedJumpPill on narrow widths. Keep search chrome
shrink-0 and wrap the header actions group instead (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size session toolbar actions to content so they wrap
flex-1 min-w-0 kept the action group beside a long search chip in a 280px
sidebar, so Browse/Settings overflowed onto the calendar. Use w-max shrink-0
and keep the router header-action cluster from shrinking (#2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep session toolbar icons in one slab when the row wraps
max-w-full plus inner flex-wrap let the action group split, so some icons
stayed beside search and the rest dropped underneath. The group is nowrap
and content-sized, so a tight row moves every icon together.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): shed secondary session toolbar icons instead of wrapping
Quest was burning a full toolbar line when the icon slab did not fit
beside collapsed search. Keep one nowrap row; hide browse/settings (and
similar headerActions) below a container-query width, with a higher bar
when the query chip is present (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): satisfy typecheck for session header-row test
CI failed because optional chaining left headerRow possibly null when
reading className. Use getByTestId instead.
* fix(web): put shed session toolbar actions in a More menu
Hiding headerActions on narrow headers removed Settings with no
alternative. Keep one toolbar row, but surface secondary actions in an
accessible ⋯ popover below the container-query threshold (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fold primary toolbar into More when query chip is active
At ~320px the collapsed chip plus unread/filter/New still overflowed past
More/Settings. When searchQuery is non-empty, put primary controls in the
same overflow menu so the row is [chip][⋯] (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pack session toolbar actions beside search
ml-auto shoved the action slab to the right edge and left a Quest-width
gap between the search icon and BlockedJumpPill (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): widen machine menu clamp and width-gate toolbar More
MachineFilterMenu near the left edge (inside More) got a tiny right-only
maxWidth. Clamp to the wider horizontal pocket and left-align when that
side wins. Fold primaries into More only when a query chip is present and
the header is under 32rem (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fold toolbar into More on narrow headers without a query
Empty query still forced toolbarCrowded=false, so at 320px / 130% font
with machines+unread the More control went offscreen. Crowd from header
width in both query states (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): stop Quest-width empty query collapsing to search+More
A flat 32rem crowd gate folded every primary into More on typical Quest
sidebars (~400px), leaving [search][⋯] and a desert of empty space. Empty
query now crowds only below ~22rem (still covers 320px/130% font); query
chip keeps the higher 32rem bar (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pin toolbar More to the trailing edge on Quest widths
Left-packing every action left a dead zone after New while Settings hid
in More. Primaries stay beside search; secondary/More get ml-auto on a
flex-1 actions row so the chrome reads [search][primaries] … [⋯]
(#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): shed session toolbar icons by keep rank
A single width gate either hid the whole row or left a hole before More.
Drop the lowest-priority controls first (browse, mark-all, settings) and
keep search, new, and the unread lens on the row while they fit (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size toolbar shed from actions box, not a search guess
Active query reserved 200px for a max-w-[9rem] chip and collapsed the
row to search+More. Measure the actions flex box and each control;
keep daily icons until width is known (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): remeasure toolbar shed after search collapse
The actions box unmounts while search is expanded, so a one-shot observer
left the next layout on a stale width. Fit math now reattaches on the
new node, subtracts a visible machine menu, and uses the measured More
width and column gap.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): let query chip yield so toolbar More stays on-screen
At 320px and 130% type a long chip plus calendar left too little room
for the machine menu and More. An active query now uses min-w-0 on
search and shrink-0 on actions so only the chip text truncates.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): stop desktop toolbar using an infinite shed budget
Empty-query first paint treated the actions gutter as infinite, so every
shrink-0 icon stayed inline and painted past the split. Use a finite
fallback, cap the budget by the real row gutter, and give the sidebar
min-w-0 so --sidebar-w actually constrains the flex item.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): use one-gap slack instead of leaving an icon-sized hole
The shed refused the next icon when the gutter was only a column-gap
short, so the row kept an empty slot the size of that icon.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fill the toolbar gutter before opening More
The shed was budgeting a guessed remainder, so More stayed open while
a wide empty stretch sat before the split. The budget is now the pixels
from the actions' left edge to the row's right edge. If every icon fits,
overflow is empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size the toolbar budget from the flex leftover, not the icons
shrink-0 made the actions box as wide as whatever was already showing, so
search lost its clear and calendar and a collapsed query could hide every
action. The actions box is flex-1 again, and a query chip cannot shrink
below its icon, clear, and calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): reserve machine menu and More before search width
A long 9rem query chip at 320px / 130% left the actions box under 50px
while machine menu + More need ~94px, so More slid off-screen. Cap the
collapsed search group to the row minus those trailing controls.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(voice): configurable ElevenLabs max conversation duration
Auto-created ConvAI agents never set conversation.max_duration_seconds, so
ElevenLabs applied its 600s default and cut voice conversations off.
Add ELEVENLABS_MAX_DURATION_SECONDS (default 1800, validated 60-7200). The cap
is applied when the hub creates an agent and reconciled onto existing
hub-managed agents (found by name) once per process. Operator-supplied agents
(ELEVENLABS_AGENT_ID / voice map / customAgentId) keep their own cap. Invalid
values fail the token request with a clear message instead of an API 422.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* feat(voice): Settings UI for ElevenLabs max conversation duration
Extend the hub-owner credentials panel (Settings -> Voice -> manage
providers) with a numeric field for ELEVENLABS_MAX_DURATION_SECONDS,
reusing the same env-wins-over-Settings overlay that already backs the
ElevenLabs API key field. A Settings save takes effect on the very next
token request in the same process; the env var still requires a restart.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): reconcile max duration on cached agents, not just once per process
Codex review on heavygee/hapi#230 caught this: getOrCreateAgentIdForVoice's
agentIdCache short-circuited before ever reaching ensureAgentOverrides, and
ensureAgentOverrides's overridesEnsuredAgents gate was a one-time-forever
marker that didn't account for the configured duration changing after the
first reconciliation. Together, any voice already used once in the hub
process would keep its old cap until a restart, contradicting the Settings
UI's 'takes effect immediately' claim.
Track the last-applied max duration per agent and compare it against the
live setting on every call, including agentIdCache hits, so a Settings
change reaches an already-cached agent on its very next token request.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): address tiann/hapi#2017 review findings
- Fix TS2769 typecheck failure: test assertions casting the credentials
response narrowed the type to omit 'editable', which upstream CI's
bun typecheck catches and this fork's (currently broken) CI did not.
- An invalid ELEVENLABS_MAX_DURATION_SECONDS env value threw out of
buildMaxDurationStatus(), which is shared by every provider's
GET/PUT credentials status — breaking visibility into OpenAI/Deepgram/
Groq settings too, not just ElevenLabs. The token-issuance hot path
(getConfiguredElevenLabsMaxDurationSeconds) still throws there by
design; only the status/update endpoints now degrade gracefully.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): interpolate duration range in the Settings hint
Nit from tiann/hapi#2017 follow-up review: the hint under the max-duration
field rendered the literal '{min}-{max}' placeholders instead of '60-7200'.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* feat(web): add Russian locale
- Add web/src/lib/locales/ru.ts
- Register 'ru' in the locale registry, i18n context, language switcher and settings
- Format outline timestamps with the day.month convention for 'ru'
* feat(web): detect initial locale from Telegram and browser
The Telegram Mini App runs in its own web view, so a language chosen in
the browser does not carry over and the app always opened in English.
Pick the first session locale from an explicit stored choice, then the
Telegram user's language_code, then navigator.language, then English.
* feat(web): translate the newly localized strings
Russian values for the keys added by the hardcoded-string fix (see the
separate web i18n PR). Keeping them on this branch so the locale PR is
complete when both land.
* feat(web): add Russian for the newly localized strings
* fix(web): recheck the Telegram language after a slow SDK load
* fix(web): keep a manual language choice over late Telegram detection
---------
Co-authored-by: mrudy <mrudy@local>
* feat(web): add Ctrl/Cmd+Shift+D dictate keyboard shortcut
Toggle composer dictation or voice assistant via the same path as the
mic buttons, with dialog/input guards and tooltip documentation.
Fixestiann/hapi#1900
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): address dictate hotkey bot findings on PR 1902
Ignore key repeat on the session listener and align assistant-mode
invoke guards with UnifiedButton send mode (canSend).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(kimi): discover configured models dynamically
* fix(kimi): address dynamic model review findings
Probe settlement (Major): parse `kimi provider list --json` on the child's
'close' event instead of 'exit', so stdio is fully drained first, and run the
parse inside a guarded settle() helper. A parser throw previously escaped the
promise from an async event callback and reached the runner's uncaughtException
shutdown handler instead of listKimiModelsForCwd's catch. The helper also
settles the promise exactly once across timeout, spawn error and close, names
the terminating signal when there is no exit code, and tolerates a kill() that
throws.
Running-session discovery (Minor): add a session-scoped Kimi catalog. The
shared RPC method `listKimiModels`, a CLI handler registered on the session
connection with the probe cwd taken from the session metadata (never from RPC
arguments), hub rpcGateway/syncEngine pass-throughs, and
GET /api/sessions/:id/kimi-models gated on an active session of flavor `kimi`.
SessionChat now reads the catalog from that endpoint, so an active session
advertises its models even when no background runner holds the machine socket.
New Session keeps the machine-scoped discovery unchanged, and model switching
still goes through the existing ACP set_model path.
* fix(kimi): register session model discovery before the local/remote loop
The session catalog handler was registered inside the remote launcher, after
ACP initialization. A session started with `hapi kimi` in local mode therefore
answered the web picker's request with an unregistered-handler error, and the
failed query was not retried or invalidated when remote control was taken over.
The catalog is backend independent, so it is now registered in runKimi session
setup alongside the other session-level handlers, using the session's working
directory, and the late registration in the remote launcher is removed. This
covers local, remote and resumed sessions on the same session connection.
Adds cli/src/kimi/runKimi.test.ts asserting the handler is registered after
bootstrap and before the loop, and that the probe cwd is the session's working
directory. The launcher wiring assertion moves out of
kimiRemoteLauncher.test.ts with the registration.
* feat(web): setting to open external links in a new tab
Left-clicking an external http(s) link renders it with no explicit
target, defaulting to _self. In an installed/standalone PWA this gets
intercepted by the browser and shown as a captive in-app overlay
instead of leaving the app; in a normal tab it navigates away from
HAPI entirely. Right-click already opens in a new tab, but that's not
a fair ask for every link.
Add an opt-in Settings > Display > Links toggle that forces
target="_blank" rel="noopener noreferrer" on genuine external http(s)
links only (relative/SPA hrefs and other schemes like mailto are left
untouched). Defaults to off, preserving today's behaviour, since the
overlay may be intentional/liked by some users.
Vibe Coding disclosure: implemented with Claude Sonnet 5 (Claude Code).
* fix(web): simplify link-toggle setting description per review
The description mentioned the target attribute (an implementation
detail, not user-facing) and asserted the in-app overlay is what every
installed PWA shows, which was only actually confirmed on Chromium/
Edge. Describe the user-visible behaviour only, without promising a
specific rendering across all installed-app environments.
* fix(web): stop promising specific behaviour when installed as an app
Per follow-up review: describing the setting as "always" opening links
in a new tab "even when installed as an app" still guarantees a
specific outcome across every browser's PWA handling, which isn't
something this change can verify or promise. State only what the
setting does (open in a separate tab), not how every installed-app
environment will render that.
---------
Co-authored-by: Ian Stevenson <is@lockhouse.systems>
* fix(cursor): pin Auto to CLI --model auto instead of account default
Selecting Auto was treated as unset/ACP default[] after the Default→Auto rename. Persist and spawn `auto` so the picker matches `agent --list-models`.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): keep live ACP model until Auto actually applies
Pin session.model to auto for the next `--model auto` spawn, but do not claim the live backend switched when ACP only advertises default[].
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): reject live Auto unless ACP auto or spawn confirmed it
Do not persist Auto when the running catalog has no auto option. Clear the spawn-auto flag after a concrete model apply so Auto → concrete → Auto cannot fake a live switch.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): keep ACP wire ids spawn-safe and apply effort
Cursor's parameterized picker (`clientCapabilities._meta.parameterizedModelPicker`)
advertises bare model bases plus separate `fast` / `thought_level` config options.
`buildCursorModelsSnapshotFromAcp` synthesized every base as
`${base}[fast=${fast}]`, and Cursor rejects any bracket id whose parameter set is
not that model's complete set, so 74 of the 76 published picker ids failed at ACP
`session/new` with `Cannot use this model` (#1818).
- Stop synthesizing wire ids: keep the advertised values verbatim (bare bases are
accepted by `--model`, defaults applied). Parameter sets differ per model, so a
synthesized id cannot be validated from the options alone.
- Flag such catalogs as `parameterized` so variant CLI skus still attach to their
base rows — the ACP apply path expresses them through config options.
- Apply effort in `applyParameterizedCursorModel` (ids `effort`, `reasoning`,
`reasoning_effort`), applied against the config options Cursor re-advertises for
the newly selected model, and skipped on models without that axis.
- Match CLI sku bases across Cursor's legacy `cursor-` family prefix
(`cursor-grok-4.6-high` → ACP base `grok-4.6`) so those variant rows are not
dropped.
- Version the shared on-disk catalog cache (v2) so runners that already cached the
rejected synthesized ids re-probe instead of serving them.
Verified with live `cursor-agent 2026.09.10-fd3934a` ACP runs: the catalog no
longer contains bracket ids, every advertised base and sampled variant sku spawns,
and in-session `claude-opus-4-8-low` / `cursor-grok-4.6-low` set the matching
`effort`/`reasoning` option.
* test(cursor): cover aliased effort ids and thinking coexistence
Covers the branches the HAPI bot review called out for #1818: the
`xhigh` → `extra-high` alias on `gpt-5.5` (`reasoning`), the
`reasoning_effort` id used by `gemini-3.8-flash`, and switching effort on a
model that also advertises Claude's separate `thought_level` `thinking`
toggle.
* test(cursor): cover legacy sku base and post-switch effort option
Adds the two apply-path cases the HAPI bot review called out for #1818:
resolving `cursor-grok-4.6-high` onto an advertised `grok-4.6` base, and
reading the effort option Cursor advertises for the newly selected model
(`reasoning` for gpt-5.5 → `effort` for claude-opus-4-8) instead of the
previous model's options.
* test(cursor): create the cache dir in the migration test
The version-envelope test wrote $HAPI_HOME/cache/cursor-models.json without
creating the directory, so running it alone (or filtering with -t) failed with
ENOENT before the assertion; it only passed because sibling tests in the same
file had already created the path. Reported in the HAPI bot review of #1822.
Verified: `vitest run ... -t 'ignores pre-versioned'` → 1 passed, whole file → 4 passed,
and every other changed test file passes when run standalone.
* Clarify Cursor Auto live-switch limitation in session pickers
* Reconcile partial Cursor model changes before acknowledging Auto
* Serialize Cursor model changes across clients with the existing async lock
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): reconcile keepalive-only sessions to an idle lifecycle
`sessionCache.expireInactive` only expires a session when `activeAt` goes
stale, and `session-alive` refreshes `activeAt` every ~2s for as long as the
CLI socket lives. A session whose agent has done nothing for days therefore
stays `active: true` / `lifecycleState: running` forever — socket pinging was
being read as agent health.
Add the missing second signal. `active` keeps its meaning (the CLI is
reachable); `metadata.lifecycleState` moves `running` -> `idle` once the hub
has seen nothing but keepalives for `HAPI_SESSION_IDLE_TIMEOUT_MS` (default
12h, `0` disables), and back to `running` on the next real progress.
`active` is deliberately not flipped: the socket really is up, and a false
`active: false` would let `resumeSession` spawn a second agent against a live
process and would unlock dedup-merge / delete on it — the #1705 class of bug.
- `sessionCache.recordAgentProgress` tracks a progress clock that keepalives
can never touch: messages in either direction, queued prompts, background
tasks. Seeded from `updatedAt` so a hub restart re-derives it from disk.
- `reconcileKeepaliveIdle` runs on the existing 5s inactivity tick. Sessions
that are thinking, running background tasks or holding a pending permission
request are never marked; `metadata.idleReconcileExempt` opts out entirely.
- Only real progress clears `idle`, never `thinking` — Cursor ACP emits
ambient idle/running churn (#1553) that would otherwise flap the lifecycle
every tick.
- `isLiveLifecycleState` replaces the `=== 'running'` literals so an `idle`
row does not read as dead to the archive route or the Cursor migrator.
- Web splits keepalive-only sessions into their own session-list bucket.
Refs #1820. Distinct from #842 (`active=false` with a stale `running` badge);
this is the inverse.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(hub): seed the idle clock from stored messages across hub restart
PR review on #1821: a cold `SessionCache` had no observed progress and fell
back to `updatedAt` alone. Assistant messages deliberately do not move
`updatedAt`, so a session that was streaming output a minute before a hub
restart read as hours idle and got marked on the very next tick — a false
`idle` that persisted until the next progress event.
Seed the clock once from `getNewestMessagePosition`, which is the durable
record of that output. `updatedAt` stays a floor on top of the seed: todos /
teamState / agentState writes bump it without routing through
`recordAgentProgress`.
Regression test recreates a SessionCache over the same store after recent
assistant output and confirms keepalives no longer cause premature idle
classification — and that the same session is still marked once the output
itself goes stale.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: cover the socket wake-up path and the idle session-list bucket
Follow-up review on #1821 reported no findings but flagged two uncovered
paths. Both have bad failure modes, so close them.
- Hub: an assistant message arriving through `registerSessionHandlers` must
wake an idle session. Assistant messages never bump `updatedAt`, so the
`onAgentProgress` hook is the only thing that can clear the mark — a
refactor dropping that wiring would silently false-idle every actively
working session, and nothing would have caught it.
- Web: extract the inline bucketing from the `runningSessions` useMemo into a
pure `bucketRunningSessions`, matching how the rest of SessionList's logic
is exported and tested. Covers idle vs quiet-but-ready separation, no
double-bucketing, the transition back out of idle on thinking/background
task/pending request, and the disconnected/pinned skips.
No behaviour change.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: cover the idle lifecycle at both isLiveLifecycleState call sites
Third review round reported no findings but named the two guards this PR
changed as uncovered. Both protect against reading a keepalive-idle row as a
dead one:
- Cursor migrator: an idle-marked row still has a live CLI behind it, so
preflight must refuse it without `forceArchiveRunning`. A regression here
would let the migrator transplant the ACP session directory out from under
a running Cursor runner.
- Archive route: once a keepalive-idle session finally loses its socket it
must stay archivable, rather than being stranded behind a 409.
Both tests were confirmed non-vacuous by reverting each guard to the
`=== 'running'` literal; each then fails.
No behaviour change.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: stop the restart regression leaking its temp DB on failure
The file-backed store's `rmSync` was a trailing call, so any failing
assertion above it skipped cleanup and left a temp DB behind — one per failed
run. Noticed while verifying the test was non-vacuous, which deliberately
fails it.
Wrap in try/finally. Verified: a forced failure now leaves zero
`/tmp/hapi-1820-*` directories.
Test-only.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): keep pin dividers coherent under search relevance sort
Address tiann's #1842 review: preserve project-pinned sections when
ranking, drop ineffective single-term IDF, add title phrase bonus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): add component regression for search pin dividers
Render tiann's four-session repro under query "home" and assert the
session list draws at most one project pin divider.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): rank session list search by field weight and IDF
Boolean match + recency buried distinctive title hits under ubiquitous
path segments like /home/. Keep paths searchable; score title higher and
down-weight terms that match most of the corpus. Boundary match so home
does not hit homelab.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): use boundary match as search score bonus, not a gate
sessionMatchesQuery is shared with @-mentions and the share picker; keeping
substring inclusion restores as-you-type. Boundary affinity multiplies the
field×IDF score instead. Path parts stay separate for wildcards; match set
is explicit so filtering never reads score > 0.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pick search field hit by post-bonus contribution
Raw field weight could beat a lighter boundary hit that scores higher
after the ×1.75 multiplier. Compare contribution after the bonus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): drop invalid updatedAt from summary fixture
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): force-dismiss indeterminate queued rows on cancel/edit
When cancel returns busy for an already-indeterminate QueuedMessagesBar
row, keep the optimistic removal instead of re-sticking the row so Edit/X
cannot leave the UI dead. Upgrade busy to invoked when queued-state
reconcile shows the steer already landed, so Edit toasts instead of
prefilling a duplicate.
Fixes#1839
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): never prefill Edit when queued cancel returns busy
Indeterminate rows can still be a live dispatch. Prefilling on busy
risked a duplicate send if the original steer later landed. Keep
force-dismiss for Cancel; toast on Edit+busy without restoring the draft.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep force-dismissed indeterminate rows for late consume
Hide abandoned outcome-unknown rows from the queued bar via
queueDismissed so Cancel/Edit still clear the stuck chip, while leaving
the message in the window for a later messages-consumed SSE.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): close queueDismissed races on cancel busy and requeue
Restore the hidden hold before getQueuedState so concurrent consume
SSE can land, skip overwriting it in onSuccess, and clear dismissal
when markMessagesRequeued confirms FIFO again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): undismiss force-held row when cancel busy finds FIFO
If getQueuedState reports the localId is queued again after a failed
steer, clear queueDismissed so Edit/Cancel return without prefilling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): preserve queueDismissed across refresh and merges
Force-dismiss of indeterminate rows survived Cancel, but latest-page
refetch and same-ID merges dropped the client-only flag. Carry it while
the authoritative row remains uninvoked and indeterminate.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): clear queueDismissed on reconnect FIFO reconcile
Reconnect queued-state reconciliation now markMessagesRequeued for
confirmed queuedLocalIds so force-dismissed rows regain Edit/Cancel
after a missed messages-requeued SSE.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Revert 8357da0a9d and its later shared-runtime integration.
Remove automatic model fallback, account usage polling, and the related UI, protocol fields, tests, and documentation without adding replacement quota handling.
Keep generic model pagination and method probing required by the current shared-session architecture. Cover idle sessions staying online without usage polling or agent-state churn.
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.
Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.
Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
Add a right-click (desktop) / long-press (mobile) menu to file entries on
the Files page: git change rows, file search results, and the directory
tree. Actions:
- Copy path (workspace-relative)
- Copy absolute path (resolved against session.metadata.path)
- Add to composer (append a backticked reference to the session composer
draft and navigate back to the chat)
Closes#1807
* fix(agy): say what the model picker is actually waiting on
The spinner in the New Session AGY picker read "Checking Antigravity
authentication…", but nothing at that point checks authentication — the
machine is running `agy models`, and the sign-in prompt is a separate
branch below it, shown only when agy reports the failure.
Name the wait after the work: "Fetching available models…", the same
words agy prints while it fetches.
* refactor(agy): describe a probe by its outcome, not by its response
The probe function returned a finished `AgyModelsResponse`, so "agy could
not be reached" and "agy listed no models" both arrived as a successful
response carrying the hardcoded mirror, and the caller could no longer
tell which had happened. Every policy decision about that answer has to
live inside the probe as a result.
Hand back what the probe observed — a live catalog, an auth failure, or
nothing usable — and let the caller turn it into a response. Same
behaviour: the mirror still stands in for both failure modes, and the
60s cache still holds whatever came out.
* fix(agy): serve the model catalog stale-while-revalidate
The `agy models` probe is a whole agy invocation — around 3s on a good
day, 15s when it times out — and the 60s window meant the New Session
picker paid that again a minute after the last look.
Keep the last listing agy actually returned and answer from it: fresh for
ten minutes, then still answered while a probe refreshes behind it, until
the entry is a day old and stops standing in for the machine at all. A
probe that times out or loses auth leaves that entry alone, so a blip no
longer empties a working picker, and the hardcoded mirror is no longer
recorded as if the machine had reported it.
Three things fall out of that and are handled here. A machine whose
sign-in has actually gone bad would otherwise look healthy for a day, so
an auth failure rides along with the catalog it can still serve — and,
because nothing else would re-probe a catalog that is still fresh, a
warning riding on the answer is itself a reason to look again. A failed
probe is not repeated on the very next request either, or a machine where
agy hangs would spawn it once per poll.
An explicit refresh always costs a probe, and never rides one that was
already running when it was asked for.
* fix(agy): let Retry force a fresh model catalog probe
With the catalog held for ten minutes, Retry would otherwise hand back the
answer it was pressed to replace, so the intent travels to the machine:
`?refresh=true` on the machine route, an optional RPC param, and a
one-shot flag on the query so ordinary mount and focus refetches stay
cheap. Every hop is optional, so a hub and a runner on different versions
still talk — the older side ignores it and answers from its cache.
Retry also has to be reachable, and honest, in the state that needs it.
The machine now answers with both a usable catalog and the sign-in failure
behind it, so the picker keeps the list and says why it may be out of
date, with the button right there rather than only once there is nothing
left to show. Pressing it runs agy, which can take tens of seconds, so the
button says so while it does.
The client contract covers both: `agy-models` is the one catalog route
that can carry an `error` on a successful response, and the one that
takes a refresh parameter.
* fix(agy): use the machine catalog in the in-session model picker
New Session already asks the machine what `agy models` lists, but a
session that is already open offered the built-in list in
`shared/src/models.ts`. That list is a hand-maintained mirror, so a model
agy started offering after the last release could be picked for a new
session and not for the one already running.
Point the composer at the same machine catalog. The mirror stays as the
fallback for the moment before the machine answers, and a model the
session is already on is kept selectable — and readable, when it is one
of the known presets — even after the catalog moves on without it.
* fix(agy): announce a model catalog re-check that changed the answer
Serving the last known catalog answers the picker instantly, but a picker
that was already open kept showing that answer until the user closed and
reopened it — the machine had no way to say it had found something newer.
Say it on the stream that already carries machine changes. The machine
daemon — the only process that answers `<machineId>:listAgyModels` —
emits it, and the hub forwards it as `machine-agy-models-updated` with
nothing but the machineId. Namespace resolution, per-machine delivery and
reconnect replay all come from the existing path.
What counts as a change is what the route would answer, not what sits in
the cache. That distinction carries the cases: a sign-in that lapsed or
came back changes no models yet changes what the user is told; a machine
whose agy was signed out has been answering from the hardcoded mirror, and
its first real listing is the largest change there is, for every client
except the one awaiting it.
* fix(agy): re-read the announced machine's model catalog
On `machine-agy-models-updated`, cancel and refetch that one machine's
catalog query — the app's global connection is always subscribed, so an
open picker redraws wherever it is.
Cancelling first is what makes it correct rather than merely likely.
query-core cancels an in-flight fetch only when the query already holds
data, so a picker opening for the first time would otherwise join the
request already on its way and settle on the listing the announcement
replaced. The refetch is answered from the machine's cache, so it starts
no probe and cannot bounce another announcement back.
A reconnect the hub could not replay takes the resync path, which clears
the agy catalogs the same way — that path has no announcement to fall back
on, so it is the one that can least afford to join a stale request.
* fix(agy): keep a model the user picked when the catalog moves under them
The catalog can now change while the New Session form is open, and the
form dropped any selection the machine no longer advertised — including
one the user had just made.
Keep that one, and list it as no longer listed so the form does not imply
agy is still offering it. A model restored from a draft or a saved
preference is still dropped: it may never have been runnable here.
* fix(agy): announce uncached authentication changes
* refactor(web): route create-form permission control through one native-select predicate
Extract usesNativePermissionSelect(flavor) (grok || codex-family, matching
the existing iOS/Android predicate of the same name) and route
PermissionField's select-vs-toggle gate through it instead of an inline
condition. Rename the codex-family-only state codexFamilyPermissionMode to
nativePermissionMode since it now backs a shared predicate, not just the
codex family. Behavior is unchanged: any stale sessionStorage draft written
under the old codexFamilyPermissionMode key has no value under the new key
and falls back to 'default', which only matters within a single browser
tab's lifetime.
* feat(web): let Claude pick a permission mode when creating a session
Claude was the only create-form flavor still on the global HAPI YOLO toggle
while grok and the codex family got the native permission select, so there was
no way to start a session in Plan Mode without creating it first and switching
the mode from the composer. usesNativePermissionSelect now gates the control
for claude as well, and the spawn body carries permissionMode (including
'default') instead of yolo, which is the shape the other native-select flavors
already send.
The stored hapi:newSession:yolo preference is bridged into the select rather
than dropped, but only for the flavors that have actually moved onto it
(LEGACY_YOLO_BRIDGE_AGENTS = codex, claude). copilot, gemini, kimi and opencode
moved earlier and settled on 'default'; re-enabling Yolo for them now would
widen permissions rather than migrate a preference. This narrows the
sessionStorage draft bridge too, which until now fired for the whole codex
family with no allow-list, so their draft restores yield 'default' instead of
'yolo' — same-tab-lifetime state only.
Claude and the codex family share one nativePermissionMode state and their mode
sets do not overlap, so the existing agent-change reset plus the flavor filters
in the draft loader and the stored launch settings are what keep a codex mode
out of a Claude spawn. Adds the regression test that pins it: pick a mode under
codex, switch to Claude, create, assert the payload carries 'default'.
* feat(ios): let Claude pick a permission mode when creating a session
Extend usesNativePermissionSelect to include claude alongside grok and the
codex family, matching the web change. buildSpawnRequest now derives both
yolo and permissionMode from that single predicate instead of two separate
local flags, so claude sends permissionMode (including 'default') and no
longer sends yolo. Unlike web, iOS carries no persistent YOLO preference
across sessions to migrate — the toggle only lives in the in-memory form or
a draft deleted on success — so there is no bridging logic to add here.
* feat(android): let Claude pick a permission mode when creating a session
Extend usesNativePermissionSelect to include claude alongside grok and the
codex family, matching the web and iOS changes. buildSpawnRequest derives
both yolo and permissionMode from that single predicate, so claude sends
permissionMode (including 'default') and no longer sends yolo. Unlike web,
Android has no persistent YOLO preference to migrate: the toggle only lives
in the form draft, which is deleted once a session is created.
The agent-switch test asserted claude renders the YOLO toggle; it now checks
the native select for claude and keeps the toggle assertion on cursor, which
still carries it.
* fix(acp): carry the live reasoning marker on the wire payload
ACP agents stream thoughts a token at a time, so the handler coalesces
them into a buffer and re-sends the whole buffer under a stable stream
id every 250ms. The converter dropped the marker that says a payload is
one of those throttled snapshots, leaving the hub unable to tell a
replaceable snapshot from the settled message that closes the stream.
Mirrors how the text variant already forwards streamSnapshot.
* fix(hub): keep one stored message per reasoning stream
OpenCode reasoning arrives as a series of growing snapshots sharing one
stream id, and every snapshot was persisted as its own message. A 26h
session reached 48,844 rows and 63MB, and because the web budgets a
fixed number of messages, its 400-message window covered barely three
minutes of conversation — scrolling up walked through duplicate
snapshots instead of history.
Retire a stream's earlier live snapshots once their replacement is
stored. Sweeping only after the insert matters: the two statements are
separate transactions, so clearing first would leave a window where a
crash takes the whole stream. Only rows marked live are eligible and the
replacement is spared, so a stream always keeps at least one row and the
settled message that closes it is never removed.
Live rendering is unchanged: the web still receives every snapshot and
already folds them by stream id.
* fix(web): spend the message window on conversation, not repeated snapshots
The window budgets raw messages, but a reasoning stream renders as a
single folded block no matter how many snapshots it arrived in. On
sessions recorded before the hub started retiring them, those snapshots
fill the window on their own: in one 26h session the newest 400 messages
covered 202 seconds, so scrolling up paged through duplicates instead of
history.
Collapse each stream to its newest snapshot before trimming. Rendering
is unchanged — the timeline already folds them by stream id — and rows
without a stream id are never touched.
* fix(ios,android): port reasoning-snapshot compaction to the native windows
The window logic in HapiProtocol and :core:protocol is a one-to-one port
of the web store, so collapsing superseded reasoning snapshots only on
the web left the native windows budgeting raw snapshot rows. The hub
stores one row per stream now, but a client that already holds the older
snapshots still spends its window on them.
Add the same stream-id reader and compaction to both ports, in the shape
each already uses for agent-run rows, and pin the behaviour with a
pagination fixture. Both fixture suites enumerate shared/fixtures/pagination
from disk, so the ports cannot drift from the web again without CI saying
so.
* feat(shared): steer capability gates and live steered signal schemas
- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which
agents can deliver queued messages into the active turn (pi, codex,
cursor ACP; legacy stream-json cursor excluded)
- AgentState.steeringActive, DecryptedMessage.steered and
messages-consumed live signal (never persisted by the hub)
* feat(cli): queue reservations and steered messages-consumed option
- MessageQueue2 gains takeByLocalId/restoreReservation/
beginReservationDispatch/commitReservation so an async steer can reserve
a queued row without racing the main loop's turn/start drain
- emitMessagesConsumed accepts steered: true to mark mid-turn delivery
* feat(codex): mid-turn steer via app-server turn/steer (#888)
- CodexAppServerClient.steerTurn + TurnSteerParams/Response types
- CodexRemoteLauncher registers the steer-queued-message RPC handler:
reserves the queued row, validates it against the active turn (no
control commands, matching mode hash), injects via turn/steer with an
epoch guard that invalidates in-flight steers on abort/cleanup
- steeringActive agent state tracks the active-turn window
- hub syncEngine gate opens to codex; messages-consumed relays steered
* feat(web): Steered badge and steer gating for codex sessions
- HappyUserMessage shows a ↳ Steered badge fed by the live
messages-consumed steered signal, preserved across server echoes and
refetches (mergeMessages carries the optimistic marker)
- SessionChat gates canSteer via isSteeringSupportedForSession instead of
the pi-only check
- clearStaleQueuedStatus normalizes a queued status on an invoked message
- fix(web): drop duplicate showSessionSummaryInChat in markdown test
(upstream typecheck breakage)
* fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer
- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise
codex and pi only; cursor joins when its soft-steer handler lands (#1609)
- turn/steer now splits dispatch (stdin accepted) from completion (turn
finished): the hub RPC acks once dispatch succeeds — never on the
concurrent turn's completion, which can exceed the 30s RPC window
- queue row commits only after the turn settles; a rejected/aborted steer
restores the row so the message still delivers via turn/start, and a
dispatched steer is never restored (no duplicate delivery)
- steer carries clientUserMessageId (echoed as userMessage.clientId) so
ambiguous transport failures can reconcile the thread later
- client tests cover dispatch/complete split and stdin-write failure
* fix(codex): reconcile dispatched steers before restoring; align error copy
- A dispatched turn/steer whose completion fails (disconnect / protocol
error) is now reconciled via thread/read by clientUserMessageId before
the queued row is restored — the instruction is only re-delivered by
turn/start when the thread never received it
- Reconcile targets the pinned steer thread, not whichever turn is
current when completion fails
- syncEngine unsupported-flavor error now matches the capability gate
(Pi and Codex only until the cursor handler lands)
- launcher tests cover steer success (ack on dispatch), reconcile-accepted
and reconcile-rejected outcomes
* fix(codex): consume the row at dispatch; drop background reconcile
- The hub RPC acks and the queue row is consumed as soon as stdin accepts
turn/steer; completion is background-only logging. A dispatched steer is
never restored, so the same localId cannot be re-delivered via turn/start
after the caller was told the steer succeeded
- Dispatch failure (stdin write error) still restores the row and reports
failure
- steer.completed rejection is always handled (no unhandled rejection on
the dispatch-failure path)
- tests updated: completion failure after dispatch keeps the row consumed;
dispatch failure restores it
* fix(codex): distinguish definite rejection from indeterminate completion
- Transport-level failures (timeout, abort, disconnect, spawn, protocol)
carry an indeterminate marker; explicit JSON-RPC error responses do not
- After a dispatched steer, turn completion resolves → commit + consumed;
a definite app-server rejection restores the row (instruction was never
accepted, so turn/start cannot duplicate it); an indeterminate outcome
leaves the row reserved so it can never be delivered twice
- Completion handling registers before awaiting dispatch so the
dispatch-failure path cannot leak an unhandled rejection
- client/launcher tests cover explicit rejection (restore), indeterminate
outcome (row stays reserved) and dispatch failure
* fix(codex): reconcile indeterminate steers instead of a permanent reservation
- After an indeterminate completion (disconnect/protocol), reconcile the
thread by clientUserMessageId immediately: accepted → commit + consumed,
provably rejected → restore, still unreadable → keep the reservation and
retry from the main-loop top on later passes (post-reconnect)
- A row never sits in dispatching forever: the hub cannot stamp it invoked
while the instruction may never have been accepted
- tests: indeterminate keeps reserved while thread unreadable; accepted
reconciliation consumes; rejected path restores
* fix(codex): accept all thread item shapes; retry reconcile; ack through abort
- Reconcile matcher accepts userMessage/user_message with clientId/
client_id, matching the shapes the thread parser supports — an accepted
steer can no longer be misclassified as rejected
- A pending reconciliation schedules a wakeLoop retry, so a temporary
app-server outage cannot strand the reservation behind waitForTurnOrRecovery
- The success-path ACK no longer checks the steer epoch: the hub already
reported steered on dispatch, so commit + messages-consumed must reach
it even when an abort resets the queue in between
* fix(codex): reinit reconnected app-server; keep reconcile retries alive
- thread/read after a disconnect auto-connects a fresh app-server, which
must be initialized before any request — reconcile now ensures
connect + initialize (isConnected getter added to the client)
- every still-unknown loop-top reconciliation schedules the next retry,
so recovery without external traffic is eventually observed
- launcher mock gains isConnected
* fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK
- Reconciliation runs on a self-rescheduling 1s timer independent of the
main loop (wakes it too), so idle loops and waitForTurnOrRecovery still
observe app-server recovery; abort clears nothing implicitly — the ACK
path commits and consumes even when the reservation was cancelled
- Absence of a durable client id is ambiguous: unmatched reads stay
'unknown' and keep retrying instead of restoring the row
- CodexAppServerClient tracks initialized state (reset on disconnect/exit)
so ensureAppServerInitialized re-initializes a fresh process before
thread/read; initialize failures leave the flag false for the next retry
- tests: accepted reconciliation via scheduled timer, indeterminate
keeps reserved, explicit rejection restores
* fix(codex): bind reconciliation to the launcher lifecycle
- runSteerReconciliation clears any armed retry timer on entry and never
installs a second one, so loop-top and timer-driven passes cannot
multiply
- shuttingDown is set when the main loop ends: timers are cleared and the
pending map is dropped, so an unresolved steer can never respawn an
app-server after cleanup (remote-to-local switch included)
* fix(codex): report steered only after app-server acceptance
- The handler now awaits steer.completed (the inject-acceptance response):
an explicit JSON-RPC rejection surfaces as failed and restores the row
for the normal turn/start path instead of a false steered
- Transport failure after dispatch reports 'Steer outcome is being
reconciled' and keeps the row reserved while the timer-driven thread
reconciliation runs
- dispatch-failure path also swallows the paired completion rejection
* fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait
- MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching
steer reservation: the hub neither deletes the row nor stamps invoked_at
(new CancelMessageResponse 'busy' status; web restores the optimistic
row); pushIsolateAndClear and reset/close share cancelReservations so
/clear-style commands cannot have a rejected steer resurrect a discarded
prompt
- turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a
lost response is indeterminate and funnels into thread reconciliation
instead of stranding the reservation
- tests updated for the tri-state cancel contract
* fix(codex,web): busy-aware edit flow; bound reconciliation reads
- QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it
never prefills the composer when the row is inside an async steer, so a
second client cannot send a duplicate
- reconcileSteerByClientId bounds thread/read with a 5s timeout so a
connected-but-silent app-server cannot hold the reservation in-flight
indefinitely
* fix(steer): inFlight-dominated cancel acks; bounded reconciliation
- hub cancel-queued-message acks check inFlight before removed: a stale
duplicate socket reporting removed can no longer delete the durable row
while another socket is dispatching the steer
- reconciliation entries expire after 60s and mark delivered: after the
rejection window, a dispatched steer that the app-server never proved
(client ids dropped on restart) is committed instead of polling
thread/read forever
- pre-dispatch failures (abort before write included) never enter
reconciliation — they restore the row and report failure
* fix(steer): persist indeterminate outcomes without replay
* fix(steer): make ambiguous delivery restart-safe
* fix(steer): recover crash-held rows and preserve retry dedup
* fix(steer): ack retries and bound stdin dispatch
* fix(steer): reconcile indeterminate dispatches and serialize retries
* fix(codex): classify stdin callback failures as indeterminate
* fix(steer): recheck indeterminate cancels after ACK
* fix(steer): close retry and abort races
* fix(steer): serialize live retries and abort admission
* fix(steer): distinguish live dispatching from unknown
* fix(steer): keep ACK failures held and reconcile busy cancel
* fix(steer): distinguish held cancel from removal
* fix(store): combine schema v24 migrations
* fix(store): reserve schema v25 for steer delivery state
* fix(steer): keep held cancel state and notify requeue
* fix(steer): release explicitly cancelled unknown reservations
* fix(codex): reject cancelled reservations before native steer
* fix(codex): make reservation restore atomic with state
* fix(codex): terminate abandoned transport writes
* fix(steer): own abandoned app-server lifecycle and consume races
* fix(codex): confirm dispatch and recover abandoned turns
* test(codex): mock abandoned transport callback
* fix(codex): clear visible turn state on transport loss
* fix(steer): claim retries and cover native delivery state
* fix(native): preserve indeterminate state on Android hydration
* fix(steer): make retry claims single-winner
* fix(steer): serialize concurrent retry claims
* fix(socket): tolerate missing steer-state ACK callbacks
* fix(native): serialize retry operations
* docs(web): document unknown steer delivery and retry controls
* fix(steer): handle retry failures and abort-before-connect
* fix(steer): reinitialize after transport loss and finish iOS retry errors
* fix(steer): preserve indeterminate rows across reconnect gaps
* test(web): mock indeterminate queued recovery state
* fix(steer): recover consumed ACK tombstones
* fix(steer): expose consumed cancel tombstones
Two new golden-fixture suites, expectations machine-generated from the web
implementation (same source-of-truth principle as the chat suite):
- shared/fixtures/sse/ (12 cases): session-updated versioned-patch
application via applySessionDetailPatch — strict version gates for
metadata/agentState/todos/teamState, out-of-order arrival, teamState:null
clear, max-monotonic updatedAt, flat last-write-wins fields, sub-minute
activeAt keep-alive drop, scratchlistUpdatedAt trigger, and the pinned
actual behavior that activeTurnStartedAt is NOT applied by the patch path.
Inputs are stored schema-normalized and validated against SessionSchema /
strict SessionPatchSchema at generation time; per-patch applied/unchanged
verdicts are part of the contract.
- shared/fixtures/pagination/ (11 cases): op scripts driving the real
message-window store with a scripted ApiClient — latest page + SSE ingest,
before-cursor older pages, epoch-mismatch reset (window discard + recorded
internal latest request), reset:true replace preserving optimistic rows,
localId echo reconciliation, messages-consumed invokedAt stamping (no
cursor advance), message-cancelled removal, cancel-too-late invoked-row
ingest, hidden-row cursor advance, 400-row trim preserving queued rows,
and queued-state gap recovery. Documents pin the exact requests the store
issued, older-load outcomes, reconcile candidates, and a minimal window
projection (ids/order, queued/optimistic flags, hasMore, epoch, viewMode,
compound cursors).
Generator: web/scripts/fixtures/{sse,pagination}/ extend the K5 framework
(canonical serialization reused; suites pruned of stale files). Web
self-conformance tests replay every fixture against the real implementation
in bun run test:web. README documents schemas, projections, replay
contracts, and native consumption for both suites.
No time injection needed: the store's only Date.now() reads gate
notification throttling, which never reaches persisted or projected state.