Commit Graph
123 Commits
Author SHA1 Message Date
wushenghua e998f05eb2 merge: hapi v0.30.7
Six text conflicts, all resolved:
- cli/apiMachine + runner/run: adopt upstream's MachinePathPolicy; keep the
  local policy that browsing stays disabled until --workspace-root is set
  (banner text + upstream's browse test adapted to assert the local gate).
- cli/codex root.test.ts: keep both the syncHistory split test and upstream's
  change_title/name test; fixture return merges metadata + syncHistory.
- web/SessionList: combine the local personal/global pinned split with
  upstream's search-relevance ranking (deps union).
- web/AskUserQuestionFooter: take upstream's draft-restore effect and thread
  the local skippedByQuestion through the draft store; the local DSH skip test
  moves to AskUserQuestionFooter.skip.test.tsx so upstream's vi.mock setup in
  the original file cannot shadow it.

Also: bun install for the assistant-ui 0.15.21 bump, regenerated
shared/fixtures from the merged pipeline.

Test status vs the pre-merge baseline (d6cf54d7), identical pre-existing
failures only: cli 7, hub 2 (opencodeClear), web 35, shared 2 (DSH); relay
clean; typecheck clean across all packages.
2026-09-29 21:14:39 +08:00
wushenghua 8e1c4a80a1 docs(deploy): the keychain failure is session-scoped, not an ACL allow-list
Measured on this machine: from a Background session (a plain `ssh localhost` is
enough) `security show-keychain-info` already fails with "User interaction is
not allowed" (exit 36) and codesign returns errSecInternalComponent, while the
same command in Aqua reports the keychain unlocked and codesign succeeds. So the
key's ACL is not what blocks us - the session cannot reach the keychain at all -
and granting the key "allow all applications" would not help.

Correct the guidance in sign-build.sh and docs/local-deployment.md (both added
earlier today) accordingly: run the deploy from a Terminal window, or unlock the
keychain inside the Background session with the password
(`security unlock-keychain -p`, the CI-style recipe; the interactive form cannot
prompt there). Verified the failure path still prints the new text.
2026-09-18 01:55:29 +08:00
wushenghua f0ea0af94a docs(deploy): record the TCC cdhash trap and how to re-grant
A fixed path plus a pinned identity does not by itself keep TCC grants valid:
each grant stores a code requirement, and a grant created while the binary was
ad-hoc signed stays pinned to that build's cdhash. Replacing the binary at the
fixed path then matches nothing, and since a record exists macOS never prompts
again - it denies silently. Processes already running keep the cached allow
until macOS re-evaluates them, then lose ~/Documents mid-life; new processes are
unaffected. That is exactly how long-running sessions stopped answering with
"OpenCode service failure" / "getcwd: Operation not permitted" while fresh
sessions on the same machine worked.

AGENTS.md gets the short version; docs/local-deployment.md gets the csreq check
(identity-based vs cdhash), the normal re-grant through System Settings, and the
fallback used on mini on 2026-09-17 (delete the stale row, restart tccd, insert a
record copied from a machine with the same signing certificate), including the
row shape and the other two services that rot with it.
2026-09-18 01:51:01 +08:00
wushenghua 12193619d8 fix(deploy): correct the errSecInternalComponent advice
The previous revision told people to run 'security unlock-keychain' first, which
cannot work from launchd's Background domain: the command cannot prompt for the
passphrase there, and an unlocked keychain alone does not let a background
process read the private key. It also guessed the session up front from
`launchctl managername`, which flagged legitimate deploys (an SSH session that
unlocked first signs fine).

Detect nothing in advance: run codesign, and when it fails with
errSecInternalComponent / "User interaction is not allowed", print the two
remedies that actually work - run the deploy from a Terminal window on this
machine, or allow the signing key for all applications in Keychain Access.
Verified both paths with the pinned identity (real sign succeeds; a shim that
returns errSecInternalComponent prints the guidance and exits 1).

Also ignore .opencode/ (OpenCode's per-project scratch dir, ~60MB here).
2026-09-18 01:49:14 +08:00
wushenghua 66fa2d9a06 docs(deploy): warn when codesign runs outside a GUI session
codesign reads the private key from the login keychain, and only the Aqua
session can reach it: agents/daemons run in launchd's Background domain,
where codesign fails with errSecInternalComponent even while Keychain
Access shows the keychain unlocked. sign-build.sh now warns with the fix
(run from a Terminal window, or `security unlock-keychain` first), and
docs/local-deployment.md records the rule.
2026-09-18 01:45:43 +08:00
wushenghua bcfc69ed8c feat(deploy): install to the fixed path again (one TCC grant, verified rollback)
Versioned filenames fixed the per-path code-signature cache kill (exit 137)
but made macOS TCC treat every build as a new app: each deploy re-prompted for
Documents/media-library access, and while the prompt was unanswered every
process touching those folders (model probes, session startup) blocked -
visible as slow/timeout requests in the app.

Go back to the fixed path ~/.hapi/bin/hapi (a real file) so TCC asks once and
remembers it across deploys. Guard the signature-cache hazard with explicit
verification: back up the installed binary to ~/.hapi/bin/backups/, install
with a fresh mtime, prove it execs repeatedly (--version x3) plus
codesign --verify, restart the hub / kickstart the runner, and restore the
backup on any failure.

- deploy-local.sh / deploy-remote.sh: fixed-path install, verify, rollback
- prune-backups.sh replaces prune-versions.sh (keeps newest N backups, drops
  legacy versioned binaries)
- AGENTS.md, docs/local-deployment.md and the hapi-deploy skill updated
2026-09-17 09:38:08 +08:00
wushenghua 7221c8caed feat(deploy): add remote deploy over SSH with a shared signing step
- extract the pinned-identity signing into scripts/sign-build.sh so local and
  remote deploys ship the same stable signature (macOS TCC grants key on the
  signing identity, not the path)
- add scripts/deploy-remote.sh: signs locally, copies the binary to a new
  versioned file on the remote host, swaps the ~/.hapi/bin/hapi symlink,
  kicks the launchd job (default com.hapi.runner, override via
  HAPI_REMOTE_LAUNCHD_LABEL), and verifies the runner executes the new file;
  running sessions survive
- document the remote flow in AGENTS.md and docs/local-deployment.md
2026-09-16 18:29:42 +08:00
wushenghua d5bf8936a5 fix(deploy): refresh the runner on local deploys
The runner keeps its old machine RPC handlers and capability flags until
the process restarts: compiled binaries never self-update because the
heartbeat compares the mtime of the runner's own resolved exec path,
which is fixed for the life of the process.

- scripts/deploy-local.sh runs 'HAPI_CLI_EXECUTABLE=<stable symlink> hapi
  runner start' when a runner is running; skips when none; warns and
  exits 1 when the refresh fails (the hub deployment stays)
- AGENTS.md and docs/local-deployment.md document the runner refresh and
  the manual sequence step
2026-09-16 18:09:28 +08:00
wushenghua 80d7f04335 fix(deploy): pin a stable signing identity for local binary deploys
Ad-hoc signatures carry a cdhash-based TCC requirement that changes on
every build, so macOS treats each local binary as a new app and re-asks
protected permissions (Documents, Downloads, media library, ...) after
every deploy.

- add scripts/deploy-local.sh: pins one Apple Development identity
  (SHA-1 stored in ~/.hapi/signing-identity, override via
  HAPI_SIGN_IDENTITY), signs with identifier run.hapi.cli, copies to a
  new versioned file, repoints ~/.hapi/bin/hapi, restarts the hub, and
  rolls back when /health fails
- document the identity step and TCC rationale in AGENTS.md and
  docs/local-deployment.md; agent sessions must prune TCC-protected
  folders (~/Music, ~/Pictures, ~/Movies, ~/Library) from recursive
  $HOME sweeps
- drop the one-off scripts/deploy-local-capacity-retry.sh (ad-hoc)
2026-09-16 17:43:46 +08:00
weishu 733a960ede refactor(ios): simplify scratchlist UI and interactions 2026-09-14 13:19:21 +08:00
weishu ee46cd6fea feat(ios): integrate scratchlist with the chat composer 2026-09-14 11:55:46 +08:00
weishu 10ac8157bb fix(ios): improve manual pairing input and paste handling 2026-09-14 11:13:11 +08:00
AnanovoandGitHub b41b9293e9 fix(web): render cold sessions from a small initial page (#1809)
* fix(web): speed up cold session first paint

* test(web): update pagination fixtures for cold page

* test(web): wait for cold page before loading history

* feat(native): match cold session pagination

* test(native): cover cached re-entry page size
2026-09-14 09:07:14 +08:00
weishu 40b6ac462c fix: align directory browsing and iOS session creation 2026-09-14 08:44:02 +08:00
weishu 93d89c7cf4 fix(ios): keep latest button hidden after tool inspection at bottom 2026-09-13 19:48:14 +08:00
wushenghua cb0dbd7d22 chore: rebase local deployment features onto hapi v0.30.3 2026-09-13 15:55:04 +08:00
weishu e0db9c6815 fix(notifications): distinguish user questions from permission requests 2026-09-13 12:41:38 +08:00
weishu 917baf1201 fix(codex): sync shared plan proposals and web actions 2026-09-13 12:09:39 +08:00
weishu 2e37419a7d fix(ios): reduce back-to-latest button sensitivity 2026-09-13 11:46:10 +08:00
weishu 051dbbd7da docs: refresh native app guides and contracts 2026-09-13 10:43:59 +08:00
weishu 1de9613df6 docs: align documentation with current implementation 2026-09-12 22:19:32 +08:00
weishu 6f4aacce6b feat(android): enable default push through official relay 2026-09-12 17:52:53 +08:00
weishu 32ca6f543a feat(android): improve native chat reading and navigation 2026-09-12 16:56:41 +08:00
weishu d29713aeda fix(native): render plan proposals inline
Render ExitPlanMode and exit_plan_mode Markdown from input.plan in iOS and Android conversations. Preserve approvals, raw source and diagnostics while hiding empty output placeholders and prewarming plan documents.

Add generated protocol fixtures and native regression coverage for long plans, live updates, recycling, themes and typography.
2026-09-12 16:06:16 +08:00
weishu 0c7f557ba3 feat(ios): browse tool groups in a native inspector 2026-09-12 13:03:27 +08:00
weishu fc2fdfc25b feat(cli): add agent picker and remove Claude default
Separate top-level help and version flags from agent arguments. Require explicit agents in scripts and preserve command argument boundaries.

Validation: bun typecheck, bun run test, targeted runner integration tests, and PTY/source/compiled argv smoke checks.
2026-09-12 12:03:59 +08:00
weishu 0c4abcb3d1 feat(codex): share sessions across terminal and web
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.
2026-09-12 10:59:17 +08:00
weishu a729456682 fix(claude): answer local permission prompts from web
Bridge main-session PermissionRequest hooks without suppressing the native
terminal dialog. Reconcile replies against native results and clean up on
timeout, cancellation, mode switches, and session changes.

Keep reply IDs distinct from native tool IDs across web and native clients;
add protocol fixtures and regression tests.

Refs #1796
2026-09-11 18:54:47 +08:00
Junmo KimandGitHub fd2822bab5 fix(agy): allow switching existing sessions to newly available models (#1814)
* 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
2026-09-11 14:19:37 +08:00
weishu 6c0ef32350 feat(ios): add native tool inspector and compact tool groups
Move tool output into a live sheet with group navigation and full-content copying. Keep lightweight grouped summaries inline, add agent process pages, and preserve transcript reading position.\n\nAdd real transcript and sheet regression coverage with localized summaries.
2026-09-11 11:30:13 +08:00
weishu 0dcc70e7c7 perf(native): optimize iOS transcript refresh and add frame profiling
Preserve hosted rows while propagating current SwiftUI environments and render captures. Add differential refresh regressions, opt-in real-clock iOS/Android profiling, and reproducible performance results.

Validation: typecheck; CLI/hub/web/shared/relay suites (Web rerun with NODE_OPTIONS=--no-experimental-webstorage for Node 26/jsdom compatibility); existing iOS Debug/Release and Android regression runs.
2026-09-10 16:22:36 +08:00
weishu c2e3d16b7e feat(native): improve anchored chat scrolling and history loading
Add viewport-driven paging with layout acknowledgements, bounded retries, cancellation gates, and epoch-safe history retention.

Preserve transcript anchors and expansion state, fix tool-group identity collisions, and serialize Android history coordination on Main.

Reduce per-scroll composition and layout work; add native regression tests, CI coverage, and profiling guidance.
2026-09-10 16:22:36 +08:00
weishu 98541c10ab fix(ios): add privacy policy links and clarify relay disclosures
Expose the public privacy policy from pairing and Settings, with a Simplified Chinese label. Document relay metadata, rate-limit state, operational logs, and deletion boundaries without inventing a retention period.

Validation: iOS Release simulator build, link UI checks, docs build, and staged diff checks passed. Full typecheck is blocked by existing Web assistant-ui export errors; the full test command stops at one unrelated piEventConverter CLI failure (2609 passed, 2 skipped).
2026-09-09 16:45:19 +08:00
AnanovoandGitHub 33f51fd0ab feat(web): add configurable PWA taskbar badge (#1759) 2026-09-09 09:28:57 +08:00
AnanovoandGitHub 17ee052d9a fix(web): preserve the visible chat window during rewind (#1766)
* fix(web): preserve chat window during rewind

* fix(web): scope rewind invalidation preservation

* fix(web): clear unknown rewind boundaries

* fix(web): deduplicate rewind invalidations

* fix(web): retain rewind dedupe history
2026-09-09 09:23:31 +08:00
matyasrathonyiandGitHub fa1caf7d71 docs: explain execution-host sleep in remote sessions (#1774) 2026-09-09 09:23:14 +08:00
3873e58496 fix(web): keep streamed reasoning/text block ids stable across snapshot rows (#1741)
* 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>
2026-09-06 14:20:19 +08:00
weishu 4948d23669 fix(android): enforce Google Play compliance 2026-08-25 16:44:03 +08:00
weishu e5a8212f4a feat(session): validate agents and browse workspace directories 2026-08-25 16:12:29 +08:00
weishu a04275b51d chore: upgrade Bun to 1.4.0 2026-08-25 13:31:59 +08:00
SSU-WEI HUANGandGitHub be1ef2a2e4 feat(dsh): integrate DeepSeek Harness through ACP (#1632)
* feat(dsh): add DeepSeek Harness ACP flavor

* fix(dsh): update mobile flavor catalogs

* fix(dsh): keep mobile spawn policy managed

* fix(dsh): keep managed policy and prompt retry

* fix(dsh): suppress unsupported runner policy flags

* fix(dsh): align native managed-policy UX
2026-08-22 12:37:28 +08:00
SSU-WEI HUANGandGitHub c9e243a466 docs(macOS): raise launchd runner file limit (#1646) 2026-08-20 19:44:01 +08:00
weishu c62cea9be8 docs: privacy policy as a VitePress page, replacing the static HTML
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.
2026-08-19 20:50:10 +08:00
weishu 47c0768c27 feat(brand): refresh HAPI icons across platforms 2026-08-19 20:35:07 +08:00
SSU-WEI HUANGandGitHub f0e5ba9c0f feat(codex): mid-turn Steer via app-server turn/steer (#888) (#1606)
* 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
2026-08-19 20:07:39 +08:00
weishu c76af90d88 feat(hub): push config joins the settings.json system (env > file > default)
FCM and iOS/APNs push were the only hub knobs read straight from env,
bypassing the configuration rule every other field follows (env >
settings.json > default, env persisted on first sight). Fold them in:
serverSettings resolves fcmServiceAccountPath / iosPushMode /
iosPushRelayUrl / apnsKeyP8Path / apnsKeyId / apnsTeamId / apnsBundleId
/ apnsEnv under the shared rule, and the two resolvers now consume
configuration instead of process.env. FCM_PROJECT_ID is gone: operators
point at the service-account JSON and the project id comes from the
file — no copying values out of it. Paths accept ~.
2026-08-18 23:10:57 +08:00
weishu 1ad78f409a feat(hub): iOS push channel — direct APNs + relay with E2E envelope (P1) 2026-08-18 20:56:49 +08:00
weishu fada270424 docs(api): sse.md — activeTurnStartedAt is not patch-applied (matches reference impl + fixtures) 2026-08-17 12:12:32 +08:00
weishu 81146e68c5 docs(api): drop temporary dead-link ignores now that all contract pages exist 2026-08-17 11:28:14 +08:00
weishu 1a40db3b2d merge: K2 client contract docs (sse/pagination/messages) 2026-08-17 11:25:46 +08:00