Skip to content

Alert the owner's phone when a session waits for approval #498

Open
0 of 2 issues completed
Open

Description

@barelyworkingcode

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 audio background 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 with notify(n). A native-push sink is a second class chosen in createNotifier (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.

Open questions for when this epic opens

  1. 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.
  2. 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.

  1. 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.
  2. Show the approval alert on the phone and open its card on tap relayClient#6 → epic/4-approval-alert: the EvePush plugin, the entitlement, the tap route.
  3. Send the approval alert to the phone over APNs, with the key and device tokens sealed in relay relay#440 → epic/439-approval-alert: the ask field, session.ws.open, the key and device store, the APNs sender, the CLI, fakerelay's push surface.
  4. 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.json names relay's epic head with devboxverify: journey budget 600 s so a full run fits all 50 journeys (#439) #440.
  5. 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.
  6. 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).
  7. 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_state frame for asking raised by a permission request gains ask:
    {"type":"session_state","sessionId":"<id>","state":"asking","since":"<RFC 3339>","ask":{"kind":"permission","toolName":"Bash","permissionId":"<id>"}}.
    ask is absent on every other state and on an asking raised 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.open at connection start, the twin of session.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"} → relay PUT /api/push/devices.
  • DELETE /api/push/devices {"token":"<hex>"} → relay DELETE /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.register names the replaced id.
  • Events (docs/events.md): push.key.import, push.device.register, push.device.remove (with reason: 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> for env: production, https://api.sandbox.push.apple.com/3/device/<token> for development, over HTTP/2. The release build has these two hosts fixed; only the relaytest build 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 BadDeviceToken or DeviceTokenNotForTopic: the device is removed. Any other failure keeps it and counts in failed. 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. token is lowercase hex. env is development in a Debug build, else production. name is UIDevice.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?.() and window.Capacitor?.Plugins?.EvePush are 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, the ask field and session.ws.open.
  • eve: Playwright against fakerelay. The plugin is a page init-script stub of the exact EvePush interface 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 asking frame with ask on an unjoined /ws; the CLI process exit.
  • eve specs: session.ws.open before eve.open (the watcher is connected); data-ready; heading "Permission Request"; push.notify, push.device.register and push.device.remove events through relay.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

Risks

  • The alert text carries the project name and the tool name through Apple's servers, as "What I'd notice" asks.
  • apns-expiration is 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-id keeps one per session on the lock screen.
  • Any frontend caller with the configure class 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

Open questions

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. INSIDE, settled: a model's question raises no alert. The story says approval, and the frame carries ask only for a permission request.
  6. INSIDE, settled: the alert has no actions. Criterion 2 asks for a tap that opens the card.
  7. 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.
  8. 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.jsonl as well, so a later journey can read it.
  • Device removal has no CLI; the checkbox and APNs 410 cover it.

Activity

  1. added
    enhancementNew feature or request
    epicTracks a multi-issue initiative
    and removed on Oct 15, 2026
  2. barelyworkingcode commented on Oct 16, 2026

    @barelyworkingcode
    OwnerAuthor

    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.

  3. barelyworkingcode commented on Oct 19, 2026

    @barelyworkingcode
    OwnerAuthor

    Coordinator approval: owner asked, 2026-10-19 (approved every waiting idea)

  4. added
    deferredApproved but not scheduled: never start
    and removed on Oct 19, 2026
  5. barelyworkingcode commented on Oct 20, 2026

    @barelyworkingcode
    OwnerAuthor

    Deferred by the owner, 2026-10-19: a low-priority nice to have. Approved, not scheduled; do not start.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    deferredApproved but not scheduled: never startenhancementNew feature or requestepicTracks a multi-issue initiative

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions