Constrain transcript rows to the viewport and replace competing scroll anchors with measured tail corrections. Preserve following through media resizing and reset scroll state when the transcript remounts.
Gate history paging on reader gestures and cover tail-following behavior with seven regression tests.
* fix(web): keep streamed reasoning/text block ids stable across snapshot rows
Streaming snapshots of one stream (pi/codex reasoning and text) arrive as
separate message rows, and the window store retires older rows as newer
snapshots land. The timeline derived the block id from whichever row was
first seen, so the id (and the threadMessageId built from it) churned on
every snapshot, remounting the rendered reasoning panel mid-stream and
replaying its open animation — the panel visibly flashed/re-rendered on
every snapshot tick.
Derive the block id from the stream id when present (unique per stream,
stable across snapshot rows) so the block is updated in place and the
smooth streaming keeps appending to the previous text. Row-derived ids
remain the fallback for content without a stream id.
Also rerun gen:fixtures to refresh the two golden fixtures affected by
the new id shape.
* fix(ios,android): mirror stream-stable block ids in native chat ports
The native HapiKit (Swift) and protocol (Kotlin) chat pipelines are ports
of the web reducerTimeline and are pinned by the same golden fixtures in
shared/fixtures/chat. After the web-side change to derive streamed
reasoning/text block ids from the stream id, the ports still produced
row-derived ids, so the iOS/Android fixture conformance suites went red
on the two refreshed fixtures.
Apply the same streamId-first id derivation (row-derived fallback kept)
to both ports so all three pipelines project identical block ids.
* fix(web,ios,android): reject blank stream ids as block identity
Blank ('' or whitespace-only) stream ids are not streams per the wire
semantics in shared/src/messages.ts (readReasoningStreamId trims before
accepting). The previous nullish fallback let accepted payloads carrying
blank ids through, so every such row shared one empty block id: the
merge maps collided and assistant-ui occurrence suffixes churned with
list position, reintroducing remounts.
Normalize with a trim guard in all three pipelines (web, HapiKit,
protocol) and add a web regression test covering both empty and
whitespace-only ids.
* fix(ios): use normalized stream id for block construction identity
The blank-id guard was applied to lookup and map insertion but block
construction still read the raw optional, so accepted payloads carrying
blank/whitespace ids produced blocks sharing one blank SwiftUI identity
instead of falling back to row-derived ids (web/Android already used the
normalized local). Hoist the nonBlankStreamId result and reuse it for
lookup, block identity, and insertion in both the text and reasoning
branches.
Also add native coverage for stream identity: stream-id derivation for
text/reasoning plus blank ('' and whitespace-only) fallbacks, which the
golden fixtures do not exercise.
* fix(web): pin blank stream-id identity contract in golden fixtures
Update the two stale fixture descriptions (stream-keyed blocks are now
keyed by the stream id, not the first message) and add a generated
conformance fixture covering empty and whitespace-only codex data.id
values for both reasoning and text: blank ids are not stream identities,
so each payload keeps its own row-derived block id instead of collapsing
onto a shared blank identity. Web, iOS, and Android all run this same
golden fixture.
* feat(hub): make title provider max_tokens and timeout env-tunable
Reasoning models used as title providers (e.g. GLM thinking models) need
more than 64 completion tokens and more than the hardcoded 10s timeout to
emit a title, and the only workaround was patching the compiled binary
after every install.
Expose both knobs via HAPI_TITLE_PROVIDER_MAX_TOKENS and
HAPI_TITLE_PROVIDER_TIMEOUT_MS, following the existing
HAPI_TITLE_SUGGESTION_RATE_LIMIT pattern; defaults are unchanged.
* docs(hub): document title provider max_tokens/timeout env knobs
Add the two new HAPI_TITLE_PROVIDER_* variables to the title-provider
configuration table in the installation guide, and extend the provider
test to cover the timeout abort path (the signal fires and rejects the
in-flight request).
---------
Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
* test(claude): reproduce fork child deadlock when init never arrives
* fix(claude): accept first fork child prompt on SessionStart:fork hook
A forked child starts query() before any prompt exists, and the SDK
emits 'init' only after the first prompt is sent. Waiting for init
deadlocked fork children until forever. Materialization is already
signaled by the SessionStart:fork hook, so accept the child prompt on
that signal instead.
* chore: restore bun.lock
* fix(cli): identify the Windows runner process through CIM
isHapiRunnerProcess() checks that the PID persisted in runner state still
belongs to a HAPI runner. On Windows it read the command line through wmic
and returned true for any live PID when that spawn failed.
wmic is absent on current Windows 11 builds, so that branch is the only one
those hosts take and the check degrades to a liveness test. When a reboot
hands the stale runner PID to another process, runner start adopts it and
exits without starting a runner.
Query the command line through PowerShell Get-CimInstance Win32_Process
first and keep wmic as the fallback, matching getProcessStartMarker() in
this file. A probe that succeeds without reporting a command line falls
through to the next one, and an unreadable identity still preserves the
live state, so a real runner is never discarded on a probe that came back
empty.
* fix(cli): keep an unverifiable runner pid out of destructive paths
The identity check answered a boolean, so "alive but unidentifiable" had to
collapse into one of the two verdicts. It collapsed into "this is the
runner", and runner start acts on that by calling stopRunner(), which force
kills the persisted pid once the HTTP stop times out. A reused pid that no
probe can read was therefore still reachable by a kill.
Report the identity as runner, foreign, unknown or dead instead. Only a
confirmed runner is reported as running. An unknown pid is neither signalled
nor cleaned up, because clearing the state would also drop the lock that
keeps a second runner from starting, and the pid may still be a healthy
runner behind a probe that failed transiently.
The posix branch had the same fail-open through isProcessAlive and now takes
the shared unknown path.
* fix(cli): gate the runner force kill on a confirmed identity
Two paths reach stopRunner() with a boolean that cannot carry "unverified".
runner start stops an existing runner, and the spawned start-sync child reads
the same answer through isRunnerRunningCurrentlyInstalledHappyVersion(), where
a false result is read as a version mismatch and also calls stopRunner().
Propagating the unknown state to each caller would leave the next path open,
so check the identity where the process is actually signalled: only a
confirmed runner is force killed.
Also stop reading WMIC's column header as a command line. wmic get CommandLine
prints the header even when the property is empty or unreadable, so trimmed
stdout was never empty on success and an unreadable process was classified as
foreign, which cleared the state and the lock instead of preserving them.
* fix(cli): gate the whole runner stop on a confirmed identity
The identity check sat in front of the force kill, but stopRunner() first
sends an unauthenticated POST /stop to the port recorded in runner state.
An http request is a signal too: if the pid and its port were both reused,
an unrelated local service receives that request before the check runs.
Move the check to the start of stopRunner() so a pid that is not a confirmed
runner is never contacted at all. The later force-kill check is redundant
once the whole sequence is gated, so it goes away.
* fix(codex): ignore late app-server writes during disconnect
* fix(codex): scope app-server responses to their process
---------
Co-authored-by: huxiang <huxiang@myai.tech>
The runner integration suite spawns sessions whose new agent-availability
preflight requires an installable Claude CLI. CI runners have none, so every
spawn returned agent_unavailable and four suite tests failed.
The isolated test env now writes a minimal stub claude binary into the temp
home and points workers at it via HAPI_CLAUDE_PATH (the same override the
production launcher honors), keeping production behavior untouched.
- Pairing now allows direct entry/scan with inline progress and error feedback
- PairingConfirmView is now deep-link-only for security confirmations
- Session list rows: move thinking spinner and unread indicator to trailing edge
- Extracted shared PairingAttempt state for reuse
- Updated strings: "Continue" → "Pair" button label
- Removed unused PendingPairing.source
Claude-Session: https://claude.ai/code/session_01ViH4oaLSpTcksxDFEMQgeD
* refactor(agy): extract the agy models probe from the fetch flow
One invocation and the decision of what to do with its output were
tangled in a single promise. Split them so the probe can be given
different arguments, and run more than once, without duplicating the
stream and timeout handling.
No behaviour change: the same argument vector, timeout, stream
handling and fallbacks remain, and the existing tests are untouched.
* fix(agy): read the model list from agy's structured output
The picker recovered ids and labels from `agy models`, whose table is
meant for people to read: it has emitted display names only, then
bare ids, then tab-separated id/label pairs over the 1.1.x line, and
each shape change silently sent the probe back to the hardcoded
mirror. Since agy started offering Gemini 3.7 Flash, that mirror is
what users see, so the three new entries never appear.
agy publishes the same listing as a structured payload, so read that
instead: `agy --output-format=json models` returns
`command.data.models[] = {id, label}`. The flag is global, so it goes
before the subcommand and takes its `=` form.
Releases that predate the flag ignore it and print the table, so the
text parser stays as the fallback, and it now also understands the
tab-separated shape. Every release checked, 1.0.16 through 1.1.13,
ignores the flag rather than rejecting it; a build that rejected it
would emit no models at all, so ask once more without the flag when
the first invocation yields nothing either parser can read.
* fix(agy): follow the Gemini 3.7 Flash row in the model picker
agy now lists Gemini 3.7 Flash as the top row of the `/model` TUI
picker, but the hardcoded row table still starts at 3.6 Flash. A
session whose current model is that row can never change its model:
the picker cannot identify the current row and the change is
rejected.
Add the 3.7 row to MODEL_ROWS/TARGETS. Every existing row shifts
down by one, which leaves their relative deltas, and therefore the
navigation keys emitted between them, unchanged. Mirror the three
new ids into AGY_MODEL_LABELS so the session pickers offer them too.
Android versionName and iOS MARKETING_VERSION move from their scaffold
values (0.1.0 / 1.0) to the CLI's current release number, so store
listings and About screens read the same version as the hub they pair
with. versionCode / CURRENT_PROJECT_VERSION stay at 1 for the first
store uploads.
local.properties is the conventional gitignored home for machine-local
secrets, but it is not part of gradle's own property chain — the seam
now loads it explicitly as the third source (gradle property > env >
local.properties, same names). Also imports java.util.Properties at the
top of the script: the bare FQN resolves against the java extension
accessor in Android Kotlin DSL and fails to compile.
Same conditional philosophy as the google-services.json plugin: with no
configuration, :app:bundleRelease builds an unsigned AAB and the repo
needs no secrets; an upload keystore supplied via user-global gradle
properties or env (HAPI_UPLOAD_KEYSTORE + passwords, ~ expanded) signs
release builds for Play App Signing. Verified both paths: unsigned
bundleRelease (first R8 run — builds clean, 8.0MB vs 18.8MB debug) and
a throwaway-keystore signed bundle (jarsigner: jar verified). Also
fixes the stale FCM_PROJECT_ID reference in the README.
#1667 pinned @v1.12 and moved the flaky-relay stream retry tuning from
codex-args into a seeded codex-home config.toml, but the action appends
its own [model_providers.codex-action-responses-proxy] table instead of
merging, so every Codex workflow fails with a TOML duplicate key error.
The overrides cannot be expressed on v1.12 at all.
Pin all three workflows to v1.11, the last release that accepts these
deliberate codex-args overrides under drop-sudo, restoring the original
behavior.
The floating @v1 tag picked up v1.12, whose protected-run validation
rejects model_providers.* overrides passed through codex-args under
drop-sudo, breaking all three Codex workflows. Pin the action to v1.12
and seed a trusted codex-home config.toml with the same settings in an
earlier step instead - the action preserves existing config content.
* 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)
* feat(acp): split request dispatch from completion and add soft steer
- AcpStdioTransport.sendRequestWithDispatch separates stdin-accepted
dispatch from the JSON-RPC response, keeping sendRequest behavior
unchanged
- AcpSdkBackend tracks concurrent session/prompt requests with an
activePromptRequests counter (main prompt + soft steers); response
completion stays pending until every concurrent prompt settles
- beginSoftSteerPrompt kicks off a concurrent session/prompt (Cursor GUI
Send semantics — no cancel, no handler swap) returning {dispatched,
completed}; softSteerPrompt awaits the full response for direct callers
* feat(cursor): mid-turn soft steer via concurrent session/prompt (#888)
- CursorAcpRemoteLauncher registers the steer-queued-message RPC handler:
reserves the queued row, rejects control commands and mode mismatches,
then soft-injects via beginSoftSteerPrompt without canceling the
in-flight turn
- Acks the hub once stdin accepts the inject (not on turn completion) to
stay inside the 30s RPC window; the launcher stays busy until the
concurrent prompt settles so handlers are not swapped mid-inject
- steeringActive agent state mirrors the active-turn window; abort and
cleanup reset it and invalidate pending steers
- Legacy stream-json Cursor sessions register a steer handler that
reports unsupported
* 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
* feat(shared): advertise cursor in the steer gate now that its handler lands
Cursor ACP sessions pass the web and hub steer gates; legacy stream-json
cursor sessions stay excluded.
* 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(cursor): consume the row at dispatch; keep waiters for prompt gating
- The hub RPC acks and the queue row is consumed as soon as stdin accepts
the concurrent session/prompt; completion is background-only. A
dispatched steer is never restored (no duplicate via the next prompt)
- softSteerWaiters are registered before awaiting dispatch so the main
loop's finally cannot start the next prompt mid-inject; they still gate
prompt handover on completion
- tests updated: post-dispatch ACP rejection keeps the row consumed
* fix(cursor,hub): never hang teardown on unresolved soft steer; align diagnostics
- Prompt-finally waits for soft-steer completion only when not exiting;
the outer finally no longer waits at all — cleanup() disconnects the
ACP transport, which rejects pending requests and settles the waiters
- syncEngine gate diagnostics and JSDoc name all supported flavors
(Pi, Codex, Cursor ACP)
- regression test: Switch with an unresolved soft-steer completion still
reaches teardown
* 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(cursor): abort drops soft-steer waiters so the next prompt never blocks
- Ordinary Abort (shouldExit false) now clears softSteerWaiters: the
prompt finally cannot wait forever on a soft steer whose completion is
unbounded; the ACP cancel rejects in-flight requests, and cleanup()
settles leftovers on session end
- regression test: unresolved soft-steer completion after Abort no longer
blocks the next prompt
* 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(cursor,acp): abort force-settles soft-steer bookkeeping
- AcpSdkBackend.abortSoftSteers() drops the concurrent-prompt counter and
notifies response-complete so the next turn's waitForResponseComplete()
cannot block on a soft steer that will never settle after abort
- handleAbort calls it before clearing the waiters; the main prompt's own
finishPromptRequest stays guarded by Math.max(0, ...)
- unit tests cover counter release and no-op when idle
* 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
* test(acp): match finishPromptRequest epoch signature in whitebox test
* 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(cursor): abort releases an in-progress soft-steer wait
- The prompt-finally wait races Promise.allSettled against the abort
signal: an Abort that clears the waiters now also releases a wait that
already started, so the launcher always reaches the next queued prompt
* 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(cursor,acp): commit on ACP acceptance; distinguish transport failures
- AcpStdioTransport marks transport-level failures (timeout, closed,
stdin write) as indeterminate; explicit JSON-RPC error responses are not
- The steer handler commits + consumes on completion (ACP acceptance) and
restores the row on an explicit rejection; an indeterminate transport
failure keeps the row reserved so a delivered instruction is never
re-sent, and the ACK reaches the hub even when abort reset the queue
- launcher/transport tests updated for the three outcomes
* 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
* test(cursor): match tri-state cancel contract for dispatching steers
* fix(cursor): drop duplicate promptInFlight declaration after upstream merge
* 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(cursor,acp,web): indeterminate close marks, steer gating precision
- AcpStdioTransport.rejectAllPending marks close/protocol failures
indeterminate, so an accepted-but-close-interrupted soft steer restores
nothing (no duplicate delivery)
- the abort race in the soft-steer wait removes its listener in finally
(no accumulation across repeated waits)
- SessionChat gates the Steer button on agentState.steeringActive for
codex/cursor instead of the queued-grace thinking flag, so Steer is not
exposed before the launcher can accept it
- codex pre-dispatch abort never enters reconciliation (merged from #1606)
* fix(steer): persist indeterminate outcomes without replay
* fix(cursor): hold ambiguous steers for explicit resolution
* fix(steer): make ambiguous delivery restart-safe
* fix(cursor): make ambiguous delivery restart-safe
* fix(steer): recover crash-held rows and preserve retry dedup
* fix(steer): ack retries and bound stdin dispatch
* fix(cursor): reject steers when prompt generation changes
* fix(steer): reconcile indeterminate dispatches and serialize retries
* fix(cursor): preserve soft-steer reservations across abort
* fix(codex): classify stdin callback failures as indeterminate
* fix(steer): recheck indeterminate cancels after ACK
* fix(steer): close retry and abort races
* fix(cursor): hold ambiguous dispatch failures
* fix(steer): serialize live retries and abort admission
* fix(steer): distinguish live dispatching from unknown
* fix(cursor): bound ACP dispatch acknowledgements
* fix(steer): keep ACK failures held and reconcile busy cancel
* fix(cursor): preserve state when dispatch ACK is uncertain
* fix(steer): distinguish held cancel from removal
* fix(store): combine schema v24 migrations
* fix(cursor): distinguish held cancel from removal
* fix(store): reserve schema v25 for steer delivery state
* fix(cursor): suppress late ACP updates after abort
* fix(steer): keep held cancel state and notify requeue
* fix(cursor): isolate late updates after abort
* fix(steer): release explicitly cancelled unknown reservations
* test(cursor): cover explicit held cancellation
* fix(codex): reject cancelled reservations before native steer
* fix(cursor): reject cancelled reservations before ACP steer
* fix(codex): make reservation restore atomic with state
* fix(cursor): make reservation restore atomic with state
* fix(codex): terminate abandoned transport writes
* fix(cursor): hard-stop abandoned writes and update native queue state
* fix(steer): own abandoned app-server lifecycle and consume races
* fix(cursor): isolate aborts and add native retry resolution
* fix(codex): confirm dispatch and recover abandoned turns
* test(codex): mock abandoned transport callback
* fix(native): reconcile retry responses
* fix(codex): clear visible turn state on transport loss
* fix(steer): claim retries and cover native delivery state
* fix(native): resync busy cancel outcomes
* fix(native): preserve indeterminate state on Android hydration
* fix(steer): make retry claims single-winner
* fix(cursor): hold restore failures for explicit resolution
* 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(cursor): drain foreground prompt after soft-steer abort
* 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
* fix(cursor): drain soft steers before handler replacement
* fix(cursor): preserve buffered output on abort
Markdown under docs/ renders through the existing docs pipeline
(deployed at /docs/privacy.html) and stays easy to maintain; the
hand-written website/public/privacy.html is gone. Footer gains a
Privacy Policy link. Content unchanged: self-hosted architecture, zero
developer-side collection, FCM transit on Android vs E2E envelopes on
iOS, camera/microphone usage, deletion, contact.
Static page at /privacy.html (public/ passthrough, no build wiring):
self-hosted architecture, zero collection by the developers, FCM
transit on Android vs E2E envelopes on iOS, camera/microphone usage,
deletion, contact. Serves as the required Google Play privacy-policy
URL.
Expedited WorkManager requests needed an FGS fallback on API 26-30,
which dragged in FOREGROUND_SERVICE + FOREGROUND_SERVICE_DATA_SYNC and
the Play Console foreground-service declaration that comes with them.
Not worth it: gate setExpedited on API 31+ (JobScheduler path, no FGS),
let 26-30 enqueue as plain work, delete the getForegroundInfo overrides
and the worker notification, and strip WorkManager's library-merged
FOREGROUND_SERVICE with tools:node=remove. Merged manifest verified
FGS-free.
* 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
* fix(web): filter Pi effort options by model thinkingLevelMap in new session form
The create-session EffortField called getPiThinkingLevelOptions without the
selected model's thinkingLevelMap, so the Pi effort select always showed the
static off..high list: levels the model marks unsupported stayed visible and
xhigh/max never appeared even for models that opt in. Pass the map through
piSelectedModel (mirrors HappyComposer), and extend the stale-effort reset
effect so a level unsupported by the newly selected model falls back to auto.
* feat(cli): probe machine Pi models over RPC to carry thinkingLevelMap
The machine-level Pi model probe parsed the `pi --list-models` text table,
which only exposes provider/model/thinking-yes-no — thinkingLevelMap (and
name/contextWindow) never reached the create-session form, so model-accurate
thinking levels could not render there (xhigh/max are map-opt-in and were
permanently hidden; see the companion web commit).
Replace the table probe with a short-lived `pi --mode rpc` child
(--no-session --no-extensions --no-skills --no-prompt-templates --no-tools)
that issues get_available_models and reuses the session path's parsePiModels
schema, so machine and session catalogs share one wire contract. The
lightweight probe also measures faster than the table probe (~0.8s vs
~1.6-2.4s) and drops the whitespace-table parsing entirely. Cache/inflight
dedupe/timeout structure is unchanged.
* fix: address review findings on the Pi model probe and effort reset
Three review findings on the RPC probe / effort-map change:
- (high) Probe teardown killed only the direct child PID: with shell:true on
Windows that is the shell, orphaning the interactive pi RPC process on
every successful probe and on timeout; finish() also resolved before the
process was confirmed gone. Use killProcessByChildProcess (taskkill /T on
Windows, Unix tree kill, SIGKILL escalation) and settle only after the
tree teardown completes.
- (medium) An explicit get_available_models success:false response was
discarded, so Pi's own error text was lost and the interactive child hung
until the generic 15s timeout. parsePiModelsProbeLine now returns a
three-way result (unrelated/models/error), also validating the response
id, and the probe rejects immediately with Pi's error.
- (medium) Switching an xhigh/max-capable model back to Default left the
now-hidden effort in state and submitted it while the select visually fell
back to auto. The reset effect now also covers model === 'auto' (undefined
map) while still not resetting mid-resolve for a concrete model.
Tests: probe-line failure/foreign-id cases; NewSession restore-to-Default
reset and map-opt-in retention (the latter guards the former against a
false green from the restore path).
* fix(web): type the Pi model test mock as PiModelSummary
The inline mock element type omitted thinkingLevelMap, so the new
capability-driven tests failed typecheck (TS2353) and the required test job
stopped before the unit tests ran. Use the shared PiModelSummary type so the
mock cannot drift from the wire contract again.
* fix(web): reconcile hidden Pi effort when model discovery fails
A restored explicit Pi model never resolves when the machine catalog request
fails, so piSelectedModel stays null for good. The reset effect required a
resolved model, so it skipped reconciliation, while EffortField rendered with
an undefined map and hid xhigh/max. Creation is only gated on the loading
state, not on the error, so handleCreate could still forward the stale hidden
level for Pi to reject or clamp.
Treat a failed catalog as a settled selection (alongside Default and a
resolved model) and reconcile against the undefined map; keep skipping the
reset while a concrete model is still resolving without an error, so a
restored xhigh/max survives until the map can prove it valid.
Test asserts the spawn payload carries no effort after a failed catalog with
a restored xhigh; verified it fails when the error branch is reverted.
* fix(cli): honor a failed probe process-tree teardown
killProcessByChildProcess reports survivors by resolving false, but the probe
discarded that result and settled anyway. The Windows graceful path is
taskkill /T without /F and escalates nothing on its own, so a probe child that
refuses the signal would be reported as cleaned up and the catalog cached,
letting interactive Pi processes accumulate across refreshes.
Escalate to the forced teardown when the graceful one reports survivors, and
reject (caching nothing, so the next call re-probes) when even that fails.
Skip the check when the child has no pid: nothing can leak, and replacing a
spawn ENOENT with a teardown error would only obscure the real failure.
Adds probe lifecycle tests (spawn + teardown helper mocked) for the escalation
path, the reject-and-do-not-cache path, the no-pid spawn-failure path, and the
timeout path; verified they fail when the escalation is reverted.
* fix(cli): keep extensions enabled for the Pi model probe
--no-extensions silently dropped providers contributed through
pi.registerProvider, which the old `pi --list-models` probe did list. Users
with such an extension would have lost those models in the create-session
form only.
Verified with a project-local .pi/extensions provider in the same cwd: the
probe with --no-extensions returned 29 models, while both the default run and
the old table probe returned 30 including the extension's model (and its
thinkingLevelMap). Dropping the flag restores parity; the extension provider
now comes back with its map intact.
Discovery that cannot contribute models stays disabled (--no-session,
--no-skills, --no-prompt-templates, --no-tools). Cost: the probe now measures
~1.4-2.0s instead of ~0.65s, still at or below the old table probe (~1.6-2.4s)
and fronted by the existing 60s cache.
* fix(cli): probe Pi models from the home directory, not the runner cwd
Under launchd/systemd the runner cwd is `/`, and starting Pi there is
pathological: project discovery plus extensions that scan from the working
directory walk the whole filesystem root. With extensions enabled (required so
pi.registerProvider models still surface) the RPC probe took 16.8s at cwd=/,
past the 15s timeout, so machine-level model discovery failed 100% of the time
and the create-session form showed only 'Pi model discovery timed out'.
Measured on macOS with 9 global extensions:
cwd=/ extensions on 16.8s -> timeout
cwd=/ extensions off 0.6s -> would lose extension providers
cwd=$HOME extensions on 1.4s -> 29 models, complete
The catalog is machine-scoped and does not depend on cwd, so probing from the
home directory is both safe and representative. Verified from cwd=/ under the
runner's exact launchd environment: 1.3-1.7s, 29 models.
The old `pi --list-models` probe was immune because it never initialized a
session; this regression arrived with the RPC probe and was missed because
every earlier verification ran from a project directory.
Tests assert the spawn cwd is homedir() and that --no-extensions stays absent;
verified the cwd test fails when the option is removed.
* fix(cli): keep the probe in the runner cwd, fall back to home only at a root
Forcing every probe to the home directory fixed the launchd timeout but broke
project-local discovery: a runner started inside a project stopped seeing that
project's .pi/extensions providers, which the replaced --list-models probe did
surface. Verified with a project-local provider: present from the project dir
(30 models), absent from home (29).
Use the runner cwd normally and fall back to home only when cwd is a
filesystem root -- the launchd/systemd case where Pi startup walks the whole
tree (16.8s, past PROBE_TIMEOUT_MS) and where there is no project to lose
anyway. process.cwd() can also throw for a deleted directory, so that falls
back to home too.
Verified under the runner's launchd environment: from / -> home, 1.6s, 29
models; from a project dir -> that dir, 1.4s, 30 models including the
project-local provider.
Tests cover all three branches and were checked in both directions: pinning to
home fails the project-cwd test, pinning to cwd fails the root test.