Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions docs/phone.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,11 +556,21 @@ group sent through this one carries that machine's id: the worker opens
once the group has connected it.

An event makes one notification on a device. iOS shows every push and does not
let a tag replace a notification the page shows, so a page whose own core
pushes to it stays quiet: `pushCovers` in `lib/notify.ts` requires the page's
push switch, the granted permission and a live browser subscription. A device
without push, and a page's notices about another machine, keep the local
notification, through the service worker and the per-thread tag a push would use.
let a tag replace a notification the page shows, so a page stays quiet when a
push brings the same notice: its own core pushes to it, or the notice is about
a member of the group, which forwards it to the device's push home.
`pushCovers` in `lib/notify.ts` requires the page's push switch, the granted
permission and a live browser subscription. Otherwise the page shows the
notice through the service worker whenever one is registered, since Android
Chrome refuses `new Notification` on a page and the phone app's page serves no
machine. It uses the tag and data a push about that thread would: `thread-<id>`
for this page's own core, `thread-<core>-<id>` with `{threadId, core}` for a
machine of the group (`GroupLinks.coreOf`, handed to `lib/notify.ts` by the
workspace), so a later push replaces it and a tap opens the thread there. A
machine with no group id gets a `key` in the data, which the open window reads
through `openNotification`, and which the worker puts in the address it opens
(`?thread=<id>&key=<key>`) when no window is open or one refuses focus. Only a page without a worker builds the
notification itself.

Routine turn notifications wait while someone watches their conversation.
Questions and permission requests notify immediately, including asynchronous
Expand Down Expand Up @@ -604,6 +614,27 @@ for those labels in the language it speaks to the worker's `boite-notify`
cache, again on each language change; the worker shows them in place of the
English body, which stays the fallback until the page has run once.

### Choosing which notifications

Each device chooses the kinds it gets, all on by default: Approvals
(permission requests, plan approvals, settings and agent settings cards asking
to apply a change), Questions (an agent's questions, `boite ask`, a delegated
agent's), Finished and Errors. The rows sit under the notifications switch, in
General on the desktop and in App & notifications on the phone, and dim while
the switch is off. The choice lives in the page's `localStorage`: the kinds
turned off under `boite.notifications.muted`, so a kind added later starts on.
Local toasts read it (`shouldNotify` in `lib/notify.ts`). For Web Push the core
sends, so a subscribed device hands it over: `push.subscribe` takes `kinds`,
`push.kinds` changes them without subscribing again, `push.status` returns
them, and the switch off sends an empty list. The page records which machine
holds the subscription (`boite.web-push.home`, its address in this page), so a
change made while another machine is shown still goes there; the kinds are one
state for every machine of the window. A subscription stored without
kinds gets every kind. Every push carries its `kind` beside the `label`; a push
forwarded through the group carries it too, and the device's home machine
filters it by that subscription's kinds. The test notification has no kind and
always goes.

Disabling removes the server subscription and unsubscribes the browser.
Revocation deletes the subscription with the pairing. Push services returning
404 or 410 retire the destination. Other delivery failures leave it subscribed
Expand All @@ -625,7 +656,7 @@ one arrives.
The path is: the installed app's Enable notifications asks the permission from
the tap, subscribes with the core's VAPID key, and stores the subscription with
`push.subscribe` against its pairing. A notification's payload is
`{title, body, threadId, tag, label, badge}` (`PushPayload` in the contracts),
`{title, body, threadId, tag, label, kind, badge}` (`PushPayload` in the contracts),
plus `core` when another machine of the group sent it through this core;
tapping it focuses an open window and posts it the thread, or, if the page
refuses focus or none is open, opens `/?thread=<id>` on the worker's own
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export const DEVICE_METHODS: ReadonlySet<RpcMethodName> = new Set<RpcMethodName>
'push.subscribe',
'push.unsubscribe',
'push.test',
// Which kinds of notification its own subscription receives: the device's choice, about nothing else.
'push.kinds',
// The sidebar and the composer's `@`.
'projects.list',
// The logo the list announces: a bounded image already stored in the journal, no path and no disk read.
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export { PREVIEW_REFERENCES_PER_TURN, previewReferencesError, previewPrompt } fr
export { previewToolOutputs, previewToolPart, inputPreview, longerThan, TOOL_OUTPUT_INLINE_CHARS, TOOL_OUTPUT_PREVIEW_CHARS, TOOL_OUTPUT_FOLDED_CHARS, TOOL_INPUT_INLINE_CHARS, TOOL_INPUT_PREVIEW_CHARS, TOOL_DOCUMENTS_INLINE_CHARS } from './message-preview';
export { forTransport, projectLiveMessage, projectLivePart, projectMessage, MESSAGE_SENT_MAX_BYTES, type TransportOptions } from './transport.ts';
export { boundedMessageWindow } from './message-window.ts';
export { lastAgentText, notificationExcerpt, requestExcerpt, NOTIFICATION_TEXT_CHARS } from './notification-text';
export { lastAgentText, notificationExcerpt, requestExcerpt, requestKind, NOTIFICATION_TEXT_CHARS } from './notification-text';
export { nativeAgentsOfTool, collectNativeAgents } from './native-agents.ts';
export { collectProcessAgents, processAgentCommand } from './process-agents.ts';

Expand Down
17 changes: 15 additions & 2 deletions packages/contracts/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,13 +229,22 @@ export const INITIAL_MESSAGE_PAGE = 40;
* page left it, and falls back on the English body.
*/
export type NotificationLabel = 'done' | 'failed' | 'needsYou' | 'connected';
/**
* What a notification is about, the unit a device turns on or off: a
* permission or a change to approve, a question, a finished turn, a failed one.
*/
export type NotificationKind = 'approvals' | 'questions' | 'finished' | 'errors';
/** Every kind, in the order the settings list them. A device that never chose gets them all. */
export const NOTIFICATION_KINDS: readonly NotificationKind[] = ['approvals', 'questions', 'finished', 'errors'];
/** A Web Push payload, as the core sends it and the service worker reads it. */
export interface PushPayload {
title: string;
body: string;
threadId: string | null;
tag: string;
label?: NotificationLabel;
/** What the notification is about; the test notification has none. */
kind?: NotificationKind;
/** The app icon's count after this notification. */
badge?: number;
/**
Expand Down Expand Up @@ -303,8 +312,12 @@ export interface MessagesRpcMethods {
'artifacts.preview': { params: { threadId: ThreadId; path: string }; result: { url: string; shown: boolean } };
'artifacts.previewClose': { params: { threadId: ThreadId; path: string }; result: { ok: true } };
/** Push credentials are owned by the authenticated pairing, never a caller-supplied session id. */
'push.status': { params: Record<string, never>; result: { publicKey: string; subscribed: boolean } };
'push.subscribe': { params: { endpoint: string; keys: { p256dh: string; auth: string } }; result: { ok: true } };
/** `kinds` is what this device's subscription receives, every kind when it never chose. */
'push.status': { params: Record<string, never>; result: { publicKey: string; subscribed: boolean; kinds?: NotificationKind[] } };
/** Without `kinds`, the subscription receives every kind. */
'push.subscribe': { params: { endpoint: string; keys: { p256dh: string; auth: string }; kinds?: NotificationKind[] }; result: { ok: true } };
/** The kinds this device's subscription receives from now on, without subscribing again. An empty list receives nothing. */
'push.kinds': { params: { kinds: NotificationKind[] }; result: { ok: true } };
'push.unsubscribe': { params: Record<string, never>; result: { ok: true } };
'push.test': { params: Record<string, never>; result: { ok: true } };
/** Reserve the final workspace and initialize a native session without publishing a conversation or sending text. */
Expand Down
12 changes: 11 additions & 1 deletion packages/contracts/src/notification-text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Message, PermissionRequest, QuestionRequest } from './index';
import type { Message, NotificationKind, PermissionRequest, QuestionRequest } from './index';

/** About two lines on a phone's lock screen. */
export const NOTIFICATION_TEXT_CHARS = 140;
Expand Down Expand Up @@ -77,3 +77,13 @@ export function requestExcerpt(request: Pick<QuestionRequest, 'text'> | Pick<Per
const target = ['command', 'file_path', 'path', 'url', 'pattern'].map(key => input[key]).find(value => typeof value === 'string' && value.trim());
return notificationExcerpt(target ? `${request.toolName}: ${target as string}` : request.toolName);
}

/**
* What a request for input is, for the device that chose which notifications
* it gets: a permission, or a card asking to apply a change (a setting, an
* agent's setting), is an approval; anything else the agent asks is a question.
*/
export function requestKind(request: Pick<QuestionRequest, 'setting' | 'agentSetting'> | Pick<PermissionRequest, 'toolName'>): NotificationKind {
if ('toolName' in request) return 'approvals';
return request.agentSetting !== undefined || request.setting?.kind === 'change' ? 'approvals' : 'questions';
}
2 changes: 2 additions & 0 deletions packages/core/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
* quotas.list and subscriptionProxy.quotas let a phone draw the same limit bars
* as the owner without a dashboard embed; a forced refresh reaches the
* providers and stays the owner's, as do monitoring switches and resets.
* push.kinds lets a paired device choose which kinds of notification its own
* subscription receives; it reaches no other device's destination.
*/

import { AGENT_EVENTS, DEVICE_EVENTS, DEVICE_METHODS, type RpcEventName, type RpcMethodName } from '@boite/contracts';
Expand Down
Loading
Loading