Repository navigation
iOS: rebuild the listening session — PiP keeper, acknowledged keyboard contract, hop-and-return, keep-open card; engine: never paste a rewrite that reads its instruction back - #311
Open
nbisht-aai wants to merge 34 commits into
Conversation
…rds, hop URLs The records the app and the keyboard will exchange about the listening window: one readiness record with a 15 s lease (never distantFuture), a typed FailureReason, an ack per command, a presence record only its own process may clear, and blurt://start?reason= / blurt://setup?need=. The command and phase payloads gain their ids and reasons with defaults, so every existing writer still compiles; SharedStore.isListening reads the record when there is one and the old keys until then. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…iP keeper onto main From feat/ios-pip-mic (park/startParked/hold, the first-frame wait, the simulator source's scripted speech) and the proven proto/ios-pip-keeper spike (the keeper). Nothing wires them yet: ListeningWindow still holds the mic for the window. The debug API-key block returns for scripted simulators; the digital-silence check gains its test row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion-tokened state machine Core, no clock, no audio, no store: the interpreter feeds it events (open requested, opened, utterance edges, interruption, resume, expiry, media reset, stop, closed, idle change) and carries out the effects it answers with (session, source, expiry timer, background task, the ListeningState record, a log line). The foreground gate lives in the machine; open-while-open refreshes; the lifetime counts from the last word with a dictation's start as the floor; an interruption keeps the window and resumes on the interruption's end (poll) or the next foreground (start); a failed resume stays interrupted; stale generations are logged and dropped. Every publish bumps the sequence and carries a real lease. 30 tests, one per row plus the record's invariants over a whole life. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rd command, and the owner-based release CommandRouter.route decides one keyboard command from values alone (the command, the ListeningState record, the coordinator's phase, the dictation in flight and its owner, the keyboard on screen, the ids already answered): the CommandAck to write, the submit to carry out (press / release / cancel), and whether to remember the id. A press is never submitted to a window that is not open; a release or cancel is accepted from the owner or from the keyboard on screen, and only for the dictation in flight. OwnerWatchdog.shouldRelease ends a dictation whose owner's presence record is missing, not on screen, or older than BlurtShared.ownerWatchdog (10 s); another keyboard on screen does not keep it alive. A dictation with no owner on record is kept by whoever is on screen. Every row is a test: 26 in CommandRouterTests, 8 in OwnerWatchdogTests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… state machine Every finger event, ack, phase, tick and readiness record comes in as an Event; every send, gate reset, line and hop goes out as an Action for KeyboardModel to perform. A press is answered by the ack or by a phase that names it, resent once at 600 ms, given up at 1.2 s (gate reset, "unanswered", the next lift hops — never a dead release). Owned, foreign and stopping each have their rows; a hold lifted before the answer waits for the dictation's id and releases at once. 33 tests, one per row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ery row tested Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ccessors out, no force unwraps Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ult injection ListeningSourceFailure gains interrupted(reason), runtimeError(code), configureFailed(code), inputSilent, couldNotResume and backgroundStartRefused(code), each its own sentence, and stays Equatable. ListeningSource gains `events: AsyncStream<SourceEvent>` (interrupted, interruptionEnded, runtimeError, routeChanged, silent). WindowedAudioSource registers the session's runtimeError / wasInterrupted / interruptionEnded and the audio session's route change on the control queue before the first startRunning, logs each (Capture, public) and throws the typed case a start finds — never "No microphone is available." for a refusal; a parked start left not running is backgroundStartRefused. UtteranceFeed keeps the 1 s silence watchdog (DESIGN.md row), held off during a parked bring-up until the first live frame. CaptureFailure maps every failure and event to the keyboard's FailureReason and a sentence. SimulatorAudioSource takes BLURT_SIM_FAULT (README) and, for tests, runs with no Mac mic. PCMConverter moves to its own file for the lint budget. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eflight, install, launch, kill, pull, syslog device-install.sh (the vault-side helper) moves into the repo as one script with subcommands, all on `xcrun devicectl` and nothing else — no Xcode window, no debugger: - preflight: the phone is `available` (the list's State column, filtered, since the JSON carries no such field), Developer Mode on (`device info details`), unlocked since boot, Blurt's install state; then the checklist of what no script can see (UI Automation, the keyboard with Full Access, an API key). Exits non-zero naming what is missing. - install: xcodegen under BLURT_TEAM, a Debug build for `id=<udid>` with automatic signing into a per-checkout derived-data directory, `devicectl device install app`. - launch [args…]: `--terminate-existing`, default `-BlurtDeveloperMode YES`; the app's arguments go after `--` (devicectl reads `-Blurt…` as its own `-t` otherwise); a locked phone is reported plainly. - kill blurt|keyboard: the pid by executable path from `device info processes`, then SIGKILL. - pull DIR [--syslog] [--last 15m]: Library/Logs/Blurt from the app's data container (a missing folder — CoreDevice error 7000 — is not a failure), and with --syslog the phone's system log through `log collect --device-udid` under osascript's administrator-password dialog, the archive chown'd back. - syslog ARCHIVE [--last 15m] [--negative]: Blurt's lines, the system daemons' lines about it and any background-start refusal, minus the usual noise; or the negative predicate every gate run asserts (no `start recording in the background` / 561145187 / `!rec` line naming Blurt), exit 1 with the lines. - flows, check: stubs (exit 2) until DeviceFlows lands with the device gate. README › Testing on a phone is now that protocol; AGENTS.md lists the script in the repo map and the verification block. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…S generation, the slim bar's mic at W/2 KeyboardProfile (BlurtKeyboard) decides the generation once per appearance from the OS (ProcessInfo, read in that one place), the width, the globe and the host's container inset read from the input view's superview at viewIsAppearing: glass only on iOS 26+ with a container detected, classic otherwise (iOS 18 phones, un-adopted iOS 26 hosts). It exposes the margins, gaps, key height and corner, the band above the keys, the bottom inset and whether the surface is painted; KeyGeometry lays the classic rows out as W/10 cells, KeyboardLayout.height takes the profile, KeyboardRootView reads its margins and sets keyboardInContainer from it, KeyboardPalette offers Apple's classic colours as the `system` theme (the store's default) on classic with the brand one row away. The numbers are the `classic/*` and `glass/*` token sets in tokens.json; the classic ones are the plan's fallback rule, marked unmeasured, because Xcode 27's downloader refuses every iOS 18.x runtime (22G86, 22F77, 22E238) — apple-geometry.sh gained --profile, the device-type scale and the os/runtime fields (ios-lib.sh: BLURT_SIM_RUNTIME, ios_device_scale) so the classic-390 and classic-375 JSONs can replace the fallback on an iOS 18.6 simulator. Same step: the slim bar places the mic in keyboard coordinates (centre = W/2, delete and return hanging on its trailing side, inboard at an edge; SlimBarLayout, tested) and the probe's placement check measures every layout against the keyboard's width; the panel's element is capped at min(box, row − 2·(clearance + glyph hit)); FullKeyboardView is bottom-aligned in its reader; the carousel's pages carry the side margin themselves; AZERTY's third row gains its apostrophe; the Face ID bottom row (123 · space · return) is documented and tested at every width. Tests: KeyboardProfileTests (generation, size class, every row adds up at 375/390/393/402/430 × both generations, the three profile JSONs), AppleGeometryTests over the three JSONs, DesignTokensTests heights per profile and a modifier-contrast test (1.3 for the system set, 1.09 for the brand's quieter modifiers — 1.21/1.10 painted, 1.13/1.28 in the container — so neither fades), VoiceRowLayoutTests at rows 362/377/389/417 and SlimBarLayoutTests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What the machine carries across transitions, each pinned by a test: the closed record keeps its reason (the failed open's, the refusal's, the cause's) across heartbeats; a hop while closing is remembered — the closing record says opening (Cause.reopen) and closed(g) goes straight to opening(g+1) with one openSource, a Stop meanwhile takes it back; Event.windowMinutesChanged applies Settings live (an open window's end moves from now, a dictation's floor holds, until-stopped cancels the timer, any other state only stores it); a source lost while opening lands opened in interrupted(resumable) with a background task and the expiry scheduled; a refresh republishes the last heartbeat's micLive (lastMicLive, true on opened and resumed); the switch turned off while interrupted logs the same stopWhileInterrupted line as Stop; openRequested documents inForeground as applicationState != .background. The types move to +Types.swift and the rig to +Rig.swift to keep every file under 400 lines. 46 tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"In flight" means one thing for every row: a release or cancel with a terminal phase acks nothingInFlight whatever ref was left, and only an in-flight phase with a ref can be stopped or cancelled. The app never acks suspended: a lapsed lease is answered from the window (reasonForPress). A resend is answered with its original outcome — Input.seen becomes answered: [UUID: Outcome] — so the keyboard's 600 ms resend sees started(d) again; Outcome.duplicate stays in the contract, the router no longer produces it. An accepted release or cancel carries recipient = the keyboard that sent it. 37 rows in CommandRouterTests (+Ending); BlurtiOSCore line coverage 93.87%. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F1 a finger down over a latched dictation that ended unseen takes no press from a closed or lapsed window: unready(reason) + [resetGate, show], lift hops. F2 foreign carries liftStops: false via ack busy (the tap's lift leaves it running, the next tap stops it), true via a finger down over a running one; a hold's lift always releases. F3 ack stale/duplicate/nothingInFlight for the press keeps it waiting; the deadline settles it. An answer at or past the deadline is late, tick or not. F4 Action.armGate: the one action on which the model arms the gate. F5 cancelTapped is the × only; canUndoBeforeRecording + undoBeforeRecording(now:) for the flip — pressing and owned(connecting) cancel, a recording keeps its words. F6 a lapsed lease or no record while owned/foreign/pressing → suspended, gate reset; a record merely not ready while owned keeps owned. F7 a finger down or up over owned(processing) buzzes, nothing sent. F8 the lift half moves to PressHandshake+Stop.swift; tests split likewise, a third suite for the gate and the record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Input.heldError: the last .error snapshot KeyboardModel holds until the next micDown; its row applies while the live snapshot is idle, so the line outlasts the ring's 2 s dwell. Handshake gains .foreign (silent, like .owned); pressing and stopping drop their since: payloads (the deadline is PressHandshake's). Tap.none → Tap.buzz. Precedence pinned: a notice beats the handshake in every state. The doc comment lists the signature deviation from the plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the status line and the hop The keyboard's half of the session contract. KeyboardModel drives PressHandshake for every finger event, signal and tick: a press goes out with the process, controller, sequence and record it decided on, is resent once and given up at its deadline; a lift stops with the dictation's id; a refused or expired press hops with its reason (blurt://start?reason=, or blurt://setup?need=fullAccess without Full Access). Presence is one compare-and-written record per process, with the older keys kept for a release; the host leaving or the keyboard leaving ends only a dictation it owns; deinit detaches the model. VoiceRowHost holds the three-way switch for the bar and the panel, with the status line under the element (on the bar's bottom edge, where nothing fits under a 32 pt box in a 44 pt row). The error's exclamation shows whether or not Blurt is ready. Tests: gate/lifecycle/fuzz rewritten on the handshake, the rig writes the ListeningState record and the old keys, the fuzz gains the ack, record, deadline and host events and the handshake invariants; the large_tuple in KeyboardProfileTests is a struct. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ned and watched; PiP idle mode The app side of the session contract, finished from the wip commit. ListeningWindow is the interpreter of ListeningWindowMachine: injected source, audio session (AudioSessionControlling, the Typeless options, haptics flag, preferred input), background tasks, clock and idle mode, with production defaults; one serial loop; the source's and the audio session's events as the machine's events with the generation; expiry timer; 5 s heartbeat with micLive from the idle mode; every transition logged and traced. HeldIdle and PictureInPictureIdle (the proven keeper recipe, untouched; a parked start iOS refuses is the source lost until the next foreground, never a counter, never a closed window); -BlurtIdleMode held|pip; the keeper's layer hosted on RootView. DictationCoordinator: start() writes ListeningState.launched; every keyboard command goes through CommandRouter and is acked (resends answered the same, 32 ids kept); a press's dictation is owned and named on every phase with the press it answers; OwnerWatchdog every keyboard beat and on the keyboard signal; a release runs under a 25 s background task; the result goes to the keyboard that sent the accepted stop, else the owner, re-signalled once at 1 s; every capture failure is said in the source's words with a typed reason; the hop URLs blurt://start?reason= and blurt://setup?need=fullAccess. The home card reads the record; a failure dwells 3 s and clears on any window-state change. Settings: window minutes applied live, "Use AirPods mic". Tests: ListeningWindowTests over fakes and a manual clock, the coordinator's ack path, ProtocolTests on the record, KeyboardRig.listening writing the record, HomeStatus on the record, the silence watchdog's start grace. DESIGN.md's two-processes table gains the window's rows; README the window's launch arguments and log predicate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e — onto the app's window interpreter Fixtures unified on the keyboard branch's KeyboardRig (launch, sequence, record, answer, latchRecording, capturingHops). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…UG_API_KEY) so a keyless simulator can open the window Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F1 A press is never accepted by a closing window: the machine publishes the
closing record before it ends the dictation and closes the source, and the
coordinator routes commands on ListeningWindow.liveState (the machine's
state this instant, ListeningWindowMachine.current) rather than the record
last written.
F2 Idle-mode reports carry the generation they were made for; the window drops
one for another generation ("stale idle report"); the keeper's park and
hold tasks are cancelled by windowClosing and re-check after every await.
F3 The owner watchdog ticks every DictationCoordinator.watchdogTick (2 s)
while a dictation is in flight, on the coordinator's clock, and stands down
for a stop already accepted (stopAccepted): no double release.
F4 sessionInterrupted resets on every openSource and on resumed(g), so a Stop
during a call does not drop the next window's capture-session interruption.
F5 The heartbeat runs from ListeningWindow.start() for as long as the process
lives, in every window state: the machine's heartbeat while a window
exists, ListeningState.renewed (through heartbeat(renewing:now:), so the
sequence keeps climbing) while closed. A closed window reads sessionOff on
the keyboard, never suspended, while the app is alive.
F6 A hop over an interrupted PiP window awaits windowClosing(), which waits
for the keeper's didStop (PictureInPictureIdle.keeperStopWait, 1 s) before
the next source opens; windowOpened never parks under a keeper that is
stopping, and a start wanted while stopping runs on didStop.
F7 SystemBackgroundTasks' expiration handler ends the task synchronously on
the main actor; the 25 s own timer stays.
F8 Coordinator tests over the real session with a stub transcript: the
accepted press and its phases, the owner's release and the result's
address, a stranger's release, the watchdog (and not after an accepted
stop), a resend, a press during a close.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
DESIGN.md › The two processes: the keyboard's rows (press and stop acks, the retry, the keyboard beat and presence record, leaving with a stop out, the hop reasons, the status line and its strings, the held error, the line's place, the log subsystem) with every number a named constant, and a marked place for the app side's rows; SessionContractNumbersTests pins each row's constant and greps the table for it. The keyboard's Container logger and the app's HostIdentity share BlurtShared.logSubsystem. A keyboard leaving with its own release out keeps its presence record on screen until the ack or KeyboardModel.leaveGrace, so the watchdog never releases the same dictation a second time. The bar's status line takes the row's bottom text line at size/line and the row draws in the band above it, so the grille sits clear of the words; the panel's line hangs a gap under the box. The fuzz invariant names the handshake's states and recovers() walks a dead app: lease lapse → suspended → the tap hops. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A dictation that caught no words comes back with an empty transcript and a rewrite the model wrote from the style instruction alone; on the phone that pasted the instruction's own words. The choice is now a pure function with a test: no spoken words, nothing pasted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nstruction back
The rewrite model answers a transcript its cleanup rules empty out ("Yeah.")
with the tail of its own instruction — the user's style preferences, word
for word — and a lone "Um." with ".": 6/6 each against the route on
2026-11-05, under every ordering of the instruction tried, llm_error null.
On the phone the style text was pasted as a dictation. `RewriteGrounding`
keeps a rewrite only when it says something and is not a run of the
instruction the request carried; `chooseTranscript` falls back to the
verbatim transcript otherwise. The choice and its tests move to sibling
files (file length).
ios: `-BlurtDeveloperMode` / `-BlurtDeviceTrace` outlive their process
(`LaunchFlags`): the hop's relaunch carried neither, so the dictation log
and the trace stopped at the first hop. `ios-device.sh launch` passes both
by default.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the keyboard and the tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t URL, the app returns to it once the window is open The keyboard reads the app it is up in off its controller (`HostApp`, `_hostBundleID`, read only where a getter or ivar is declared) and puts it on `blurt://start?reason=…&host=…`. The app holds it (`returnHost`) and, on the first record that says open with the mic staying live — at once under a held mic, on the keeper's `pip` report, or `BlurtShared.hopReturnCap` 1.5 s past the open — asks iOS for the host (`HostReturn`, `LSApplicationWorkspace openApplicationWithBundleID:`, as Typeless does) and leaves. A window closing before it opened, or the user leaving first, cancels it; no host, or the call refused, leaves the "◀" hint as before. `ListeningWindow.onRecord` reports every record; `DictationCoordinator` takes `appState` and `hostReturner` for the tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Port the Blurt-is-on screen from feat/ios-keep-open as the card iOS shows for Blurt in the app switcher. RootView fades KeepOpenView over the app whenever the scene leaves the front with a listening window on, and out again as it returns, so the card — and the snapshot iOS keeps — says to keep Blurt in the background and for how long it listens. The words are KeepOpen and the DVD screensaver is Bounce, both in Core and tested; no close, no stop, no swipe hint: the scene is inactive whenever it shows. Tokens for the tints, the bounce, the flare and the fade; Wordmark takes a height and a tint; -BlurtKeepOpen renders it as the root for a capture. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t, an idle layer carries the window, a capture change is verified on a phone Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Mac project, wrap two test lines Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment on lines
+140
to
+146
| for wallX in 1...wallsX { | ||
| // The drift at which x touches this wall, and where y is then. | ||
| let atX = CGFloat(wallX) * x.travel - x.start | ||
| let distanceY = y.distance(atX) | ||
| let wallY = (distanceY / y.travel).rounded() | ||
| guard wallY >= 1, abs(distanceY - wallY * y.travel) <= tolerance else { continue } | ||
| let atY = wallY * y.travel - y.start |
There was a problem hiding this comment.
cornersHit(byDrift:) revisits every x-wall hit on each rendered frame, so its per-frame cost grows with animation duration and repeatedly recalculates past corners.
Show fix
Suggested change
| for wallX in 1...wallsX { | |
| // The drift at which x touches this wall, and where y is then. | |
| let atX = CGFloat(wallX) * x.travel - x.start | |
| let distanceY = y.distance(atX) | |
| let wallY = (distanceY / y.travel).rounded() | |
| guard wallY >= 1, abs(distanceY - wallY * y.travel) <= tolerance else { continue } | |
| let atY = wallY * y.travel - y.start | |
| let wallY = (y.distance(drift) / y.travel).rounded() | |
| let distanceY = wallY * y.travel | |
| guard wallY >= 1, abs(y.distance(drift) - distanceY) <= tolerance else { return (0, nil) } | |
| let atY = wallY * y.travel - y.start | |
| for wallX in 1...wallsX { | |
| // The drift at which x touches this wall, and where y is then. | |
| let atX = CGFloat(wallX) * x.travel - x.start | |
| guard abs(y.distance(atX) - distanceY) <= tolerance else { continue } |
Details
✨ AI Reasoning
The screensaver computes a frame from elapsed time and calls cornersHit(byDrift:) each time. That function iterates from the first x-wall hit through all hits so far, recomputing historical corner events on every frame. Because the animation can continue indefinitely, the per-frame work keeps growing and repeats prior work.
Reply on this comment to give feedback or ignore the issue.
More info
This branch has not been deployed
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.
What & why
The iPhone app's dictation only worked keyboard-first on a phone: capture was being restarted from the background on every press, which iOS refuses (
!rec), and the window, the keyboard's press and the capture failures had no owner, no acknowledgement and no names. This rebuilds the session onmainfrom the roots, on the mechanism Typeless uses (measured on an iPhone 13, iOS 18.7.8).BlurtiOSCore/SessionContract.swift): one readiness record (ListeningState: 15 s lease, published on every transition and by a 5 s heartbeat), a per-commandCommandAck, keyboard presence compare-and-written by process,FailureReason, hop URLsblurt://start?reason=&host=andblurt://setup?need=fullAccess. Every number sits in DESIGN.md's "two processes" table and is pinned bySessionContractNumbersTests.ListeningWindowMachine+ListeningWindow): a pure, generation-tokened state machine (closed / opening / open / interrupted / closing). The foreground gate lives in it; the lifetime counts from the last word; interruptions pause and resume, never close.IdleModeisPictureInPictureIdle— a 369 × 0.079 ptAVSampleBufferDisplayLayerkeeper with hidden controls, started in front, which keeps the processUserInteractiveFocalso later presses start capture from the keyboard with no hop and the orange dot is off between dictations — withHeldIdleas the fallback.CommandRouter,OwnerWatchdog,PressHandshake,VoiceNotice): a press is ackedstarted/busy/notListening(reason)or times out at 1.2 s into "Tap to open Blurt"; release and cancel only from the owner or the on-screen keyboard; a dead keyboard's dictation is released by the watchdog within 10 s; one status line under the mic says why a press cannot record.ListeningSourceFailure), observed (AVCaptureSessionruntime error and interruption, route changes), mapped to reasons;DeviceTraceJSONL andKeyboardLog;scripts/ios-device.sh(preflight / install / launch / kill / pull / syslog) for the real-phone loop; simulator fault injection throughBLURT_SIM_FAULT.HostApp,HostReturn,HopReturn): with the session off, the mic tap opens Blurt, which turns the session on and brings the host app back by itself once the window is open and the keeper is up (cap 1.5 s). Private API, knowingly: the keyboard reads the host's bundle id off its controller (_hostBundleID, read only where a getter or ivar is declared) and the app callsLSApplicationWorkspace openApplicationWithBundleID:— what Typeless does, seen in its logs. Both fall back to the "◀" hint when absent or refused.KeepOpenView): the app-switcher card shows "Keep Blurt in the background" with the brand screensaver while a session is on and the scene is not active.RewriteGrounding,AssemblyAITranscriber+Transcript): the dictation API's fast rewrite model answers a transcript the cleanup empties ("Yeah.") with the user's style instruction word for word (6/6 against the live route, every prompt ordering,llm_errornull). The app no longer pastes a rewrite that is a run of the instruction, or that says nothing, and pastes the verbatim words instead. The service-side fix is a separate DeepLearning PR.-BlurtDeveloperMode/-BlurtDeviceTracepersist across the hop's relaunch (LaunchFlags), so the phone's logs no longer stop at the first hop.Not in this PR, tracked in the plan: the touch-model step (a lift after a sent press is a stop; grille only), consent v2 and its copy, the
DeviceFlowsprobe, the on-phone geometry measurement.How it was tested
scripts/check.shon the merged tree: ios-check 424 iPhone tests,BlurtiOSCoreline coverage 96.3 %, swiftlint analyze and periphery clean; engine 1233 tests; the three formatting steps the merge ofmaintripped (design-sync, swift-format, prettier) fixed inc210324.iPhone 13 / iOS 18.7.8, installed with
scripts/ios-device.sh: the window opens, the PiP keeper comes up, presses from Messages areaccepted:startedwithcapture start parked=true running=true,Recording: 1only during dictations, results inserted, and nostart recording in the background/561145187line in the system log. The style-text leak was reproduced against the API and is fixed.Not yet verified on the phone: the hop's automatic return (the host-id read and the
LSApplicationWorkspacecall) and the keep-open card — the phone was unavailable for the last install. The trace'shop host=andreturn opened=lines decide.scripts/check.shpassesI read AGENTS.md and this doesn't reintroduce anything deliberately removed — the silent loop and background
startRunningstay out;check-invariants.shpassesDocs updated:
App/BlurtiOS/README.md("Testing on a phone"),App/BlurtiOS/DESIGN.md("The two processes, and every number"), AGENTS.md (the iPhone's platform scope)🤖 Generated with Claude Code