Repository navigation
Description
Follow-on epic to #495, split off by the owner on 2026-10-15. It needs the owner's approval (needs-review). It has no children yet. The children that store or move the push key or the device token are a credential (rules item 3): each one files with needs-review and waits for the owner's own approval.
User story
As the owner, I want my phone to alert me when a session waits for my approval and the app is not on screen, so that I can answer before the request expires.
Acceptance criteria
- A pending approval reaches the owner's phone while the screen is off.
- A tap on the notification opens the pending approval card.
What I'd notice
My phone shows a notification: " needs approval: ". A tap opens the approval card.
Depends on
#495 (the hold) merged to main. It makes a viewer-less approval wait instead of failing, and it fires the existing session_state asking broadcast for it. This epic reads that event.
Channel
APNs (option A), as the owner chose on 2026-10-15. It is confirmed when this epic opens. The options, kept for that decision:
| Option | Needs | Reaches a screen-off phone | Cost |
|---|---|---|---|
| A. Native push (APNs) in the phone client | Push key (a credential), entitlement, device token store, client hygiene first | Yes | Highest |
| B. Telegram message with Approve and Deny buttons | Revive the Telegram bridge: unconfigured, last commit 2026-03-09, built against an older Eve API, plus a bot token (a credential) and a Telegram account | Yes | Medium to high: a dormant project to bring up first |
| C. Local notification from the running app | A live audio session | Only while the voice session is alive | Medium |
| D. Web push | A trusted certificate and a home-screen install | Yes, but the LAN certificate is self-signed | Not viable now |
Findings from exploring the phone client (relayClient)
- It is a thin Capacitor 8 iOS shell that loads the web UI. Plugins: SSL trust, Safari passkey login, voice, audio bridge.
- It declares only the
audiobackground mode. It has no push entitlement, no notification code and no remote-notification mode. - Background survival with the silent audio keepalive is recorded as unverified in its own plan notes.
- It has no tests, no CI and ad hoc diagnostic logging. It is not on the project's logging and test practices.
- A suspended app cannot hold a socket. A screen-off alert needs a push from outside the app.
Seams that exist
- eve
notifier.js: a notifier is any object withnotify(n). A native-push sink is a second class chosen increateNotifier(docs/design-on-the-go.md). - eve
routine-failure-watcher.js: an always-on server-side relay connection that does not depend on a browser. An approval watcher can follow the same pattern.
Tasks
Filed 2026-10-15, each with needs-review. Parents in the other repos: barelyworkingcode/relay#439, barelyworkingcode/relayClient#4.
- Build and test every app change in CI, and log through one logger relayClient#5: CI, guards, unit tests and one logger (prerequisite). Gates: owner's clause 2.
- Show the approval alert on the phone and open its card on tap relayClient#6: APNs registration and the
APPROVAL_WAITINGcategory; a tap opens the thread's approval card. Device token: credential, owner approval needed. - Send the approval alert to the phone over APNs, with the key and device tokens sealed in relay relay#440: push sender, key and device-token storage, and the
askfield onasking. Credential: owner approval needed. - Raise the approval alert to a registered phone, and let the phone sign up for it #502: approval watcher on the
askingevent, the push sink behindnotify(n), and the "Approval alerts" checkbox. Moves the device token: owner approval needed. - Close child (relay): merge the approval-alert epic relay#441: close child (relay).
- Close child (relayClient): merge the approval-alert epic relayClient#7: close child (relayClient).
- Close child (eve): merge the approval-alert epic #503: close child (eve), then the joint close task in the Contract.
Open questions for when this epic opens
- BEYOND: where the push key lives, and which process sends: eve's notifier, or relay. Recommendation: decide with the key's storage, since it is the credential.
- BEYOND: is 60 s enough from push to card (unlock, app resume, reconnect, re-join)? Recommendation: measure on the phone first. Hold a tool approval while no screen shows the session #495 keeps 60 s by the owner's decision.
Contract
Owning parent of a cross-repo epic across three repos. relay's parent is barelyworkingcode/relay#439; relayClient's parent is barelyworkingcode/relayClient#4. Each child's ## Contract holds its own file ownership, tests, waits and budget. Nothing here starts before #495's epic is merged to main in eve and relay; the epic branches are cut from main after that. relayClient's track does not depend on #495 and may start once approved.
The contract is written for the recommended answer to open question 1 (relay holds the key and sends). If the owner picks the alternative, barelyworkingcode/relay#440 shrinks to the ask field, session.ws.open and fakerelay's push routes, and the sender and key move into #502 (see question 1).
Epic branches and slices, in merge order
Epic branches: eve epic/498-approval-alert, relay epic/439-approval-alert, relayClient epic/4-approval-alert. Child PRs target their repo's epic branch.
- Build and test every app change in CI, and log through one logger relayClient#5 →
epic/4-approval-alert: CI, guards, the unit test target, one logger. Prerequisite for As a user I want to see git changes per worktree so I can monitor what agents changed #6. - Show the approval alert on the phone and open its card on tap relayClient#6 →
epic/4-approval-alert: theEvePushplugin, the entitlement, the tap route. - Send the approval alert to the phone over APNs, with the key and device tokens sealed in relay relay#440 →
epic/439-approval-alert: theaskfield,session.ws.open, the key and device store, the APNs sender, the CLI, fakerelay's push surface. - Raise the approval alert to a registered phone, and let the phone sign up for it #502, pin PR
chore/502-relay-pin→epic/498-approval-alert:test/e2e/relay-pin.jsonnames relay's epic head with devboxverify: journey budget 600 s so a full run fits all 50 journeys (#439) #440. - Raise the approval alert to a registered phone, and let the phone sign up for it #502, feature PR
feat/502-approval-alert→epic/498-approval-alert: the watcher, the push sink, the device routes, the Settings checkbox, the spec. - Close children: Close child (relay): merge the approval-alert epic relay#441, Close child (eve): merge the approval-alert epic #503, Close child (relayClient): merge the approval-alert epic relayClient#7 (lines 1, 3 and 4 each).
- The joint close task below.
Steps 1 and 2 run beside steps 3 to 5. A child starts only after reading its epic branch's latest CI result; red is fixed first. At most two children are in build at once.
Cross-repo interface
relay-sessions → every /ws connection (barelyworkingcode/relay#440):
- The
session_stateframe foraskingraised by a permission request gainsask:
{"type":"session_state","sessionId":"<id>","state":"asking","since":"<RFC 3339>","ask":{"kind":"permission","toolName":"Bash","permissionId":"<id>"}}.
askis absent on every other state and on anaskingraised by a question. No other frame changes. This is Hold a tool approval while no screen shows the session #495's open question 2: the field the push needs. - A new event
session.ws.openat connection start, the twin ofsession.ws.close. It is the test signal that eve's watcher is connected.
eve browser → eve (#502), both requireAuth, the body forwarded to relay unchanged, relay's status and body returned:
POST /api/push/devices{"token":"<hex>","env":"development|production","name":"iPhone"}→ relayPUT /api/push/devices.DELETE /api/push/devices{"token":"<hex>"}→ relayDELETE /api/push/devices.
The token rides in the body, never the path: relay logs paths.
eve → relay, frontend socket, class configure (barelyworkingcode/relay#440):
| Route | Request | Success | Errors |
|---|---|---|---|
PUT /api/push/devices |
{"token":"<64 to 200 lowercase hex>","env":"development"|"production","name":"<1 to 64 chars>"} |
200 {"id":"pd_<8 hex>"}; the same token again keeps its id and updates env and name |
400 {"error":"<field>: <why>"} |
DELETE /api/push/devices |
{"token":"<hex>"} |
204, also for an unknown token | 400 |
POST /api/push/notify |
{"text":"<1 to 200 chars>","sessionId":"<1 to 128 of A-Z a-z 0-9 _ ->"} |
200 {"sent":N,"failed":M} after every device's attempt; {"sent":0,"failed":0} with no device |
400; 409 {"error":"no push key: run relay push key import"} |
- At most 8 devices. A ninth replaces the one added first, and
push.device.registernames the replaced id. - Events (
docs/events.md):push.key.import,push.device.register,push.device.remove(withreason:request,replaced,apns_410,bad_token),push.notify(session_id,sent,failed, and each failure's device id and APNs reason). No event, log line, DTO or error text holds the key or a token.
relay → APNs (barelyworkingcode/relay#440):
POST https://api.push.apple.com/3/device/<token>forenv: production,https://api.sandbox.push.apple.com/3/device/<token>fordevelopment, over HTTP/2. The release build has these two hosts fixed; only therelaytestbuild takes a test URL.- Headers:
authorization: bearer <JWT>,apns-topic: <topic>,apns-push-type: alert,apns-priority: 10,apns-expiration: <unix now + 60>,apns-collapse-id: <sessionId>. - JWT: JWS ES256 (signature as raw R‖S, 64 bytes), header
{"alg":"ES256","kid":"<key_id>"}, claims{"iss":"<team_id>","iat":<unix>}. One token is reused for at most 50 minutes. - Body:
{"aps":{"alert":{"body":"<text>"},"sound":"default","category":"APPROVAL_WAITING","thread-id":"<sessionId>"},"sessionId":"<sessionId>"}. - 410, or 400 with reason
BadDeviceTokenorDeviceTokenNotForTopic: the device is removed. Any other failure keeps it and counts infailed. Each attempt is bounded by a 10 s request deadline.
Key and token storage (barelyworkingcode/relay#440): settings.json gains push:
{"apns":{"key_id":"<10 chars>","team_id":"<10 chars>","topic":"com.barelyworkingcode.relayclient","key":<Secret: the .p8 PEM>},"devices":[{"id":"pd_…","name":"iPhone","env":"development","token":<Secret>,"added":"<RFC 3339>"}]}.
key and each token are config.Secret, sealed like every other secret. The key enters only by relay push key import, a presence-gated op (push.key.import).
relayClient ↔ eve's page, the Capacitor plugin EvePush (barelyworkingcode/relayClient#6 implements, #502 calls):
EvePush.register(): Promise<{token: string, env: 'development'|'production', name: string}>. It asks iOS for alert and sound permission when it has not asked before, then registers with APNs.tokenis lowercase hex.envisdevelopmentin a Debug build, elseproduction.nameisUIDevice.current.model.- Rejects with
{code:'denied'}when notifications are off for the app,{code:'failed', message}when APNs registration fails. EvePush.status(): Promise<{permission: 'granted'|'denied'|'prompt'}>.- eve shows the checkbox only when
window.Capacitor?.isNativePlatform?.()andwindow.Capacitor?.Plugins?.EvePushare both present.
Tap → page (barelyworkingcode/relayClient#6): a tap on an alert whose sessionId matches ^[A-Za-z0-9_-]{1,128}$ sets location.hash = '#session/<sessionId>', after the first page load on a cold launch. Any other value opens the app without navigating. eve's existing #session/<id> route joins the thread, relay replays the held request (#495), and the card shows (#496). While the app is in the foreground an arriving alert shows no banner.
eve's alert (#502): on a session_state frame with state: "asking" and ask.kind: "permission", eve sends one POST /api/push/notify with text: "<project name> needs approval: <toolName>" (each part capped at 60 characters; A session when the project is not found) and the frame's sessionId. It writes the same alert to notifications.jsonl:
{"v":1,"kind":"approval_waiting","at":"<ISO>","title":"<the text>","message":"","url":"#session/<id>","sessionId":"<id>","projectId":"<id>","toolName":"<tool>","permissionId":"<id>"}.
Paths
- Simple: in the phone app, Settings → checkbox "Approval alerts", off until ticked. Ticking it raises iOS's own permission prompt once. Then a waiting approval shows on the lock screen, and a tap opens its card. Nothing changes on the desktop.
- Power:
relay push key import --key-id K --team-id T [--topic BUNDLE] FILE.p8(presence-gated, one-time setup),relay push status [--json],relay push test. No hand-edited config: the key and the tokens are sealed. The key has no screen: it is a one-time owner step that carries a credential.
Acceptance mapping
| Parent criterion | Children | Interface | Proof |
|---|---|---|---|
| A pending approval reaches the owner's phone while the screen is off | relay#440, #502, relayClient#6 | ask field; POST /api/push/notify; the APNs request; EvePush.register; PUT /api/push/devices |
relay e2e against a fake APNs server; eve spec g15-approval-alert.spec.js; the phone pass (joint close step e) |
| A tap on the notification opens the pending approval card | relayClient#6, #502 | tap → #session/<id>; replay on join (#495) |
XCTest PushRouteTests; eve spec: #session/<id> during a held approval shows "Permission Request"; simulator pass with xcrun simctl push; the phone pass |
Test surface
- relay: e2e feature tests drive the real relay through its CLI and routes, with a fake APNs HTTP/2 server under
e2e/fakes/. One fakerelay proof test for the push routes, theaskfield andsession.ws.open. - eve: Playwright against fakerelay. The plugin is a page init-script stub of the exact
EvePushinterface above, because Chromium cannot load a native plugin. No new devbox journey: the devbox world has no scripted tool call without model output (as in Hold a tool approval while no screen shows the session #495). - relayClient: XCTest unit tests in CI; an interactive simulator pass on the test machine with
xcrun simctl push, if the simulator build can load a devbox eve (As a user I want to see git changes per worktree so I can monitor what agents changed #6's first task checks). - Interactive phone pass, by the owner, at joint close step e. Lock the phone during a held approval: the alert shows; a tap opens the card. Record the seconds from alert to card (question 2). It goes on the merged-for-review list.
Waits (each child's Contract has its own)
- relay e2e: the fake APNs server's request channel (a completion handle); the
askingframe withaskon an unjoined/ws; the CLI process exit. - eve specs:
session.ws.openbeforeeve.open(the watcher is connected);data-ready; heading "Permission Request";push.notify,push.device.registerandpush.device.removeevents throughrelay.waitForEvent. - relayClient: the CI run id with
gh run watch --exit-status;xcrun simctl bootstatus <udid> -b. - APNs delivery to the phone: none possible: an external service with no hook. The phone pass looks at the lock screen for at most 30 s.
Joint close task
All three epic PRs stay drafts until step c, so none merges alone. eve's relay-pin.json names relay's epic head; once relay merges, that head is an ancestor of main with the same tree, so nothing is re-pinned. If relay's sync PR moves relay's head after #502's pin, #503 bumps the pin first (a pin-only PR, Closes #503).
a. Each close child reports lines 1, 3 and 4 green: barelyworkingcode/relay#441 (sync/439-main), #503 (sync/498-main), barelyworkingcode/relayClient#7 (sync/4-main), each with a dispatched ci green on its head. relayClient has no verify harness, so its line 2 is not applicable.
b. One run of both heads together: node devboxverify/set.js --relay <epic/439-approval-alert head> --eve <epic/498-approval-alert head> --epic 498. Green posts eve's devbox/verify-full.
c. Mark the three epic PRs ready. merge-check passes on each: relay's says Closes #441, eve's Closes #503, relayClient's Closes #7. Merge relay, then eve, then relayClient, minutes apart. eve calls relay's push routes, so relay goes first; eve hides the checkbox without the plugin, so relayClient may go last.
d. From relay main, go run ./cmd/devboxverify --epic barelyworkingcode/relay#439 posts relay's Full verify. The coordinator rebuilds Relay.app from relay main. Close each parent once its sub-issues are closed.
e. The owner's phone pass (above), after the owner's setup steps. It is on the merged-for-review list, not a merge gate.
Revert-first covers the minutes between merges, in reverse order: relayClient, eve, relay. A gap in any step gets one comment here and the step stops.
Budgets
- Build and test every app change in CI, and log through one logger relayClient#5: 20 files, about 450 lines, most of them mechanical log-call changes.
- Show the approval alert on the phone and open its card on tap relayClient#6: 8 files, about 350 lines, about 100 of them tests.
- Send the approval alert to the phone over APNs, with the key and device tokens sealed in relay relay#440: 38 files, about 1,300 lines, about 600 of them tests, fakes and docs. The biggest child; its review runs on the default model.
- Raise the approval alert to a registered phone, and let the phone sign up for it #502: 15 files, about 600 lines, about 250 of them spec.
- Close children: no code. Each owns its repo's union of child paths for the sync and epic PRs.
Risks
- The alert text carries the project name and the tool name through Apple's servers, as "What I'd notice" asks.
apns-expirationis 60 s. A phone off the network for longer gets nothing; the approval has expired by then.- A session that asks again and again while the owner is away sends one alert per ask.
apns-collapse-idkeeps one per session on the lock screen. - Any frontend caller with the
configureclass can make relay alert the phone. Today that is eve. - Sessions relay does not track (chat kind; headless, not an agent) raise no
asking, so no alert. Send the approval alert to the phone over APNs, with the key and device tokens sealed in relay relay#440 checks whether a chat-kind session can raise a permission request; if it can, that comes back here as a question. - The simulator inside the VM may not load an eve or reach APNs. Then the phone pass is the only proof of criterion 1.
- A tap after the 60 s opens the thread with no card. That is Close a permission prompt that expired, including one that expired while eve was disconnected #499's ground.
Out of scope
- Approve and Deny buttons on the alert.
- Alerts for a model's question, or for a failed routine.
- Time-sensitive alerts that pass Focus (a further entitlement).
- A CLI to remove a device: the checkbox removes it, and APNs 410 prunes it.
- Any change to the 60 s timeout.
- relayClient#3's simulator journeys and devbox world: that epic stays deferred.
Owner steps
- Clause 4, outside the owner's repos: create an APNs auth key (.p8) and note its key id and team id; turn on Push Notifications for the app id
com.barelyworkingcode.relayclient. - Clause 3, credential: run
relay push key importat the console and answer the presence prompt. Approve Send the approval alert to the phone over APNs, with the key and device tokens sealed in relay relay#440, Raise the approval alert to a registered phone, and let the phone sign up for it #502 and Show the approval alert on the phone and open its card on tap relayClient#6 yourself. - Clause 2, the gates: Build and test every app change in CI, and log through one logger relayClient#5 adds relayClient's first CI workflows and guards. Set the repo secret
HYGIENE_PATTERNSthere (clause 3 too).
Open questions
- BEYOND: where the push key lives and which process sends. Recommendation: relay. It seals the key and the device tokens like its other secrets, gates the import with a presence prompt, and records it. eve watches and composes through its notifier seam. Alternative: a 0600 key file in eve's data dir and eve sends; it is smaller in relay, but puts a signing key and a second internet egress in eve, which by design holds no credential.
- BEYOND: is 60 s enough from alert to card (unlock, app resume, reconnect, re-join)? Recommendation: measure in the phone pass. Hold a tool approval while no screen shows the session #495 keeps 60 s by the owner's decision.
- BEYOND: how the phone signs up. Recommendation: the checkbox "Approval alerts" in Settings, in the app only, off by default. iOS asks permission when it is first ticked, not at app launch.
- BEYOND: alert while a screen already shows the session? Recommendation: yes, on every ask. The phone shows no banner while the app is in front, and one alert per session stays on the lock screen.
- INSIDE, settled: a model's question raises no alert. The story says approval, and the frame carries
askonly for a permission request. - INSIDE, settled: the alert has no actions. Criterion 2 asks for a tap that opens the card.
- INSIDE, settled: Build and test every app change in CI, and log through one logger relayClient#5 takes CI, guards, unit tests and logging only. relayClient#3's batches stay deferred.
- INSIDE, settled: a plain alert, not time-sensitive.
Decisions made in this contract
- Collapse one alert per session (
apns-collapse-id), expire it after 60 s (apns-expiration). - At most 8 devices; a ninth replaces the oldest.
- The token travels in request bodies only.
- eve writes each alert to
notifications.jsonlas well, so a later journey can read it. - Device removal has no CLI; the checkbox and APNs 410 cover it.
Activity
barelyworkingcode commentedon Oct 16, 2026
Approved by the owner in session on 2026-10-15 ("for this session all approved", then "Lift and go"). Scope for this pass: the planner's contract and batch list only, since APNs needs an Apple developer account and a push key (clauses 3 and 4). Each child that stores or moves the key or token files with needs-review and waits for the owner.
barelyworkingcode commentedon Oct 19, 2026
Coordinator approval: owner asked, 2026-10-19 (approved every waiting idea)
barelyworkingcode commentedon Oct 20, 2026
Deferred by the owner, 2026-10-19: a low-priority nice to have. Approved, not scheduled; do not start.