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
4 changes: 4 additions & 0 deletions .agents/skills/verify-deepseekbot/features/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,7 @@ CLI deadline regression: let the real appearance relay refuse at eight seconds,
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.

Permanent Remove regression (F01): enable a Remote Host and change its notification kinds, then remove it through the icon context menu. Verify the Host revokes this browser, the local pairing disappears, open notification handles close and `botharness.remoteNotifications.v1` no longer contains that Host. Reload and pair the same Host again: notifications start off with default kinds. Repeat while activation is waiting; a late permission or notification-page response cannot restore the forgotten choice. Other Hosts retain their choices; temporary disconnection, reconnect and page disposal retain the existing choice. The phone-global preference is outside this browser operation. The historical T4 driver assertion `persistedChoiceUnchanged` was incorrect for permanent Remove; retain that historical artifact but do not count it as F01 proof.

Storage-refusal regression: selectively reject deletion of the notification key (removeItem for the last choice, setItem when another choice remains), while pairing storage stays writable. Permanent Remove must still stop the connection/feed, close handles, cancel late activation and revoke the pairing. The existing Client diagnostic stream records only the fixed remote-notification-forget-persistence-failed code. In-memory removal is complete but persisted removal is not; do not promise reload/re-pair will forget a choice the browser refused to delete. Pairing-storage failures are outside this regression.
Binary file added .github/pr-screenshots/f01/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/f01/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/f01/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/f01/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.
11 changes: 11 additions & 0 deletions changes/2500-remote-notification-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
section: Fixed
---

## en

- Removing a Remote Host now forgets its notification choices in that browser. Pairing it again starts with notifications off; temporary disconnections and other Hosts keep their choices ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)).

## zh

- 移除远程 Host 后,该浏览器会忘记它的通知选择,重新配对时默认关闭通知;临时断线和其他 Host 的通知选择不受影响([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。
2 changes: 1 addition & 1 deletion docs/architecture/botharness-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1144,7 +1144,7 @@ Remote conversation presentation retains the source Channel Journal entry throug

Activity Center consumes each Remote Host's shared Inbox through `RemoteHosts.activity`, reusing the existing `HumanInboxView` for Host groups. Its Host-scoped source supplies identity, pagination, source navigation and commands; it never receives This Host's `BridgeActions`. `createHostActivityActions` adapts the shared attention and request operations to the existing approval and question cards. Each operation resolves the current pairing before calling its Host client, and confirmed changes refresh that Host's Inbox. Filtered observers share the Host client and stop with their last subscriber. This Host still reads its existing bridge/store until that Host Feed slice migrates.

The phone owns its persisted four-kind Device Notification Preference and copies it to every paired Host on changes, pairing and retry. Existing Host switches seed it only before a device choice exists. The Host stores the copy independently of its Expo token and reads it only for delivery; Human Inbox facts are unchanged. A browser owns per-Remote-Host choices and consumes the shared Inbox while subscribed, suppressing the initial snapshot and reconnect backlog, deduplicating Host-scoped items and closing notifications on disposal. The browser retains exact seen attention identities per Host and credential for the active owner lifetime, including ordinary reconnects and overlapping subscriber changes. Source Event creation time is not attention arrival order. While the same Host connection and credential remain active, stale or failed Inbox reads preserve an established notification baseline and its seen identities; incomplete responses are not consumed. A connection interruption requires a new complete silent baseline for the reconnect backlog but retains identity history. An owner without a completed baseline stays uninitialized through read failures. There is no timestamp watermark or identity eviction: memory is O(distinct identities seen), not bounded to 2,000 entries. Removing the Host, replacing its credential or releasing the final subscriber clears this history; the next owner establishes a complete silent baseline. The browser has no closed-page notification path. Notification discovery uses the existing Host Feed client and live invalidation, with `humanAttention({ notifications: true })` paging Host-decided actions, unread group mentions/replies and ordinary DM replies independently of the Activity UI window. The shared Inbox publishes notification items only after every page completes; invalidation during a scan restarts it, and a failed page cannot replace the notification baseline. Browser activation keeps the previous choice until a fresh complete shared-Inbox scan succeeds; this acknowledged commit is the enable boundary, so an in-flight backlog cannot become new alerts. Activation has a ten-second deadline and is cancelled on target change, settings close, permission loss, removal or disposal; these cancellations do not create another feed. `deepseekbot inbox --notifications` exposes the same Host query. Phone copy failures retry while foreground with bounded backoff (1–30 seconds), without subscribing to every paired Host feed; backgrounding cancels the timer. Development-client AX invokes the existing phone owner through a precisely targeted loopback Metro inspector (`phone-notifications`); this does not provide release-client CLI parity.
The phone owns its persisted four-kind Device Notification Preference and copies it to every paired Host on changes, pairing and retry. Existing Host switches seed it only before a device choice exists. The Host stores the copy independently of its Expo token and reads it only for delivery; Human Inbox facts are unchanged. A browser owns per-Remote-Host choices and consumes the shared Inbox while subscribed, suppressing the initial snapshot and reconnect backlog, deduplicating Host-scoped items and closing notifications on disposal. The browser retains exact seen attention identities per Host and credential for the active owner lifetime, including ordinary reconnects and overlapping subscriber changes. Source Event creation time is not attention arrival order. While the same Host connection and credential remain active, stale or failed Inbox reads preserve an established notification baseline and its seen identities; incomplete responses are not consumed. A connection interruption requires a new complete silent baseline for the reconnect backlog but retains identity history. An owner without a completed baseline stays uninitialized through read failures. There is no timestamp watermark or identity eviction: memory is O(distinct identities seen), not bounded to 2,000 entries. Removing the Host, replacing its credential or releasing the final subscriber clears this history; the next owner establishes a complete silent baseline. Permanent removal also deletes that Host's persisted browser notification choice through the notification owner and cancels pending activation. Reloading or pairing that Host again starts with notifications off and default kinds; temporary disconnect, page disposal and other Hosts' choices are unaffected. This does not delete the phone's global Device Notification Preference. If notification storage refuses deletion, removal still clears the in-memory choice, cancels activation, closes handles and revokes the pairing; a bounded redacted `remote-notification-forget-persistence-failed` diagnostic reports that persistence failed. Reload may still read the old choice in that refusal case. The browser has no closed-page notification path. Notification discovery uses the existing Host Feed client and live invalidation, with `humanAttention({ notifications: true })` paging Host-decided actions, unread group mentions/replies and ordinary DM replies independently of the Activity UI window. The shared Inbox publishes notification items only after every page completes; invalidation during a scan restarts it, and a failed page cannot replace the notification baseline. Browser activation keeps the previous choice until a fresh complete shared-Inbox scan succeeds; this acknowledged commit is the enable boundary, so an in-flight backlog cannot become new alerts. Activation has a ten-second deadline and is cancelled on target change, settings close, permission loss, removal or disposal; these cancellations do not create another feed. `deepseekbot inbox --notifications` exposes the same Host query. Phone copy failures retry while foreground with bounded backoff (1–30 seconds), without subscribing to every paired Host feed; backgrounding cancels the timer. Development-client AX invokes the existing phone owner through a precisely targeted loopback Metro inspector (`phone-notifications`); this does not provide release-client CLI parity.

Remote Host settings resolve the selected Host's existing client from `RemoteHosts`. The browser reuses `apps/mobile/src/host/model-settings`, `usage` and `storage` for default models, presets, usage/Memory activity and cleanup; the existing Web settings, model picker, charts and destructive confirmation controls present those operations. Settings unmount their content when the source is disconnected or removed. Model resources are keyed by client and scope; there is no This Host fallback, Bot-profile link or automatic write replay. Cleanup retains the owning Host's preview token, scope and explicit confirmation. Host looks remain device-local and independent of these Host-owned settings.

Expand Down
1 change: 1 addition & 0 deletions packages/client/src/client/remote-hosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export function createRemoteHosts(options: {
const forget = (hostId: string): void => {
const entry = clients.get(hostId);
if (entry === undefined) return;
notifications.forget(hostId);
entry.release();
stop(entry);
clients.delete(hostId);
Expand Down
31 changes: 26 additions & 5 deletions packages/client/src/client/remote-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ type Snapshot = {
const off: Choice = { enabled: false, preferences: DEFAULT_NOTIFICATION_PREFERENCES };
type NotificationDiagnostic =
| 'remote-notification-show-refused'
| 'remote-notification-show-failed';
| 'remote-notification-show-failed'
| 'remote-notification-forget-persistence-failed';
function reportDiagnostic(code: NotificationDiagnostic): void {
if (typeof window !== 'undefined')
window.dispatchEvent(new CustomEvent('botharness:notification-diagnostic', { detail: code }));
Expand Down Expand Up @@ -73,6 +74,11 @@ export function createRemoteNotifications(options: {
let snapshot: Snapshot = { hosts, permission: api?.permission() ?? 'unsupported' };
const listeners = new Set<() => void>();
const reported = new Set<NotificationDiagnostic>();
const report = (code: NotificationDiagnostic) => {
if (reported.has(code)) return;
reported.add(code);
(options.diagnostic ?? reportDiagnostic)(code);
};
const state = new Map<
string,
{
Expand Down Expand Up @@ -119,6 +125,24 @@ export function createRemoteNotifications(options: {
getSnapshot: () => snapshot,
choice,
cancel,
forget(hostId: string) {
cancel(hostId);
const updated = { ...hosts };
delete updated[hostId];
let persistenceFailed = false;
try {
if (Object.keys(updated).length === 0) storage?.removeItem(REMOTE_NOTIFICATION_KEY);
else storage?.setItem(REMOTE_NOTIFICATION_KEY, JSON.stringify(updated));
} catch {
persistenceFailed = true;
}
hosts = updated;
state.delete(hostId);
revisions.delete(hostId);
close(hostId);
publish();
if (persistenceFailed) report('remote-notification-forget-persistence-failed');
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
Expand Down Expand Up @@ -310,10 +334,7 @@ export function createRemoteNotifications(options: {
(error.name === 'NotAllowedError' || error.name === 'SecurityError')
? 'remote-notification-show-refused'
: 'remote-notification-show-failed';
if (!reported.has(code)) {
reported.add(code);
(options.diagnostic ?? reportDiagnostic)(code);
}
report(code);
}
}
entry.baseline = true;
Expand Down
55 changes: 29 additions & 26 deletions packages/client/test/client-observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,35 @@ describe('early real Client diagnostic observer', () => {
}
).__BOTHARNESS_CLIENT_DIAGNOSTICS__;

it('records only fixed redacted notification failure codes through the existing diagnostic stream', async () => {
vi.useFakeTimers();
const diagnostics = createClientDiagnostics();
vi.stubGlobal(
'fetch',
vi.fn((url, options) => diagnostics.fetch(new Request('http://localhost' + url, options))),
);
installClientObserver(window);
window.dispatchEvent(
new CustomEvent('botharness:notification-diagnostic', { detail: 'private payload' }),
);
window.dispatchEvent(
new CustomEvent('botharness:notification-diagnostic', {
detail: 'remote-notification-show-refused',
}),
);
await vi.advanceTimersByTimeAsync(300);
const report = await (
await diagnostics.fetch(new Request('http://localhost/api/botharness/client-diagnostics'))
).json();
expect(report.attempts[0].events).toContainEqual(
expect.objectContaining({ code: 'remote-notification-show-refused', source: 'lifecycle' }),
);
expect(JSON.stringify(report)).not.toContain('private payload');
expect(report.attempts[0].firstFailure).toBeUndefined();
});
it.each(['remote-notification-show-refused', 'remote-notification-forget-persistence-failed'])(
'records only fixed redacted notification failure code %s through the existing diagnostic stream',
async (code) => {
vi.useFakeTimers();
const diagnostics = createClientDiagnostics();
vi.stubGlobal(
'fetch',
vi.fn((url, options) => diagnostics.fetch(new Request('http://localhost' + url, options))),
);
installClientObserver(window);
window.dispatchEvent(
new CustomEvent('botharness:notification-diagnostic', { detail: 'private payload' }),
);
window.dispatchEvent(
new CustomEvent('botharness:notification-diagnostic', {
detail: code,
}),
);
await vi.advanceTimersByTimeAsync(300);
const report = await (
await diagnostics.fetch(new Request('http://localhost/api/botharness/client-diagnostics'))
).json();
expect(report.attempts[0].events).toContainEqual(
expect.objectContaining({ code, source: 'lifecycle' }),
);
expect(JSON.stringify(report)).not.toContain('private payload');
expect(report.attempts[0].firstFailure).toBeUndefined();
},
);
it('retains a late shell observation when its first report follows a warning flood', async () => {
vi.useFakeTimers();
const diagnostics = createClientDiagnostics();
Expand Down
Loading
Loading