release - #82
Merged
Merged
Conversation
Pin the terminal packages to the squash merge of antgrid-ai/dart_terminal#10 so Antgrid receives the origin-safe scrollbar synchronization and selection auto-scroll fix.
* A frame that answers one sentence is never spent on the next A partial cap hit appends, records instruction_dropped AND emits a status snapshot whose backlog that same sentence just moved. Only the amendment path was credited for that, so the cap hit's frame was left to answer for the next sentence in the queue — retiring its row and lifting the edit lock while its extraction was still running. A survivor is now always credited; crediting a frame the bridge never sends costs one re-baseline, which is what the survivor was doing anyway. Alongside it, four places where one rule had two homes: oneLine is defined once in the import-free leaf and re-exported, hostsIn is built on destinationsIn so "the subset of" is true by construction, clipQuote escapes through previewForUser like every other user-facing preview, and a shared clip() stops a cap landing between a surrogate pair. The drawer's waits-on lines resolve against one map per build rather than re-walking the backlog per link. * The goal seeded at arm answers for its own append A goal is extracted on the same per-terminal chain instructions queue on, and lands ahead of them. Its items moved the backlog count that was the only evidence a sentence had, so a preset tapped while the goal was still running was retired by the goal's own append — taking the "sending" row away, lifting the debounce, and lifting the drawer's edit lock while the preset's extraction had not started. The next wholesale edit then went out built from a list missing the items about to arrive. One client reaches this: arm from the new-session prompt, tap a chip before the goal comes back. The mark is set on exactly the condition the bridge queues that pass on — a goal with words in it and no backlog beside it — spent by the first frame that actually moved, and dropped once the backlog is non-empty, since a goal is only ever extracted into an empty one and a mark left standing would swallow the frame the user's own sentence raised.
* fix(app): show the provisioning run instead of calling a queued session stopped While worktree.setup runs the bridge holds the session's start, so the entry reports running:false with setup.pendingStart set. TerminalScreen branched on running alone and rendered "Session stopped" over a Start button whose press only re-entered the same gate — a dead control directly under a banner saying the workspace was being prepared. The pane now branches on sessionStartQueued to a provisioning state whose body is the setup transcript itself: during the run that PTY is the only live output the session has, and it was collapsed behind a chevron above an empty pane. It carries the two verbs that end the wait — Start agent now (skip) and Cancel setup, the latter a bridge verb no surface had offered since it shipped. Renames the banner's Skip to Start agent now: nothing about the run is skipped, the queued agent is released and the install keeps going. The banner drops its tail line and log disclosure while a start is queued so the transcript is not mounted twice, both derived from the same wire field. * feat(bridge): let a project launch its agent alongside worktree.setup Adds worktree.setup.startAgent: afterSetup | immediate. The default keeps today's behaviour — the session:start is queued and fired when the run settles — while immediate lets the agent come up with the first step, for a project whose setup is a cheap .env copy rather than a cold install. The mechanism is one seeded field: beginSetup births the run with gateReleased true, which is the same state a Skip produces, so setupGate declines to report it and start() falls through to the spawn with no branch of its own. Skip stays idempotent, cancel and rerun are untouched, and firePendingStart finds nothing queued. checkoutDeclaresSetup widens to checkoutSetupPolicy, returning declares plus startAgent; an unreadable config now fails closed on both axes rather than one. The rerun path re-reads the policy instead of remembering the create-time answer — a rerun is exactly when the checkout's branch has changed it. The services: deferral stays tied to declares alone: bun run dev against an unprovisioned node_modules fails with nobody watching, unlike an agent. * feat(app): warn when the agent is live in an unprovisioned tree Under startAgent: immediate — or after a hand-pressed release — the agent is running in a checkout that has no node_modules yet, which is a different claim from a neutral progress line. The banner is the only surface that can make it, so it takes the warning tone, says 'Workspace still installing' rather than promising a wait, and offers 'Cancel setup' in place of a release that has nothing left to release. Derived from the two live facts (setup running AND session running) rather than a mode flag, so a config-set immediate and a hand-pressed Start agent now reach the same warning. * feat: make the provisioning wait legible "2 of 5" is actively misleading on a real setup block: step 1 is a 10ms copy: and steps 2-5 are the minutes. The pane now carries the ledger — done, current, still to come — and the banner an elapsed readout beside the rule, which is what separates a slow step from a hung one when a bun install prints nothing for four minutes. The step names are the one new wire field: CheckoutSetupProgress carries them on every report of a run rather than once, SetupRuntime retains them like terminalId, and they are optional in both directions so an older app ignores the key and a state recovered from disk (which knows how many steps ran but not what they were called) renders no ledger at all rather than a column of blanks. The elapsed reading comes off the bridge's clock, which for a remote machine is not ours; a negative result is the one shape of skew we can detect and it is answered by saying nothing. * chore: start this repo's isolated agents alongside setup Five steps, minutes on a cold worktree, and the agent is useful for most of that — reading, planning and searching all work in a tree that is only checked out. Revert this one line to put the wait back. * fix: close the review's findings on the setup-start work The load-bearing one is a bug: a rerun under startAgent: immediate re-arms the previous run's prompt AND opens the gate, and nothing fires a start behind an open gate — firePendingStart runs only when a run settles. The policy was therefore silently ignored on the one path that queues a start of its own, leaving the user to press Start agent now by hand on a project configured never to wait. The rerun test that was supposed to cover this passed for the wrong reason: settleSetup awaits startDeferredServices, so the synchronous stop() beat firePendingStart and lastQueuedPrompt was never banked — the rerun took the no-requeue path. It now waits for the queued start, and fails without the fix. App side: a chat session mounts AgentTranscriptView where the provisioning pane would be, so standing the banner's log and tail down on a queued start left a four-minute install with no output anywhere; the suppression now keys on the pane actually being there. The banner also stood down its action, since two Start agent now buttons 100px apart race for a run only one can end. The pane is keyed by session so an in-flight verb and its refusal cannot land on whichever session is on screen when the reply arrives, a null registration is named rather than dropped, and an expansion the chevron no longer offers is cleared instead of masked — masking alone unfolded the log by itself the moment the gate released. Also folded two duplicated helpers back into their existing versions: listEquals for the step-name comparison, and the transcript's formatDuration so the two live elapsed readouts on one screen spell the same seconds the same way.
Regression from #64. Retiring a sentence off an activity record now always credits the terminal's next status frame, but two bridge paths recorded an instruction row and returned without emitting one — an amend-only drop, and a cap hit with no room at all. The credit was then spent by the NEXT sentence's own append, so that sentence's row stood forever and held the backlog edit lock, which under a full backlog is the only way to free room. Fixed on the bridge, because no app-side rule can work: nothing in an instruction_dropped record says whether a frame is coming. Every path that records an instruction row now emits a snapshot straight after it, making the app's blanket rule a real invariant. An unchanged snapshot answers for nothing and costs one re-baseline. The arm-time goal mark also now mirrors the bridge's goalChanged gate, so re-arming with an unchanged goal no longer sets a mark nothing will satisfy. A goal the bridge rehydrates from its own disk record still cannot be predicted from the app.
… answer the judge question before arming (#69) * The one undo that leaves this machine asks first Three of the four snapshot actions undo locally; undoing a force push writes to a shared remote. The offer is a scrolling list row whose entire body is the tap target, newest first, so the row most likely to sit under a thumb was also the one that could overwrite a ref for everyone on it. Confirmed for force_push alone. The other three keep the one-tap prevention §5.2 buys back, and the dialog promises no recovery: the bridge pins the remote tip before overwriting it only when the ref still exists there. * An escalation that stopped the agent sorts above one that merely waited urgency reached exactly one consumer, the OS notification title, and that path returns early while you are viewing the session it names. Inside the app the field was invisible: high and normal rendered identically, and oldest-first sorting filed a fresh urgent row last. high is not judge opinion. The engine mints it with no judge call at all for a blocking prompt, so it means the agent is stopped right now. Banded ordering with age deciding within a band, applied on the live push too, and marked in the meta column the three escalation row shapes share. * Escalate-only is answerable before arming, not after walking away The catalog already carried judgeCapable and the app already resolved the agent the way the bridge does, so the pre-arm half of the coverage answer was one field away. Until now it surfaced only as an ESCALATE ONLY chip, found on returning to a session that had woken you for everything. The prediction is exact rather than approximate because nothing writes a per-session judge override yet: observabilityFor falls back to the session's own tool, which is what the catalog describes. A judge picker inherits the job of keeping that true. Withheld from the unwatchable arm, which already carries the stronger fact. * Corrections to the three fixes above latestEscalationId read escalations.last, which the new banding turned into the newest NORMAL escalation — never an urgent one, i.e. never the row a caller asking for the latest wants. Folded on at instead. No live caller today, so this was a trap rather than a bug. The undo tap started async work from a void callback with a bare unawaited, which app/CLAUDE.md forbids outright: a throw past the dialog reaches PlatformDispatcher.onError as a FATAL with no in-app frames. Uses detached now. The urgency test is a shared escalationMeta closure rather than three hand-written copies, which is what the adjacent comment already claimed. compareEscalations' doc asserted high is engine-minted only. It is not: escalate passes the judge's own notify.urgency through, so a judge-authored high sorts into the same band. The band is still right; the claim was not. Two bridge comments calling backlog.ts import-free were wrong the same way — it imports zod and ./evidence; what it actually has is a position below every consumer. * The shield answers the judge question every time it is asked The escalate-only caveat added to the arm explainer almost never rendered: the explainer is gated on FirstRunState.handlerArmedOnce, a once-EVER latch, while coverage is per-agent. A user whose first arm was a judge-capable agent never sees that dialog again and would meet an escalate-only one with no warning at all. The shield tooltip is the pre-arm surface that answers every time, and it already carried the observability half. handlerShieldTooltip is top-level for the reason handlerArmExplainerBody is — the precedence is testable without pumping the panel — and keeps the explainer's order: unwatchable outranks escalate-only, since a session reporting nothing makes its judge moot.
…g, and mono inline code The file viewer rendered markdown through markdown_widget's defaults — GitHub's light-theme tokens on a dark ground — with no reading measure, no way to follow a link, and no sense of a document's structure. Adds three files. markdown_document_config.dart is the whole-document config: a document-scale heading ramp pinned at all six levels, dark-appropriate blockquote/hr/table tokens, mono tables that scroll internally rather than overrun the measure, list markers whose shape encodes nesting depth, task boxes drawn with AbIcons, a copy button on every fence, and a chip for a repo-relative image the viewer can open in its own image view. markdown_outline.dart is the heading rail: MarkdownWidget publishes its heading list and scroll index only into its TocController, so MarkdownTocController subclasses it to get both out, and the rail is built from AbListRow instead of the package's Material ListTile. markdown_link_target.dart classifies an href so a relative repo link opens in the viewer, a #anchor jumps in-document, and only http(s)/mailto leave the app. Also fixes inline code across the agent transcript. CodeNode.style resolves as codeConfig.style.merge(parentStyle), and merge lets the argument win every non-null field, so the paragraph's sans family overwrote the configured mono one and CodeConfig was inert — every backticked identifier, path and flag rendered byte-identical to the prose around it. markdownAntgridGenerator re-asserts the family after that merge, family only, and transcript/markdown_body.dart renders through it too.
1.4.1 removes the `SetUp(messenger_, nullptr)` call from `~WindowsHostApi`. That call ran from a plugin registrar destruction callback, which FlutterWindowsEngine fires from Stop() after its own destructor has already nulled the messenger's engine pointer — so it dereferenced null inside FlutterDesktopMessengerSetCallback, whose only guard is an FML_DCHECK that is compiled out in release. The app never pinned the platform package directly, so this is a lock-only change: `webview_all: ^1.3.5` in pubspec.yaml already admits 1.4.1, and no dependency_override or fork was ever added. Verified the fix is in the bytes we resolve: the published archive's sha256 (d9b81f1…) matches both pub.dev and this lockfile, and that archive's `~WindowsHostApi` no longer makes the call. Upstream: abandoft/webview_all#37, fixed by abandoft/webview_all#38. Not yet confirmed at runtime in Antgrid. Four local configurations (profile/release × with/without a host, three launch-and-close runs each) exit cleanly even on the buggy 1.3.10, so no loose build reproduces the fault and a clean run proves nothing. The signature is an access violation escaping a window-proc callback (0xC0000005 paired with 0xC000041D at the same offset), which is plausibly swallowed outside the packaged app — so confirmation has to come from an MSIX build. Refs #63, whose third close condition (no Application Error event on exit) is still open. Claude-Session: https://claude.ai/code/session_01Y8Fukr3ARpsyhoxrSMcPcm
… for it (#71) Two reports from a live two-session run, both confirmed against the logs. The injected reply never sent. A TUI tokenizes a PTY read as a whole, and Claude Code emits a control character as its own key event only while the read is under 64 characters — so `text\r` in one write inserted a literal newline into the composer and waited for a human Enter. Every submit now writes the line, waits out a gap, then writes the CR alone, through a per-terminal queue that keeps any other writer from joining that read. The queue is a synchronous pass-through whenever no submit is in flight, so nothing else on the terminal pays for it. A bare slash verb is padded: splitting the CR would otherwise leave the suggestion list armed, and the key that used to submit would accept a completion instead. Handler answered for the agent. It holds less context and fewer tools than the model it supervises, so a confident guess reads as fact and costs the agent a correction it has no reason to make. It asks now, and decides from what comes back. Seven further findings from the log dig, each adversarially validated: the runaway guard reset on keystrokes that submitted nothing; the judge's timeout was silent on two of the three legs that spend the budget; the activity feed reported a blocked action with prose about the pause rather than the text a guard refused; the destructive floor missed several outward-moving operations, and the wrap-up push buried an expiring undo offer behind an unbounded summary. Floor patterns are one operation each. Section 5.4 keys an authorization lift on the pattern source, so an alternation over two operations lets a lift on either grant both.
…#72) The driver re-sends terminal:resize only when its computed grid differs from _lastSentCols/_lastSentRows -- what the app BELIEVES the PTY holds. Three ways that belief goes wrong with nothing to report the break, and in all three the panel has stopped moving, so the wrapper recomputes the same grid forever and the gate never reopens. _TerminalGridFreeze re-armed its settle countdown on every invocation, and LayoutBuilder re-runs its builder whenever the parent rebuilds, not only when constraints change. Any rebuild cadence faster than the 150ms delay -- a streaming agent, a selection drag, a session-list tick -- cancelled the timer forever, so the grid stayed pinned to whatever it held when the panel last changed size: content clipped at the stale column with dead space beside it, for exactly as long as the agent keeps working. _settlingTo now measures quiet from the last real move, _sameSize replaces exact Size == so sub-pixel flex jitter does not read as movement, and dispose() cancels. sendResize dropped a request whose per-install client id had not resolved while the caller booked it as sent. It now reports whether the frame was QUEUED -- true is not a delivery receipt -- and the three paths that discard an armed frame later (the debounce's driver guard, a cancellation in _handleTerminalSize, disposal) each hand the geometry back through an invalidation. Nothing re-asserted geometry across a reconnect or a same-id respawn. A resize sent into a keyless window vanishes unreported, and a respawned PTY takes terminal-manager's process-wide lastDriverGeometry -- whichever terminal on that bridge resized most recently, not the one this driver sent the dead process. TerminalTab.sizeEpoch is the invalidation edge, the exact parallel of the snapshot-seq cutoff dropped beside it in _rehydrateTerminals, bumped on re-drive and on both respawn signals (terminal:started is not in kCheckoutDurableReplayTypes, so a relay app builds its tabs from the replayed agent:status and sees only that one). The wrapper's per-PTY latches lived in a State that is not per-PTY: only terminal_screen keys it by terminalId, so the pinned pane, the detail view and the setup banner reused the previous terminal's booking across a swap. didUpdateWidget retires them. Also corrects a load-bearing false premise. Two comments justified the freeze by claiming ghostty_vte_flutter does not reflow. It does -- soft-wrapped rows re-join when the grid widens. The freeze's actual rationale survives intact and is the other half: a TUI that wraps its own output writes hard breaks, which reflow never re-joins, so a grid change under an Ink-style redraw leaks stale fragments. terminal_reflow_contract_test.dart pins both halves with margin-filling rows.
… comments from a missing spec (#73) * Notify-only leaves, and takes the config file's last setting with it `notifyOnly` meant "tell me, never act": every pause escalated without spending a judge call, and a park wake notified instead of typing `continue`. Nothing in the product could turn it on. It was only ever sent as `defaultNotifyOnly`, which is read from handler-config.json — a file the bridge and app only ever read and never write. Hand-editing JSON was the sole path in, so the mode was unreachable by construction. Two engine branches go with it, and `outputSnippet()` behind them, which had no other production caller. `defaultNotifyOnly` was handler-config.json's only setting, so the schema, its v1 migration, the loader, the engine's config cache and `cfg()` go too; config.ts survives on ActivityRecord and appendActivity, which is what a later judge or personality picker actually needs from it. No message type is added or removed, so the checkout-variable sets are untouched. The accepted trade, stated rather than denied: anyone who did hand-edit that file has `notifyOnly: true` persisted in their handler-session record. Their parked sessions will now inject `continue` into a terminal they had opted out of auto-driving, and every pause will spend a judge call. Pre-v1, with no users, that is worth more than a compatibility shim for a mode the UI never exposed. One test is replaced rather than deleted: the notify-only sub-case was the only thing asserting that a standing `guard_blocked` report is not a pending question, and the park-wake gate reads exactly that predicate. It comes back as a park-wake equivalent. * The morning-after summary outlives the session that earned it The wrap-up was composed from three private helpers, spent on one push, and thrown away. The `wrapped_up` activity row kept only the goal, which the app renders nowhere. That left the phone-was-off case with nothing at all: handler-activity.jsonl is write-only, `handler:activity` is not a REPLAY_TYPE, and HandlerState is never persisted — so an app that reconnected after the disarm had no feed to go back to, which is exactly when the summary is read. A WrapUpRecord now persists in handler-wrapups.json and replays on handler:status, which is already a REPLAY_TYPE. One module owns content selection so the push and the stored copy cannot drift: buildWrapUp decides which items and which caps, and the push body and the activity detail are two thin renderings of its output. The undo count is the one thing that must never be frozen. It is an argument to the push renderer, not a field on the record, and the app re-derives it: an undo taken after the wrap-up, or a re-arm retiring the offers, would each make a stored count permanently wrong on a card whose whole job is to be read later. The blocked count and its reasons ARE frozen, deliberately — they die with the session when disarm drops it. Freeze what dies with the session, never freeze what outlives it. Sized for the wire it rides: handler:status is emitted twice per handler event, held by reference in the replay cache, and crosses the relay to a phone, so item text is clipped at 120 chars and five records are kept — a worst case of roughly 22K chars per frame rather than the 190K the uncapped shape allowed. The push loses its "see the activity feed" tail, because that feed is not durable and pointing at it was the bug. Wrap-ups survive a re-arm. Snapshots are retired because each pins a stash, a backup ref and a trash copy and owes a release(); a wrap-up owns no resource, so retiring one would keep that cost and drop its reason — losing the previous session's report is the failure this change exists to fix. * A wrap-up is read hours after the disarm, so it gets a card The bridge replays a WrapUpRecord on handler:status; this is the surface that reads it. The Handler screen gains a pinned Wrap-up section between Sessions and Undo, and a project whose only remaining artifact is a wrap-up no longer renders as "Handler is off" — the morning after, that is the whole screen. The undo count is derived at the call site from the terminal's snapshots and passed to the card, mirroring the bridge's own renderer taking it as an argument. Neither side of the wire is given a field it could freeze the count into, which is the only reason the number is still true when the card is finally read. Sans, not mono: a wrap-up is the user's own prose plus chrome labels, never a path or a command. The outcome labels are a local switch rather than a reuse of the activity-row labels — those are keyed on activity decisions (`item_done`), not wire outcome statuses (`done`), and bridging them would be a second hand-mirror to keep in lockstep for four words. * Comments stop citing a spec nobody can open Roughly 180 comments pointed at numbered sections of a handler spec — `§4.3`, `spec §5.2` — that is not in this repository and is not in its history. A reader who followed one found nothing, and the tag was doing the work a sentence should have been doing. Where the prose already carried the reason, the tag is simply gone. Where the section number WAS the justification, the reason is written in from the code that enforces it and names the symbol a reader can grep: `§5.4` becomes instruction-scoped authorization and the file that implements it, `§5.3` becomes the HARD floor tier, `§2.2` becomes the terminal states. The references that resolve are untouched: bridge/src/e2e/ and packages/ cite docs/protocol/e2e-handshake.md by named section and that file exists, and two more cite RFCs. A path plus a named section is a working pointer; a bare number pointing at nothing is not, and that is the whole distinction being applied here. No spec document was written to make the numbers resolve. Reconstructing one from the code would invent a source of truth that never existed and leave 180 comments citing a document written after them. * Test names say what they check, not which section asked for it The same dangling references ran through test names — `describe("§2.2 allTerminal is the wrap-up predicate")`, `group('quick choices (§4.6)')`. A test name is read in failure output, where a section number from a missing document is worth less than nothing: it names an authority the reader cannot consult instead of the behaviour that broke. Renamed to describe the behaviour, and the comments inside these files got the same treatment as the source. Pass counts are unchanged — 3159 bridge, 3087 app.
… reserving it (#76) * The drawer's trailing glyphs share one column, and hover actions stop reserving it Every drawer row right-anchored a different-width box against the same 12px gutter, so the outermost glyph landed at a different x per row type: the PROJECTS refresh at 24px from the panel edge, a band's status dot at 15, a project row's + at 28. Box-edge alignment is not optical alignment — an AbIconButton pads a 14px glyph inside a 24px box and a status dot is a bare 6px circle. AbRowTrailingCell centres either in a cell of the button's own reported footprint, so the column holds at any UI Size and on both platforms; a scalar token would have been right at exactly one of them. Row(spacing:) also charged a 4px gap for the zero-width children that _RemoveButton and DrawerProjectAggregateDot returned when they had nothing to show. Kit assembly drops absent children before layout instead. The hover-only buttons held their width through Visibility(maintainSize:), which reserved both axes to fix a problem that only existed on one: an icon button is the tallest thing in an sm row, so mounting one on pointer-enter grew the row ~10px. AbRowContentFloor anchors the height on the row, which lets the actions collapse — 32-72px per row given back to names, on a 288px panel. session_row.dart already worked this way; its hardcoded 24px leading anchor moves to the same floor, which also stops it jittering at UI Size 1.25, where the scaler-multiplied kebab beat it. Reveal is hover, keyboard focus, or an open confirm dialog. maintainSize excluded semantics but not focus, so the invisible trash and discard buttons were already tab-reachable; collapsing on hover alone would have made them unreachable instead. Ordering is now actions-outermost on every row class. The file tree takes the floor and the collapse but no cell — its terminal element is a variable-width diff-stat badge in a resizable pane with no fixed edge to align to. Comments that cited the old mechanism are rewritten, including file_tree_view.dart's claim to use "the same technique session_row.dart uses", which described the opposite of what session_row.dart does. * Hold what the rail revealed: latch it, key it, and report the focus it drops Review of the trailing-rail change turned up four ways a revealed affordance outlives or outlasts the state that owns it. The + now latches the row it was revealed from for as long as its create runs, the way the trash already did: a cold remote open takes tens of seconds, and unmounting mid-flight took the re-entrancy guard with it, so a second hover and a second tap launched a concurrent session and the failure snackbar reported to a dead context. AbListRow only mounts the detector that owns the focus highlight for an enabled, interactive row, and dropping it reports no closing false. A row that goes disabled while focused therefore latched every focus-revealed glyph on with nothing focused. didUpdateWidget now clears the bit and reports it, deferred because the caller answers with setState. AbRowTrailingSwap excluded the resting STATUS glyph from semantics the moment the row was revealed, and reveal is driven by focus as well as hover — so a screen-reader user arriving at a machine band was the one reader who never heard whether it was online. Only the invisible ACTION leaves the tree now. _AdvertisedProjectRow became stateful in the same change but was built unkeyed in a list the control plane reorders live, so positional reconciliation handed one project's focus latch to another. Two subscriptions were being churned rather than held: DrawerProjectAggregateDot.needsUser ran inside HoverableDrawerRow's builder, where ConsumerStatefulElement retires anything the element's own build did not re-read, and _RemoveButton.offersFor sat behind an && whose left side is hover, giving one predicate opposite lifetimes at its two call sites. The file tree floors row height on whether the TREE was wired with git callbacks, not on the platform: the Files tab mounts no buttons at all and was paying a third of a row's height for them. And a kit nested inside another kit no longer claims a rail cell — the panel-edge column belongs to the outer one — which kit() now expresses with ownsColumn instead of leaving to the caller. AbDockedColumn's minBodyExtent scales with the text scaler. The rows it holds room for floor on AbIconButton.boxExtent, so a raw 44 fell short of the FIRST row above UI Size ~1.15 and the drawer's list strip stopped containing a whole one; the restored assertion measures the band's bottom rather than pinning a sliver.
The app's browser-to-bridge frames now leave in one FIFO, so async sealing can no longer put the first SignalR frame ahead of its tunnel:ws-open. Ordering is only half of it: a send with no session keys installed completes SUCCESSFULLY and delivers nothing, so the queue checks isEstablished, bounds itself at 64 frames / 1 MB, times each send out, and closes the browser socket on any frame it cannot vouch for — the page then reconnects instead of holding a socket against a tunnel the bridge never heard of. The close waits on the backlog for a bounded 2s rather than strictly behind it, since a wedged queue has already lost the data the close would follow and the bridge's upstream dev-server socket stays open until it lands. Inbound needed the same treatment and is where the reordering actually bites: decrypt is async and the platform AES-GCM implementation dispatches by payload size, so a small frame overtakes a large one — a ping ahead of the 30 KB render batch it acknowledges. Chained per channel in MachineSession; channels stay independent. On the bridge, a pre-open buffer that overflows or expires now POISONS its tunnelId instead of quietly dropping frames and carrying on. Replaying a stream with a hole in it is worse than the guard this replaced: a dev server handed a spliced message stream believes it holds a valid session and hangs, where a refused tunnel gives the browser the close event its reconnect logic waits for. The tombstone outlives the refusal so frames still in flight cannot start a second, tail-only buffer, and a bridge-initiated teardown leaves one too — the app answers that close by dropping its own entry, so onWsClose never runs and trailing frames would otherwise hold a slot for a full TTL each. Tombstones are the first eviction candidate when the table fills, which is what stops a dev server in a reconnect loop from starving the live tunnel. The post-open buffer gained the same ceilings, and it is the window that actually needed them: a port that accepts TCP but stalls the upgrade holds it open for the OS connect timeout, tens of seconds against the pre-open path's five. stop() is now terminal and sends its own tunnel:ws-close before closing each socket — a socket still CONNECTING never fires a close event, so a session deleted mid-handshake left the app believing the tunnel was live.
…red (#79) * Bridge: report its own crashes, under the same consent the app answers to The host process had no crash reporting at all. It now runs @sentry/bun behind three gates that must all hold: the user's telemetry consent, carried on the stdin bootstrap as `telemetryEnabled` and read once for the host's lifetime (absent means off, so a CLI or test host never reports); a SENTRY_DSN baked in by --define, exactly like LICENSE_API_URL; and a scrubber that strips paths, source lines, locals and the hostname, kept in lockstep with the app's. OnUncaughtException/OnUnhandledRejection are kept, not excluded: they are what stamps a fatal handled:false, which a hand-rolled captureException reports as generic/handled:true. They are re-added with options pinned rather than inherited, and the contract's other half lives in index.ts -- the SDK re-counts the OTHER uncaughtException listeners AT CRASH TIME, so it defers to our teardown only while one of ours is registered. Init also verifies getClient()?.getDsn() and logs at error when it is missing. Sentry.init never throws and never returns a status, so a DSN it refuses leaves a transport-less client on which every capture and even flush still succeed -- silently sending nothing. That is not hypothetical: the JS SDKs require a NUMERIC project id and errex issues slugs, so the DSN CI bakes in is refused outright. sentry-dart takes the last path segment as an opaque String, which is why only this side is affected. * App: give sentry-native a database path it can actually write on Windows sentry_flutter never assigns nativeDatabasePath, and sentry-native then falls back to `.sentry-native` relative to the CURRENT WORKING DIRECTORY. That is unwritable in exactly the configuration we ship: a Store-launched MSIX gets C:\Windows\System32 as its cwd, and its own install dir under WindowsApps is read-only. sentry_init fails, and native crash capture is absent with nothing to notice it by -- no crashpad handler, no database, and (auto-session-tracking being a native option) no release-health sessions either. Measured on the shipped 1.20698.1008 package: sentry.dll loaded, no .sentry-native anywhere on the machine, no crashpad_handler process. Dart-level reporting was unaffected throughout, which is why production still received Dart fatals while every native crash was lost. Also turns options.debug on outside release builds. The SDK reports its own init failures at debug level and nowhere else, which is the whole reason this went unnoticed: a broken native layer looks exactly like an app that never crashed. docs/release/build.md records that errex has no symbol-upload endpoint -- sentry-cli's chunk-upload and legacy dsyms paths both 404 while implemented routes answer 401 -- so desktop native frames arrive as module+offset and a sentry-cli upload step would only fail in CI. * Blurring an inline rename must not dispose a FocusNode mid-notification Production fatal, 3 occurrences: ConcurrentModificationError: Concurrent modification during iteration: _Set len:4, culprit _CompactIterator.moveNext under FocusManager.applyFocusChangesIfNeeded. Every frame was in_app:false, which made it read as a framework bug; it is ours. applyFocusChangesIfNeeded notifies listeners with `for (final node in _dirtyNodes) node._notify()`, and FocusNode.dispose detaches, which makes FocusManager._markDetached do _dirtyNodes.remove(node) -- mutating the Set being iterated. The field's onFocusChange commits the rename, detached() runs that action through Future.sync, and _commitEdit calls _exitEdit BEFORE its first await, so the dispose landed inside the notification. _exitEdit now clears the fields first and disposes in a microtask, so the notification unwinds before the detach and nothing can reach a disposed node in between. Fixing it here rather than at the callback covers both commit triggers, Enter and blur. The regression test drives the real row: double-tap to rename, move focus away, assert no exception. Reverting the fix makes it throw the same ConcurrentModificationError.
… path it enabled was unscrubbed (#80) * Bridge: refuse a DSN before the SDK installs anything, not after The gate ran after Sentry.init, which installs both top-level process handlers before it ever looks at the DSN — and nothing takes them off again, since Sentry.close() disables the client but leaves the listeners. A client that could never transmit therefore kept owning both fatal paths, with the warn-mode rejection handler printing raw unredacted reasons into the stderr teed to host.log. hasNumericProjectId now decides ahead of init, so a refusal installs no client and no listener; the test pins the listener counts. CI baked in secrets.SENTRY_DSN, the app's slug-project DSN the JS SDK refuses outright, so every desktop bridge shipped inert. The builds now read SENTRY_DSN_BRIDGE, which does not exist yet — until it does, reporting stays off loudly rather than silently. tracesSampleRate, spotlight and debug are pinned because getClientOptions fills each from the ambient environment, and the host inherits its environment from whatever spawned it. SENTRY_SPOTLIGHT would fan every envelope to a second loopback destination and SENTRY_TRACES_SAMPLE_RATE would emit transactions, which beforeSend never sees. Also: Modules excluded and event.modules dropped (it walks up from a cwd the host did not choose); redactNullable no longer throws on a null, which beforeSend would swallow into a dropped event; redactDeep uses fromEntries so a __proto__ key cannot silently delete its sibling; debug_meta code_file redacted; a failed first-project open is captured before its bare process.exit; startControlPlane moved below the handler registration, since host.json on disk lets the app drive project:open during the relay handshake. * App: scrub the breadcrumbs the native layer copies, and say what stays out of reach beforeSend is not the whole story where there is a native layer. sentry_flutter's C binding never calls sentry_options_set_before_send, so sentry-native writes and posts its own envelope for a native crash — and the nativeDatabasePath fix is precisely what turns that path on for the first time, taking it from broken-and-silent to working-and-leaking. beforeBreadcrumb runs before NativeScopeObserver mirrors the scope down, so it is what keeps a path out of the copy the native layer holds. Frames and contexts of a native crash stay unreachable from Dart; the comment now says so rather than implying coverage. The support-dir catch restored the exact behaviour the function exists to fix and had no symptom by construction: no handler process, no database, no release-health session. It now warns. Consent reads through telemetryEnabledProvider, and frame module/package are redacted alongside absPath — native frames carry an absolute path there. fileName deliberately is not: a Dart frame's is a package:/dart: URI, and _pathLike would eat it. The symbols note claimed native frames can be hand-symbolicated against the build's PDBs. No workflow archives them, and the toolchains are not bit-reproducible, so rebuilding the tag yields build ids that do not match. Recorded as the gap it is. * The rename field's disposal waits for the frame, not just the microtask Deferring to scheduleMicrotask unwound the focus notification but still landed inside the frame showing the TextField, so the field outlived the controller and focus node it is built against — any pointer, key or traversal event in that gap touches a disposed ChangeNotifier. A post-frame callback runs after the setState rebuild that takes the field down. The regression test also closed neither the CachedSessionsStore nor the ProjectSession; both own timers and subscriptions that would outlive the tree and fail some later test with a pending-timer assertion pointing nowhere near this file.
* feat(git-sync): enhance sync state reporting and add sync messages * feat(git): implement git log retrieval and commit file details * feat: implement stash functionality for worktree handling * feat: update demo branch constant and fixed files view * Git: bound every argument the wire supplies, and drain the writes it starts The new git verbs reach argv positionally, and parseMessageFast skips Zod on that path — so a stash ref of "--index" pops a stash nobody tapped, a commit id of "--output=<path>" turns a read into a write, and "--help" hangs a non-interactive Bun.spawn forever holding the checkout. Refs are now matched against exactly what listStashes emits, shas against a sha pattern, and log paging is clamped. git:sync, git:stash-pop and git:stash-drop are tracked rather than fired: each holds the checkout as its child's cwd, and awaitGitRefreshes is what teardown waits on before git worktree remove. Untracked, a session deleted mid-push takes a Windows sharing violation and is undeletable forever. handleGitSync also answers a throw with a result — git:sync-result is not a replay type, so sending nothing left the panel's spinner turning for the life of the session. The app was applying replies it never correlated: any git:log page landed under the skip it was not asked for, and a late push result cleared a pull's spinner and reported the push's outcome as the pull's. Both now carry a guard. A probe's counts supersede the local pair in the frame reporting its verdict, git:sync-state is forced where the bus would dedup the re-push a resync exists to perform, and five widgets that started async work from void callbacks got detached() — two were carrying a WidgetRef across the await that disposes them. The status-cache suite failed about half the time: bootCore waited only for the first agent:status, so boot's post-refresh re-send landed mid-test. It now drains on the git:sync-state that closes that same .then(), which also makes the counts mean something — the re-send is deduped, so only the pull's own recompute can move them. --------- Co-authored-by: Bharath Mohan <2254476+bharathm03@users.noreply.github.com>
…d publish the security page (#75) * Site: make the terms, the policy and the pricing page say the same thing Terms section 1 said local use is free and paid plans add remote control, which contradicts section 5 and the free plan the whole funnel rests on. The wrong version came first, in the operative agreement. The privacy policy described the relay briefly buffering messages for offline recipients. It does not: server.ts refuses the frame with PEER_OFFLINE and drops it. The uniform refusal is deliberate — an unauthorized sender must not learn which devices are online — so the policy was describing a weaker relay than the one we ship. Support named Gemini CLI, which is not in the agent registry at all; Cursor is. SSO, audit log and IP allowlist were claimed as shipped on the marketing pricing page, in web's plan UI and in support: the capability flags exist on the plan model but nothing reads them, so all three now say roadmap and cross-reference each other. Enterprise leads went to /support, which put a budget holder on the troubleshooting page. * Site: answer "does this work with my agent" before selling the gate The headline spent the largest type on the overview ("One screen."), which is a watching claim for a product that acts, while the argument the ProofCard beneath it makes went unstated in anything above body copy. The kicker now carries scope and the search terms; the headline carries the wedge. Fleet still owns the overview claim, being the section that proves it. Leading with the gate needs the roster to be honest about who gets it. The new band under the hero lists every supported agent and reserves signal for the three handlerObservable answers true for — a terminal session needs the integration to POST /handler-event, a chat session needs a driver. In this palette the accent means the system is doing something (see .live-cells, and why Eyebrow gave the colour up), so ten glowing marks would promise the paid feature to six agents that cannot run it. The sentence below names the three in words as well: colour reinforces, it never carries alone. The roster left CrossAgent because section seven is too late to answer a question that decides whether someone keeps scrolling. Typography: the kicker's first sentence needed 783px in a 672px box and stranded a two-word tail above a display headline; the availability line was four clauses in a 21rem column and split "iOS &" from "Android". Both are now one sentence per line. Mobile availability read as "you can't have it yet" while TestFlight and Play internal testing invites were in fact open. * Site: publish the security page, and let the reader check it The repo is public, SECURITY.md exists, the handshake spec is written down and the relay client is Apache-2.0 and auditable. None of that was reachable from the site, so the strongest evidence we have was also the least visible. The page claims architecture only: what the crypto is, what has to be true before a phone can drive a machine, and — the section that does the most work — exactly what metadata the relay does see. Every claim that has a limit prints the limit beside it, including the ones that are unflattering. No SOC 2, no DPA, no residency, no compliance language of any kind, because none of it is true today and a reader can check. Adds .well-known/security.txt per RFC 9116, and the text/plain MIME type Azure needs to serve it: the global X-Content-Type-Options: nosniff means a wrong content type would have been fatal rather than cosmetic. home.spec.ts asserted nothing links to /security. It now asserts the opposite. * Replace the dead paid buttons with a founding-price capture on all three surfaces Every paid CTA rendered as a disabled "Available after beta" control, and the site had no email capture anywhere. That is the one instrument that produces pricing signal before launch, so the shutters become a form: POST /api/waitlist on the web service, backed by a Prisma model, Zod-validated, CORS'd for the marketing origin, and idempotent on a repeat address so it never leaks whether one is already on the list. The capture names no figure. An address is not consent to a price, and we have no pricing data yet to set one with. The card hosting it no longer strikes through $99. That price has never been charged, so rendering it as a crossed-out former price invents a reference price the product never had — the thing CCPA's dark-pattern rules and EU Omnibus Art. 6a both reach. Stated forwards as the list price at launch it is the same contrast and a true sentence. pricing.spec.ts now asserts the absence of any strike-through as well as the figures. The app's worker-cap dialog stops offering an Upgrade button that could not be pressed and points at the same list; it is copy and a link, so it degrades to a dead link rather than a broken flow if web deploys later. * Site: point Features at the paid feature, and stop the title claiming the wrong category Nav and footer sent Features to #fleet. Phases.astro (#handler) sits above Fleet.astro, so the one link a reader clicks to find out what the product does opened one section PAST the only thing anyone pays for. It is now #handler; scrolling on from there still reaches the fleet view. The home title said "remote dev for concurrent coding agents". "Remote dev" names the cloud-workspace category — Codespaces, Gitpod, devcontainers — which is the opposite of what this is: the agents run on hardware the reader already owns. It now says "remote control for Claude Code, Codex and Cursor", which is accurate, names the agents people actually search for, and fits in 58 characters. The description drops "sequences the work and proves it's done" for the hero's own sentence, so the result and the page it opens agree. 404.astro answers every unknown path, so a mistyped inbound link could be indexed under its own URL as a page saying nothing exists. Seo.astro grows a robots prop, set only there; every other page still emits no robots tag at all. Three contracts, because none of this could fail loudly. Anchor hrefs are excluded from home.spec.ts's dead-link sweep (a fragment never reaches the server), so a renamed section id is the one link on the site that rots silently — all of them are now resolved against the DOM. Features is pinned by target. And the noindex is asserted on /404 together with its absence on /, since a stray default there delists the site. * Site: reposition the home page from remote control to a control plane Remote control is 1:1 and commoditising — every agent vendor is shipping phone access, and no company buys "reach my laptop". A control plane is 1:many: N machines by M agents on one screen, which is the version of this a team recognises as its own problem. Remote development was the other candidate and is worse: it is the Codespaces/Gitpod shelf, judged on workspace provisioning and browser IDEs, and it centres a human at a keyboard, which is what we are positioning away from. The headline takes the overview claim back from Fleet.astro. It was handed over when the h1 carried the evidence gate, and the gate was never entitled to it: Handler is opt-in and it is on Pro (CAPABILITIES in bridge/src/entitlement.ts), so "Make it prove it" was false on every free machine until someone armed it. The one promise a stranger is asked to believe now holds on a bare install. Fleet keeps the proof, and the ProofCard under the hero still shows the gate working. The gate drops to the second beat rather than out: it is the only thing here anyone pays for and the only part that is hard to copy, so the hero points at it with a verb — you ARM Handler — and Phases.astro keeps the whole argument. Fleet's lede stops restating the hero and picks up the team scale instead; the footer follows the same order. The og card needed no re-cut. It already says "Every agent. Every machine. One screen.", which disagreed with the old headline and agrees with this one. * README: answer what a repo visitor asks before they scroll Three questions decide whether someone stays: what is this, may I use it, may I contribute. The first was answered in prose and the other two were 100 lines down, so a reader who cared about the licence found out about ELv2 and the closed PR queue only after investing in the page. All three are now above the fold in one NOTE callout. The tagline follows the site to the control-plane position. The supervisor keeps its place as the differentiator — a README has room for both, unlike a hero — but it is now labelled as the paid tier and cites the gate (CAPABILITIES in bridge/src/entitlement.ts). Someone who builds from source and finds the supervisor inert should learn why from us, not from an issue thread. The security section gains what it was missing: the limits. No external penetration test, no certification, and the trust boundary is emptied of the relay but not of the account service — app.antgrid.ai serves the device inventory your phone reads a machine's Ed25519 identity from, so it is trusted to hand you the right key even though the relay never is. A CI badge, since the workflow is public and a green one is cheap evidence for a pre-release repo. antgrid.ai/security is deliberately NOT linked: it 404s until the site deploys, and the in-repo spec and sources it would point at are already linked here. * Site: make the hero's light survive a narrow frame The glow was a fixed 980x620 box with a closest-side gradient, so its visible circle was 434px across at every viewport. On a desktop that is roughly a third of the frame with dark air either side, and the falloff is what makes it read as a light source. On a phone it is wider than the screen: no falloff lands in frame, so the light flattens into a brown tint over the kicker with one hard horizontal terminus and no shape to it. Below md it stops being an orb and becomes an edge — the gradient's centre sits on the top edge, so only its lower half is ever visible and the falloff runs down the one axis a phone has room for. Alpha drops with it, because 0.16 across the whole of a small frame is a far larger event than 0.16 across a third of a large one, and it sits behind the lowest-contrast text on the page. Above md the halo is unchanged in look but sized min(980px, 68vw), which also fixes 768-1024 where the fixed box overflowed the same way. The live cells go with it. They are placed in raw px so they land on the 32px background pitch, which means on a phone only the leftmost column is on screen and all of it sits behind the copy rather than beside it; a warm block fading in and out under muted body text reads as a rendering fault. Liveness is already carried in that viewport by the beta pill and the ProofCard's loop. The one cell at x=96 moves to the right gutter for the same reason a viewport wider: the shell is 72rem, so its left margin shrinks with the frame and by 1280 that cell was sitting on the kicker. astro check 0/0/0, 86/86 Playwright. Verified at 320, 360, 390, 430, 768 and 1440; the closing CTA's glow is left alone, its card frame contains it. * Waitlist capture: check the answer, scope the bindings, keep the focus Success was decided on res.ok alone. Any 2xx from something that is not this endpoint -- a maintenance interstitial, an SSO landing page, a CDN error page served 200 -- hid the form and told the reader they were on the list. No row was written and no retry was possible, because the form was gone. Both clients now require the body's `ok` as well as the status. web's submit button shipped enabled while the form's action names a JSON-only endpoint, so a page whose script failed to load fired a native urlencoded POST and navigated the reader off /pricing onto a raw error body. It ships disabled now and the script enabling it is what says the handler is attached; a noscript note gives the scriptless reader the email route. The site's card already worked this way. web's card bound its controls by fixed id -- and its own comment claimed a second copy would bind its own. It would not: duplicate ids, and the second card's label focusing the first card's input. Bindings are per-form data attributes now, ids are per instance, and the status paragraph carries one so aria-describedby can reach it. Disabling the control a reader just activated blurs it, and focus fell to <body>: their next Tab restarted at the top of the document, permanently on the success path since the button stays disabled there. Focus is reclaimed only when it did in fact land on <body>, so someone who tabbed on keeps their place. WAITLIST_SOURCE was "pricing" on both surfaces. email is UNIQUE and the insert is ON CONFLICT DO NOTHING, so the app's logged-in card and the public marketing page were indistinguishable in the one column that exists to tell them apart -- and unrecoverable after the fact, on the release whose whole point is measuring founding-price demand. web sends "app_pricing". The 400 echoed Zod's issues from an anonymous, cross-origin-allowed writer that neither client reads them from -- both pick their wording from the status code. Dropped. The 254 bound now applies to the trimmed value; the outer bound only stops an unbounded string reaching toLowerCase. Site: WaitlistSource is a closed union, so a surface added with a space or a capital fails astro check instead of 400ing at every reader with copy that blames their email address. The status line reserves two lines -- every message there wraps at the card's mobile width. And cors() gets maxAge, since the fetch default caches a preflight for 5s and every retry paid a second round trip. * Say only what the code does: the claims this release got wrong Fleet's "On a team, every seat's machines land in this same list" has no code path. mayRoute is the only routing authorization the relay has and it is same-uid; devices are listed per userId, and each seat signs in as itself. The security page shipped in the same release says the opposite in as many words. The security page called Handler's headless runs "read-only tools over that working tree". That is the readonly tier. opencode is the transcript tier, and registry.ts says why in as many words -- config-level rather than flag-proven -- with judge.ts compensating by withholding the transcript path. opencode is one of the three agents this same release advertises as Handler-supervised, on a page whose entire argument is that it states every limit. "It never borrows a different vendor's agent" also ignored a user-set judgeTool. support.md offered three sign-in options. better-auth enables emailAndPassword, the app has a real password step, and the security page added in this same release correctly names four. The README said CAPABILITIES is the whole gate and everything above it is free. The machine count is a second paywall (FREE_WORKER_LIMIT, the only one enforced on a server), and this same branch replaced that dialog's Upgrade button with a waitlist -- so a second machine is not merely refused, it is currently unpurchasable. privacy.md never disclosed the waitlist row this release starts writing: no collection entry, no retention period, and a deletion path that requires a registered account a waitlist signer does not have. All three added. The hero pill quoted a percentage off $99, a price that has never been charged -- the exact invented reference price PlanCard's own comment refuses to print, and for the same stated reason. It quotes both figures forwards now. The FAQ's founding/list framing is gated on OFFER_ACTIVE like every other offer string, so flipping the documented switch no longer leaves the card and the FAQ contradicting each other on one screen. Compat's prose is derived from the array the chips are built from, so it cannot go on naming three while a fourth chip lights up -- the accent has to stay in lockstep with handlerObservable. home.spec's cross-agent test was asserting the roster at page scope after it moved to Compat.astro, so it stayed green while checking a different section than its name; it is scoped now, and a new test pins the supervised three and the catch-all in #agents. Docs: relay-requirements' archived banner now covers the offline queue that never shipped and that /privacy denies exists, and DEVELOPMENT records that production CORS_ORIGINS must list the marketing origin -- nothing can catch that mismatch but a reader. * Site: name the seven brand marks instead of inlining 3,700 icon() was registered with no include, and astro-icon assigns an installed collection ["*"] -- so adding @iconify-json/simple-icons inlined the whole pack into the build's virtual module, measured 2,029,251 to 6,752,970 bytes of build-time source, to draw seven chips in Compat.astro. The cost is invisible in the output, which is how it would compound with each brand pack anyone adds. Collections left unnamed (tabler) still get the whole pack. * Site: recut the social card for the headline the site actually has The hero became "Your machines. Your agents. One control plane." and the card kept saying "Every agent. Every machine. One screen." — so every shared link sold a headline no page carries, and the og:image:alt beside it repeated the retired line to anyone reading with a screen reader. New filename rather than a re-shoot in place, which is the rule Seo.astro already states: scrapers cache og:image by URL, so overwriting one-screen.png would have left the superseded card in previews for as long as they hold it. The old PNG stays for the same reason — a scraper still on the old URL re-fetches it, and deleting the file turns those previews into a broken image rather than an out-of-date one. That is now written down, because an unreferenced binary is exactly what a later cleanup deletes. 2.5rem, down from 2.75: the new headline is a sentence longer, and at the old size "Your machines. Your agents." no longer cleared the 39rem column on one line. The third line that bought would have pushed the ProofCard's amber wake off the bottom edge, which is the one row the card's composition has no slack for. And a contract test that the og:image the meta tag names is in the build at all. The filename tracks the card's claim, so every recut edits a string in Seo.astro that nothing checked; getting it wrong 404s the card on every page at once while the rest of the site stays green. It paid for itself on the first run — it caught a stale preview server serving a dist built before the recut. * Correct what the upgrade screen's comment claims about the cap dialog The comment I added a commit ago said device_cap_dialog.dart "takes a founding-price waitlist signup". It does not: it opens antgrid.ai/pricing externally, with an open-external glyph on the button and a line above saying why the button is a waitlist and not a purchase. The app captures no address anywhere. Which is the more useful fact for the next reader of this file, because it is the pattern this screen is missing rather than an argument for building a fourth capture: the app already has an answer to "the paid path cannot be bought yet", one screen over, and it is a link out. * Site: build the app-shell mockup (drawer, terminal pane, three scenes) The drawer now shows multiple machines (This machine, macbook-pro, prod-box), matching drawer_entry_row.dart's local/expanded/collapsed band semantics. The shared agent pane renders a terminal transcript instead of a chat transcript, since Terminal is the app's default mode and Chat is still alpha (mode_segmented.dart). Handler's escalation copy was updated to match what the terminal actually shows: no test command ran, three file edits, and the auto-answer is the write permission the transcript records rather than a rerun that never happened. Fleet's composer chip now names a machine (studio-workstation) instead of a generic 'Local', consistent with the drawer's multi-machine framing. * Site: pin the drawer's sign-in row to the window's stretched bottom edge The rail column stretches to match its taller siblings, but the footer just followed the list instead of the stretched box, so it sat a few rows down with a dead gap beneath it instead of flush against the window's bottom edge like the real drawer's account row.
* The Handler tab answers for the session in focus Files, git and terminals all scope to the focused checkout; the Handler tab was the one workspace panel still mixing every armed session in the project into a single list, leaving the reader to do the routing from the terminal id on each row. HandlerState.forTerminal narrows a project's state to one terminal, and focusedSessionHandlerStateProvider is what the tab renders. A null focus narrows to nothing rather than to everything: the mixed list is exactly what "everything" would draw, so falling back to it would undo the narrowing at the moment focus is unresolved. defaultTool rides through unfiltered because it is the project's judge fallback, and pendingUndo is filtered against the narrowed offers so a spinner cannot outlive the row it belonged to. The service and the bridge engine stay project-wide. Escalations, undo offers and wrap-ups have to keep arriving for sessions nobody is looking at, so only the presentation narrows. The workspace badge narrows with it: a count spanning the project would send the user to a tab narrowed past the escalation it promised. * A session nobody is looking at gets its count on its own row Narrowing the tab took away the only surface a sibling session's escalation had. The NEEDS YOU pill does not cover it: the pill yields to the focused session whenever that session is itself waiting, which was safe while the tab drew every row and stopped being safe the moment it drew one. Two sessions each holding a question meant the unfocused one had no surface anywhere. SessionHandlerBadge puts the count on the drawer row, the surface that survives whatever is in focus. It answers for the focused project only: handlerStateProvider follows project focus, so another project's row would be answered out of this project's sessions and attach a real count to the wrong name. The two docs that promised the old behaviour said the opposite of it once the tab narrowed, so they now name the row badge instead. * The pill opens the session it named, and the tab it opened stays open Two faults, both only reachable now that the tab shows one session. The destination came from the project-wide escalation list, which is banded by urgency, while the label came from the focused session's own count: an urgent question on B beside a normal one on the focused A drew a pill reading that A needs you and switched the whole workspace to B. Focus is now read back after the write, because ActiveSessionId.set refuses a session the bridge is already deleting and such a session keeps its replayed escalations for the seconds before its row goes. Revealing the tab in the same turn as a focus change is silently undone: the focus write arms WorkspaceShell's per-session UI restore, which re-applies the target session's own saved tab from a post-frame callback registered after any callback the tap schedules. The destination goes through pendingWorkspaceViewProvider instead, the same handover a deep link uses, drained after the restore. * Choosing how much Handler handles, and who judges for it Handler had one behaviour and one judge, both picked by the bridge. A session now carries a posture — watchdog, closer, autopilot — that sets how much it decides alone, and a per-session judge tool and model that say who does the deciding. The posture rides the wire as an optional field, so a bridge that has never heard of it keeps its old behaviour and an app that is told nothing shows nothing. Both live on one sheet, opened by the first arm and by the PA bar for as long as a session is armed. Arming stays one tap: the sheet appears once, sends only the fields the user actually moved, and every later arm carries nothing at all — a cold settings cache must never clear a judge the bridge holds and this app was never told about. A judge that cannot run headless makes the posture inert, because the bridge gates the whole decide pass on that same answer. The sheet says so where it can be fixed: the presets paint as chosen-but-not-running, the caption is replaced by the consequence, and the notice sits between the posture and the picker that fixes it. The PA bar chip tints for the same reason. Adds AbSegmented.inactive for that state — a selection that is stored and not in force, which is not the same as one the control refuses to make. * The sentence you arm with, and who will be reading it Arming Handler was a screen you tapped through on your way out. It explained what Handler does, offered a posture and a judge, and gave you nowhere to say what you actually wanted done — the backlog came from a prompt you had typed on a different screen, minutes earlier, for a different purpose. Now the box is on the sheet. It is not the arm's goal. A goal is a statement of what a session is for and it grants nothing; handler:instruct is the one feed point instruction-scoped authorization derives from, so text that reads as a request has to arrive as one. That text cannot ride the arm either — the bridge drops an instruct with no armed session, into a stdout no phone reads — so it goes out from inside the confirmation latch, at the only moment there is a session to send it to. When no confirmation comes, the sheet says the words were not queued rather than letting them vanish. The four preset chips are gone. What replaced them is the judge, in the composer's own control row, because the judge is what READS the sentence you just typed: it splits it into backlog items when it can run headless, and hands the whole thing over as one truncated item when it cannot. That was true before and no surface said so. Judge and model are one chip, since a model names nothing without the judge above it. Under width pressure the model half sheds and the judge name never does. The panel it opens drills rather than stacking — one agent lists twenty-odd models — and the model always drills, even for a tool with three, because a panel that changes shape per agent is one you relearn per agent. Default is pinned above the search field, outside the filter, so putting the model back never means clearing a query first. The field is multiline now, in both hosts, and Enter follows the same policy as the transcript composer: hardware Enter sends, Shift+Enter breaks the line, and a soft keyboard's Enter writes a newline. It used to do the opposite on a phone. The arm sheet carries no send key. Its one commit is Arm Handler, and a second key beside it would have been dead on empty text while that button stayed live — two controls for one act, disagreeing about whether the act was available. * A settings tap is never a fresh arm, and an unreported posture is never named The settings sheet and the drawer's judge panel both committed arm(armed: true) without checking the session was still armed. On a session that wrapped up underneath the open modal that is not the bridge's edit path, it is a fresh arm, and a fresh arm retires the slot's undo offers — so a stray posture tap could destroy the undo for a 3am force push. Both re-read the session now and drop the send if it is gone; the drawer also pins its optimistic value only once the send is real, so a dropped send cannot leave every later delta computed against a `from` the bridge never held. forTerminal filtered snapshots and wrap-ups by terminalId, but a wrap-up disarms the session it reports on, so its own undo offer became unreachable the instant it landed. Narrowed by ownership instead: this terminal's rows plus every orphan no live session can claim. The tab badge counts escalationBadgeCount — the larger of the sessions' tally and the rows actually held — because an escalation push appends a row without moving any session's pendingEscalations, and a badge must never be able to answer differently from the surface it points at. A null personality means the far end never reported one, not watchdog. handlerSessionSettingsFor carries it through uncoerced, the segmented control takes a nullable type so no cell paints as chosen, and the caption says why the pick would change nothing. The arm sheet still seeds a real preset: arming is the user choosing one, not a report of what the bridge holds. Two no-op taps destroyed state. Re-picking the judge already in force fired the change anyway, handing judgeModel: null to the delta — so a tap that only confirmed the current pick wiped the model override beside it. Both the chip and the settings row return early now. The delta always carries the model across a tool change, even where both sides read null, since omitting it left the previous CLI's id on the bridge under the new judge. The arm sheet recomputes its headless-judge warning against whichever judge is picked while it is up, rather than freezing it at open — the picker that fixes the warning is on the sheet. Judge coverage prefers the armed session's own report over lastKnownSettings, which is a plain field no provider watches. Smaller, same theme: the catalog cache's never-throws-on-read contract now covers the directory resolution and exists() that sat outside the try; the label measurer disposes its TextPainter, which it leaked once per layout pass; the composer checks the host-owned selection is in range before replaceRange; and two async boundaries go through detached rather than a bare unawaited that installs no catchError. * Every arm gets its sheet, and the sheet stops re-teaching Arming opened the composer once ever, on the first arm any session on the install had. After that the shield sent a payload-free arm, and the one surface that can give a session a backlog at arm time, or name the judge and model it will run under, was unreachable for good. The sheet now opens on every arm. The standing "what is Handler" paragraph is the only thing the once-ever latch still gates. Everything under it answers about THIS arm -- what the agent will report, what its judge can do, what is about to be queued -- and none of that is retired by having read the explanation once. With a covered agent and nothing seeded there is no paragraph at all, and the sheet is the composer, the judge and the posture. The seeded-goal line names Handler outright now, since the sentence that was its antecedent is gone on every arm past the first. An arm carrying a settings delta on every pass is what makes the posture seed dangerous. The service cache is empty for a disarmed session after a restart while the bridge still holds that session's posture on disk, so diffing the seeded preset against a null would have sent Watchdog on every re-arm and quietly reset the user's pick. The seed is now the from side of the delta, and an untouched control sends nothing. A control the user did move sends even when it lands back where it started -- the seed is a display value, and returning to it is still a choice about a posture this app may never have been told. Renames the flow armWithSheet: it is no longer a first-run explainer, and the surfaces that call it, the flag that no longer gates it, and the tooltip that used to be the only pre-arm warning all said so. * A Handler the plan will not cover says so, and says which fix it has Arming a session on a machine whose plan does not carry Handler refused with a log line and nothing else -- the engine's own comment claimed that log was the whole user-visible signal by design. On screen a refused arm was byte-identical to a dropped tap: same shield, same silence, no way to tell a paywall from a bug. The bridge now sends the reason. It rides handler:status as an optional entitlement key, present only while the machine is refused, so absent covers both an entitled machine and every bridge predating the field -- which want the same rendering -- and appended last, the pattern wrapUps and observability already set. Every emit carries it, not just the one a refused arm raises on its way out: the shield nobody has pressed yet is the surface that most needs to know, and it is on screen long before any arm. It is derived per emit and never latched on either end, because the tier claim is a thunk over a token re-minted at 80% of an hour, so an upgrade clears the gate on the next frame with nothing to reset. EntitlementVerdict became a discriminated union to make that projection safe. What crosses the wire is the refused half only, and a flat shape would have let unwired -- the developer carve-out, an ALLOWED case -- be reported to the app as a refusal by one slip at the call site. The app answers with the fix each reason actually has. The shield tooltip says why before the press, outranking both coverage notices since those describe what an arm would get rather than whether one can happen. The press opens a refusal sheet in place of the arm sheet, because a form that cannot commit is a form you fill in only to be told it was never going to send. A tier refusal names the plan the machine is on and offers the plans screen; an unreadable claim offers sign-in and no purchase, since buying a second subscription fixes nothing and offering it teaches the wrong fix. A reason this app cannot name says Handler is unavailable rather than guessing between two opposite remedies. After the upgrade screen the flow falls through into the ordinary arm rather than re-reading the refusal it just showed. Nothing re-emits a status frame when a device token is re-minted, so the app's cached verdict is at its stalest exactly then; the bridge reads its own live, and a refusal that still holds comes back on the frame the refused arm raises. The arm latch speaks that frame, which also covers the payload-free arm -- until now the one path that could fail with no feedback at all, since the lost-instruction toast needed an instruction to lose.
3 tasks
* Update dart_terminal viewport fix (#65) Pin the terminal packages to the squash merge of antgrid-ai/dart_terminal#10 so Antgrid receives the origin-safe scrollbar synchronization and selection auto-scroll fix. * Handler: a status frame answers for exactly one append (#64) * A frame that answers one sentence is never spent on the next A partial cap hit appends, records instruction_dropped AND emits a status snapshot whose backlog that same sentence just moved. Only the amendment path was credited for that, so the cap hit's frame was left to answer for the next sentence in the queue — retiring its row and lifting the edit lock while its extraction was still running. A survivor is now always credited; crediting a frame the bridge never sends costs one re-baseline, which is what the survivor was doing anyway. Alongside it, four places where one rule had two homes: oneLine is defined once in the import-free leaf and re-exported, hostsIn is built on destinationsIn so "the subset of" is true by construction, clipQuote escapes through previewForUser like every other user-facing preview, and a shared clip() stops a cap landing between a surrogate pair. The drawer's waits-on lines resolve against one map per build rather than re-walking the backlog per link. * The goal seeded at arm answers for its own append A goal is extracted on the same per-terminal chain instructions queue on, and lands ahead of them. Its items moved the backlog count that was the only evidence a sentence had, so a preset tapped while the goal was still running was retired by the goal's own append — taking the "sending" row away, lifting the debounce, and lifting the drawer's edit lock while the preset's extraction had not started. The next wholesale edit then went out built from a list missing the items about to arrive. One client reaches this: arm from the new-session prompt, tap a chip before the goal comes back. The mark is set on exactly the condition the bridge queues that pass on — a goal with words in it and no backlog beside it — spent by the first frame that actually moved, and dropped once the backlog is non-empty, since a goal is only ever extracted into an empty one and a mark left standing would swallow the frame the user's own sentence raised. * feat: start an isolated session's agent alongside worktree.setup (#66) * fix(app): show the provisioning run instead of calling a queued session stopped While worktree.setup runs the bridge holds the session's start, so the entry reports running:false with setup.pendingStart set. TerminalScreen branched on running alone and rendered "Session stopped" over a Start button whose press only re-entered the same gate — a dead control directly under a banner saying the workspace was being prepared. The pane now branches on sessionStartQueued to a provisioning state whose body is the setup transcript itself: during the run that PTY is the only live output the session has, and it was collapsed behind a chevron above an empty pane. It carries the two verbs that end the wait — Start agent now (skip) and Cancel setup, the latter a bridge verb no surface had offered since it shipped. Renames the banner's Skip to Start agent now: nothing about the run is skipped, the queued agent is released and the install keeps going. The banner drops its tail line and log disclosure while a start is queued so the transcript is not mounted twice, both derived from the same wire field. * feat(bridge): let a project launch its agent alongside worktree.setup Adds worktree.setup.startAgent: afterSetup | immediate. The default keeps today's behaviour — the session:start is queued and fired when the run settles — while immediate lets the agent come up with the first step, for a project whose setup is a cheap .env copy rather than a cold install. The mechanism is one seeded field: beginSetup births the run with gateReleased true, which is the same state a Skip produces, so setupGate declines to report it and start() falls through to the spawn with no branch of its own. Skip stays idempotent, cancel and rerun are untouched, and firePendingStart finds nothing queued. checkoutDeclaresSetup widens to checkoutSetupPolicy, returning declares plus startAgent; an unreadable config now fails closed on both axes rather than one. The rerun path re-reads the policy instead of remembering the create-time answer — a rerun is exactly when the checkout's branch has changed it. The services: deferral stays tied to declares alone: bun run dev against an unprovisioned node_modules fails with nobody watching, unlike an agent. * feat(app): warn when the agent is live in an unprovisioned tree Under startAgent: immediate — or after a hand-pressed release — the agent is running in a checkout that has no node_modules yet, which is a different claim from a neutral progress line. The banner is the only surface that can make it, so it takes the warning tone, says 'Workspace still installing' rather than promising a wait, and offers 'Cancel setup' in place of a release that has nothing left to release. Derived from the two live facts (setup running AND session running) rather than a mode flag, so a config-set immediate and a hand-pressed Start agent now reach the same warning. * feat: make the provisioning wait legible "2 of 5" is actively misleading on a real setup block: step 1 is a 10ms copy: and steps 2-5 are the minutes. The pane now carries the ledger — done, current, still to come — and the banner an elapsed readout beside the rule, which is what separates a slow step from a hung one when a bun install prints nothing for four minutes. The step names are the one new wire field: CheckoutSetupProgress carries them on every report of a run rather than once, SetupRuntime retains them like terminalId, and they are optional in both directions so an older app ignores the key and a state recovered from disk (which knows how many steps ran but not what they were called) renders no ledger at all rather than a column of blanks. The elapsed reading comes off the bridge's clock, which for a remote machine is not ours; a negative result is the one shape of skew we can detect and it is answered by saying nothing. * chore: start this repo's isolated agents alongside setup Five steps, minutes on a cold worktree, and the agent is useful for most of that — reading, planning and searching all work in a tree that is only checked out. Revert this one line to put the wait back. * fix: close the review's findings on the setup-start work The load-bearing one is a bug: a rerun under startAgent: immediate re-arms the previous run's prompt AND opens the gate, and nothing fires a start behind an open gate — firePendingStart runs only when a run settles. The policy was therefore silently ignored on the one path that queues a start of its own, leaving the user to press Start agent now by hand on a project configured never to wait. The rerun test that was supposed to cover this passed for the wrong reason: settleSetup awaits startDeferredServices, so the synchronous stop() beat firePendingStart and lastQueuedPrompt was never banked — the rerun took the no-requeue path. It now waits for the queued start, and fails without the fix. App side: a chat session mounts AgentTranscriptView where the provisioning pane would be, so standing the banner's log and tail down on a queued start left a four-minute install with no output anywhere; the suppression now keys on the pane actually being there. The banner also stood down its action, since two Start agent now buttons 100px apart race for a run only one can end. The pane is keyed by session so an in-flight verb and its refusal cannot land on whichever session is on screen when the reply arrives, a null registration is named rather than dropped, and an expansion the chevron no longer offers is cleared instead of masked — masking alone unfolded the log by itself the moment the gate released. Also folded two duplicated helpers back into their existing versions: listEquals for the step-name comparison, and the transcript's formatDuration so the two live elapsed readouts on one screen spell the same seconds the same way. * A dropped instruction owes the app the frame its record spends (#68) Regression from #64. Retiring a sentence off an activity record now always credits the terminal's next status frame, but two bridge paths recorded an instruction row and returned without emitting one — an amend-only drop, and a cap hit with no room at all. The credit was then spent by the NEXT sentence's own append, so that sentence's row stood forever and held the backlog edit lock, which under a full backlog is the only way to free room. Fixed on the bridge, because no app-side rule can work: nothing in an instruction_dropped record says whether a frame is coming. Every path that records an instruction row now emits a snapshot straight after it, making the app's blanket rule a real invariant. An unchanged snapshot answers for nothing and costs one re-baseline. The arm-time goal mark also now mirrors the bridge's goalChanged gate, so re-arming with an unchanged goal no longer sets a mark nothing will satisfy. A goal the bridge rehydrates from its own disk record still cannot be predicted from the app. * Handler: confirm the undo that leaves this machine, band escalations, answer the judge question before arming (#69) * The one undo that leaves this machine asks first Three of the four snapshot actions undo locally; undoing a force push writes to a shared remote. The offer is a scrolling list row whose entire body is the tap target, newest first, so the row most likely to sit under a thumb was also the one that could overwrite a ref for everyone on it. Confirmed for force_push alone. The other three keep the one-tap prevention §5.2 buys back, and the dialog promises no recovery: the bridge pins the remote tip before overwriting it only when the ref still exists there. * An escalation that stopped the agent sorts above one that merely waited urgency reached exactly one consumer, the OS notification title, and that path returns early while you are viewing the session it names. Inside the app the field was invisible: high and normal rendered identically, and oldest-first sorting filed a fresh urgent row last. high is not judge opinion. The engine mints it with no judge call at all for a blocking prompt, so it means the agent is stopped right now. Banded ordering with age deciding within a band, applied on the live push too, and marked in the meta column the three escalation row shapes share. * Escalate-only is answerable before arming, not after walking away The catalog already carried judgeCapable and the app already resolved the agent the way the bridge does, so the pre-arm half of the coverage answer was one field away. Until now it surfaced only as an ESCALATE ONLY chip, found on returning to a session that had woken you for everything. The prediction is exact rather than approximate because nothing writes a per-session judge override yet: observabilityFor falls back to the session's own tool, which is what the catalog describes. A judge picker inherits the job of keeping that true. Withheld from the unwatchable arm, which already carries the stronger fact. * Corrections to the three fixes above latestEscalationId read escalations.last, which the new banding turned into the newest NORMAL escalation — never an urgent one, i.e. never the row a caller asking for the latest wants. Folded on at instead. No live caller today, so this was a trap rather than a bug. The undo tap started async work from a void callback with a bare unawaited, which app/CLAUDE.md forbids outright: a throw past the dialog reaches PlatformDispatcher.onError as a FATAL with no in-app frames. Uses detached now. The urgency test is a shared escalationMeta closure rather than three hand-written copies, which is what the adjacent comment already claimed. compareEscalations' doc asserted high is engine-minted only. It is not: escalate passes the judge's own notify.urgency through, so a judge-authored high sorts into the same band. The band is still right; the claim was not. Two bridge comments calling backlog.ts import-free were wrong the same way — it imports zod and ./evidence; what it actually has is a position below every consumer. * The shield answers the judge question every time it is asked The escalate-only caveat added to the arm explainer almost never rendered: the explainer is gated on FirstRunState.handlerArmedOnce, a once-EVER latch, while coverage is per-agent. A user whose first arm was a judge-capable agent never sees that dialog again and would meet an escalate-only one with no warning at all. The shield tooltip is the pre-arm surface that answers every time, and it already carried the observability half. handlerShieldTooltip is top-level for the reason handlerArmExplainerBody is — the precedence is testable without pumping the panel — and keeps the explainer's order: unwatchable outranks escalate-only, since a session reporting nothing makes its judge moot. * feat(app): markdown document viewer with heading outline, link routing, and mono inline code The file viewer rendered markdown through markdown_widget's defaults — GitHub's light-theme tokens on a dark ground — with no reading measure, no way to follow a link, and no sense of a document's structure. Adds three files. markdown_document_config.dart is the whole-document config: a document-scale heading ramp pinned at all six levels, dark-appropriate blockquote/hr/table tokens, mono tables that scroll internally rather than overrun the measure, list markers whose shape encodes nesting depth, task boxes drawn with AbIcons, a copy button on every fence, and a chip for a repo-relative image the viewer can open in its own image view. markdown_outline.dart is the heading rail: MarkdownWidget publishes its heading list and scroll index only into its TocController, so MarkdownTocController subclasses it to get both out, and the rail is built from AbListRow instead of the package's Material ListTile. markdown_link_target.dart classifies an href so a relative repo link opens in the viewer, a #anchor jumps in-document, and only http(s)/mailto leave the app. Also fixes inline code across the agent transcript. CodeNode.style resolves as codeConfig.style.merge(parentStyle), and merge lets the argument win every non-null field, so the paragraph's sans family overwrote the configured mono one and CodeConfig was inert — every backticked identifier, path and flag rendered byte-identical to the prose around it. markdownAntgridGenerator re-asserts the family after that merge, family only, and transcript/markdown_body.dart renders through it too. * fix(app): bump webview_all to 1.4.1 for the Windows exit crash (#70) 1.4.1 removes the `SetUp(messenger_, nullptr)` call from `~WindowsHostApi`. That call ran from a plugin registrar destruction callback, which FlutterWindowsEngine fires from Stop() after its own destructor has already nulled the messenger's engine pointer — so it dereferenced null inside FlutterDesktopMessengerSetCallback, whose only guard is an FML_DCHECK that is compiled out in release. The app never pinned the platform package directly, so this is a lock-only change: `webview_all: ^1.3.5` in pubspec.yaml already admits 1.4.1, and no dependency_override or fork was ever added. Verified the fix is in the bytes we resolve: the published archive's sha256 (d9b81f1…) matches both pub.dev and this lockfile, and that archive's `~WindowsHostApi` no longer makes the call. Upstream: abandoft/webview_all#37, fixed by abandoft/webview_all#38. Not yet confirmed at runtime in Antgrid. Four local configurations (profile/release × with/without a host, three launch-and-close runs each) exit cleanly even on the buggy 1.3.10, so no loose build reproduces the fault and a clean run proves nothing. The signature is an access violation escaping a window-proc callback (0xC0000005 paired with 0xC000041D at the same offset), which is plausibly swallowed outside the packaged app — so confirmation has to come from an MSIX build. Refs #63, whose third close condition (no Application Error event on exit) is still open. Claude-Session: https://claude.ai/code/session_01Y8Fukr3ARpsyhoxrSMcPcm * Handler: submit the line it types, and ask the agent before answering for it (#71) Two reports from a live two-session run, both confirmed against the logs. The injected reply never sent. A TUI tokenizes a PTY read as a whole, and Claude Code emits a control character as its own key event only while the read is under 64 characters — so `text\r` in one write inserted a literal newline into the composer and waited for a human Enter. Every submit now writes the line, waits out a gap, then writes the CR alone, through a per-terminal queue that keeps any other writer from joining that read. The queue is a synchronous pass-through whenever no submit is in flight, so nothing else on the terminal pays for it. A bare slash verb is padded: splitting the CR would otherwise leave the suggestion list armed, and the key that used to submit would accept a completion instead. Handler answered for the agent. It holds less context and fewer tools than the model it supervises, so a confident guess reads as fact and costs the agent a correction it has no reason to make. It asks now, and decides from what comes back. Seven further findings from the log dig, each adversarially validated: the runaway guard reset on keystrokes that submitted nothing; the judge's timeout was silent on two of the three legs that spend the budget; the activity feed reported a blocked action with prose about the pause rather than the text a guard refused; the destructive floor missed several outward-moving operations, and the wrap-up push buried an expiring undo offer behind an unbounded summary. Floor patterns are one operation each. Section 5.4 keys an authorization lift on the pattern source, so an alternation over two operations lets a lift on either grant both. * A terminal resize the PTY never got is one the app must keep offering (#72) The driver re-sends terminal:resize only when its computed grid differs from _lastSentCols/_lastSentRows -- what the app BELIEVES the PTY holds. Three ways that belief goes wrong with nothing to report the break, and in all three the panel has stopped moving, so the wrapper recomputes the same grid forever and the gate never reopens. _TerminalGridFreeze re-armed its settle countdown on every invocation, and LayoutBuilder re-runs its builder whenever the parent rebuilds, not only when constraints change. Any rebuild cadence faster than the 150ms delay -- a streaming agent, a selection drag, a session-list tick -- cancelled the timer forever, so the grid stayed pinned to whatever it held when the panel last changed size: content clipped at the stale column with dead space beside it, for exactly as long as the agent keeps working. _settlingTo now measures quiet from the last real move, _sameSize replaces exact Size == so sub-pixel flex jitter does not read as movement, and dispose() cancels. sendResize dropped a request whose per-install client id had not resolved while the caller booked it as sent. It now reports whether the frame was QUEUED -- true is not a delivery receipt -- and the three paths that discard an armed frame later (the debounce's driver guard, a cancellation in _handleTerminalSize, disposal) each hand the geometry back through an invalidation. Nothing re-asserted geometry across a reconnect or a same-id respawn. A resize sent into a keyless window vanishes unreported, and a respawned PTY takes terminal-manager's process-wide lastDriverGeometry -- whichever terminal on that bridge resized most recently, not the one this driver sent the dead process. TerminalTab.sizeEpoch is the invalidation edge, the exact parallel of the snapshot-seq cutoff dropped beside it in _rehydrateTerminals, bumped on re-drive and on both respawn signals (terminal:started is not in kCheckoutDurableReplayTypes, so a relay app builds its tabs from the replayed agent:status and sees only that one). The wrapper's per-PTY latches lived in a State that is not per-PTY: only terminal_screen keys it by terminalId, so the pinned pane, the detail view and the setup banner reused the previous terminal's booking across a swap. didUpdateWidget retires them. Also corrects a load-bearing false premise. Two comments justified the freeze by claiming ghostty_vte_flutter does not reflow. It does -- soft-wrapped rows re-join when the grid widens. The freeze's actual rationale survives intact and is the other half: a TUI that wraps its own output writes hard breaks, which reflow never re-joins, so a grid change under an Ink-style redraw leaks stale fragments. terminal_reflow_contract_test.dart pins both halves with margin-filling rows. * Handler: retire notify-only, make the wrap-up durable, and unhook the comments from a missing spec (#73) * Notify-only leaves, and takes the config file's last setting with it `notifyOnly` meant "tell me, never act": every pause escalated without spending a judge call, and a park wake notified instead of typing `continue`. Nothing in the product could turn it on. It was only ever sent as `defaultNotifyOnly`, which is read from handler-config.json — a file the bridge and app only ever read and never write. Hand-editing JSON was the sole path in, so the mode was unreachable by construction. Two engine branches go with it, and `outputSnippet()` behind them, which had no other production caller. `defaultNotifyOnly` was handler-config.json's only setting, so the schema, its v1 migration, the loader, the engine's config cache and `cfg()` go too; config.ts survives on ActivityRecord and appendActivity, which is what a later judge or personality picker actually needs from it. No message type is added or removed, so the checkout-variable sets are untouched. The accepted trade, stated rather than denied: anyone who did hand-edit that file has `notifyOnly: true` persisted in their handler-session record. Their parked sessions will now inject `continue` into a terminal they had opted out of auto-driving, and every pause will spend a judge call. Pre-v1, with no users, that is worth more than a compatibility shim for a mode the UI never exposed. One test is replaced rather than deleted: the notify-only sub-case was the only thing asserting that a standing `guard_blocked` report is not a pending question, and the park-wake gate reads exactly that predicate. It comes back as a park-wake equivalent. * The morning-after summary outlives the session that earned it The wrap-up was composed from three private helpers, spent on one push, and thrown away. The `wrapped_up` activity row kept only the goal, which the app renders nowhere. That left the phone-was-off case with nothing at all: handler-activity.jsonl is write-only, `handler:activity` is not a REPLAY_TYPE, and HandlerState is never persisted — so an app that reconnected after the disarm had no feed to go back to, which is exactly when the summary is read. A WrapUpRecord now persists in handler-wrapups.json and replays on handler:status, which is already a REPLAY_TYPE. One module owns content selection so the push and the stored copy cannot drift: buildWrapUp decides which items and which caps, and the push body and the activity detail are two thin renderings of its output. The undo count is the one thing that must never be frozen. It is an argument to the push renderer, not a field on the record, and the app re-derives it: an undo taken after the wrap-up, or a re-arm retiring the offers, would each make a stored count permanently wrong on a card whose whole job is to be read later. The blocked count and its reasons ARE frozen, deliberately — they die with the session when disarm drops it. Freeze what dies with the session, never freeze what outlives it. Sized for the wire it rides: handler:status is emitted twice per handler event, held by reference in the replay cache, and crosses the relay to a phone, so item text is clipped at 120 chars and five records are kept — a worst case of roughly 22K chars per frame rather than the 190K the uncapped shape allowed. The push loses its "see the activity feed" tail, because that feed is not durable and pointing at it was the bug. Wrap-ups survive a re-arm. Snapshots are retired because each pins a stash, a backup ref and a trash copy and owes a release(); a wrap-up owns no resource, so retiring one would keep that cost and drop its reason — losing the previous session's report is the failure this change exists to fix. * A wrap-up is read hours after the disarm, so it gets a card The bridge replays a WrapUpRecord on handler:status; this is the surface that reads it. The Handler screen gains a pinned Wrap-up section between Sessions and Undo, and a project whose only remaining artifact is a wrap-up no longer renders as "Handler is off" — the morning after, that is the whole screen. The undo count is derived at the call site from the terminal's snapshots and passed to the card, mirroring the bridge's own renderer taking it as an argument. Neither side of the wire is given a field it could freeze the count into, which is the only reason the number is still true when the card is finally read. Sans, not mono: a wrap-up is the user's own prose plus chrome labels, never a path or a command. The outcome labels are a local switch rather than a reuse of the activity-row labels — those are keyed on activity decisions (`item_done`), not wire outcome statuses (`done`), and bridging them would be a second hand-mirror to keep in lockstep for four words. * Comments stop citing a spec nobody can open Roughly 180 comments pointed at numbered sections of a handler spec — `§4.3`, `spec §5.2` — that is not in this repository and is not in its history. A reader who followed one found nothing, and the tag was doing the work a sentence should have been doing. Where the prose already carried the reason, the tag is simply gone. Where the section number WAS the justification, the reason is written in from the code that enforces it and names the symbol a reader can grep: `§5.4` becomes instruction-scoped authorization and the file that implements it, `§5.3` becomes the HARD floor tier, `§2.2` becomes the terminal states. The references that resolve are untouched: bridge/src/e2e/ and packages/ cite docs/protocol/e2e-handshake.md by named section and that file exists, and two more cite RFCs. A path plus a named section is a working pointer; a bare number pointing at nothing is not, and that is the whole distinction being applied here. No spec document was written to make the numbers resolve. Reconstructing one from the code would invent a source of truth that never existed and leave 180 comments citing a document written after them. * Test names say what they check, not which section asked for it The same dangling references ran through test names — `describe("§2.2 allTerminal is the wrap-up predicate")`, `group('quick choices (§4.6)')`. A test name is read in failure output, where a section number from a missing document is worth less than nothing: it names an authority the reader cannot consult instead of the behaviour that broke. Renamed to describe the behaviour, and the comments inside these files got the same treatment as the source. Pass counts are unchanged — 3159 bridge, 3087 app.
#84 was intended to merge main into development with a real merge commit so main's history becomes an ancestor again, but it was squash-merged instead -- same failure mode as #74. development's tree already has all of main's content from that squash, so this carries no diff, only the missing parent edge. IMPORTANT: this PR must be merged with "Create a merge commit", never squash or rebase -- either would drop the origin/main parent and reproduce the same break a third time.
DO NOT SQUASH: record main as development's ancestor (#84 got squashed)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.