Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4df11e3
feat: keep notification choices on each device
DoodleBears Oct 21, 2026
d838c3d
fix: preserve icon Host menus and cover preference lifecycle
DoodleBears Oct 21, 2026
df3d159
test: capture remote notification settings in both themes
DoodleBears Oct 21, 2026
7f6f639
fix: discover Host notification attention and retry device copies
DoodleBears Oct 21, 2026
e8cedf8
build: regenerate portable client bundles and document review checks
DoodleBears Oct 21, 2026
4d92033
docs: attribute notification follow-up changes to issue
DoodleBears Oct 21, 2026
030a018
fix: establish notification baseline before enabling alerts
DoodleBears Oct 21, 2026
86b2024
fix: respect notification control deadlines and locale
DoodleBears Oct 21, 2026
56d7ffc
fix: deduplicate remote attention by owner-scoped identity
DoodleBears Oct 21, 2026
54bd755
fix: preserve notification baseline across connected read failures
DoodleBears Oct 21, 2026
449fd45
fix: narrow remote notification translation contract
DoodleBears Oct 21, 2026
4d48e25
docs: refresh remote notification runtime screenshots
DoodleBears Oct 21, 2026
c9c9ad7
test: isolate notification relay recovery and delayed delivery
DoodleBears Oct 21, 2026
5ee6509
test: distinguish Host notification and Activity attention fixtures
DoodleBears Oct 21, 2026
c6a2b5b
test: provide the complete relay storage fixture contract
DoodleBears Oct 21, 2026
40eff7b
test: synchronize notification CLI deadlines with delivered requests
DoodleBears Oct 21, 2026
d47d76f
test: await inspector WebSocket peer closure before timer assertions
DoodleBears Oct 21, 2026
39780fb
test: control stalled Git source deadline independently of fallback I/O
DoodleBears Oct 21, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ The phone app keeps every paired computer and shows one. A push notification is
- `tap` routes a notification: a Tool approval or question opens the request (`/inbox/[id]`, or the card in the Bot's DM when the shown computer's Roster has that DM), a reply opens the DM, and a test or badge-only notification opens Activity. A notification for another computer switches first.
- `badge` sets the app badge to the shown computer's unresolved actions and dismisses banners for items no longer unresolved whenever the shown computer's Inbox changes.
- `register` stores one Expo push token per device on the Host (`/botharness-device/push/register`); revoking the device deletes it, and the Host log records `push phase=…` lines without the token.
- `device-preference`: "stop spamming me with notifications."

Verify Settings → Notifications stores the four notification kinds on the phone and copies changes to every paired Host. Seed from existing switches only once; toggle on one Host, switch to the other, restart the app and re-pair to prove preservation. With a Host offline the phone retains its choice and reports pending sync; reconnect/resume retries. The kinds remain configurable without OS permission or an Expo token. Check Host copies with `deepseekbot device-notifications --device-file <private-pairing.json>` and keep pairing files private. Native default/intermediate/maximum text × light/dark on both platforms remains required.

Keep Host A connected and current while Host B fails, then restore B without switching or resuming: foreground retry must copy the latest choice. Backgrounding cancels the retry timer; removing B stops retries to it. On the assigned development client, `deepseekbot phone-notifications --metro-port <port> --target-id <id> --device-name <name>` reads the same phone owner; `--preferences-stdin` updates it and the UI, survives restart and reports saved separately from pending Host copies. This requires an exact unique Metro development target and does not qualify release CLI parity or replace native QA.

- `environment-paging` (#2194) taps/swipes All → Computer → member Organization → guest Organization without changing the paired Computer. Show an empty member Organization too. Open/send through real source routes, read back author/channel via public API/CLI, then isolate the task Computer route: keep Cloud send working, preserve the Computer draft across restart and prove reconnect does not auto-send. Inspect Activity in Cloud scope for its unavailable notice and absence of unrelated Computer badge; category swipe must remain. Capture matched light/dark before/after screenshots and a short paging recording at the frozen PR head. Default/intermediate/maximum text, keyboard, source removal and cold restoration are distinct checks. One Computer is not simultaneous multi-Computer proof.

Expand All @@ -30,7 +35,7 @@ Notifications opened from Settings or a Bot profile stay inside that parent draw

- **Windows Android (queued runner).** Follow [Android device QA](../../../../docs/agents/android-device-qa.md): claim the explicit emulator or phone serial, use `scripts/android-qa.ps1` to build/install/open the dev client, then exercise the phone entry points above and capture PNG/XML/MP4 evidence. Pairing, switching computers and permission UI require their visible and canonical facts; scrcpy working alone does not pass them. The Mac `simctl push` journey cannot simulate Android notifications. Real FCM delivery and notification actions stay untested until verified on a configured phone build.

- The Simulator cannot get an Expo push token without an EAS projectId, so Notifications shows "This build of the app cannot receive notifications." and the per-kind switches and test button do not appear. Real APNs/FCM delivery needs a physical device and EAS credentials; say it is still owed.
- The Simulator cannot get an Expo push token without an EAS projectId, so Notifications shows "This build of the app cannot receive notifications." The phone-owned kind switches remain available after preference initialization; sending a test stays disabled. Real APNs/FCM delivery needs a physical device and EAS credentials; say it is still owed.
- `xcrun simctl push` stands in for the Expo push service. The notification data goes under the payload's `body` key, which is where expo-notifications reads it on iOS.
- The banner belongs to SpringBoard, so Maestro cannot find it by text: the flow taps it by point (top centre). agent-device can tap it the same way (`press 196 90` on an iPhone 16).
- `maestro test` blocks while it runs; the journey serves its push trigger from the same process, so it runs Maestro asynchronously.
Expand Down
11 changes: 11 additions & 0 deletions .agents/skills/verify-deepseekbot/features/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu
- `viewers`: a remote Bot DM opens Browser or Computer when that Bot has access. The shared Host viewer modules read frames and send input to that Host only; closing, switching source or losing the connection tears down the viewer and releases requested control.
- `viewer-control`: queued input belongs to one takeover. Resume or frame loss invalidates it; frame recovery remains read-only even when the Host Feed never disconnected. Remove closes viewers and waits up to five seconds for takeover/release cleanup before revoking the pairing.
- `settings`: right-click a Remote Host icon → Remote settings opens that Host's models, usage and storage. Default model and preset changes use its shared Host client. Usage includes Memory activity, without a link to a This Host Bot page. Storage retains preview, acknowledgement and destructive confirmation.
- `notifications`: right-click a Remote Host icon and choose Remote notifications; enable browser permission explicitly and choose Tool approvals, Questions, Private input requests, Mentions & replies. Choices survive reload in this browser only. Verify the initial Inbox and reconnect backlog are silent, a new permitted Host attention item alerts once, disabled kinds stay silent and removing the Host closes its alerts. Private input text is never shown. Capture the new entry and dialog in light/dark; page-open API evidence does not qualify closed-page push or Desktop.

Discovery uses the Host's notification query, not the Activity display window. Keep more than 30 old mentions and produce a new ordinary DM reply and a non-mention group reply; both must alert once when enabled. Traverse `deepseekbot inbox --notifications` cursors and compare the Host-decided items. Verify stable same-timestamp pagination, a live invalidation during paging, a partial-page failure followed by recovery, and no historical replay. A refused Notification constructor records a bounded redacted `remote-notification-show-*` diagnostic through the existing Client diagnostics surface. Settings apply immediately and close through the normal close control.

## How to get to it (user POV)

Expand Down Expand Up @@ -51,3 +54,11 @@ For message bodies, upload an image and a text file to Host B through its attach
- Host looks are device-local by design. DSH Desktop and two physical machines over a real tailnet are not qualified by a two-Host local run; native phone qualification is separate.
- Control release uses the same device credential. An unreachable Host is retried when this viewer reconnects or reopens; a revoked credential cannot resume a Browser. In that case the Host operator resumes it. The Computer also has its existing Host-side pause lease.
- Dismiss Continue and Skip onboarding overlays before selecting Messages. Retain any failed drive evidence before retrying.

Activation race regression: while replies are disabled, write 35 old mentions and keep a notification page pending, then enable replies. The switch/CLI write commits only after a complete fresh baseline; old mentions stay silent. After success, send a new group reply and ordinary DM (including tied timestamps in automated fixtures): each alerts once. Fail a page and retry, disable/re-enable, change Host, close settings, revoke permission and dispose while activation waits; failed/cancelled operations cannot commit later. Preserve the original 37-alert failing trace separately from any subsequent pass.

CLI deadline regression: let the real appearance relay refuse at eight seconds, then complete the notification baseline at nine seconds. Browser preferences and persisted storage must remain unchanged, and no late acknowledgement is sent. Repeat after control unmount; a pre-deadline successful write remains operable. Change the app locale while the DOM language is unchanged and verify private-input notification copy follows the bound translator without exposing the input.

Attention identity regression: after a complete baseline, admit new Host attention IDs whose Source Events predate already observed items, including reverse timestamp order across notification kinds. Each permitted new identity alerts once. After more than 2,000 observed identities, remove old IDs from attention and reintroduce them: no replay, while a genuinely new older ID still alerts. Ordinary reconnect and overlapping subscriber replacement preserve identity history. Removing a Host, replacing its credential or final owner disposal releases that history; the new lifecycle first establishes a silent complete baseline. Identity memory grows with distinct observed IDs for that owner lifetime; it is not capped or persisted.

Read-failure recovery: keep the same Host connected with the same credential after a complete notification baseline. Fail only an ordinary Activity query, then admit a new attention ID before recovery. The next successful complete scan alerts that new ID once and never replays the original IDs. Repeat with only the notification tail page failing. An initial failed/partial read never establishes a baseline; after a real disconnect, failed reads likewise cannot consume the silent reconnect baseline. A pending enable with either read failure leaves preference and storage unchanged; an explicit successful retry establishes its complete enable boundary before new alerts.
Binary file added .github/pr-screenshots/n4/a-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/n4/a-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/n4/b-d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/pr-screenshots/n4/b-l.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 4 additions & 84 deletions apps/mobile/scripts/tob-mobile-driver.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { readdirSync, writeFileSync } from 'node:fs';
import { connect } from '../../../packages/core/src/bots/development-inspector.mjs';
export { connect, targetSocket } from '../../../packages/core/src/bots/development-inspector.mjs';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { pathToFileURL } from 'node:url';
import { createHash, randomUUID } from 'node:crypto';
import { argsOf, privateFile } from './tob-uat-session.mjs';

Expand Down Expand Up @@ -44,87 +45,6 @@ export function fixtureExpression(mode, state) {
return { accessToken: entry.session.accessToken, refreshToken: entry.session.refreshToken };
})()`;
}
export function targetSocket(target, port, simulatorName) {
if (
!target ||
target.deviceName !== simulatorName ||
target.appId !== 'ai.botharness.deepseekbot.dev'
)
throw Error('EXPLICIT_SIMULATOR_APP_TARGET_REQUIRED');
const url = new URL(target.webSocketDebuggerUrl);
if (
!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.port !== String(port) ||
url.protocol !== 'ws:'
)
throw Error('LOOPBACK_DEBUGGER_REQUIRED');
url.hostname = '127.0.0.1';
return url;
}
export async function connect(port, targetId, simulatorName) {
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw Error('INVALID_METRO_PORT');
const targets = await (
await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5000) })
).json();
const target = targets.find((t) => t.id === targetId);
const url = targetSocket(target, port, simulatorName);
const root = fileURLToPath(new URL('../../../', import.meta.url));
const store = join(root, 'node_modules/.pnpm');
const dependency = readdirSync(store)
.filter((name) => name.startsWith('ws@8.'))
.sort()
.pop();
if (!dependency) throw Error('DEBUG_WEBSOCKET_DEPENDENCY_MISSING');
const WebSocket = createRequire(join(store, dependency, 'node_modules/ws/package.json'))('ws');
const ws = new WebSocket(url, { origin: `http://127.0.0.1:${port}` });
await new Promise((resolve, reject) => {
ws.once('open', resolve);
ws.once('error', reject);
});
let id = 0;
const pending = new Map();
ws.on('message', (raw) => {
const r = JSON.parse(String(raw));
const p = pending.get(r.id);
if (!p) return;
clearTimeout(p.timer);
pending.delete(r.id);
if (r.error || r.result?.exceptionDetails) {
const description = r.result?.exceptionDetails?.exception?.description ?? '';
const code = [
'DEBUG_ONLY',
'CLOUD_MODULE_AMBIGUOUS',
'SESSION_BUSY',
'INVALID_VAULT',
'TRUSTED_IMPORT_REQUIRED',
'EXISTING_SESSION_REFUSED',
'LOCAL_SESSION_REMAINS',
'VERIFIED_NATIVE_IDENTITY_REQUIRED',
].find((v) => description.includes(v));
p.reject(Error(code ?? 'INSPECTOR_EVALUATION_FAILED'));
} else p.resolve(r.result);
});
ws.on('close', () => {
for (const p of pending.values()) {
clearTimeout(p.timer);
p.reject(Error('INSPECTOR_CLOSED'));
}
pending.clear();
});
return {
send: (method, params) =>
new Promise((resolve, reject) => {
const n = ++id;
const timer = setTimeout(() => {
pending.delete(n);
reject(Error('INSPECTOR_TIMEOUT'));
}, 20000);
pending.set(n, { resolve, reject, timer });
ws.send(JSON.stringify({ id: n, method, params }));
}),
close: () => ws.close(),
};
}
export async function main(mode, args) {
const state = JSON.parse(privateFile(args.session));
const sims = JSON.parse(
Expand Down
144 changes: 144 additions & 0 deletions apps/mobile/src/host/device-notification-preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { SavedHost } from './hosts';
import { PUSH_KINDS, type HostPush, type PushPreferences } from './push';

export const DEFAULT_NOTIFICATION_PREFERENCES: PushPreferences = {
approval: true,
question: true,
'private-input': true,
reply: true,
};

export interface DevicePreferenceSnapshot {
preferences: PushPreferences | undefined;
syncing: boolean;
failed: readonly string[];
}

export function parseNotificationPreferences(value: unknown): PushPreferences | undefined {
if (typeof value !== 'object' || value === null) return undefined;
const record = value as Record<string, unknown>;
if (!PUSH_KINDS.every((kind) => typeof record[kind] === 'boolean')) return undefined;
return Object.fromEntries(PUSH_KINDS.map((kind) => [kind, record[kind]])) as PushPreferences;
}

export function createDeviceNotificationPreferences(options: {
storage: { read(): Promise<unknown>; write(preferences: PushPreferences): void };
push: Pick<HostPush, 'settings' | 'setPreferences'>;
}) {
let snapshot: DevicePreferenceSnapshot = { preferences: undefined, syncing: false, failed: [] };
let hosts: readonly SavedHost[] = [];
let foreground = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let retryDelay = 1000;
const clearRetry = () => {
clearTimeout(retryTimer);
retryTimer = undefined;
};
const scheduleRetry = () => {
clearRetry();
if (!foreground || snapshot.failed.length === 0) {
retryDelay = 1000;
return;
}
retryTimer = setTimeout(() => {
retryTimer = undefined;
retryDelay = Math.min(retryDelay * 2, 30_000);
void enqueue(() => (foreground ? sync() : Promise.resolve())).catch(() => undefined);
}, retryDelay);
};
const listeners = new Set<() => void>();
const publish = (patch: Partial<DevicePreferenceSnapshot>) => {
snapshot = { ...snapshot, ...patch };
for (const listener of listeners) listener();
};
const ready = options.storage.read().then((value) => {
const preferences = parseNotificationPreferences(value);
if (value !== undefined && value !== null && preferences === undefined)
throw new Error('notification-preferences-invalid');
publish({ preferences });
});
void ready.catch(() => undefined);
let queue: Promise<void> = ready;
const enqueue = (run: () => Promise<void>): Promise<void> => {
const next = queue
.catch(() => undefined)
.then(() => ready)
.then(run);
queue = next;
return next;
};
const save = (preferences: PushPreferences) => {
options.storage.write(preferences);
publish({ preferences });
};
const sync = async () => {
clearRetry();
publish({ syncing: true });
try {
if (snapshot.preferences === undefined) {
const seeds = await Promise.all(
hosts.map((host) => options.push.settings(host).catch(() => undefined)),
);
const seed =
seeds.find((entry) => entry?.registered) ?? seeds.find((entry) => entry !== undefined);
if (seed !== undefined) save(seed.preferences);
}
const preferences = snapshot.preferences;
const results = await Promise.all(
hosts.map(async (host) => {
if (preferences === undefined) return host.hostId;
try {
await options.push.setPreferences(host, preferences);
return undefined;
} catch {
return host.hostId;
}
}),
);
publish({ failed: results.filter((id): id is string => id !== undefined) });
} finally {
publish({ syncing: false });
scheduleRetry();
}
};
return {
ready,
getSnapshot: () => snapshot,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
sync(next: readonly SavedHost[]) {
hosts = next;
return enqueue(sync);
},
retry() {
return enqueue(sync);
},
setForeground(active: boolean) {
foreground = active;
scheduleRetry();
},
prepare(host: SavedHost) {
return enqueue(async () => {
const preferences = snapshot.preferences;
if (
preferences === undefined ||
!hosts.some(
(entry) => entry.hostId === host.hostId && entry.credential === host.credential,
)
)
throw new Error('notification-preferences-unavailable');
await options.push.setPreferences(host, preferences);
});
},
set(patch: Partial<PushPreferences>) {
return enqueue(async () => {
save({ ...(snapshot.preferences ?? DEFAULT_NOTIFICATION_PREFERENCES), ...patch });
await sync();
});
},
};
}
Loading
Loading