Skip to content

first public release - #17

Merged
bharathm03 merged 17 commits into
mainfrom
development
Aug 26, 2026
Merged

first public release#17
bharathm03 merged 17 commits into
mainfrom
development

Conversation

@bharathm03

Copy link
Copy Markdown
Contributor

No description provided.

Twelve defects found reviewing the setup work after it merged, plus one
found while fixing them. Grouped by what breaks.

Setup runs that outlive their own bookkeeping:

- `stop()` never cleared the setup gate's `pendingStart` and returned early
  when no PTY existed, so stopping a session queued behind setup was a total
  no-op: the agent still launched, with the original prompt, when setup
  settled. `archive()` handles exactly this at the sibling line.
- `settleSetup`'s tail was not guarded by run identity. `cancelSetupRun`
  suspends on `killSetupTree` for as long as a real install tree takes to
  reap on Windows; a rerun arriving in that window passes the only guard
  (state already moved to "skipped"), clears the marker and mints run #2 --
  and the stale settle then stamps run #1's outcome over it and can fire
  run #2's queued start early.
- `begin()` emitted the `running` progress carrying `terminalId` BEFORE
  `terminals.spawn()`, so a spawn that threw left `setup.terminalId`
  pointing at a PTY nobody created. The emit now follows a successful spawn.

State that survives what should end it:

- `sweepCheckoutRuntime` calls `manager.forget()` after `killAndAwaitTree`
  resolves, which is strictly before node-pty dispatches the exit -- so the
  exit re-created the `stoppedTerminals` row the sweep had just deleted, and
  with the owner row gone too the corpse was attributed to main and
  advertised there for the life of the process. `forget()` now tombstones a
  terminal whose exit is still owed, and the exit handler honours it.
- `recoverSetupStates` re-seeded the durable `done` marker on every launch,
  so "Workspace ready" came back for every isolated session that ever ran
  setup, once per launch, with no action to take and dismissal in memory only.
- The "checkout record is gone" delete branch cancelled the run but never
  released the setup hold on failure, stranding a deferred `services:` block
  with nothing able to release it -- that worktree's dev server never starts
  again. The sibling catch on the main delete path already did this.

Things that were never bounded:

- `rerunSetup` required only that a `CheckoutRecord` exist, never that the
  directory did. A rerun against a checkout removed out of band reported
  `done` with zero steps and stamped a durable success over an empty tree.
- `setupTerminalId()` mints `<checkoutId>:setup`, byte-identical to what
  `internalTerminalId()` computes for a `services:` or terminal slot named
  `setup` -- and service names are an unconstrained string. The two shared
  one TerminalManager slot: an app-sent `terminal:stop` reaped the live
  install, and a slot spawning into it destroyed the retained provisioning
  transcript "View setup log" still points at. The computed id is now
  suffixed on collision; the external id the app sees is unchanged.
- `parseSetupStepMarker` fed unbounded digit runs from any OSC-2 title on
  the setup PTY straight into wire state, so a long enough run yielded
  `Infinity` -- which `SessionEntry.setup.stepIndex` declares as
  `z.number().int().nonnegative()` and `JSON.stringify` emits as `null`.
  The setup PTY runs arbitrary shell from the checkout's own antgrid.yaml.
- `copyStep` used `copyFileSync`, so any directory in a `copy:` list failed
  the whole run with a raw EISDIR -- nothing in the schema or the docs
  restricts `copy` to files. Now `cpSync` with `recursive` off `statSync`.

Two more:

- The resolved plan -- every `run` line and `env` value with `${env.*}`
  already expanded -- was written as plaintext JSON with default permissions
  into a shared directory, and a force-kill leaves it there indefinitely.
  Plan dir is now 0700 and the file 0600.
- Both `void this.settleSetup(...)` and `void this.recoverSetupStates()`
  were fire-and-forget with no `.catch`, in a process whose
  `unhandledRejection` handler shuts the whole host down. The rejection
  surface is empty only by coincidence today. `checkout-setup.ts` guards its
  own `void` call with a comment naming this escalation.

- `_dismissedRunKey` was a single nullable slot on a State shared by every
  session in the workspace, so dismissing one session's banner un-dismissed
  every other. Now a Set, matching `_expandedSessionId`/`_actingSessionId`,
  whose doc comments say why a single slot misbehaves across a switch.

Not fixed here, deliberately:

- `focusedSessionByClient` is keyed by `InboundSource`, which has two values,
  so every remote client collapses into one slot -- a second phone steals the
  suppression and the completion push lands on the wrong one. A correct fix
  needs per-client identity the wire does not carry.
- An in-app project switch clears no focus entry, so a stale entry mutes the
  push for exactly the long run it exists for. Same missing identity.
- `rerunSetup` warms the checkout through `resolveCheckout`, which prepares
  the runtime with no `deferServices` -- so a rerun starts `services:` into an
  unprovisioned worktree and runs the install underneath it. The coherent fix
  changes a helper shared by three call sites and alters the `holdsServices`
  contract; that is a behaviour decision, not a mechanical one.
… RELEASE_REPO (#7)

run_number is scoped to one workflow in one repository and restarts at 1 here, but the stores remember every build number they have ever accepted. This repo's counters sit at 4 and 5 against high-water marks of 190 (Play) and 120 (App Store Connect), so arming publishing without an offset submits versionCodes far below what Play already holds and every upload is rejected.

vars.BUILD_NUMBER_OFFSET was already set to 1000 here and nothing read it. deploy-ios, deploy-android and build-desktop now stamp run_number + offset, validating the offset is a non-negative integer first. Ported from the origin repo so the three files are byte-identical to it apart from the Flutter pin.

build-desktop also gains the RELEASE_REPO gate the other publishing workflows already had. It writes to antgrid-releases and the Microsoft Store, neither scoped to a repo, so both repos holding RELEASES_TOKEN could publish to the same destinations from a v* tag. The gate goes on the version job because every other job needs it and none uses always()/!cancelled(), so the skip propagates — unlike deploy-ios's gate job, whose empty output satisfies a != 'false' test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01MBugxfV5xQPq3F94WWqDP9
A relay app builds its per-checkout terminal tabs from the frames its state.snapshot request is answered with, and from nothing else: terminal:started is not a replay type, and a stream attach runs no resyncState -- only a loopback owner connect does. The handler served the bus cache verbatim, and nothing republishes a checkout's status when a PTY spawns (session:start does not, startCheckoutRuntime's call runs before the spawn, the git poll fires only on a branch change), so the answer could predate every terminal in it. Replayed, that terminal-less status DELETES the tabs the app has -- the isolated session sits on 'waiting for agent...' until an unrelated PTY forces a republish.

Recompute each live checkout's status before dispatching the RPC, mirroring the machine control plane's own state.snapshot intercept in host-server.ts. An unchanged payload is a no-op, since the bus dedups on it.

Three smaller routing fixes in the same path: resyncState replayed scrollback under an already-externalised terminal id, so sendTerminalFrame's own owner lookup missed and stamped an isolated checkout's replay as main's; terminal:snapshot:request had no wrong-checkout guard (its tree and preview siblings do), so every runtime answered one request and the app applied whichever reply landed last; and the app's seq cutoffs now clear on transport re-establish, since a PTY that exits and respawns across a disconnect restarts its counter at 1 under a cutoff that would filter its entire output.

The status-tier scoping moves from inside ProjectStatusNotifier to the stream ProjectSession hands it, so the notifier stays a plain reducer and matches every other per-checkout consumer.
fix: close review findings on the worktree.setup path
fix: stale state.snapshot leaves an isolated session's terminal pane empty
* feat: make a new-session start a visible, cancellable operation

Starting a session was a single in-flight boolean: the form went quiet, and every non-throwing bail-out - a form edit mid-flight, a refused create, a reply that never came - ended the start with nothing on screen to say so.

The start now publishes a phase model (newSessionStartProgressProvider): the composer locks its controls and shows the stage it has reached, Send becomes Stop while the start is still abandonable, and Recents grows an optimistic STARTING row that becomes the real one. Esc is owned by a running start rather than leaving the composer behind it. Every bail-out records a NewSessionStartAbortReason, so a start that produces no session still says why.

An abort also carries the branch the start already checked out. The checkout runs first and is the one step the app cannot undo, so it is orthogonal to the reason: a Stop, a form edit and a refused create all leave the folder somewhere the user did not ask for, and the snackbar names it.

Three smaller things the same surface needed: Recents scrolls back to the row a start adds instead of shoving the list down under the user; a touch start drops the prompt focus, so the soft keyboard does not spring back over the snackbar explaining the abort; and the composer's trailing slot is re-keyed per child, so an ending start does not play the Enter hint's fade-out over the status line's opacity.

Reconciliation ordering is this repo's, not the branch this was written on: session:start still goes out before leaveNewSession and is awaited after the hand-off, because a queued isolated start cannot answer for the agent. The hand-off is now gated on the user still standing on the canvas - TerminalScreen watches activeSessionIdProvider, so setting it is itself the yank - and the reply's outcome reaches an unmounted composer as an abort nobody reads, which is cheaper than deriving the reason from which await threw.

* fix: close the review findings on the start-progress path

Nine fixes from a max-effort review of #10.

A double "Start cancelled." snackbar on every Stop: the finally's "has an abort already been recorded?" guard read the abort provider back, but the composer's listener CONSUMES the abort synchronously before startNewSession resumes, so the guard always saw null and recorded a second one. Latched locally instead.

A StateError out of a fire-and-forget _submit(): the ActiveSessionsBranchSwitchException arm read ref through _endedByCancel before checking mounted, and a mid-start resize disposes the composer. The mounted check moves first.

Pull-to-refresh went inert for short recents lists: ScrollView only defaults to AlwaysScrollableScrollPhysics while it has no controller, so adding the scroll controller silently dropped it. Stated explicitly, matching the empty branch.

"Waking <machine>..." could never paint: connecting was published before the activation call with no await in between, so it superseded activating in the same synchronous turn. It now fires from a caller-supplied callback at the point project:start is accepted, which keeps the phase write out of the shared helper the drawer also calls.

A detection resolving mid-start was dropped rather than deferred, and for a LOCAL target nothing re-delivers it - the form stayed parked on an agent the machine does not have. The snap is extracted and re-run when the start ends. end() also clears the recorded checkout, which begin() alone could not.

One test asserted nothing: the frozen-prompt case ran on the default android platform, where the start's own listener drops prompt focus, so Enter never reached the guard it was testing. Pinned to windows.
`@stable` is a branch in that repo, not a tag. The action selects the Rust
channel from the ref it was called with, so the reference is mutable in a way
even a tag is not: whatever is pushed to `stable` runs on the next iOS deploy,
in a job holding the App Store Connect key and the distribution cert.

Pinned to the stable branch's current tip. Two things this deliberately does
not do: it does not freeze the Rust version -- rustup still resolves the stable
channel at run time -- and it is not the start of a repo-wide pinning pass,
which was considered and declined as more maintenance than it is worth here.

`toolchain: stable` is now explicit. Each channel branch carries its own
action.yml default, and on master the input is required with no default, so a
future re-pin onto a master SHA would otherwise fail to parse the toolchain.
* web: a promotional grant must not block account deletion

ensureDefaultSubscription hands every new account the pro_yearly plan while checkout is disabled, and hasRenewingPaidSubscription exempted only the free plan — so every account was refused deletion and pointed at a subscription page with nothing to cancel.

The grant rides a paid plan's row, so the slug test cannot tell it apart from a purchase; check promotional first. The existing tests all seeded real purchases, which is why this went unnoticed — the new one goes through provisionProductAccountForUser, the path a real signup takes.

* web: the account page offers deletion under the promotional grant

account-page.test.ts pinned the old behaviour as an "accepted limitation during the promo". It was acceptable only while nobody needed to delete: the grant renews nothing, no checkout sold it, and no surface can cancel it, so the limitation was a permanent block on every account rather than a temporary one.

Flipped to assert the control is offered, with the reasoning recorded where the old expectation was.
* app: an offline demo mode so the app is reviewable without a desktop

Antgrid is unusable on a phone without a computer running the bridge, which makes it unreviewable for an app store: a reviewer signs up, lands in a shell with no machines, and has nothing to exercise. This adds a sample project reachable from the sign-in screen with no account, no keychain and no socket.

The demo drives the REAL workspace — DemoTransport is an AgentTransport over canned wire frames, so the sample project flows through the real router, services and widgets rather than a parallel set of fakes. Anything it cannot honestly do refuses in one fixed sentence.

Isolation is held by call-site gates rather than a boundary, so it leaks by default: every persistence store, host-spawn path, analytics sink and first-run latch checks demoModeProvider for itself. The host-spawn class is the sharp one — a LocalProject target arms ensureHost() and the demo's target is one.

* demo mode fixes

* app: sign-in's five identical buttons become three tiers

Step 1 stacked five full-width buttons of identical weight, four of them opening with the same word, and spent no accent at all. Continue looked exactly like Continue with a password, and scanning the column gave you Continue four times over with the distinguishing word last in every label.

Three tiers now carry three visual classes, so they are told apart before they are read: an accent-filled Continue (the only fill on the screen), the three auth methods as one bordered group, and the demo as an outlined card.

The demo left the credential stack because it is not a way through this screen - it leaves the account behind entirely. As the only left-aligned, two-line, outlined thing here it stays as prominent as the button was, which the mobile case needs: this screen is the whole app until an account exists. Its caveat now rides inside the card rather than floating under a button that has already been read.

The password cell is a peer of the two providers, not the link a wall-flattening pass would make it. _startOAuth records the TYPED address rather than the one the user authenticates as, so the hint can land wrong, and this is the only path that reaches step 2 - and the link it carries - whatever the hint says.

The method group borrows AbSegmented's construction (outer border, ClipRRect, 1px dividers, inset focus rings) but is deliberately not one: these cells fire actions, and a selected state would promise a choice that persists.

_SignInButton gained a primary variant, applied to the single primary action on every other phase so the accent reads as a language rather than a one-off. Its corners moved to radius5 - the token's documented value for buttons and inputs - so it matches the field above it.
…it (#15)

The rail is a pinned popup: nothing dismisses it, so it hangs over the agent's transcript for as long as the context pane is closed. At full strength the whole time, that is a claim on attention it has not earned. It now rests translucent over a blur, flat, divided by an ordinary border, with its labels at the muted foreground, and comes back to a full popup on hover or keyboard focus. A touch platform never recedes: the resting state is only earned where the pointer that undoes it exists.

The shell also holds the rail down for as long as the context pane is on screen, since the pane's own tab strip already lists the same five views. Closing the pane hands it back — but only if the shell was the one that took it away, so a rail the user shut by hand stays shut.

Two things the rail was getting wrong on its own. It was laid out at a width reserved for the widest row it could ever hold (the Git row's whole-worktree +/-), which left it two thirds empty every other time; it now ends where its longest label does. And its trailing figures carry their own colour — the badge's border, the diffstat's green and red — so they could not recede by taking the muted foreground the way the labels do, which left them the loudest thing on a surface nobody had reached for. The Git +/- was also rendering a size smaller than the counts beside it, at AbDiffStat's default, which is set for the dense per-file badge in the changed-file tree.
)

* fix: make OSC 8 hyperlinks in the agent terminal actually clickable

Text an agent emits as a hyperlink -- a PR reference, an issue number --
painted blue and underlined but did nothing when tapped, while a bare URL
in plain text was tappable without ever looking like a link. The two were
exact inverses: rendering read the native snapshot's per-cell hasHyperlink,
hit-testing only ran a bare-URL regex over visible text, and the styled VT
formatter emits no OSC 8, so the URI never reached the path that needed it.

The engine-side half is antgrid-ai/dart_terminal#3, merged as 1277a6b, so
the three fork overrides move to that SHA. It wraps a symbol the shipped
v0.1.4 prebuilt already exported, so no native release was needed -- noted
in docs/dart-terminal-fork-release.md, whose Status table this would
otherwise look like a counterexample to.

The app half stops relying on the package's fallback launcher. An OSC 8
payload is written by whatever program is running in the terminal, so a tap
acts on untrusted input: only http and https open now, and a failure to
launch is visible instead of silent.

* fix: open the URI that was validated, and show every terminal-link failure

Review of the previous commit. The scheme check parsed a trimmed string
but handed the raw one on to Uri.parse, so a padded URI cleared the check
and then diverged from it: measured, a trailing space parses to host
`example.com%20`, and a leading one throws FormatException. The test
asserting a padded URI was openable certified exactly that input. The
validator now returns the Uri it validated, so the checked URI is the
opened one.

openExternalUrl parsed outside its own try, so that FormatException
escaped the function -- an unhandled rejection for the update callers, and
for the terminal caller a log line and nothing else, which app/CLAUDE.md
calls out as indistinguishable from a dropped tap. It now tryParses and
routes an unparseable URL down the same visible path as a failed launch.

A URI written by a terminal program is unbounded, so both the SnackBar and
the log field elide at 120 chars; the log component moves to TerminalView,
which is the key logs from this layer are filtered by. Adds an injectable
open seam, matching HelpAboutSection.openUrl, so the function's behaviour
is testable rather than only its predicate -- plus a wiring test, since
onOpenHyperlink is optional and its package default launches any scheme.

THIRD-PARTY.md still pinned the fork two bumps back; it names the modified
work for an ELv2/MIT audit and nothing checks the pairing with pubspec.
The fork-release note also claimed the symbol shipped in v0.1.4 'all
along' -- true of the native binaries, not of ghostty-vt.wasm, which
exports no _hyperlink_uri at all.

* chore: bump the dart_terminal pin so terminal links survive a mouse-tracking TUI

antgrid-ai/dart_terminal#4. Resolving an OSC 8 URI was not enough on its
own: the view returned on _currentPointerUsesTerminalMouse before the
resolution ran, so under any live mouse mode -- which is to say under a
full-screen agent, the case this was written for -- the click went to the
program and the link stayed dead. Shift now bypasses mouse reporting for
mouse-like pointers, the xterm escape hatch, and the hover affordance
follows it.

Touch still goes to the program: there is no modifier to hold, so a link
under a mouse-tracking TUI remains unreachable on the phone until it gets
an affordance of its own. That is the remaining half of this fix.

THIRD-PARTY.md moves in the same commit, per the lockstep note it grew
last time.

* feat: confirm a terminal link's destination before opening it on touch

OSC 8 lets a link's visible text disagree with where it goes, so the text
under a finger is not evidence of anything: https://github.com@evil.example/
reads as GitHub and resolves to evil.example. Desktop reveals the target on
hover before the click, so a click there is already informed. Touch has no
hover at all, which is why the mobile path asks instead of guessing that the
user knew.

The sheet leads with the host on its own line -- that is the whole of what
an impostor URL misrepresents, and burying it inside the full string is how
a userinfo prefix goes unread. A dismiss reads as no, not as null.

Pairs with antgrid-ai/dart_terminal#5, which lets a touch tap reach an
explicit OSC 8 link at all when a full-screen agent holds the mouse. Bare
URL matches stay with the program there -- taking a TUI's clicks away on a
regex guess is a hole nobody can explain.

* chore: bump the dart_terminal pin so a touch tap can reach a terminal link

antgrid-ai/dart_terminal#5. Shift covered the desktop; touch had no
modifier to hold, so on a phone a link under a full-screen agent was
unreachable however it was painted. A tap on an explicit OSC 8 cell now
opens it and its forwarded click is suppressed, so the TUI does not also
react. Bare-URL matches stay with the program: taking a TUI's clicks away
on a regex guess is a hole nobody can explain.

This is what the confirm sheet in 245073f exists for -- the tap that
reaches a link is also the tap that cannot see where it goes.
* A remote session must not offer to open a folder on this machine

The session kebab's working-directory rows (Open folder, Open in <editor>, Copy path) resolve their path over the LOOPBACK control plane, so they only mean anything for a checkout this device hosts. They were hidden behind a remote-blocklist — an exact-id match against the paired-agent list — which could not answer for either shape it meets today: a remote project's id is compound (`<machineUuid>.<projectId>`) while a machine record is keyed by the bare uuid, and `paired_agents` is written by nothing any more (admission is account trust; only `forgetMachine` still touches that store, to remove). So the rows showed for every remote session, and picking one spawned the local host to ask about a project it has never seen.

Gate on an allowlist instead: the local project store, asked the same way projectDisplayNameProvider asks it. An id it does not hold is a remote project, a machine, or something removed — all of which must lose the rows — so a new source of remote entries is excluded without this gate being revisited, and an unresolved device uuid answers false rather than guessing.

Enforce the same predicate inside openCheckoutIn/copyCheckoutPath. Their doc already said callers must not offer them for a relay-backed project; an unenforceable contract is what produced this.

* A remote machine must be recognised by the machine part of a project id

Every one of these sites asked "is this entry relay-reached?" by matching an id exactly against the paired-agent list. That answers for neither shape that reaches it: a remote PROJECT id is compound (<machineUuid>.<projectId>) while every machine record is keyed by the bare uuid, and the paired list is a dead store on any install that never QR-paired — admission is account trust now, so the list is empty and nothing writes to it.

So the focused remote project reported itself local: no host chip in the title bar, and activeAgentProvider resolved to no machine at all. Resolve through baseDeviceUuid instead, and consult the account inventory (/account/agents) and the reconnect list beside the legacy paired rows, so a machine the user never scanned still counts.

ensureRemoteOnline no longer routes a compound id through selectRemoteAgent — that threw Bad state: No element on the recent-agents lookup, and re-pointed the focus at the machine instead of the project the user opened. It dials the given registration id and writes no focus.

RemoteHostChip's platform becomes nullable and is read from the inventory: the reconnect list caches coordinates and not a platform, and every host is desktop-class, so unstated renders as desktop and the server glyph is left for a platform this build does not know.

session_row_start_refusal_test now installs the standard store overrides — the tap reads the recent-agents store, and a throw there is swallowed as a failed activation, i.e. a tap that silently does nothing.

* Stop telling the user to scan a QR code

QR scanning is gone — there is no scanner screen, no camera permission, no scanner package, and nothing left that emits a QR payload. What survived is the copy in the Forget-machine dialog, which promised the user a way back that does not exist: "You'll need to scan the QR code again to reconnect."

Forgetting a machine clears its cached sessions, ports and connection details; the machine itself comes straight back from the account inventory while it is signed in. Say that instead.

Also drops the unreferenced PairException (it existed to report a failed QR coordinate import) and corrects the comments and requirements that still described admission as a QR pairing. The compound <machineUuid>.<projectId> id shape those comments explain is not QR-era — it is how a remote project is addressed today — so the shape notes stay, minus the attribution.

* Delete the paired-agent store, the last of QR pairing

Nothing has written the paired_agents blob since admission became account trust — the QR scan was its only producer — so the list was empty on every install and every reader of it was answering from nothing:

- entryIsRelayProvider and activeAgentProvider matched it first and fell through to the reconnect list; only the fallback ever fired. - _buildRelayTransportFor preferred the paired row's pinned relayUrl over the coordinates it had just resolved freshness-first from /account/agents. - selectRemoteAgent's paired branch could not be reached, and selectAgent behind it could not resolve anything. - forgetMachine rewrote a list that was already empty.

PairedAgentNotifier keeps only the machine-connection actions (select, forget, cancel, retry) and is renamed MachineConnectionNotifier over a void state; StorageService keeps only clearPairedAgents, which stays so sign-out still evicts the legacy blob from an install that predates the change.

The six test fakes that existed to stop the real notifier reading secure storage go with it; paired_agent_forget_test becomes forget_machine_test.

* Close the review findings on the QR/base-uuid sweep

The base-uuid rewrite armed three branches that were unreachable while the paired list was empty, and each of them was wrong once live:

- cancelActiveAgent released the machine socket unconditionally. It is machine-level and un-refcounted, so cancelling from a project whose last session was just deleted killed every other warm project stream and the control plane on that machine. Guarded on a non-Connected supervisor, the same way the control-plane reaper guards its own release. - ensureRemoteOnline awaited a transport element already settled in an error that noProviderRetry guarantees Riverpod will never re-run, so an offline machine could never be re-dialled from a row. It now retries the supervisor AND invalidates, and reports a null transport as a failure rather than as success into a 30s warm-up. - The duplicate-tap guard read the FOCUSED machine's reachability while gating a tap on ANY entry, so one machine mid-dial made every other drawer row inert. It now requires the tapped machine to be the focused one.

entryIsRelayProvider grew the two filters the transport builder applies (never the local uuid, never a machine with no relayUrl), so it can no longer call an entry relay that the transport opens locally. entryIsLocalCheckoutProvider takes noProviderRetry — a gate the kebab awaits must settle rather than sit pending through a backoff — and its refusal arm now speaks. RecentAgentsStore.list() drops a corrupt blob instead of throwing it into the synchronous providers that now watch it.

forgetMachine resolved its purge set from the reconnect list, which holds bare machine uuids, while every per-entry store is keyed by the project's compound drawer id — so no remote project's cached sessions were ever purged. It now unions the warm registry and the session cache, the latter being the only source that names a machine's COLD projects, which is most of them at the moment it is forgotten. It also bails on an unmounted ref between awaits.

activeAgentProvider becomes focusedMachineNameProvider (a String, so == dedupes the rebuild) and shares one resolver with the title bar's host chip: recents-first for the name, matching the three other machine-label sites, inventory for the platform and for the first connect, where nothing is cached yet.

Copy: sign-out no longer promises a re-pairing step, and the relay-URL setting describes the field it actually controls.
… is re-provisioned (#18)

This device's own host identity can move under an already-stored project. `localDeviceUuidProvider` mints and persists an anonymous uuid whenever it is read with an empty keychain, and `ensureCurrentUserDeviceRecord` reads the prefs key BEFORE its provisioning round trip — so an anonymous uuid minted during that window is replaced the moment the account record lands. A folder opened in the same window keeps the outgoing uuid.

The prefs key self-heals; the project rows stamped with the old value never did, and `AbProject.isLocalFor` then reads the folder as hosted elsewhere for good: no Open-in-editor or Copy-path rows, a refusal from the direct callers, and a "Remote host" chip for a folder on the user's own disk. The only repair available was removing the project and re-picking it, which destroys the bridge's sessions.json and the project's isolated worktrees.

Repaired at the two points that can know: `ensureCurrentUserDeviceRecord` is the ONE place a persisted host uuid is replaced by a different one, so it re-stamps the rows carrying the outgoing value as it goes; and `registerPickedFolder` re-stamps the row it finds, since the pick itself is proof the folder is on this machine. Both are exact — no blanket "non-matching uuid becomes local" migration, which would be right today only because the local-open path is the sole writer of the field.

The doc comment on hostDeviceUuid already promised this backfill. Nothing implemented it.
@bharathm03
bharathm03 merged commit 4976570 into main Aug 26, 2026
8 checks passed
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.

1 participant