Skip to content

Implement watch provisioning and enhance browser debugging - #233

Merged
SWangHash merged 192 commits into
mainfrom
wc/rebase
Aug 16, 2026
Merged

SWangHash merged 192 commits into
mainfrom
wc/rebase

Conversation

@SWangHash

Copy link
Copy Markdown
Owner

Summary

Fixes #

Type and Areas

Type:

Areas:

Motivation / Impact

Verification

Reviewer Notes

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable.

wgqqqqq and others added 30 commits August 10, 2026 19:12
A watch has no keyboard, so it cannot log into a BitFun account the way
every other surface does. Add `provision_peer_device`: a paired client
asks the desktop over the already-encrypted room channel to register the
watch on the account, and the desktop answers with a full credential.

Why not reuse `GetDelegatedIdentity`: that yields a 24-hour delegated
token, which suits a phone that can re-authenticate on its own. The watch
is a primary surface with no way to re-enter a password, so it needs the
30-day full credential the relay's `/api/auth/provision-device` mints.

`request_id` is minted by the device being provisioned rather than by the
desktop, so a retry anywhere along the watch -> phone -> desktop chain
replays one idempotent relay request instead of registering a second
device on the account.

The relay never sees `master_key`: it travels back inside the room
channel's own encryption, which the relay only forwards.

The provisioner holds the same account lease as delegation and re-checks
the account generation on both sides of the network call, so an account
switch mid-flight fails the request instead of minting a credential
against the account that just went away.

Covered by 7 tests in `service::remote_connect::tests`: provisioning
requires a trusted pairing, is bound to the account established during
that pairing, rejects a provider for another account, rejects a
credential minted for a different device, rejects device ids the relay
would refuse, surfaces the relay's own failure reason, and holds the
account lease until the response is released.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The phone is the only device in the chain that can both reach the desktop
over a QR-paired room channel and show a human the request. It relays a
watch's provisioning request to the desktop, asks for approval, and hands
the minted credential to the watch over distributed KV.

The approval card sits above every route rather than inside a settings
screen: a watch is waiting on the answer, and burying it behind whatever
screen the phone happens to be on turns a five-second confirmation into a
support question.

`provisionPeerDevice` goes straight to the room client instead of through
`send()`. The account-device transport reaches a different desktop
handler that has no trusted pairing identity and would answer "not
available on this host"; `hasRoomChannel()` lets the caller find that out
before offering the feature rather than after a 45-second wait.

The outcome is reported, not thrown: "the desktop answered and refused"
carries a message worth showing verbatim, while "no answer at all" covers
both an offline desktop and one too old to know the command — an older
build decrypts the payload, fails to deserialize the unknown variant, and
replies with nothing. An `Error` cannot carry that distinction back
through the transport's error collapsing.

`master_key` is never written to the distributed KV in the clear. The
watch publishes an ephemeral X25519 public key with its request, and the
phone wraps the key to it, so the KV holds ciphertext only the requesting
watch can open.

Bundle name changes from the scaffold's `com.example.bitfun_mobile` to
`com.bitfun.app`, shared with the watch. `distributedKVStore` isolates
stores by bundleName + storeId, so two different bundles get two
unrelated databases and the handoff cannot work at all. The cost is that
an existing install becomes a different app: no data carries over and
users re-login. The HAP signing profile has to be regenerated for the new
bundle before this ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chromium drops chrome:// URLs handed to it on the command line and
silently substitutes the New Tab Page, so spawning the browser with
chrome://inspect/#remote-debugging never reached the settings page: the
browser popped open on a blank tab, there was no switch to tick, and the
setup wait always timed out.

macOS now routes the URL through the browser's own AppleScript
`open location` handler, which is not subject to that filter. Other
platforms have no equivalent entry point, so they stop opening a
throwaway tab and hand the URL to the user instead, reported as a
distinct `requires_manual_user_profile_setup` status carrying the URL.

The chrome://inspect page is English-only even under a localized
browser, so the prompts now quote the switch by its real label,
"Allow remote debugging for this browser instance".

Also rewrite the browser control copy that leaked internal vocabulary
("guarded CDP", "real user profile", "empty profile") into plain
language describing what the setting does.
The browser keeps its remote debugging preference across its own
restarts, and keeps an approved connection grant for as long as it stays
running. BitFun's connection registry, however, is process-wide state,
so restarting BitFun dropped the connection even though there was
nothing on the browser side left to redo — Settings reported "not
connected" until something asked for the browser again.

Reattach at startup when, and only when, there is a live endpoint to
attach to. This never starts a browser and never opens a settings page,
and a denial or approval timeout is logged rather than surfaced, since
the user did not ask for this particular connection.

Settings also could not tell "ready, nothing attached yet" apart from
"nothing to attach to" — both read as "not connected", which looks
broken when the browser is in fact set up correctly. Report the ready
state separately, and resolve it from a single endpoint probe that also
answers whether the persistent setting is on. That probe is a file read
plus a short local TCP connect, so unlike attaching it never prompts the
browser.
feat: 手表免密加入账号(桌面端签发 + 手机端确认转交)
…id-model-response

fix(agent): recover transient model routing and goal worktrees
feat(input): show paths in file mentions
…rrors

fix(ai): retry every provider error
Reattaching on every start turned out to be too eager. The browser only
keeps its approval grant while it stays running, so whenever the browser
had restarted since the last connection, BitFun's startup reattach put
an approval dialog in front of the user before they had asked for the
browser at all.

Gate it behind ai.browser_control_auto_connect_on_startup, off by
default, alongside the default CDP row it applies to. The setting's
description says what turning it on does, including the dialog after a
browser restart, and what stays true when it is off: BitFun attaches the
first time an agent needs the browser.
…ect-opt-in

fix(browser): make startup reattach opt-in
The root agent guide only warned about remote SSH workspaces, so changes
routinely shipped complete for the local desktop and incomplete for the
other remote paths: relay-driven remote control (mobile web and IM bots),
Peer Device Mode, and detached Dispatch jobs.

Replace the "Remote compatibility" section with "Remote scenarios":

- A table naming all four scenarios with their design entry points.
- Cross-cutting rules: design the remote path with the feature, degrade
  loudly instead of falling back to the local host, keep blocking prompts
  answerable from a distance, survive disconnect, and treat remote
  workspace paths as POSIX on every client OS.
- Per-scenario obligations pointing at the real enforcement points: the
  remote-workspace policy registry, the RemoteCommand wire protocol and
  bot command router, the three peer deny lists that must stay in sync,
  and the dispatch observer/capability contract.

AGENTS-CN.md is updated to match.
docs(agents): expand remote scenario guardrails
- refresh reasoning projections while editing explicit models.dev bindings
- warn when the active API format cannot reliably apply catalog presets
- add combined provider and model search with ranked multi-keyword matching
- streamline custom preset naming and hide internal preset IDs
fix(settings): streamline copy and align controls
Remote scenarios routinely put two different BitFun versions on one
connection, and users upgrade in place, but the agent guide had no rule
covering either. Add an "Upgrade compatibility" global rule next to the
remote scenarios section, matching what the existing remote-workspace and
transport designs already practice:

- persisted shapes stay tolerant, defaulted, and never repurposed;
- unparseable data degrades instead of being deleted or reset;
- cross-version boundaries advertise and check a capability rather than
  assuming behavior from a package version;
- a rename is a migration, including the data it references;
- upgrade coverage means legacy deserialization and old-payload round
  trips, not only the current shape.

AGENTS-CN.md is updated to match.
The Web UI transport adapter denies 10 controller-owned commands that
neither peer host refuses: the nine `speech_*` capture/model commands and
`dispatch_continue`. The controller-side list is only an optimization —
an older controller build, or any non-Web-UI controller, still reaches a
peer host over HostInvoke, and the host would then run them.

That means microphone capture sessions and speech model file management
executing on the peer instead of the machine the user speaks at, and a
dispatch control-plane verb running against the peer's outbound observer
records and SSH credentials rather than the controller's. Every sibling
dispatch verb is already denied on all three lists; `dispatch_continue`
was simply missed.

Deny both families on the desktop and CLI peer hosts, and add contract
tests covering them.

The README already required these three lists to stay aligned, but
nothing enforced it. Add a peer command policy check to the core boundary
checker that fails when a controller-denied command is missing from either
host list, so the boundary cannot drift again. The check is one-way: a
host denying more than the controller stays allowed. The CLI's four
pre-handled control-plane commands are declared exceptions, and a stale
exception is itself reported.

Verified: cargo test -p bitfun-cli --bins peer_host, cargo test -p
bitfun-desktop --lib peer_host_invoke / remote_workspace_policy, and the
new check both passing on the fix and reporting all 10 commands without it.
Reconnecting to a peer device left the chat blank until the user clicked a
session in the sidebar. Three gaps in `initializeWorkspace`, all reachable
only in Peer Device Mode because `clearAllSessionsForPeerSwitch` is the one
place that bumps the store surface generation:

1. A metadata page loaded across a surface-generation bump is discarded by
   `processPersistedSessionMetadataList`, but the page is still returned
   with its sessions. The caller then sees "history exists" over an empty
   store. Reload once against the settled generation.

2. History was only restored on the auto-select path, so a session that was
   already active but still metadata-only was never hydrated. The breadcrumb
   and turn rail render from the catalog while the message area stays empty
   — the reported symptom. Restore history for an active historical session
   too.

3. When metadata claimed sessions but none were selectable, initialize
   returned true while selecting nothing. The caller reads true as "do not
   create a session", so the surface ended up with neither. Report no
   history so the caller creates against the live workspace instead.

No persisted shape, command, or protocol changes; an older peer host is
unaffected.

Verified: all three tests fail without the fix and pass with it; the 8
existing FlowChatManager cases are unchanged; 252 tests across
src/flow_chat/services pass; pnpm run type-check:web clean.
…r-retries

fix(agent): unify live model retry budget
wgqqqqq and others added 28 commits August 14, 2026 22:11
`WorkspaceStdio` documents that the three IO streams are what lease the
process, and that `control` and `completion` do not keep it alive by
themselves. Both channel loops contradicted that: `control_rx.recv()`
returning `None` — which is just the last `WorkspaceProcessControl`
sender going out of scope — was matched together with `Kill`, so a
caller that drives the process purely over stdio killed it the moment it
dropped the handle it had no use for.

Remote ACP is exactly that caller. The agent was SIGKILLed microseconds
after exec, and the only trace left was a broken pipe on the first
`initialize` — no stderr, because it never got to write any.

Resolve the arm only on real signals, and cover both loops with a test
that dropping the handle lets the process run to its own exit status.
An agent that dies mid-session leaves behind the same broken pipe as one
that never started. The exit code says which.
A client that exits before answering `initialize` produced one sentence —
"exited before initialization completed" — which tells the user only what
they already know. Its stderr held the whole explanation and went to a
log file, or, for a local agent, to a terminal a packaged app does not
have.

Read both transports' stderr into the log line by line, keep the tail,
and quote it in the error the user is shown. Waiting for EOF first is
what makes the quote complete: the pipes closing is the signal that the
agent is gone, and its last lines are still in flight then.

Also ask the remote host for its Node version in the probe round trip we
already make, and refuse the launch when it is below 20.12 — the release
that added `util.parseEnv`, which the harness imports on its first line.
dsh declares no `engines`, so npm installs it onto Node 18 without a word
and the failure surfaces from deep inside the launcher.
Allow permission changes made during an active turn to affect the
next model round while keeping the current round stable.

- Keep active-turn overrides mutable and process-local
- Clear temporary overrides when the owning turn ends
- Persist session-scoped selections and clear active overrides
- Add a dedicated active-turn permission command
- Distinguish next-message and current-turn scopes in the UI
- Preserve compatibility with older session permission requests
DeepSeek Harness's PTC preset answers a whole step with one `run_code`
call whose argument is a TypeScript program. The bridge classified it by
kind alone, so it landed on the terminal card, which reads `command` —
a field the call does not have — and drew an empty card.

Give it its own identity and card: `run_code` (and anything shaped like
it: Execute kind, a `code` argument, no `command`) becomes `RunCode`,
rendered as the program plus what it printed. A shell call that happens
to carry a `code` argument is still Bash.

While reading the result path: ACP results carry their text in content
blocks, but the terminal card reads `output`/`stdout`, so every ACP Bash
card showed the command with nothing underneath it. Lift that text into
`output` for both cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An ACP session came back empty after a restart. Its turns were never
written, and three layers each had a reason.

The projection is the only writer of these turns, but it had no storage
slot to write into. That slot arrives with the backend's
`DialogTurnStarted`, and no such event exists for a turn an external
agent runs — nothing in the local runtime starts it. Every save was
therefore deferred, forever. Allocate the slot in the projection instead,
from the turns and catalog it already holds, and decline to guess when
the session has persisted turns none of which are projected yet, where a
guess would overwrite history.

The backend then refused the save it did receive. `save_persisted_dialog_turn`
validates a turn against the runtime's history branch for that session,
and an externally driven session has none — the runtime neither starts
nor completes those turns — so a first turn failed with OutcomeUnknown.
Read the session's `provider` metadata and, when an external agent owns
it, persist straight through. A runtime-owned session still needs its
branch, which the new test pins from both sides.

Finally, the desktop path loaded every session into the session manager
before saving, which for an ACP session restored nothing and rewrote its
persisted mode to a local fallback. Skip the load for the same reason:
there is no runtime state to restore.

The `provider` key and its `acp` value move into core-types, so the ACP
client that writes them and the runtime that reads them back share one
definition rather than two string literals that have to agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing a dsh session and clicking it again gave a blank one: the bridge
never advertised ACP's `loadSession`, so BitFun had only `session/new` to
fall back on. Every one of a user's stored sessions carries
`acpResumeStrategy: "new"` for that reason. The reopened conversation
lost its history and its context, and its mode picker unlocked and
reverted to the roster default — a session that has already spoken must
not be able to change the composition its transcript was written under.

Implement `session/load`. A live session replays from memory; a cold one
resumes out of the harness's own persistence, which is the case that
matters, since a client restart is exactly when nothing is in memory. The
archive is read through `inspect`, not a listing: a session disposed a
moment ago is still draining, and `list` does not wait for it while
`inspect` does — reopening the session you just closed is the first thing
a user does.

The mode comes back from the session's own log rather than the roster, so
a conversation started under a preset reopens under it however the
default has moved, and `presetOptions` locks the picker for a session
whose conversation has started. A session is refused when it was never
stored, or when it belongs to another directory — answering the latter
would hand back a session whose sandbox boundary points somewhere else.

`scripts/smoke.mjs --load <id>` drives the path against a real
installation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat(acp): ship an ACP bridge for DeepSeek Harness
The 0.2.18 Desktop Package run failed in `Package (windows-x64)` with
`spawnSync npm ENOENT` out of `build-profile.mjs`, taking the whole
`frontend:build-all` down with it. Three separate Windows assumptions:

- `npm` is a `.cmd` shim there, and Node has refused to spawn one without
  a shell since CVE-2024-27980. A shell then re-splits every argument, so
  passing an absolute `--pack-destination` would break on its first space.
  Both `npm pack` and `tar` now run *in* the staging directory, which
  leaves their arguments as bare package names and one filename — no
  quoting to get wrong, and no drive letter reaching `tar -f`, which GNU
  tar would read as a remote host.
- `copyTree`'s filter derived a basename by slicing on '/', which on a
  '\'-separated path yields the whole path and therefore matched nothing.
  A vendored tree would have dragged `node_modules` along.
- `hashTree` recorded native separators, so the same sources produced a
  different content stamp per build host. Digests are unchanged on Unix
  (verified byte-for-byte against the previous script).

`prepare:dsh-profile` runs only inside `frontend:build-all`, which no CI
job invokes, so its first Windows execution ever was a release build.
Add a small `windows-latest` job that runs the packaging and asserts the
profile is complete, stamped, and carries nothing it must not ship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…endor

fix(dsh): package the bridge profile on Windows
Preserve YAML and TOML frontmatter through Markdown and TipTap
round trips without exposing structural delimiters for editing.

- Render labeled frontmatter metadata in preview mode
- Support protected frontmatter editing in IR mode
- Preserve delimiters, line endings, comments, and body spacing
- Show unsafe syntax warnings only when IR requires fallback
- Add focused parser, preview, and editor regression tests
Add the private AgentClient, Session, and Query vertical slice over the existing Agent Runtime owner. Consolidate bounded JSON, JSON-RPC, and WebSocket mechanics in the cross-platform transport foundation while keeping IPC framing and product protocol policy with their owners. Generate strict runtime validators from the Rust wire contract and preserve bounded lifecycle and process-tree cleanup.
cargo check and desktop:dev no longer require packages/dsh-acp/dist-profile.
Official packaging still builds the profile and injects it as a Tauri resource.
…pile-resource

fix(desktop): keep the DeepSeek bridge off the compile path
Keep release-sync cron on the in-repo script and document the host
export/import path so a new server does not need a detached AutoUpdate copy.
docs(deploy): restore the OpenBitFun origin from the BitFun checkout
Remove automatic snap-back when the user scrolls into the reserved
blank below the conversation tail.

- Preserve explicit session, rollback, navigation, and tail alignment
- Keep tail-follow recovery when output catches up with the reader
- Update viewport ownership tests and FlowChat behavior documentation
- keep the file explorer on a single virtualized tree renderer
- prevent scrollbar appearance from shifting tree content
- add regression coverage for stable renderer and scrollbar behavior
Switching devices used to move the whole client onto the target: the UI
swapped, and the device you left stopped being usable. So an account with
several devices could still only run one task at a time.

Split the two concepts that were fused together. An *attachment* is a live
control link to a peer and is what keeps its agent running; the *rendered
surface* is the single device this window draws. Attachments now survive UI
switches, so dispatching a turn on B, switching back to A, and dispatching
another turn there leaves both running.

Three things had to change for that to hold:

- A surface switch no longer mutates the device being left.
  resetProductSurface() runs before the transport swap, so its
  terminal_shutdown_all / lsp_close_workspace calls landed on the outgoing
  device and killed the PTYs and language servers an agent turn was still
  using. It is frontend-only now.

- Product events are routed by source device. The controller re-emits peer
  DeviceEvents under their original event name, so background attachments put
  several agent streams on one bus. Re-emitted payloads carry
  __bitfunSourceDeviceId, and deviceSurfaceRouting delivers a surface-scoped
  event only when its producing device is the rendered one.

- Snapshot reconciliation is no longer Peer-only. A turn left running on the
  local device produces events that routing drops while another device is
  rendered, and the relay has no ACK/replay, so returning to it needs the same
  repair the peer surface already had.

The sidebar row becomes a device switcher: always present once signed in,
listing this machine plus every online device with live running indicators, a
count of devices working elsewhere, and an explicit per-device disconnect that
stays distinct from simply looking somewhere else.
The appearance contract audit requires a compound styled owner to expose at
least four distinct parts; the switcher declared three, so themes could not
address its label, status dots, or disconnect action independently.

Tag the remaining styled nodes and register them, plus the busy/offline states
the status dot already rendered through class names only.
Switching devices while a local session was working reported
"Session lost after adding dialog turn" and the message never ran.

`startTurn` adds its projection turn, then awaits the state-machine
transition, an optional worktree bind, and a model-selection sync before
reading the session back. A surface switch clears every projection
synchronously, so a submission caught in that window resumed against an empty
store and threw. The throw lands before `start_dialog_turn`, which is the real
damage: the turn had reached no host, so the user's message was gone rather
than merely rendered somewhere else. The previous change kept the *backend*
running across a switch but left frontend work in flight over the teardown.

Close the window, then survive losing it anyway:

- `resetProductSurface` waits for in-flight submissions to hand their turn to
  a host before clearing the surface, bounded so a wedged submission cannot
  make the switch feel stuck.
- `sendMessage` captures the store's surface generation and compares it when a
  submission fails. A change means the switch caused the failure, not the
  turn: skip the error toast and the error transition, and re-queue the
  message when no host accepted it. Pending queues are keyed by session and
  survive a switch, so returning to that device drains it.

`TurnTracker` gains `hostAcceptedTurn` so the recovery can tell "never
submitted" from "already running elsewhere" instead of guessing.
Switching away from a working local session and back left the prompt on
screen with the whole response, its progress, and its result gone.

Active-session reconciliation has a wholesale replace path that skips the
forward-progress comparator, so a settled turn can adopt the host's
authoritative copy. Two things make that unsafe. A turn keeps its identity and
user message independently of its rounds, so a windowed or not-yet-checkpointed
snapshot can name the turn while carrying none of its work. And a projection
rebuilt by a surface switch has no state machines, so every turn reads as idle
and every snapshot qualifies for replacement. Reconciling then overwrote a
fully hydrated turn with an empty one — exactly the screen the report showed.

Gate the replace on `snapshotDropsProjectedTurnContent`: a snapshot may correct
a turn, never drop rounds, streams, or tool calls the projection already
shows. Forward progress and genuine host copies still replace as before.

Refusing a snapshot must not also cost the re-attach, since the same rebuilt
surface is what needs one. When a snapshot changes nothing but the host reports
an executing turn and the local machine is idle, align the state machine
anyway. While a turn really is streaming the machine is already processing, so
this cannot churn it on every tick.

Found by reproducing the sequence against the real store rather than by
inspection: the projection goes rounds=1 -> rounds=0 across one reconcile.
Both guards are covered by tests that fail without them.
Keep the current scene visible while a lazy target is loading, then
switch atomically once the target is ready.

Apply a subtle entrance fade only to the incoming scene and add
regression coverage for session and agent scene navigation.
* fix(chat): make remote file mentions reliable

* fix(chat): tolerate sessions without config metadata

* fix(chat): register mention picker error state
* refactor(peer): add the device surface identity and epoch contract

Foundation for the multi-device rework: a DeviceSurfaceId that names which
device a piece of state belongs to, a monotonic activation epoch with an
AbortSignal, a typed SurfaceChangedError for unwinding stale work, and the
key-scoping helper every per-device cache must use.

Nothing consumes it yet; the layers land on top of this contract.

* refactor(peer): isolate device surface state and switching
@SWangHash
SWangHash merged commit 66a9a4b into main Aug 16, 2026
2 of 8 checks passed
@SWangHash
SWangHash deleted the wc/rebase branch August 21, 2026 02:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants