Skip to content

release - #91

Merged
bharathm03 merged 3 commits into
mainfrom
development
Sep 3, 2026
Merged

release#91
bharathm03 merged 3 commits into
mainfrom
development

Conversation

@bharathm03

Copy link
Copy Markdown
Contributor

No description provided.

* A push that cannot name its machine is a push the phone cannot open

A sealed push carried projectId as its only routing id, and projectId is sha256(realpath(folder)) with no machine input — two machines holding the same repo at the same path mint the identical id. A phone addresses a project as <machineUuid>.<projectId>, so a tap had no machine to open.

composePush now returns the routing ids alongside the strings, because it is the only per-message-type switch with Zod-narrowed access to escalationId and sessionId. The dispatcher seals machineUuid from a new required ProjectCoreRemoteDeps.machineDeviceId(), and terminalId when the message names a session — omitted rather than emitted empty, since the phone treats a present key as a session it can resolve.

machineDeviceId is required, not optional: optional would let the wizard-promotion supplier ship unroutable pushes and still compile. Its two suppliers differ in how well they can answer — host-server reads the live machine socket, relay-promotion can only report the uuid the enabling agent:enableRelay carried.

The title is capped alongside the body: the relay rejects an oversized box outright and answers no push:result, so an uncapped title loses the whole notification rather than truncating it.

* A notification says which project it came from, so a toast can point at it

The three fan-in notification providers merge every warm project's stream into one, then threw away the only thing that said whose it was. HandlerEscalation and TerminalNotificationMessage name no project themselves, and a consumer that falls back to the FOCUSED project is wrong for exactly the case this fan-out exists to serve — a background project's agent.

Each stream now carries ProjectScoped<T> = (entryId, message), the entryId being the registry key already in its correct local-or-remote shape. The surfacers take it through to the toast layer.

Dedup keys are unchanged and deliberately NOT the scoped record: keying on the escalationId alone is what keeps the provider's per-rebuild re-seed idempotent, and what lets one escalation arriving both live and via push surface once.

* A toast about another session is a way to get to it

A notification named a session and then left the user to find it by hand. The routing information was already there — the fan-in streams carry the entryId, an escalation carries its terminalId — and it was discarded at the last step.

NotificationRoute is the wire shape; resolveNotificationRoute turns it into a place, and refuses rather than guesses. There is deliberately no projectId-only fallback: computeProjectId hashes the folder path with no machine input, so two machines holding the same repo at the same path mint the identical id and that match can name the wrong one. An old bridge that sealed no machineUuid is unroutable, not resolvable.

applyNotificationRoute is the one idempotent applier, taking a ProviderContainer because the row that raised the toast is disposed by the switch it triggers. It never calls revealHandlerTab or switchToAgentPage after a focus write — the per-session UI restore re-applies the target's own saved tab a frame later and would silently undo them — and hands over through the pending providers instead. It never writes pendingActiveSessionId for the already-focused project, where nothing would drain it and its presence stops reconcileActiveSession falling back.

Its dedup store is separate from the notification surfacer's and namespaced: sharing that set would make every tappable notification's id already-present and the tap a permanent no-op. A claim is released on every non-success exit, a throw included, so a tap that could not land stays retryable.

A notification tap means show me what happened, not resume this, so it suppresses the pending id's auto-start. The suppressor holds the id it names rather than a flag: five other sites queue a pending id without knowing it exists, and a bare flag surviving a bootstrap's early return would eat an unrelated Recent-list tap's resume.

Both drains hold a stamp back while a queued session id is unresolved. The applier stamps after activating the project, while the bootstrap is still fetching the new project's list, so spending it then would reveal a transcript that is not the one asked for — and the restore that resolving it arms would hide the panel again a frame later with the request already gone.

* A tapped notification opens the session it is about

The four OS tap entry points — a warm fln tap, an iOS push tap, and each of
their cold-start reads — become thin adapters over the applier W3 already
built. Each decodes a route and hands it to one funnel in main; none of them
knows anything about navigation.

The path that mattered most was the one with no adapter at all:
pushBackgroundHandler renders EVERY Android background push, because the FCM
message is data-only, and it was showing them with no payload. A payload is
the only channel a route rides on, so without it no Android notification was
tappable-to-route — the headline case of the whole change.

A route is sealed into the OS notification whenever the producer named one,
which is deliberately a weaker gate than the in-app toast's. The toast chip
lives 8s, so resolving at show time is as good as resolving at press time; a
notification sits in the shade indefinitely and the applier re-resolves
against freshly awaited state, so gating it here would bake a cold-cache miss
into a notification that would have opened fine an hour later.

The cold-start read now waits for endOfFrame first. runApp only SCHEDULES the
first frame, and the applier's cross-project path pushes on the root
navigator; reading before that navigator attaches spends the route on nothing
with no retry behind it.

Two platform gates are extracted as pure predicates so they are no longer
deletable with every gate green: the push tap is iOS-only (on Android
PushPlugin fires it for any launch intent whose extras decode, and fln's own
select-notification intent carries a payload extra, so it would deliver every
Android tap twice), and launch details are read only where fln implements
them (Linux throws, Windows replays a tap it already delivered).

Not covered by any test, and stated rather than implied: the four
registrations themselves, the _nextId isolate seed, and show(payload:)
reaching the platform channel all need a device.

* Review fixes: a queued route that resolves to nothing is refused, not spent

A max-effort review of the four-wave notification-tap work found eight real defects. The through-line is a route that survives further than it can be honoured.

The pending-id guard held two surface drains back but nothing watched the provider that clears them, so the only retry notified nobody: reconcileActiveSession selects the queued id off the persisted cache, _bootstrapSessions re-sets the same value, and Riverpod skips on ==. build() now watches it.

_applyAcrossProjects restored the prior pending ids unconditionally on failure, writing back a value another project's bootstrap had already consumed; each restore is now conditional on our own write still standing. nav_controller's project-switch branch clears the start suppressor alongside the id it qualifies, so it cannot re-pair with a later Recent-list tap and eat the start that tap is.

decodeNotificationRoute returned a non-null route for {}, spending the applier's unconditional side effects (exitDemoMode above all) on a tap that then resolves nowhere; a route naming nothing is now no route. namedOrNull returns the trimmed value rather than testing trim() and returning the padding, and is shared with the push decoder so the two cannot accept different ids -- the sealed-push title and body go through it too, since the casts they were are the only thing in that try that could throw on payload content, and a throw there costs the whole alert.

The focused-project path never sent session:focus, so the bridge still believed the client was on the previously selected session and the session now on screen kept its unread dot. ensureMachineRelay returned the inbound auth's deviceUuid as agentDeviceId where every sibling delegates to the live socket, so a re-enable over a running socket sealed pushes naming a machine the relay does not have this host on.

Cleanup: the toast chip's resolve moved inside its own branch (it was computed and discarded on the backgrounded path, which is the common one), an unreachable mobile guard in _revealAgentPanel deleted, and push-dispatcher's sourceMessageId re-pinned to msg.id rather than any string -- that equality is what the app dedups on. bridge/CLAUDE.md records machineDeviceId, added to the remote-deps contract by W1.

Left alone, as design decisions rather than repairs: the pending stamp surviving an interactive cross-project bootstrap, cross-isolate push dedup, the focused-project start suppressor, service-PTY notification ids, and the absent retry/failure surface for a route that cannot be applied.

Gates: flutter analyze clean, flutter test 3356 pass, bridge 3335 tests 0 fail, check:font-tokens OK.
…stream (#90)

A Vite-family dev server (Astro, Nuxt, SvelteKit, plain Vite) upgrades an HMR request only when it names `vite-hmr`, and the previewer proxy dropped `Sec-WebSocket-Protocol` in BOTH directions: the app's 101 answered the browser with no echo, which Chromium fails outright, and the bridge stripped the header before dialing the dev server, which left the upgrade unanswered rather than refused. The app now echoes the browser's first choice and the bridge forwards the whole list to Bun as `protocols`.

The bridge also closed an upstream socket whose tunnel ended while its handshake was still in flight. Bun aborts an unfinished handshake with a RESET, and Node hands an `upgrade` to its listeners with its own error handler already removed — so a dev server that ignored the upgrade holds that socket with no error listener at all and the reset lands as an unhandled `read ECONNRESET` that exits the process. Measured on Node 26 against Astro. Such a socket is now parked and closed once it opens, bounded by `WS_ABANDONED_MAX`.

`openUpstream` wraps the WebSocket constructor, which throws `SyntaxError` for a subprotocol that is not an RFC 6455 token. Nothing between the relay's message listener and there catches, and an uncaught exception shuts the whole host down — so the one tunnel is refused instead.

Known limits, recorded in bridge/CLAUDE.md: the park has no TTL and its cap is per-manager, past the cap eviction performs the very reset the park exists to avoid, and the echo stays optimistic — a server that picks a later entry is logged, and one that echoes nothing cannot be detected at all (Bun reports `ws.protocol` as the first entry offered).

Claude-Session: https://claude.ai/code/session_01Ca7qjBqVbkUGQ71TewJZN5
* Site: draw the mockup's window chrome for the reader's own OS

The shared AppWindow bar only ever drew the Windows caption block, but the app does not: paintsWindowControls is Windows-only, and macOS keeps the real NSWindow traffic lights on the left with a title-bar inset reserved for them. A mac reader was being shown a window they will never see.

Both spellings now ship in the markup and a data-os attribute, set pre-paint by an inline script in the head, reveals one. Apple UAs (macOS, iOS, iPadOS in desktop mode) get the traffic lights; everything else, including Android and any no-JS render such as the OG screenshot, keeps the Windows controls.

* Hero copy correction

* Site: OS-matched download flow, per-platform confirmation page, nav rework

The hero and closing band now offer Windows/macOS/Linux download buttons that reveal the reader's own build via data-os (Base.astro's OS sniff now also detects Linux), all routed through a new /download/started confirmation page that fires the actual artifact download and orients the reader (setup guide, star-the-repo ask), keyed on the ?platform= it was asked for rather than the visitor's own OS.

get-started.astro gained anchor ids so the confirmation page's step cards land on the right instruction; the sitemap excludes both og-card and download/started since neither is meant to be indexed.

Nav: dropped Sign in and Features (both stay reachable from the footer), renamed the CTA from Start free to Download free (the free plan's pricing-card CTA deliberately keeps Start free), added Escape-to-close and current-page indication, and unified the desktop/mobile item lists into one array.

Footer gained the mark+wordmark lockup (previously wordmark-only) and a bare GitHub link. Eyebrow labels dropped their accent rule for a neutral terminal-cursor mark that blinks once on arrival. Tests updated to match; get-started.spec.ts's coverage folded into contracts.spec.ts.

* Site: tighten hero subtext and Handler copy

Below the download button: dropped the VPN/port-forwarding line (redundant with the security page's own argument) and collapsed the OS-availability sentence into a plain builds link, so the subtext reads End-to-end encrypted / See all builds.

Reworded the Handler line — 'a phase held until the evidence is there' assumed the reader already knew what a phase gate was. Now: hand off a session on your instructions, wakes you only when the call is truly yours — same honesty constraint (Handler never runs unsupervised, see Phases.astro), plainer language.
@bharathm03 bharathm03 added the changelog-ignore Not user-facing: keep this PR out of the generated release notes label Sep 3, 2026
@bharathm03
bharathm03 enabled auto-merge September 3, 2026 16:49
@bharathm03
bharathm03 merged commit 8fd2e58 into main Sep 3, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog-ignore Not user-facing: keep this PR out of the generated release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant