From 4df11e32fa235c326f4fb75e07b0e59d430622d4 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 00:18:09 +0900 Subject: [PATCH 01/18] feat: keep notification choices on each device Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../features/phone-notifications.md | 3 +- .../features/remote-hosts.md | 1 + .../host/device-notification-preferences.ts | 119 + apps/mobile/src/host/host-session.ts | 10 +- .../host/native-notification-preferences.ts | 18 + apps/mobile/src/host/push-session.ts | 25 +- apps/mobile/src/ui/notification-settings.tsx | 37 +- .../src/ui/shell/notification-screen.tsx | 24 +- apps/mobile/src/ui/strings.ts | 2 + .../device-notification-preferences.test.ts | 104 + changes/2500-device-notifications.md | 11 + docs/architecture/botharness-architecture.md | 2438 +++++------ docs/bot-cli.md | 4 + docs/bot-cli.zh.md | 4 + packages/browser/lib/client.js | 156 +- .../client/src/client/appearance-control.ts | 18 +- packages/client/src/client/host-tabs.tsx | 13 + packages/client/src/client/locale.ts | 22 + packages/client/src/client/remote-hosts.ts | 19 + .../client/remote-notification-settings.tsx | 93 + .../client/src/client/remote-notifications.ts | 243 ++ .../client/test/remote-notifications.test.ts | 137 + packages/computer/lib/client.js | 82 +- packages/core/src/appearance/control.ts | 11 +- .../src/bots/cli-browser-notifications.ts | 69 + .../core/src/bots/cli-device-notifications.ts | 64 + packages/core/src/bots/cli-live.ts | 3635 +++++++++-------- packages/core/src/database/schema-plan.ts | 16 + packages/core/src/push/delivery.ts | 52 +- packages/core/src/push/http.ts | 21 +- packages/core/test/appearance-control.test.ts | 27 + .../test/cli-browser-notifications.test.ts | 36 + .../test/cli-device-notifications.test.ts | 73 + packages/core/test/push-delivery.test.ts | 42 + scripts/cli-ui-inventory.json | 11 + 35 files changed, 4458 insertions(+), 3182 deletions(-) create mode 100644 apps/mobile/src/host/device-notification-preferences.ts create mode 100644 apps/mobile/src/host/native-notification-preferences.ts create mode 100644 apps/mobile/test/device-notification-preferences.test.ts create mode 100644 changes/2500-device-notifications.md create mode 100644 packages/client/src/client/remote-notification-settings.tsx create mode 100644 packages/client/src/client/remote-notifications.ts create mode 100644 packages/client/test/remote-notifications.test.ts create mode 100644 packages/core/src/bots/cli-browser-notifications.ts create mode 100644 packages/core/src/bots/cli-device-notifications.ts create mode 100644 packages/core/test/cli-browser-notifications.test.ts create mode 100644 packages/core/test/cli-device-notifications.test.ts diff --git a/.agents/skills/verify-deepseekbot/features/phone-notifications.md b/.agents/skills/verify-deepseekbot/features/phone-notifications.md index 1ccf3f53be..47333e4224 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-notifications.md +++ b/.agents/skills/verify-deepseekbot/features/phone-notifications.md @@ -9,6 +9,7 @@ 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`: 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 ` and keep pairing files private. Native default/intermediate/maximum text × light/dark on both platforms remains required. - `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. @@ -30,7 +31,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. diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 0dc94b6c47..0cb4968d8d 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -16,6 +16,7 @@ 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`: select a Remote Host tab and open Remote notifications (bell); 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. ## How to get to it (user POV) diff --git a/apps/mobile/src/host/device-notification-preferences.ts b/apps/mobile/src/host/device-notification-preferences.ts new file mode 100644 index 0000000000..63819b1d2e --- /dev/null +++ b/apps/mobile/src/host/device-notification-preferences.ts @@ -0,0 +1,119 @@ +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; + 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; write(preferences: PushPreferences): void }; + push: Pick; +}) { + let snapshot: DevicePreferenceSnapshot = { preferences: undefined, syncing: false, failed: [] }; + let hosts: readonly SavedHost[] = []; + const listeners = new Set<() => void>(); + const publish = (patch: Partial) => { + 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 = ready; + const enqueue = (run: () => Promise): Promise => { + 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 () => { + 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 }); + } + }; + 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); + }, + 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) { + return enqueue(async () => { + save({ ...(snapshot.preferences ?? DEFAULT_NOTIFICATION_PREFERENCES), ...patch }); + await sync(); + }); + }, + }; +} diff --git a/apps/mobile/src/host/host-session.ts b/apps/mobile/src/host/host-session.ts index 299472a52f..98f5110d55 100644 --- a/apps/mobile/src/host/host-session.ts +++ b/apps/mobile/src/host/host-session.ts @@ -19,6 +19,7 @@ import { avatarInputs } from './notification-avatars'; import { revokePairing } from './pairing'; import { createHostPush } from './push'; import { createPushSession, type PushStatus } from './push-session'; +import { notificationPreferences } from './native-notification-preferences'; import { createQuietChannels } from './quiet-channels'; import { loadSavedHosts, saveHosts, type SavedHost } from './saved-host'; import { mobileTelemetry } from '../telemetry/native'; @@ -50,6 +51,8 @@ export const pushSession = createPushSession({ 'remove-device': t.removeDevice, }), hostPush, + beforeRegister: () => notificationPreferences.retry(), + prepareRegistration: (host) => notificationPreferences.prepare(host), platform: Platform.OS === 'android' ? 'android' : 'ios', locale: locale === 'zh' ? 'zh' : 'en', }); @@ -65,6 +68,7 @@ function adopt(next: SavedHosts, trigger: ConnectionTrigger = 'open'): void { mobileTelemetry.foreground(AppState.currentState === 'active', next.hosts.length); const previous = state.saved; stored = next; + void notificationPreferences.sync(next.hosts).catch(() => undefined); const saved = currentHost(next) ?? null; if ( previous !== undefined && @@ -100,7 +104,10 @@ function adopt(next: SavedHosts, trigger: ConnectionTrigger = 'open'): void { offConnection = client?.subscribe(() => { const snapshot = client.getSnapshot(); const now = snapshot.connection.status === 'connected'; - if (now && !connected) void pushSession.retryFailed(); + if (now && !connected) { + void pushSession.retryFailed(); + void notificationPreferences.retry().catch(() => undefined); + } connected = now; const inputs = snapshot.roster === undefined ? undefined : avatarInputs(snapshot.roster); if ( @@ -136,6 +143,7 @@ void loadSavedHosts().then( AppState.addEventListener('change', (next) => { mobileTelemetry.foreground(next === 'active', state.hosts.length); if (next !== 'active') return; + void notificationPreferences.retry().catch(() => undefined); pushSession.reflectAgain(); void pushSession.retryFailed(); state.client?.retryNow('resume'); diff --git a/apps/mobile/src/host/native-notification-preferences.ts b/apps/mobile/src/host/native-notification-preferences.ts new file mode 100644 index 0000000000..dde1858ced --- /dev/null +++ b/apps/mobile/src/host/native-notification-preferences.ts @@ -0,0 +1,18 @@ +import { File, Paths } from 'expo-file-system'; +import { createDeviceNotificationPreferences } from './device-notification-preferences'; +import { nativeCarrier } from './native-carrier'; +import { createHostPush } from './push'; + +const file = () => new File(Paths.document, 'deepseekbot-notification-preferences.json'); +export const notificationPreferences = createDeviceNotificationPreferences({ + storage: { + async read() { + const saved = file(); + return saved.exists ? JSON.parse(await saved.text()) : undefined; + }, + write(preferences) { + file().writeSync(JSON.stringify(preferences)); + }, + }, + push: createHostPush(nativeCarrier()), +}); diff --git a/apps/mobile/src/host/push-session.ts b/apps/mobile/src/host/push-session.ts index 1577812e0b..7b36624ccb 100644 --- a/apps/mobile/src/host/push-session.ts +++ b/apps/mobile/src/host/push-session.ts @@ -42,6 +42,8 @@ export function createPushSession(options: { platform: 'ios' | 'android'; locale: 'en' | 'zh'; tokenTimeoutMs?: number; + beforeRegister?: () => Promise; + prepareRegistration?: (host: SavedHost) => Promise; }): PushSession { const { notifications, hostPush } = options; const listeners = new Set<() => void>(); @@ -56,6 +58,12 @@ export function createPushSession(options: { }; const registerAll = async (hosts: readonly SavedHost[]): Promise => { + try { + await options.beforeRegister?.(); + } catch { + publish({ status: 'unavailable', reason: 'notification-preferences-unavailable' }); + return; + } let token: string; try { let timer: ReturnType | undefined; @@ -77,13 +85,16 @@ export function createPushSession(options: { } const results = await Promise.all( hosts.map((host) => - hostPush - .register(host, { - token, - platform: options.platform, - locale: options.locale, - ...(options.platform === 'android' ? { render: 'app' as const } : {}), - }) + Promise.resolve() + .then(() => options.prepareRegistration?.(host)) + .then(() => + hostPush.register(host, { + token, + platform: options.platform, + locale: options.locale, + ...(options.platform === 'android' ? { render: 'app' as const } : {}), + }), + ) .then( () => undefined, () => host.hostId, diff --git a/apps/mobile/src/ui/notification-settings.tsx b/apps/mobile/src/ui/notification-settings.tsx index 464c0fa05c..4fbe26aa0a 100644 --- a/apps/mobile/src/ui/notification-settings.tsx +++ b/apps/mobile/src/ui/notification-settings.tsx @@ -9,6 +9,7 @@ import { type PushSettings, } from '../host/push'; import type { PushStatus } from '../host/push-session'; +import type { DevicePreferenceSnapshot } from '../host/device-notification-preferences'; import { space } from './design/tokens'; import { useDesignTheme } from './design/theme'; import { useMounted } from './mounted'; @@ -20,7 +21,7 @@ import { t } from './strings'; export interface NotificationSettingsPort { load(): Promise; - set(preferences: Partial): Promise; + set(preferences: Partial): Promise; setEnabled(enabled: boolean): Promise; test(): Promise; } @@ -58,12 +59,14 @@ export function NotificationSettings({ status, hostName, port, + preference, onEnable, onOpenSettings, }: { status: PushStatus; hostName: string; port: NotificationSettingsPort; + preference?: DevicePreferenceSnapshot; onEnable(): void; onOpenSettings(): void; }) { @@ -80,16 +83,25 @@ export function NotificationSettings({ port.load().then(setSettings, (reason: unknown) => setError(failure(reason))); }, [port]); const mount = useMounted(() => { - if (on) load(); + load(); }, [on, load]); const toggle = (kind: PushKind, value: boolean) => { - if (settings === undefined) return; + if (settings === undefined && preference?.preferences === undefined) return; + if (settings === undefined) { + void port.set({ [kind]: value }).catch((reason: unknown) => setError(failure(reason))); + return; + } setSettings({ ...settings, preferences: { ...settings.preferences, [kind]: value } }); - port.set({ [kind]: value }).then(setSettings, (reason: unknown) => { - setError(failure(reason)); - load(); - }); + port.set({ [kind]: value }).then( + (next) => { + if (next !== undefined) setSettings(next); + }, + (reason: unknown) => { + setError(failure(reason)); + load(); + }, + ); }; const allow = (enabled: boolean) => { @@ -148,6 +160,9 @@ export function NotificationSettings({ ) : null} {error !== undefined ? {error} : null} + {preference?.failed.length ? ( + {t.notificationPreferencePending} + ) : null} {on && settings === undefined && error === undefined ? : null} {on && settings !== undefined ? ( ) : null} - {on && settings?.enabled === true ? ( + {preference?.preferences !== undefined || (on && settings?.enabled === true) ? ( {t.notifyAbout} @@ -182,9 +197,9 @@ export function NotificationSettings({ trailing={ toggle(kind, value)} - disabled={!settings.registered} + disabled={preference?.syncing ?? !settings?.registered} /> } /> @@ -196,7 +211,7 @@ export function NotificationSettings({ tone="secondary" onPress={sendTest} busy={test?.state === 'sending'} - disabled={!settings.registered} + disabled={!on || !settings?.registered} /> {test?.state === 'sent' ? {t.testSent} : null} diff --git a/apps/mobile/src/ui/shell/notification-screen.tsx b/apps/mobile/src/ui/shell/notification-screen.tsx index 3a46492c7f..756f9062fe 100644 --- a/apps/mobile/src/ui/shell/notification-screen.tsx +++ b/apps/mobile/src/ui/shell/notification-screen.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { Linking } from 'react-native'; import { @@ -9,21 +9,36 @@ import { pushSession, } from '../../host/host-session'; import { NotificationSettings, type NotificationSettingsPort } from '../notification-settings'; +import { notificationPreferences } from '../../host/native-notification-preferences'; export default function NotificationsScreen() { const { saved } = useHostSession(); const status = usePushStatus(); + const preference = useSyncExternalStore( + notificationPreferences.subscribe, + notificationPreferences.getSnapshot, + ); const port = useMemo( () => saved === null || saved === undefined ? undefined : { - load: () => hostPush.settings(saved), - set: (preferences) => hostPush.setPreferences(saved, preferences), + load: async () => { + await notificationPreferences.ready; + const settings = await hostPush.settings(saved); + return { + ...settings, + preferences: + notificationPreferences.getSnapshot().preferences ?? settings.preferences, + }; + }, + set: async (preferences) => { + await notificationPreferences.set(preferences); + }, setEnabled: (enabled) => hostPush.setEnabled(saved, enabled), test: () => hostPush.test(saved), }, - [saved], + [saved, status.status], ); if (saved === null || saved === undefined || port === undefined) return null; return ( @@ -31,6 +46,7 @@ export default function NotificationsScreen() { status={status} hostName={saved.hostName} port={port} + preference={preference} onEnable={() => void pushSession.enable(hostSession.getState().hosts)} onOpenSettings={() => void Linking.openSettings()} key={saved.hostId} diff --git a/apps/mobile/src/ui/strings.ts b/apps/mobile/src/ui/strings.ts index 5c15e8f039..a7e03cc432 100644 --- a/apps/mobile/src/ui/strings.ts +++ b/apps/mobile/src/ui/strings.ts @@ -899,6 +899,7 @@ const en = { turnOnNotifications: 'Turn on notifications', openSettings: 'Open phone settings', notifyAbout: 'Notify me about', + notificationPreferencePending: 'Saved on this phone. Some Hosts will sync when reachable.', allowNotifications: 'Allow notifications', allowNotificationsOff: 'This phone gets no notifications from this computer. Your choices below come back when you turn them on again.', @@ -2131,6 +2132,7 @@ const zh: typeof en = { turnOnNotifications: '开启通知', openSettings: '打开手机设置', notifyAbout: '通知我', + notificationPreferencePending: '已保存在此手机。部分远程恢复连接后会同步。', allowNotifications: '允许通知', allowNotificationsOff: '这台手机不会收到这台电脑的任何通知。重新打开后,下面的选择会恢复。', channelNotifications: { diff --git a/apps/mobile/test/device-notification-preferences.test.ts b/apps/mobile/test/device-notification-preferences.test.ts new file mode 100644 index 0000000000..9f83853f5e --- /dev/null +++ b/apps/mobile/test/device-notification-preferences.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createDeviceNotificationPreferences, + DEFAULT_NOTIFICATION_PREFERENCES, +} from '../src/host/device-notification-preferences'; +import type { SavedHost } from '../src/host/hosts'; +import type { PushPreferences } from '../src/host/push'; + +const host = (id: string, credential = id): SavedHost => ({ + hostId: id, + hostName: id, + deviceId: credential, + credential, + routes: [`https://${id}.ts.net`], +}); + +function fixture(saved?: PushPreferences) { + let disk = saved; + const copies = new Map(); + const offline = new Set(); + const settings = vi.fn(async (entry: SavedHost) => { + if (offline.has(entry.hostId)) throw new Error('offline'); + return { + registered: true, + enabled: true, + quietChannelIds: [], + preferences: { ...DEFAULT_NOTIFICATION_PREFERENCES, approval: false }, + }; + }); + const options = { + storage: { + read: async () => disk, + write: (value: PushPreferences) => { + disk = value; + }, + }, + push: { + settings, + setPreferences: vi.fn(async (entry: SavedHost, preferences: Partial) => { + if (offline.has(entry.hostId)) throw new Error('offline'); + const copy = { ...DEFAULT_NOTIFICATION_PREFERENCES, ...preferences }; + copies.set(entry.credential, copy); + return { registered: false, enabled: true, quietChannelIds: [], preferences: copy }; + }), + }, + }; + return { + prefs: createDeviceNotificationPreferences(options), + options, + copies, + offline, + disk: () => disk, + }; +} + +describe('phone-owned notification preferences', () => { + it('seeds once from existing switches and copies every change to every pairing', async () => { + const f = fixture(); + await f.prefs.sync([host('a'), host('b')]); + expect(f.disk()?.approval).toBe(false); + await f.prefs.set({ question: false }); + expect(f.copies.get('a')).toEqual(f.copies.get('b')); + expect(f.copies.get('b')?.question).toBe(false); + const reloaded = createDeviceNotificationPreferences(f.options); + await reloaded.sync([host('a', 'repaired'), host('c')]); + expect(f.copies.get('repaired')?.question).toBe(false); + expect(f.copies.get('c')?.approval).toBe(false); + expect(f.options.push.settings).toHaveBeenCalledTimes(2); + }); + it('keeps offline choices, reports the failed Host and retries without reseeding', async () => { + const f = fixture(DEFAULT_NOTIFICATION_PREFERENCES); + f.offline.add('b'); + await f.prefs.sync([host('a'), host('b')]); + await f.prefs.set({ reply: false }); + expect(f.disk()?.reply).toBe(false); + expect(f.prefs.getSnapshot().failed).toEqual(['b']); + f.offline.clear(); + await f.prefs.retry(); + expect(f.copies.get('b')?.reply).toBe(false); + expect(f.options.push.settings).not.toHaveBeenCalled(); + }); + it('serializes rapid patches and refuses to overwrite unreadable device storage', async () => { + const f = fixture(); + await f.prefs.sync([host('a')]); + await Promise.all([f.prefs.set({ question: false }), f.prefs.set({ reply: false })]); + expect(f.copies.get('a')).toMatchObject({ question: false, reply: false }); + const broken = createDeviceNotificationPreferences({ + ...f.options, + storage: { read: async () => ({ broken: true }), write: vi.fn() }, + }); + await expect(broken.set({ approval: true })).rejects.toThrow( + 'notification-preferences-invalid', + ); + }); + it('does not establish defaults while all legacy Hosts are unreachable', async () => { + const f = fixture(); + f.offline.add('a'); + await f.prefs.sync([host('a')]); + expect(f.disk()).toBeUndefined(); + f.offline.clear(); + await f.prefs.retry(); + expect(f.disk()?.approval).toBe(false); + }); +}); diff --git a/changes/2500-device-notifications.md b/changes/2500-device-notifications.md new file mode 100644 index 0000000000..87203ad4d7 --- /dev/null +++ b/changes/2500-device-notifications.md @@ -0,0 +1,11 @@ +--- +section: Changed +--- + +## en + +- Notification kinds now stay on the phone and sync to every paired Host, including after pairing again; each Remote Host in the browser has its own notification choices for alerts while the page is open ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). + +## zh + +- 通知类型现在保存在手机,并同步到所有已配对 Host,重新配对后也会恢复;浏览器可为每个远程单独选择通知,仅在页面打开时提醒([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index dc67458e48..26b457ee4a 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1,1218 +1,1220 @@ -# BotHarness 架构与数据流 - -#2080 第一条切片让 Web 请求卡与在线 CLI 共用 application-defined Workspace 请求批准命令。Grant owner 在 Channel owner 的现有提交事务内写入读写权限,规范决议与 Inbox Admission 同时提交;成功读回后再沿原运行时处理原任务。新目录与旧只读升级均需 Human 明确确认,不由 Client 串联多个独立成功动作。Orchestrator 与 Assignment 的原生文件写入都检查当前 Grant 写权限;Assignment 仍只使用其原 Grant 和单 cwd。普通目录设置、移动端交互和完整重启恢复矩阵仍按后续切片交付,不能从本次 Web/CLI 路径推定已完成。见 [ADR-0238](../adr/0238-workspace-request-approval-commits-write-authority-with-the-channel-decision.md)。 - -#2053 的本地 Group 发送复核试点由 Channel owner 在正式 Source Event / placement 追加的同一事务内比较实际观察依据。 -#2054 复用该 owner:held 标出实际返回的同伴正式消息;Bot 可引用这些消息显式让话,或提交独立补充。Host 只核对引用是否真实观察且仍有效,不判断语义重复,也不自动结清 Inbox;让话依据随现有草稿 JSON 保存。 -待复核草稿保存在现有 Operational Database,仅正式提交进入 Channel/Inbox;仅 drop 不解决 Inbox;显式 `resolve_inbox_ids` 可在 drop 成功后结清所选事项,过期或重启不自动恢复。 -原始依据与修订草稿归同一 owner,purge 清除派生内容。默认关闭,按群 opt-in;CLI 与模型使用同一契约。 -#2056 的显式恢复复用该记录:`review_action: inspect` 只返回当前授权下的有界元数据;重启后通过 `recover` 携带新观察与修正正文,保留原过期时间与预算。成功提交在同一事务留下消息 ID/意图指纹回执并清空草稿正文;重放只确认规范消息,不新增 Inbox 或恢复旧 Tool Promise。权限、来源与附件检查先于回执读回,详见 [ADR-0232](../adr/0232-group-recovery-retains-authorized-commit-receipts.md);本票真实模型验收仍在进行。 -组合两票时,复核记录保存观察所属 Host,避免跨重启比较进程内序号;新观察仍须覆盖全部必读消息并通过当前内容/附件指纹检查。恢复不重置版本、额度或已用的显式继续机会。显式继续与普通发送共享同一原子提交回执。 -重启重新准入本地群任务前,Host 复查当前成员资格;已撤权且未开始副作用的可重试 Admission 记为 Host 策略忽略,保留原因,不伪造 Bot 观察或完成结论。不确定副作用继续待修复。新的 Human DM 进入独立授权回合,原撤权任务的跨 Channel 发送 fence 保持有效。 -附件 owner 的有界当前内容摘要参与观察指纹,等长文件修改也会触发重新复核;摘要不进入公开消息,不改变附件身份,也不锁住外部文件写入。 -held 工具结果复用 Channel 的有界模型读取投影直接返回新增正文与显式观察凭据;仅完整返回的内容计入观察,省略内容仍需读取,后续提交仍在同一事务重查。其持久边界和保守取舍见 [ADR-0227](../adr/0227-group-send-review-shares-the-channel-commit.md),效果与 Human 验收仍由试点评估决定。 - -忙群最多进行两次复核,耗尽后明确 drop、既有 Inbox snooze 或一次显式继续申请(freshness_override)。显式继续仍原子核对当前消息;出现未读变化则拒绝本次申请,保留责任并延后,不能跳过新纠正。 -Override 绑定 owner、原 Group、当前草稿版本与实际观察;只越过自动复核次数上限,不越过当前消息检查,保留停止、权限、过期与 purge fence。 -耗尽预算时的 `suggestedSnoozeUntil` 是 Host 时间之后 30 秒的延后建议,复用现有 Inbox 调度,不自动安排唤醒;Human 的更晚时间、等待条件与取消约束仍优先。 -选择理由、观察与提交 ID 留在原草稿记录;不新增 wake scheduler 或公开系统消息。见 [ADR-0231](../adr/0231-busy-group-review-has-an-explicit-progress-boundary.md)。 - -Group composer 的 `/workspace` 和工作区 chip 复用 DM 的目录选择器与授权确认组件。默认选中当前未暂停、未删除的全部 Bot;Human 可以取消勾选,只授权指定几个。Group 只是逐 Bot 授权的入口,不持有权限;预览固定确认目标,后来加入的 Bot 不自动继承 Grant。共享目录预览相对于权限是只读的:native Workspace controller 可以登记、解析一个 DSH Workspace,但确认前不会建立任何 Workspace Grant。确认展示每个 Bot、canonical 目录、既有 Assignment mode 和 Orchestrator 写入状态;这些权限设置保持不变。逐 Bot 调用既有 `grantCreate`,可选的 application-defined Group scope 仅约束当前 Channel 成员与确认目录,并在异步 Workspace status 检查后、没有后续 await 的实际写入边界再次校验。SQLite `workspace_grants` 仍是唯一授权权威;无 Channel Workspace Binding、Group Grant 或统一 cwd。成功、有效 Grant 复用、拒绝和未知结果分别展示;失败/未知成员重试前通过同一个 `grants` readback 验证成员、撤销状态与当前目录,读回失败不发起写入。原侧栏与 chip 使用既有逐 Bot 变更通知刷新,正常委派仍通过原 Channel、Orchestrator 与 Assignment seam(#1433)。 - -#1386 已在 Human 授权的隔离 Linux Profile 中复验既有 QA2 PersonaBot:真实 Lark → Owner Web 多字段输入 → 既有审批 → 固定 CLI/MCP 引用认证 → 原私聊回帖;普通容器 CLI/MCP、真实 Spill、跨 Bot 拒绝、轮换/撤销及停止活跃使用、重启与自然到期 Schedule 均保留 native/canonical 证据。模型 wire 正文及运行记录扫描未发现私密 canary;结论限于记录的实际安装版本和两个固定消费者,不推导任意 CLI/MCP。Linux 接收器已停止并恢复原 Windows QA2,正常回帖已核对。当前仍为 opt-in,Linux 工作区文件权限需显式准备。详见[复验记录](https://github.com/BotHarness/DeepSeekBot/blob/main/docs/research/2026-10-12-private-input-linux-qualification.md)与[双语 Linux 指南](https://github.com/BotHarness/DeepSeekBot/blob/main/docs/dev/guides/private-input-linux.md)。 - -#1385 让当前已授权 IM Source Event 发起同一个 Owner Web Chat 私密卡;Host 经既有 Messaging Outbox 向原会话投递仅含 Chat/卡片定位 ID 的 Web 链接,实际 Owner Peer 仍是唯一提交 authority。进程内 source/reply lease 关联真实 Session 当前轮次、Bot 创建身份及 Binding,来源变化过期卡片并停止使用,账号替换/撤销禁止迟到结果投递;最终回复在 native Provider 开始前重新验证。受保护 Profile 仅放行实际注册的四个非私密 Messaging Tool Definitions,不增加第二个 receiver、持久表或 Pairing 策略。独立 Lark QA2 私聊已验证真实 Bot、多字段 Web 输入、现有允许一次、引用 CLI 认证 HTTP 200 与原私聊回帖;其余路径不从该结果推断。详见 [ADR-0172](../adr/0172-im-private-input-keeps-owner-web-authority-and-source-reply-fences.md) 与[双语可运行指南](../dev/guides/private-input-im.md)。 - -#1384 新增 application-defined ProtectedMcp Consumer,复用 native MCP Tool 适配器及官方 SDK stdio Client。模型与既有审批仅携带账号及字段引用;同一个 PrivateInputs owner 核对真实 Bot、当前版本和 Channel configured proof 后,才在私有 MCP tools/call 管道内提供原值。固定 Host 进程只执行一次认证 GET,丢弃正文、头部和错误,Host 在 native 结果持久化前只接受有界状态投影。普通 MCP 保留 native Client Plugin 与隔离 Linux 工作区;任意第三方 MCP 不自动获得凭据。详见 [ADR-0170](../adr/0170-private-mcp-consumption-uses-fixed-server-and-bounded-projection.md)。 - -#1383 复用既有 application-defined Bot Schedule → Source Event → Bot Inbox → Orchestrator 路径。Schedule 只保存引用;重启后仍由 native Credentials 及当前账号绑定核验真实消费,既有执行与 Channel 发送授权分别生效。受保护 Profile 允许 owning Orchestrator 使用四个 `bot_schedule_*` 管理工具。新建私密输入卡还需 Host 当前轮次的真实 Human Source Event;旧 Session 历史、目的 Channel 或模型自述不能提供此证明。无人值守缺少有效配置时立即返回安全 Owner-action 错误,不创建 waiter;详见 [ADR-0169](../adr/0169-private-input-interaction-follows-current-turn-source-events.md)。 - -#1382 在同一个 PrivateInputs owner 内加入 PersonaBot 创建身份与账号范围的 native Credentials 版本记录。Owner 从既有 Web Chat 卡片打开空白替换卡或撤销本地访问;新 grant 激活及 canonical Channel configured resolution 都成功后才返回引用。每次真实消费在账号串行队列内核对当前 UUID,登记 AbortSignal 后释放队列;替换/撤销写入新版本或持久 tombstone,并尝试停止既有使用。宽执行授权不能越过版本检查,停止结果区分已停止、已完成与不确定,401/403 只返回状态及安全修复建议。启动恢复先于 Owner 写入,撤销跨重启保留。详见 [ADR-0167](../adr/0167-private-account-versions-fence-consumption-and-revocation.md);旧程序不理解 tombstone,回滚需恢复完整升级前备份或向前修复。 - -#679 将入口收束为 Bot 模式设置左侧的紧凑图标/未读 Chip;折叠侧栏时则在 Bot 模式下方以相同尺寸对齐。展开时仅显示未读数字 badge,最多为 99+;有未读时入口常显,没有未读时仅在 Bot 模式开启时悬停或键盘聚焦才显示,与设置按钮一致;模式关闭时 hover 不显示,也不进入 Tab 顺序;折叠时入口仅在 Bot 模式开启后显示,图标右上角红点表示有未读或待行动;模式关闭时入口隐藏且不进入 Tab 顺序。无障碍名称保留完整未读数量与待行动提示。活动中心始终直接进入个人 Human Inbox;历史总览导航偏好不再改变入口(#2026)。 Web 页面与 Dock 共用三组可独立折叠的 Activity 投影,浏览器按 Host origin 保存折叠偏好;每组拥有独立分页与错误状态。标题栏搜索、排序和筛选经既有应用自定义 `humanAttention` 查询;查询版本 1 将搜索与未读过滤作用于分页前的合资格内容,并支持提及优先排序。Client 丢弃旧查询结果,首次加载与后台刷新分离,后台读保留已加载的空态或卡片。Host 在规范事务提交或原生请求 owner 变更后合并失效通知,经已有 roster RemoteSnapshotStream(HTTP SSE 兼容入口为 `human-attention/changed`)传递;消息列表与活动中心仍查询同一 Source Event、Channel placement、已读位置和请求 owner,不复制持久通知数据。Client 在订阅后读取基线,重连后重新查询,合并突发通知并补读查询期间的变更;频道列表的 latest 预览按 25ms 窗口合并为一次状态发布,保留各 Channel 最新消息与每个 Bot 的最大活动时间,退出或销毁时清除待发布队列;健康连接不轮询,仅断线时使用 10 秒恢复读取。最后一个展示面关闭后释放订阅和计时器。稍后提醒由 Host 调度最近到期时间,通知后重读规范查询。共享查询实现沿用移动端 #1954 / PR #1966 的实现。 #2231 第一切片沿用 Host Roster 的 Human Channel 免打扰偏好,在 Human Attention 消息查询的搜索、聚合、分页前排除静音 Channel(含已读保留的提及/回复);全局未读同口径,Channel 原始未读位置与明确 Human 请求不变。Web 共用 Activity feed 观察已有 Roster 状态变更,重查可见分组并丢弃旧偏好下的在途结果;全局状态同步重查,不新增连接或轮询。control rail 的独立红点范围由浏览器按 Host origin 单独保存,支持全部、待处理、个人未读、其他未读或关闭,同源窗口通过 storage 事件同步。既有 Human Attention 状态查询可请求完整分类摘要;先按 Source Event 去重再分区,不从面板首屏推算。状态失败或旧 Host 缺少分类时显示未知提示,关闭偏好始终生效;Activity 列表筛选与真实状态不受红点设置影响。 - -BotHarness 是 DSH(DeepSeek Harness)之上的插件层,给 Agent 持久产品身份:**PersonaBot**。PersonaBot 用一个 Orchestrator Session 管理 Inbox,并可同时管理多个独立 Assignment Session;Memory 是 optional capability,Persona 是其中的 optional 内容;两者都不是聊天或执行的前置依赖。DeepSeekBot 是首个应用,提供 roster、Bot Inbox、Assignment Directory、委派和 IM 接入。 - -#2290 修正私密输入的本轮自动来源判定:已接受且仍有效的本地 Human DM/Group Source Event 沿用本地 Owner 交互,混入的 IM 批次或 steering 不再强制选择 IM 来源。显式 IM 选择与账号本轮已记住的来源继续受原有 lease、歧义及撤销检查约束;历史消息不提供交互权限。详见 [ADR-0172](../adr/0172-im-private-input-keeps-owner-web-authority-and-source-reply-fences.md)。 - -#1380 的历史 opt-in 私密输入由 application-defined PrivateInputs owner 关联真实 Orchestrator、PersonaBot 创建身份、账号及多字段 Channel 卡片;Web 与 CLI 经现有 Typert/API Gateway,以真实 Owner Peer 提交原值,Host 原子写入一个 native Credentials Service grant。Channel 与模型只接收声明、状态和引用;既有执行审批先批准引用调用,固定 trusted CLI 才解析并消费原值,只投影 HTTP 状态。Human 已认可卡片交互与真实认证成功,#1380 已合并。原生 `bash` Consumer 由 application-defined LinuxShell Provider 经 native Subprocess argv 启动 digest-pinned 容器,挂载当前授权工作区并保护 `.git`;普通 MCP 保留 native MCP Client Consumer,其 SDK 管理的 stdio 进程也由受控 Docker argv 启动。容器不挂载 Host 凭据/socket、不继承 Host 环境;其他未验证的 Host Capability 仍被整个 Profile 拒绝。该 opt-in 不是默认架构;固定秘密 MCP 消费已由 #1384 交付,实际 Linux 安装验收及限制见 #1386 复验记录。详见 [ADR-0164](../adr/0164-private-input-candidate-separates-native-storage-from-qualified-execution.md) 与[可运行验证路径](../dev/guides/private-input-cli.md)。 自 ADR-0189 起,私密输入在普通 Profile 默认可用:`ask_secret`、固定的 `protected_cli` 与 `secret_http` 对每个 Orchestrator 注册,原值仍只进 native Credentials Service 并绑定发起的 PersonaBot。`secret_http` 由 Host 子进程发出请求,目标是 Owner 在卡片中填写的地址,Bot 只决定其下的路径、方法和 `{{字段}}` 占位符。凭据存储的保护有两层:Host 改写 native Sandbox Provider 返回的命令行,为每条 Shell 命令加上“不可读取该存储”的规则(Seatbelt 规则或 bwrap 遮盖),并在审批之前拒绝针对它的文件工具与点名它的命令。`danger-full-access`、Landlock 与 Windows 下只有后一层。历史 opt-in 的 `privateInputExecution` 曾采用 ADR-0164 的容器边界。上述容器 Shell、容器内 MCP 与 `privateInputExecution` 已由 [ADR-0192](../adr/0192-the-docker-protected-profile-is-removed.md) 删除:密钥输入在所有 Profile 默认可用,使用方为 `protected_cli`、`secret_http` 与 `secret_cli`,凭据存储由沙箱读取拒绝和命令文本拦截保护。 - -#1381 让每张私密卡片持有独立 live authority 与十五分钟 Host deadline;Human Inbox/Bot attention 仅投影已提交且仍有效的请求,并跳转原 Web Chat 卡片。配置、取消与过期共享一个幂等 Channel resolution ID,提交在 canonical SQLite transaction 内重新检查 live authority 后才成为最终 winner。版本二 native grant 必须具备完整且匹配的 configured resolution 才能消费,跨存储失败的孤儿 grant 不可用;启动恢复将丢失 waiter 的卡片持久标为 expired 并清理孤儿 grant,保留已配置引用。私密 resolution 不创建 Inbox Admission,不以恢复或过期唤醒停止的 Bot。Host disposal 排空 pending settlement 与启动恢复;Client 清空输入并拒绝旧 mount/迟到 pending poll 覆盖最终状态。详见 Proposed [ADR-0166](../adr/0166-private-input-settlement-commits-before-continuation.md)。 - -本文描述 #71 确认后的目标架构。M1 registry、M2 Memory 与 #66 roster storage 已实现;#77 已验证 DSH runtime seams,显式 Session ownership、Messaging、Assignment Runtime、统一 operational database 和可移植性按 #79–#81 分阶段落地。更新:2026-10-03。 - -Bot 模式首次体验由 application-defined Onboarding owner 在现有 operational database 保存 Profile receipt。五步 Desktop 引导(#2192,[ADR-0233](../adr/0233-desktop-onboarding-follows-tob-creation-receipts.md))依次为模型配置、通过受保护 Tob 的原 DM 创建 Bot、创建包含该 Bot 的群聊、面板布局教学,以及实际点击 Home 标题关闭首页并进入原 Tob 私聊。沿用 Home 与共享输入框,示例只填写草稿。可信 scoped CLI adapter 在执行前保存 operation 关联,owner 从既有 Bot/Channel 创建回执与实际成员关系恢复进度;拒绝、预留和模型文字均不算创建成功。布局教学完成记录属于当前设备,Host 只保存教程生命周期与必要结果引用。旧完成/跳过状态保持安静,旧未完成状态暂停等待主动重开;升级不重排布局、不修改快捷键或草稿。新 Bot 与 Tob 不再写入默认欢迎消息,既有消息与旧欢迎消息渲染保持兼容。旧 first-reply 证据仍保留作为历史事实,而非新版创建步骤的成功条件。 - -历史欢迎消息保留兼容显示,提供能力介绍、每日晚间问候与十分钟定时测试示例,点击后沿用正常 Human DM 请求与现有执行能力。新闻选项由 Host 在 onboarding 查询中投影当前 native 搜索配置和所选 Bot preset 的 `web_search` 可见性,只返回就绪布尔值,不保存第二份配置或凭据。固定 RC1 的已知 DeepSeek 搜索 Provider 与其独立凭据已配置时提供带真实来源链接的今日 AI 新闻;未配置、无法确认或不支持的搜索组合显示通用工作规划示例。配置就绪不保证网络请求成功,实际搜索失败、空结果和不可用必须如实说明。模型保存和问题发送是两次独立操作:单独选择模型不创建问题;因配置受阻的请求保存模型后进入单独的发送确认,关闭或恢复均不自动发送。引导模型配置复用 native model/credential Services,经 Typert/API Gateway 保存并读回 Profile 默认模型;默认勾选后当前 Bot 继承全局,取消勾选则写独立 Model Plan。新 Bot 无独立 plan 时继承全局;保留 model-plan revision 以校验返回继承的编辑。全局修改只影响之后的继承请求,不覆盖固定计划或运行中的 Assignment。模型失败后的“重试这条消息”沿用原 Human 消息与 Admission,经既有 retryability/side-effect gate 拒绝不安全重放,不再追加 Human 消息。 - -当前 Client UI 由独立 `@botharness/ui` Bundle 挂载,源码仍在 `packages/client`;RC2 的插件图把结尾 `/client` 解释为导出子路径,因此包身份依 [ADR-0066](../adr/0066-rc2-client-bundle-identity.md) 避开该后缀。Client HMR 只暂存当前 Bot/Channel 选择以恢复视图,不复制 Host 中的 PersonaBot、Channel 或消息权威。 - -产品术语以根目录 [`CONTEXT.md`](/zh/dev/design/context) 为唯一词表;[BotHarness Runtime 架构](/zh/dev/design/bot-runtime) 单独展开 PersonaBot、Bot Inbox、Orchestrator、Assignment 与 DSH execution 的关系。DSH/Cordis 本身的术语和 Plugin 开发决策位于 `/zh/dsh`,不在这里重复定义。 - -迁移阶段保持可验证:#79 建立 `botharness.db` owner;#885 的 Schema Generation 56 已将 #66 的 roster arrangement 单向迁入 owning SQLite module。旧 `botharness_roster` Storage Domain 仅作一次性输入,校验失败时 roster 只读且返回 `storage-unavailable`,不会当成空排列;成功后排列与 import marker 同一事务提交,旧源保留供恢复参考,正常运行不再打开或读写该 domain。Session ownership 保持既有数据库权威;#886 的 Schema Generation 72 增加恢复收据、逐 Bot 模型授权/激活记录与 Session 内容可用性。 - -#56 and #137 extend the current roster global slot to `{ pins, hidden?, sectionOrder, topOrder? }`: `pins` canonically orders Channel IDs for both group Channels and PersonaBot DMs, `hidden` omits Channels only from roster navigation while retaining their placement, and `topOrder` mixes section blocks with loose Channels while membership remains owned only by section records. The unary client bridge now has ten arrangement methods, including `topReorder`, `hiddenSet`, and bounded `rosterBatch` (one Host completion notice and one final Client snapshot for multi-select); #885 preserves this order, hidden presentation state, and single-membership invariant in the owning `roster_arrangement` singleton; each command mutates a transient draft and commits the complete arrangement atomically before publication, without dual writes. A missing import marker refuses edits until a valid source is imported; an already populated target without a marker is never overwritten. Sorting remains native DSH Settings, and collapse remains per-Client. 已提交的 roster mutation 会在 Host commit 后发送 `roster/changed` live invalidation;其他窗口只重读权威 roster,不接收也不复制拖拽中的预览状态。新提交的非通知 Channel 消息另以 `roster/latest`(类型化流为 `latest` 帧)推送该 Channel 的最新消息,各窗口只更新对应行的预览与时间,不为每条消息整体重读 roster(#1530)。 - -BOT mode 的折叠 rail 复用这份 Channel 排序读模型:置顶项在分隔线上方,其余 DM 与 group Channel 按 section/未分组的扁平顺序排列。`botharness/channels` 额外投影可选 `latestMessage` 供 hover 摘要使用;该字段从当前 Messaging authority 派生,不成为新的持久化权威。“最近更新”模式下,每段相邻的未分组 Channel 按最新可见消息时间排序,section 位置不变;“手动排序”保留存储的扁平顺序。客户端打开或更新某个 Channel 不会单独改变它的位置(ADR-0031 2026-10-13 更新,#1530)。 - -置顶格也是独立的排序 scope:默认继承全局最近更新/手动模式,可用 `ui-bot-mode.sortModes.pinned` 覆盖;拖动置顶卡片会把当前完整顺序(包括被搜索或隐藏过滤的置顶项)冻结为手动模式,通过 `pinsSet` 持久化,不影响 section 归属或其他 scope 的排序。折叠 rail 使用同一置顶顺序。 - -Hidden Channel 是 application-defined 的可逆 roster presentation state:它会从展开列表、pin grid、搜索与折叠 rail 中消失,但不会改动 Channel、消息、PersonaBot、Memory 或原 placement。破坏性删除仍受 ADR-0037 的 dependency report 与独立确认约束(#138)。 - -当前 Channel Chat 的实时显示遵循 ADR-0054:正式消息先写入当前持久化权威,再以该 Channel 的单调 revision 发出进程内通知。#143 的时间线读取遵循 ADR-0061:Host 的 `channelTimeline` 用不透明游标提供 latest / older / newer / around 窗口,Client 仅保留一段连续的按提交顺序排列的可见消息;SQLite Channel placement 的游标解释留在 Host。首次打开 Channel 取最新页;有上次已读锚点时,重开会在锚点附近分页并定位;向上补页维持视口锚点,定位旧消息可前后补页并高亮,并可继续向 newer 分页直至最新消息;用户离开底部后,新消息只更新未读提示,不强制滚到底,也不把实时消息拼接进尚有后续页的旧窗口。旧 `channelMessages` 端点暂作兼容。已选 Channel 通过经过 DSH 认证的 `/api/botharness/stream` SSE 接收已提交消息;重连可回放、缺口则从 Host 修复。同一连接还传送 Orchestrator 显式 `channel_send` 工具参数产生的进程内草稿预览:`channel/draft` / `channel/draft-settled` / `channel/draft-abandoned` 带 attempt ID 与独立于持久消息的进程内草稿 revision;连接时先发送完整 baseline,断档则重读 committed。草稿不从历史回放,不进入 Inbox,提交后由正式消息替换,放弃时移除;呈现层可消费草稿,任何不可逆操作只认已提交消息(ADR-0054)。Human send 使用 Client 生成的幂等 message ID;网络失败时本地气泡保留失败状态,Human 可把原文和附件恢复到 composer 后再次确认发送,而 Host 对响应丢失后的同 ID 重试只接受一次 durable append。Orchestrator 普通 final 与 Assignment 输出不等于 Channel 消息;当前正式 Channel 消息由 SQLite Source Event 与 placement 事务提交,旧 NDJSON 历史只在首次升级时单向导入。Group 中经候选列表选中的多个 Bot mention 在同一事务形成独立 Inbox Admission;Bot 处理状态经同一 SSE 流独立投影。已入群 Bot 的 channel_send 可提交稳定 ID 列表;Host 从可信 Orchestrator ownership 得到发送者,读取当前成员与名称生成 mention 标签,并在同一事务为不同目标建立独立 Admission。Bot-authored Group 消息保留因果根和跳数;同根同目标只投递一次,过限消息保留在群里但不再唤醒目标。收件 Bot 可以在同一群回复,重启后 pending/retryable Admission 仍可恢复。 Group 之外,Bot-to-Bot DM 使用确定性的双成员 Channel ID;Orchestrator 的 `bot_dm_send` 或该 DM 内的 `channel_send` 以可信 Session ownership 确定发送者。每次发送在同一事务中提交 Bot DM Source Event、placement、收件 Bot Admission(受因果根去重与跳数上限约束),以及只链接原消息的无正文 Channel Notice。Notice 放在引起这次发送的 Source Event 所在 Channel(群、Human DM 或 Bot DM),该 Channel 不可用时回到发送者 Human DM;同一事务为发送者写入一条已处理的 `bot-action` Bot Inbox 记录。若引起发送的正是这个 Bot DM 本身(两个 Bot 来回回复),消息已在原对话中可见,不再写 notice 或记录。Channel Notice 不计入 Human 未读、回执、活动统计与 Channel 预览,也不为其他成员建立 Admission(ADR-0154)。重启时恢复 pending/retryable 收件投递;Bot DM 默认不进入 Human roster,但在隐藏频道管理器中可发现、只读打开,Human 无发送或重命名权限。 -Group Channel 的气泡收件圆环只投影该消息已有的 PersonaBot Inbox Admission,分母是实际收件者而不是群成员总数。入队是「已投递」;运行时 claim 本身仍显示已投递,直到 observed_at 标记消息进入 Orchestrator 回合上下文才显示「处理中」。若 Bot 通过 Channel 查询主动读到消息而没有进入处理该消息的回合,Admission 保持 pending 但 observed_at 非空,投影为独立的「已读」;回合结束是「已处理」,并不意味着发出了回复。明确忽略、可重试失败和需修复失败各有独立状态。每次权威状态变化通过 Channel revision/SSE 通知并重读该投影,不新建第二套已读存储。Bot 发送者不产生自己的 Inbox Admission,也不进入收件人数。Group 的本机 Human 成员以 Host 固定身份、显示名、加入时间及首个可见 revision 保存在 Messaging 权威中;Human 阅读位置按 Channel ID 与 Human ID 共同持久化。既有 Group 从首条消息起可见,旧 Channel 阅读位置迁移到本机 Human。Host 仅对有可见权限的非作者 Human 投影已读/未读,Bot 状态仍只取 Inbox Admission;同一圆饼与明细展示两种语义,不再从浏览器 tab 或作者推断已读(ADR-0078、#347)。Human 已读推进只广播一条携带阅读 revision 的 Channel SSE;重连时从权威已读位置发送 baseline,Client 用消息的 placement revision 更新当前可见窗口,避免阅读大量历史时逐条广播。当前只支持本地一个 Human;跨账号多人 Channel 仍需独立身份与授权设计(#377)。 -Channel 的消息引用(#145)只保存同 Channel 的已提交目标 ID;Human 发送与 PersonaBot 显式 `channel_send` 由同一持久化权威校验,跨 Channel/缺失目标不能产生消息。引用作者和截断摘要在时间线读取时由当前历史投影,不复制为另一份持久内容,也不为每条消息单独读取。目标后来不可见则降级为不可点击提示;点击有效引用复用 `around` 窗口和高亮,不改变相邻消息分组与时间语义。 - -新 Channel 附件(#576,ADR-0100)保存到 profile 管理的独立真实文件,消息持久化 `{fileId,name,mime,size}` 引用。Host 在 append 前校验 profile 归属,查询时从当前文件投影 MIME 与大小,不改写 Source Event envelope;保留的旧 `{hash,name,mime,size}` 通过 generation 39 的 Messaging 绑定解析到独立真实文件,转换前后均校验字节且不改写 envelope;失败保留旧对象可读状态并给出有界修复日志。Composer 的固定上传 key 与消息 ID 分别保障传输和发送重试幂等。认证 Fetch 上传仍走 `/api/botharness/attachment/upload`,新下载以 `channelId + messageId + fileId` 验证归属并用 `no-store` 返回当前字节。Human 文件/图片菜单复用现有 DSH 原生打开能力;Orchestrator `channel_read_image` 使用 `attachment_id`(fileId)或 legacy `hash`,先验证当前成员资格、消息引用、当前 MIME 和大小,再传给 DSH attachment service,模型不接触 Host 路径。引用感知清理保护所有保留 Source Event 的文件身份;不启用自动调度或保留期。详见[双语文件指南](../file-open.zh.md)。 - -拥有该 Session 的 Orchestrator 可复制 `channel_read`(含通过 `message_id` / `content_cursor` 完整读取的内容)或旧版授权读取结果中的最多 10 个可信附件引用,四个字段原样保留:`{fileId,name,mime,size}`。#577 后旧消息投影迁移后的文件身份;过期 `{hash,name,mime,size}` 结果必须先重新读取所属消息再转发,旧 hash 图片读取仍须带所属消息以解析当前字节。Orchestrator 还可通过 `channel_attachment_import` 明确选择并导入已授权的本地结果文件;不能猜测身份与元数据。DSH schema 将 `size` 声明为整数;Host 继续校验 0–9007199254740991 的安全非负字节范围。固定版本的 DSH schema 不支持 `maxItems` 或数值上下界,因此数组上限由描述与运行时校验共同表达。Group 最多接受 20 个提及 ID,每个都必须是其他活跃现成员。`channel_send` 只有在 Messaging 权威接受写入后才返回紧凑的 `{channelId,messageId}` JSON;省略目标时使用入站 Channel。该结果取代原文本确认;附件身份、profile 归属、当前文件语义、回复目标、delivery-key 重试与因果循环边界继续沿用([#570](https://github.com/BotHarness/BotHarness/issues/570))。 - -应用定义的 `group_leave` Tool 只在规范成员移除提交后返回 `{channelId,left:true,outcome:"changed"}`;Bot 当前不是该群成员时返回 `{channelId,left:false,outcome:"unchanged",reason:"not-member"}`。无变更既包括重复退出,也包括从未加入的群;不推断历史成员身份,不返回未加入群的名称或名单。缺失 Channel 抛出 `group_leave: channel-unavailable`,非群聊目标抛出 `group_leave: group-required`,替代旧的含糊 `left:false` 成功结果;Tool 异常保持为失败。既有 `{channelId,left}` 字段及 Core 返回结构保持兼容,调用方须处理新的无效目标失败。ADR-0073 的同一事务仍承担创建者移交 Human、待处理 Admission 撤销、单条持久离群通知及剩余成员注意力策略;提交后的实时通知警告不否定已提交变更。读写权限立即撤销,因此 Bot 应通过仍可访问的 Channel 向 Human 报告([#571](https://github.com/BotHarness/BotHarness/issues/571))。 - -The reply Consumer can compose an explicit `resolve_inbox_ids` selection with a successful send/drop, using the existing Inbox triage owner afterward; completion failures remain separate from delivery. For an exhausted drop without observed peer-yield evidence, the draft owner identifies the original Source Event and Group Message IDs as keep-open work; the Consumer excludes them from composed completion while still resolving selected FYI. A later explicit cancellation/completion decision remains with the Inbox owner. A revoked initiating opted-in Group prevents that run from continuing in another Channel. A later independent DM remains available (ADR-0227). - -Home/Tob(#2082,[ADR-0223](../adr/0223-tob-is-a-protected-local-personabot.md))复用现有 PersonaBot、Memory、Workspace、Channel、审批和 native Settings owners。全局 Home 不保存第二份对话;提交进入 Tob 的同一个 Human DM,消息面板继续展示。空 Profile 的 onboarding 使用 Tob,已有普通 DeepSeek Bot 保持原身份和历史。Generation 96 的本地单例引用决定 Tob 身份,名字、Soul 或导入内容不授予权限。首片的 Assignment 并发上限路径在 #2088 扩展为明确的 CLI allowlist:Host 默认模型、Bot 模型计划、模型预设创建/读取/更新/应用、四个 native runtime 字段、telemetry、Git 可用性/安装,以及带显式确认的发布安装/重启。受限 release-install/release-restart 每次必须由 Human 新作决定,full access、已记住规则或自动审批均不能替代;此规则不为 Git 安装新增确认参数。各操作继续使用其已有 owner;preset 删除尚无 owner,凭据仍经原生私密输入,其余设置不因 CLI 已存在就自动授予 Tob。Tob 通过 DSH-native bash/pwsh Tool → 隔离的 Shell Service → DeepSeekBot CLI 发起操作;application-defined CLI broker 为每次 Shell 执行提供短期 loopback capability,不向模型提供 Human 管理凭据。Host 从真实 ToolExecution 绑定受保护 Tob 的 root Orchestrator 与准确的已准入 Human DM Source Event 集合;不接受 CLI 自报身份。解析后的设置操作沿用 native Settings owner 和现有审批,审批前后及写入时重验来源、旧值和 revision,并读回结果。Shell 完成、取消或 Plugin 释放时撤销 broker;原生 Assignment 工具不变。详见 [ADR-0224](../adr/0224-tob-management-uses-scoped-native-shell-cli.md)。布局 v8 保留 v7 子树与比例,并备份升级前原文;Home 默认快捷键使用独立稳定 ID。 - -#2089 扩展同一受限 CLI seam 到空白 Bot 创建、资料展示和生命周期([ADR-0225](../adr/0225-bot-creation-receipts-and-reviewed-profile-lifecycle.md))。Generation 97 的 `bot_creation_operations` 在原 operational database 保存 operation key、输入指纹与预留身份;Registry/Memory 和 Channel owners 仍分别创建规范 Bot、受管 Memory Repository 与 Human DM。同 key、同 payload 的响应丢失重试核对原身份;不同 payload、已归档/删除身份均拒绝。目录已创建但 Registry 身份缺失时保留现场并返回 `creation-memory-conflict`,不假装文件系统与数据库是原子事务。受限资料写入绑定完整 Registry record 的 `profileFingerprint`,审批后重新检查并读回;元数据 patch 仅允许名称、简介和标签。删除复用原 owner token、依赖和准确路径,默认保留 Memory,显式擦除和重试均不能改变已审核范围;受限删除确认/重试每次需 Human 新作决定。Tob 保护身份不变,导入与跨 Bot 既有 Memory 编辑仍为后续独立能力。 - -#2090 首个切片将同一受限 CLI seam 扩展到群创建和邀请([ADR-0226](../adr/0226-group-management-receipts-preserve-channel-authority.md))。Generation 98 在既有 operational database 保存稳定操作回执,绑定输入、Bot 身份代次与预留 Channel/invitation 身份;Channel 和 invitation owners 仍是群与成员状态的权威。创建时显式指定的初始 Bot 按现有 UI 语义直接入群;后续邀请沿用默认开启的 `autoAcceptGroupInvites=true`:管理操作审批通过后,同一事务记录已接受邀请与成员关系,受邀 Bot 立即入群,无需额外邀请决定。仅当 Human 已关闭自动接受时才保留原有逐邀请决定分支;本片不新增 UI 开关,Tob 报告实际状态。邀请的审核指纹包含频道元数据、成员及邀请策略,不含消息流或未读计数;审批后与写入前重验,拒绝过期审核。同 key、同 payload 重试核对原结果,已完成的受限重放只读回,不再次申请变更审批;删除、身份变化或 payload 冲突不回退到新建。Tob 始终从已准入 Human DM 管理其他 Bot,不加入群,群内容不授予管理权限。#2090 下一切片通过同一 seam 加入群名称/头像、成员普通消息唤醒策略与成员移除,不新增模型 Tool、数据库迁移或 UI。所有受限编辑必须携带已观察的 `expectedChannel`,审批绑定旧状态、准确目标和新值;执行时重验 Human 当前群访问权限、频道快照及目标 Bot 身份。指纹也包含每个成员的有效唤醒策略与继承状态,因此 Bot 默认策略变化会使旧审核过期。移除成员保留 Bot、Memory、其他频道与群历史;移除 owner 同时清除其管理身份,转由 Human 管理。这些编辑不使用操作回执;结果不确定时先读回,仍需修改则依据新快照重新审核,不盲目重试。已认证 Human 命令可选指纹以保持兼容;Tob 不能省略。后续待处理决定切片沿用同一 seam,为 Tob 开放 `group-invite-cancel` 和 `group-join-decide`。两者必须携带已读取指纹,绑定准确的待处理邀请/申请及目标 Bot 身份代次,审批后重验 Human 当前群成员资格、频道快照与记录仍待处理。取消不能撤销默认自动接受的邀请,也不移除现有成员;接受/拒绝申请由原 Channel owner 持久保存状态与适用成员关系,原决定通知只记录一次。受限入口只处理待处理记录;Human 入群决定命令未携带指纹时保留原有同向终态的幂等读回。结果不确定仍先查规范状态,不增加操作回执、数据库迁移或模型 Tool。个人展示设置、删除群与外部成员管理仍为后续范围。 - -## 原生 Human 等待的验证边界 - -[#1036 的有界实验](../research/1036-native-wait-experiment.md) 在固定 DSH -`0.2.0-rc.1` 上证明:独立 Assignment 等待原生工具审批时,Orchestrator -可真实处理并回复无关 Inbox 消息,但没有释放 Assignment 运行许可。 -Orchestrator 自己的原生审批或正式提问仍占有当前 Step;Admission 已接收、 -`steer` 已排入下一 Step 不等于模型已处理,原调用决定并返回后才处理无关输入。 -准确 Session/call 归属及当前 Grant 校验仍由既有 authority 承担,变更/撤销 -范围拒绝原操作。[ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md#native-human-wait-qualification-2026-10-09) -记录本次资格边界,不改变 ADR-0035 的单一 Orchestrator root 约束。 -[#1220](https://github.com/BotHarness/DeepSeekBot/issues/1220) 追踪缺失的受支持原生 -continuation 机制;#1036 保持阻塞,#1037/#1038 生产实现尚未交付。 - -后续[隔离 RC2 实验](../research/1220-native-timed-question-experiment.md) 验证原生 timed -question 可先真实返回 pending,问题保持可回答;同一 Session 可回复无关消息,再通过 -带原 call 身份的 qualified Inbox 输入处理稍后答案。这是 Tool 已结束、问题仍持续的机制, -不是挂起未结束调用。[DM 卡片适配](../research/1220-timed-question-card-e2e.md) 以原 Session/call -关联卡片:只有 `ASK_TIMED_OUT` 结束前台等待,卡片仍查询原生 Projection;稍后答案通过 -原生 `userQuestions.answer` 提交,显示已提交,直到原生接收后才写入 Channel 回答记录。 -稍后提交进入既有 per-Bot Runtime 队列,为同一 live Session 建立新的 application run,保留 -原 Source Event 上下文并重验当前归属、DM 与源内容 fence;run 持续到原生 idle,让模型能使用 -既有 Channel 工具,不追加普通输入或 Inbox Admission。 -进程内映射仅关联已存在的 live Agent/卡片,不是第二个问题权威,也不实现冷恢复。 -生产依赖仍为 RC1,timed 模式仅在显式配置的隔离 RC2 Profile 验证。权限审批、容量释放/重获和同群隐私验收 -继续阻塞,不从这次 DM 能力实验推断完成。 - -## 1 · 系统上下文 - -新附件已遵循 [ADR-0100](../adr/0100-file-open-actions-target-real-host-files.md):独立上传彼此独立,显式复用身份才共享编辑结果。原生打开指向真实目标;后续消息读取、预览和下载使用当前内容,上传源独立。保存不保留附件历史,不生成 Source Revision、Inbox Admission、通知或 Bot wake。目标缺失则报告不可用,不自动重建。旧 CAS 迁移(#577)按 Source Event 附件出现位置预留可重启恢复的身份,仅在保留依赖未转换时继续保护旧对象;其他 CAS 数据与 Memory Git 行为保持各自语义。 - -```mermaid -flowchart LR - Human["Human
DSH Web / IM"] - External["Feishu / Lark
webhook / future providers"] - - subgraph Browser["DSH Web Client"] - UI["DeepSeekBot UI
Roster · Chat · Human Inbox · Assignments · Settings"] - end - - subgraph Host["DSH Host · single profile writer"] - API["Client Bridge RPC"] - Identity["PersonaBot identity"] - Memory["Optional Memory Service
Service Definition · Git Provider"] - Messaging["Messaging
Source Events · Inbox · Outbox"] - Assignments["Assignment Runtime
Orchestrator · Assignment Directory"] - Transfer["Portability
Export · Backup · Restore"] - DB[("botharness.db")] - end - - subgraph DSH["DSH-owned runtime"] - Sessions["Agent / SessionPersistence
Orchestrator · Assignment Sessions · Subagent"] - Credentials["Credentials · profile settings"] - end - - Human --> UI - External <--> Messaging - UI <--> API - API --> Identity - API -.-> Memory - API --> Messaging - API --> Assignments - API --> Transfer - Identity --> DB - Identity -. attachment .-> Memory - Messaging --> DB - Assignments --> DB - Transfer --> DB - Identity <--> Sessions - Messaging --> Assignments - Assignments <--> Sessions - Assignments -. scoped Consumer .-> Memory - Messaging -.-> Credentials - Transfer -.-> Sessions -``` - -浏览器只通过 RPC 访问 Host read models 和 commands。Provider adapter 只负责验证、规范化和执行能力;它不拥有 Inbox,也不能直接唤醒 Agent。DSH 继续拥有 Agent 执行、SessionPersistence、Subagent 与凭据;BotHarness 不复制这些 runtime 权威。 - -Assignment 完成报告通过既有 Human Inbox 最新报告、忽略和来源级隐藏事实派生 `informationalCount`,进入同一有 revision 的 Activity snapshot。仅有信息时显示中性的 `i`,但不会单独让输入框活动行常驻,该行只在执行中或有待处理事项时出现;红色待处理数字不包含信息更新,同时存在时悬浮/聚焦摘要分别说明数量。打开报告本身不代表确认;忽略、隐藏,或主会话已处理这条汇报的 Inbox 投递时清除提示,汇报本身仍列在收件箱中;重启可从权威事实恢复,执行状态不变。 - -## 2 · Deep modules 与所有权 - -```mermaid -flowchart TB - Root["Host composition root
lifecycle · dependency wiring"] - DB["Operational Database Owner
writer lease · schema generation · transaction"] - - subgraph Modules["BotHarness deep modules"] - Bots["PersonaBot
identity · lifecycle · Session ownership"] - Memory["Optional Memory Service
repositories · Git commits · events"] - Msg["Messaging
events · channels · inbox · triggers
grants · outbox"] - Assignments["Assignments
directory · capacity · requests · reports"] - Usage["Usage
retained daily model tokens · read model"] - Portable["Portability
Soul · export · backup · restore"] - Views["Read models
RPC · UI projections"] - end - - Root --> DB - Root --> Bots - Root -. optional Provider .-> Memory - Root --> Msg - Root --> Assignments - Root --> Portable - Root --> Usage - Root --> Views - DB --> Bots - DB --> Memory - DB --> Msg - DB --> Assignments - DB --> Portable - DB --> Usage - Bots -. attachment .-> Memory - Bots --> Assignments - Msg --> Assignments - Bots --> Views - Msg --> Views - Assignments -. scoped Consumer .-> Memory - Memory --> Views - Assignments --> Views - Usage --> Views - Portable --> Views -``` - -| Module | Owns | Does not own | -| ---------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| PersonaBot | Host-owned ID、display name / role badges、lifecycle、explicit Session ownership | DSH Session lifecycle、Memory 内容 | -| Memory | generic Git-backed repositories、semantic commits、operation events | PersonaBot lifecycle、Inbox、Session | -| Messaging | Source Event、Channel placement、Inbox Admission、Attention、Trigger/Wake Policy、Service Grant、Outbox | Agent execution、provider credentials | -| Assignments | Assignment Directory、Assignment Request/Delivery Intent、capacity admission、report/lifecycle routing | DSH transcript、Subagent runtime | -| Usage | per-PersonaBot/执行类型/实际模型的保留日统计、usage read model 契约 | DSH Session 日志、价格表、金额总账 | -| Workspace Grants | Human 对 DSH Workspace 的授权与撤销、Assignment 创建时的权限快照 | DSH Workspace registry、Session 权限实现 | -| Portability | SoulSnapshot、PersonaBot Export、Profile Backup/Restore/Transfer 协调 | credentials、可执行插件、DSH 私有格式 | -| Read models | 查询、分页、PersonaBot Activity Projection、Human Inbox、UI-friendly projection | 业务事实与写入规则 | - -`botharness.db` 是 BotHarness core 的物理事务宿主,不是共享的 generic repository。Memory 内容与 commit 由 optional Git-backed Provider 掌管;每个 deep module 只通过自己的接口拥有表和不变量,跨模块流程由显式 command/port 协调。 - -Usage 是 application-defined 的保留统计:从 DSH durable SessionEvent 中按实际请求提取 provider/model 与 provider 报告的 input、output、cache-read、cache-write token,按可信 Session ownership 归属 PersonaBot,分别汇总 Orchestrator、Assignment 和 DSH Subagent;同一 Turn 用多个模型时分别入桶,缺失用量标为未知。`botharness.db` 按 `(bot, day, execution role, provider, model)` 保留日汇总,日界取 Host 本地时区;增量折叠必须幂等,普通 Session 删除后不可用剩余日志全表重建或重复计数,彻底清除 PersonaBot 才删除其可识别统计(ADR-0094,#39)。金额由价格表在查询时另行估算,1.0 不落金额总账(#35 留 v1.1)。Browser 只经 read model 查询,不直读 Session 日志或投影表;PersonaBot Profile 的 token 卡是当前消费者(#34 Decisions、#428)。 - -Usage generation 40 在同一 Operational Database 事务中写入匿名 HMAC 去重凭据与日增量;校准只补记未见调用,不清空保留汇总。凭据不保留原始 Session 标识或逐调用内容。旧汇总迁移为 Bot 级时间基线,升级前无法确认的补计明确留诊断;新调用持久幂等。归档保留用量;Registry Purge 清理汇总、凭据与基线,当前 Bot 创建身份与可信根 ownership 防止旧 Session 重新填入。 - -首个可运行切片(#499)扩展现有 26 周有界 `profileActivity` 查询,返回实际 provider/model 的日用量、可为未知的报告分项,以及即使某缓存分项缺失仍可确定的 provider 总量。Profile 按 Host 本地日期查看用量,与允许调用的 Model Plan 独立。逐调用切片(#503)独立结算每条追加的 Assistant 调用事件,成功调用使用实际 source,失败调用使用已记录的请求路由;Profile 区分可信 ownership 对应的 Orchestrator、Assignment 和子代理。Session 序号去重实时通知与重放快照,消息替换不产生新用量。Session 删除后的保留统计仍由 #502 完成。 Profile 在现有 26 周公开查询范围内,以一个有界时间选择联动每日用量、实际 provider/model 用量构成和缓存比例图,默认最近 7 天。模型/提供商切换按选中的实际模型 ID 或提供商 ID 汇总,不在行内嵌套另一维度;Host 原始行和折叠的执行详情保留两个标识。模型/提供商行紧凑地显示选中维度的名称与报告总量;图表悬浮提示展示总输入(未缓存输入加缓存读写)、缓存读/输入及输出/报告总量的比例;缺失分项或零分母的比例保持未知。执行类别默认折叠于详细信息(#592)。 - -筛选查询 `profileUsage`(#507)由 Usage 深模块拥有,经 Typert/API Gateway 提供:真实且非未来的日期最多覆盖 182 天;模型/提供商与执行类别条件同时作用于每日记录与累计汇总,累计值仅忽略日期。Profile 默认近七天,执行类别筛选位于折叠详细信息中;首次打开、改变筛选或手动刷新时查询,不轮询或按时间自动过期,同条件刷新失败明确标为过期,旧条件迟到的响应不会覆盖新筛选。响应包含查询/核对时间、正常/核对中/降级状态、可空未知用量,以及显式明细/选项上限(2,000 行/每维度 1,000 个实际选项);总量完整,截断图表隐藏。Session 来源是否可用不决定保留统计是否存在(#502)。 - -Model Preset 的当前模板存储由 Profile SQLite 的 owning module 管理,Schema Generation 54 将旧 JSON 模板一次性校验迁入,保留 ID/revision,并写入包含空源的切换标记;成功切换后旧文件仅保留为恢复参考,不再读写(#883)。Schema Generation 55 将 PersonaBot Registry 的身份、外观快照、访问开关与独立 Model Plan 一次性校验迁入 `persona_bots`,导入标记与记录同一事务提交;旧 `bot.json` 与 Soul 文件保留,但正常读写仅使用 owning Registry module/Profile Writer Lease(#884)。无效登记使 Core 启动失败并释放 lease,不会把坏数据当成空名单;修复保留的旧源后重试。Generation 72 的完整 Profile Backup 保存可复用模板与独立 Model Plan 的现值和 revision,恢复不会重新套用模板(#886)。 - -Model Preset 是部署本地可复用模板;Human 在 Profile 应用时,PersonaBot 保存独立 Model Plan 快照,后续模板编辑不传播到已应用的 Bot。Plan 固定 Orchestrator 的 provider/model/reasoning effort,并定义 Assignment 可用的精确模型、各模型允许及默认的 effort 和默认模型。Host 在每个执行入口按当前 Plan 验证选择,而 DSH SessionEvent 记录实际调用:Orchestrator 的变更在当前 Turn 结束后生效,已有 Assignment 保留当前路由,之后的显式切换按最新 Plan 校验;新建 DSH Subagent 默认继承仍获允许的父路由,否则选当前 Assignment 默认并告知父 Agent。不可用或有歧义的路由停止请求,交由 Human 修复,不静默回退。Model Preset 与 Model Plan 可进入保持身份的 Profile Backup,不进入 SoulSnapshot 或 PersonaBot Export(ADR-0027、ADR-0093,#488)。 - -快捷模型/effort 切换只修改此 PersonaBot 的 Orchestrator,并在下一 Turn 生效;DM 不猜测某个 Assignment 作为目标。Model Plan 可以同时省略 `assignmentDefault` 与 `assignmentModels`,表示任务继续继承 Profile 全局模型;既有固定任务配置不变。Registry 的 `modelPlanCustomize` 用 revision 校验创建或修改这种独立配置,Web、移动端和 CLI 共用该 Host 操作。已有 Assignment 仅使用自己保留的路由;没有保留路由时继续继承全局,不补用后来出现的 Bot 任务默认值。完整模型设置显示继承状态,选择完整模板或任务模型才建立固定任务策略([ADR-0218](../adr/0218-quick-model-selection-only-customizes-the-orchestrator.md),#2000)。 - -当前命名模板保存在 `botharness.db` 的 `model_presets` 表(#883,取代 #498 初始 JSON 存储),应用后的独立快照及修订号保存在 owning Registry 的 SQLite 记录中(#884),后续模板修改不会自动替换。Profile 经现有 Host Bridge 读取 DSH 当前注册的 provider、model 与 effort 能力,并在创建及应用时校验确切路由;Orchestrator 的 Agent-scoped Model Selection 在每个新 Turn 前读取最新快照,不修改 DSH 部署默认值。DSH 请求头仍是实际调用路由的事实来源(#498)。 - -后续切片允许 Human 修订命名模板而只改变未来应用的值与模板修订;Profile 的紧凑切换按当前模板复制新 Bot 快照,手动改一个 Bot 的 Orchestrator 路由则清除模板来源标记、保留 Assignment 默认值并增加 Bot 计划修订。Host Bridge 在写入前验证现有 DSH 路由,正在执行的 Turn 持续使用其已组装选择,下一 Turn 才重新读取 Bot 快照(#501)。 - -新建 Assignment 的模型选择由当前 Bot Model Plan 管理:Human 在 Profile 保存精确 provider/model 集合、各模型允许及默认的 effort 和整体默认路由,Host Bridge 在写入前对照 DSH 当前 Model Catalog 校验并递增 Bot 计划修订。Orchestrator 通过 `list_assignment_models` 读取该集合,再在 `create_assignment` 中省略选择以使用整体默认值,或指定获准的模型与 effort;Host 在认领 Session 和启动工作前拒绝越界选择。选定路由随 Assignment ownership 记录保存在 `botharness.db`,并通过该 Assignment 独有的 DSH Agent-scoped Model Selection 进入真实请求;读取 Assignment 或 Host 重启不会把选择改为后来更新的 Bot 默认值。这个切片不修改 Orchestrator 路由或 DSH 部署默认值(#504)。 - -PersonaBot 的 Agent scope 提供 `bot_subagent`、`bot_subagent_fork` 和 `list_bot_subagent_models`;Host 在每次启动子代理时从该 Bot 的最新 Model Plan 验证路由,并用 DSH LLM Service 预检可用性,再交给 DSH Subagent Service 创建真正的子会话。Bot scope 拒绝绕过计划的原生 Subagent 工具调用。省略路由时,获准的父路由被继承;已从当前计划移除的旧 Assignment 路由改用当前 Assignment 默认值,并在工具结果中告知父 Agent。子代理沿用根会话的 Workspace Grant 与工作目录;DSH 子会话原生的 `never` 审批策略不放宽根会话的模式约束(#508)。 - -Model Route Readiness 在 Profile 查询及 Orchestrator/Assignment 新 Turn 入口读取同一 PersonaBot Registry。旧 `model` 字符串只有在 DSH Model Catalog 唯一匹配且路由配置有效时才迁移为独立 Model Plan;有歧义或缺失则保留旧值并等待 Human 选择,绝不拼接部署默认 provider。修复状态即时计算,不是第二套策略存储。请求前校验精确 provider/model/effort;provider 在真正执行时拒绝凭据则通过 DSH `turn/end` 和现有 Channel Session failure 通知给出修复指引。成功应用 Model Preset 后移除旧字段,保持 Agent preset 不变,下一次 Turn 使用修复后的路由(#500)。 - -application-defined Memory Service 使用 `Consumer → Service Definition → Provider` capability seam。每个 PersonaBot 都有一个 Git-backed Memory Repository,Orchestrator Session 固定以它为 working directory。当前检出的工作树文件立即是记忆,无论是 Markdown、代码还是二进制文件。Agent 通过原生文件、搜索、Shell 与 Git 能力读写仓库;没有面向模型的 Memory CRUD 工具。新建 PersonaBot 时可选择空白记忆,或由 Host 检查 Git 并以 HTTPS/SSH 地址在暂存目录克隆远端仓库;克隆成功才建立 Bot,远端当前分支及文件立即成为记忆(#298)。私有仓库使用 Host 已配置的 Git 凭证,不经创建页面收集密钥;已有 Bot 仍通过原生 Git 整合远端仓库。外部仓库应合并、替换或放到其他位置若有歧义,Orchestrator 经 DSH 原生 Ask Question 询问 Human。分支、合并与冲突由 Git 决定;Git 改变工作树后,同一 Session 立即看到新文件,但该 Session 已冻结的 Soul 与 Core Memory prompt 不追溯更新(ADR-0060)。Memory 根目录的 `SOUL.md`(Soul)与 `MEMORY.md`(Core Memory)在 Session 首次组装 prompt 时冻结为同一份快照注入,只在 compaction 边界整体刷新;各自受每 Bot 可配置的字符上限约束,超限时截断并明确提示。旧 `PERSONA.md` 在每次 Host 启动时幂等迁移为 `SOUL.md`,读取仍兼容旧名(ADR-0134)。跨设备延续沿用同一路径:Orchestrator 按 Human 请求用原生 Git 与 Human 自配置的远端同步,新设备以 Git URL 创建新的 PersonaBot;不操作 Git 的所有者使用 Profile Backup,而不是 Host 持有的同步服务或一键同步(ADR-0084)。 - -Idle Compaction(#2310):当前 Orchestrator Session 没有进行中的轮次、也没有待处理的 Bot Inbox Admission 时,Host 按最后一次模型回答的时间,在该线路提示缓存寿命的一半(最少 15 分钟)经 DSH 命令注册表为该 Agent 执行 `/compact`(`compactNow`;compaction 服务隔离在每个 Agent 的 preset 组内,Host 无法直接取得),只压缩超过该线路 DSH 默认自动压缩阈值一半的会话;摘要格式与 `compaction/end` 刷新 Soul/Core Memory 的行为不变,期间到达的 Human 消息由 DSH 排队到压缩结束。缓存寿命依次取 Host 设置中的每线路值、模型目录、观测(请求前空闲间隔与该请求缓存命中率)与 DeepSeek 的 2 小时默认值,不足 15 分钟或未知则跳过。Assignment 与审批复核会话不参与,聊天中不显示;每次执行写入 `usage_idle_compactions` 并在 Bot 活动中返回。开关与每线路寿命是 Profile 级 Host 设置,Web 设置页与 `deepseekbot idle-compaction*` 共用同一 bridge 方法。 - -[ADR-0140](../adr/0140-the-host-falls-back-to-a-managed-git.md) 规定 Git 的来源:Host 每次启动时解析 Git,可运行且不低于 2.28 的系统 Git 优先;否则使用 Profile 中的 Managed Git。找不到、macOS 占位程序与版本过低都算不可用。进入 Bot 模式时先检查,不可用时只给一个「安装 Git」按钮并禁用创建与导入。Managed Git 是按 deepseekbot 版本固定版本号与 SHA-256 的便携构建,先从 `media.botharness.ai` 镜像、再从 GitHub 下载,无需管理员权限;使用它时 Host 把其目录加到 DSH 进程 PATH 最前,让 Orchestrator 的 Shell 也能用同一个 Git。SSH 导入地址克隆失败时,Host 自动转换为对应的 HTTPS 地址重试一次并告知 Human。 - -Memory Service 拥有 repository identity 和 lifecycle、可信 Session ownership、受限的 UI 查询,以及审计/恢复检查点。成功回合后,它可以把观察到的状态与可信 Source Event、Session 上下文记录下来;观察不会暂存、提交或隐藏工作树文件,也不推断 Git 内容的作者。当前文件与历史以 Git 仓库为权威,数据库 ledger 只是辅助记录。Memory Service 为每个 PersonaBot 保存一个 commit 游标:回合开始时静默对齐当前 HEAD,回合成功收尾后,把 HEAD 可达而游标不可达的每个 commit 交给 Channel 写成一条 `memoryCommit` Channel Notice(放在该回合的 Cause Channel,不可用时回到 Human DM),并为该 Bot 写一条已处理的 `memory-commit` 记录,随后推进游标;同一 Bot 的同一 sha 只记录一次(ADR-0154)。起因在群或 Bot DM 的 commit 记录同时放入该 Bot 的 Human DM(ADR-0182)。PersonaBot DM 的 Channel sidebar 分为「记忆文件」和「记忆演化」:前者以可展开目录树展示当前工作树,选中文件后在 Channel body 只读查看文本或二进制提示;后者展示所有本地分支及可达提交的 Git graph,选中 commit 查看完整 diff。当前差异默认按「新记忆/已有记忆的更新」对文件去重,切换并持久保存 Git 术语后,显示可折叠的未暂存、已暂存和未跟踪分组及状态标识。打开视图时定时读取、窗口重新聚焦时立即读取外部编辑,侧栏折叠标题栏也有刷新按钮;查询不暂存或提交(ADR-0088)。Human 文本保存服务仍先比较当前 HEAD,再显式生成 Git commit;普通导航不提供页内编辑。Memory 文件查询阻止访问 `.git` 控制路径及指向仓库外的符号链接,但不限制 Git 可保存的文件类型。旧 Repair 归档与检查点记录仍保留兼容;普通未提交改动不会阻止下一回合或强制修复(ADR-0068)。 - -恢复检查点额外记录分支、HEAD、暂存区与工作树,并用隐藏的 Git ref 保留对象。观察记录区分 Host 扫描、Agent 会话上下文和明确的本地 Human 命令;后者使用与 Channel membership 相同的 Host-owned `local-human` 身份,不能区分共享 Host 凭据的多人。Human 在记忆演化页选择检查点并确认恢复;Host 校验当前状态与检查点引用,在副本中准备目标状态,完整归档原仓库后切换。未被观察的中间状态无法恢复;Git ignored 文件留在完整归档中(ADR-0097)。界面上这份列表放在记忆齿轮菜单的「从自动备份恢复…」里。Human 也可以把记忆「恢复」到 Git graph 的任意提交:Host 先强制记录检查点,有未提交改动时把工作树提交为一个备份提交(可顺带用 annotated Git tag 给恢复前的版本起名),再把所选提交的文件树作为新子提交(分支以 compare-and-swap 前移),然后把工作树重置到该提交;历史不被改写,之后的提交仍可再次恢复(ADR-0190)。 - -Memory 外部打开沿用 ADR-0100 的交互,#574 实现当前 Memory 首片:显示路径的区域可点击弹出菜单,密集文件树提供右键菜单和键盘/触屏可到达的入口,合适位置提供带 Tooltip 的图标按钮。普通文件选择仍进内置阅读,目录选择仍展开。首片覆盖 Workspace 的 Memory Repository 路径及当前 Memory 文件/目录,#575 扩展已授权的普通 Workspace 路径,后续扩展 PR #435 的消息附件 chips,不识别任意正文路径。Memory Service 在 Host 从 PersonaBot 仓库根解析相对路径,沿用 `.git`、越界与符号链接检查;其他来源由各自 owning module 解析,不开放通用任意路径 authority。Client 沿用 DSH Typert/API Gateway seam 消费原生打开能力,不运行拼接 Shell 命令。目录菜单取 DSH 探测到的应用,文件菜单取默认和注册关联应用,首片不加自定义程序或持久默认应用设置。 - -默认打开遵循 [ADR-0178](../adr/0178-file-open-defaults-are-explicit-client-preferences.md)(#1461):Memory 当前文件阅读器、聊天普通文件卡及 Workspace 的 Memory Repository/已授权目录行共用一个 application-defined split button——应用图标与「打开」主按钮加独立下拉,下拉即原文件菜单。主动作优先 Human 的显式偏好,其次 DSH 报告的系统默认;pinned DSH 只为文件关联报告系统默认,目录在 Human 设置前没有可信默认项,此时主按钮说明原因并展开选择,不使用列表首项。下拉里的一次性选择不修改偏好;「设为默认」「恢复系统默认」写入同一个 Client 偏好 owner(`file-open-preferences`),文件按扩展名、目录单独,存于浏览器 `localStorage`,并以 Host 提供的 Profile 身份作键;身份未就绪时不读取任何偏好,写入失败如实报错。偏好只保存 Host 曾报告的应用标识,不授予路径权限、不修改 OS 关联、不跨设备同步。每次手势都由对应 file owner 重新解析目标并重新查询可用应用,已卸载或不再报告的应用不会被启动,也不会被静默替换。派发成功只表示请求已发送到 Host,不代表窗口已出现。记忆演化的 commit diff 与未提交差异在每个文件行提供「打开当前文件」(#1462),复用同一 split button 与偏好 owner,提交 PersonaBot slug 与该行路径,由 Memory Service 按现在的仓库状态重新解析;它不读取所选 commit 的内容,也不暂存、提交或修改仓库。Host 的变更列表不做 rename 检测,rename 表现为旧路径删除加新路径新增,因此新路径行打开新文件、旧路径行只检查该路径当前是否存在。历史上标为删除但后来重建的同路径文件可正常打开;当前不存在时提示「这个路径现在没有文件」,仅当该行本身带删除标记时附上「此提交/当前工作区变更将该路径标记为删除」,不从历史内容重建,也不追猜后续 rename。点击按钮不折叠/展开 diff。 - -普通 Workspace 路径复用同一 Client 菜单,但提交 PersonaBot slug 与 Grant ID,由 application-defined Workspace Grant Store 的 `requireActive` 在 Host 检查拥有者、撤销状态和当前 DSH Workspace Registry 身份,重新解析 canonical 目录。显示路径不承担 authority;失效、移动、未注册或缺失目录会拒绝。打开前再次校验,菜单只列 Host 探测到的目录应用,能力不可用时只保留复制 Host 路径。打开不修改 Grant、Session cwd 或访问权限,也不创建 Session;不提供目录下载(#575)。 - -原生打开始终作用于 DSH Host 所在电脑,Tailscale/Cloudflare Tunnel 只提供连接而不证明 Client 与 Host 同机。菜单明确目标和能力不可用原因;文件另提供下载到浏览器设备及复制 Host 路径,二进制/超大文件不因内置预览受限而失去打开和下载能力。下载后的编辑不自动回写远端,首片不做目录下载或历史 Memory 文件导出。 - -手机端 Memory 文件阅读器同样消费现有 Memory Service 的 `memoryFile` 表示和认证 PNG 字节入口(#2214)。应用定义的 `MemoryPreviewIO` 是客户端进程内的字节与临时文件适配器:本地图片引用携带文档的 source/revision,Host 继续拥有路径、Git 版本和陈旧检查;手机只在原生可见区域相交后读取有界 PNG,把校验过的字节写入临时缓存供 `expo-image`、既有缩放查看器和系统分享使用,离开时取消请求并清理。Markdown 使用既有原生文本/表格组件,图片节点单独呈现,原始 HTML 不执行;外部 HTTPS PNG 经逐图点击才读取,不携带 Host 认证或 Cookie,并拒绝重定向。文件树和普通资料行不加载媒体依赖;原生 Git diff 导航、其他格式及附件预览仍是后续切片。 - -Memory 格式表示进一步支持 JPEG/JPG、GIF、WebP 和安全静态 SVG(#2214)。纯客户端可用的 `media/image-format` 统一检测原始字节 MIME、尺寸和处理预算,Host Memory 入口及手机临时文件适配器消费同一规则。SVG 通过有界 XML/CSS 解析,仅允许无外部资源和主动内容的静态图片子集;仍以图片资源呈现,不注入页面标记,也不在 Host 转码或读取 SVG 引用文件。版本 SHA-256、原始字节和独立原文件下载语义保持一致;GIF/WebP 在帧数/总画布像素预算内保留原动画。 - -Memory Service 在 Host 启动、Orchestrator 回合前,以及本地 DM 或群提及并入活动回合前比较各 Bot 当前分支、HEAD、未提交文件内容和 Git index,与数据库中的每 Bot 观察检查点求净变化。首次观察只建立基线;之后在单次事务中写入有界路径摘要的 `memory-change` Source Event、该 Bot 的 Inbox Admission,并推进检查点。事务失败不推进基线,重启后重试;相同状态重复扫描不重复投递。启动扫描只入 Inbox,不主动唤醒 Agent;下一次普通回合在同一 Inbox 上下文领取并处理。Event 不推断编辑者,也不复制文件内容;Agent 需要时用原生文件和 Git 工具查看。并入消息时发现的净变化随消息在下一个安全步骤进入同一回合的 Inbox 上下文,成功投递才标记已观察,并随回合成功或失败处理;拒绝并入时保留待处理,供后续普通回合领取。回合结束更新剩余状态的基线,不额外通知,也不推断回合中变更的编辑者。离线期间改动又复原的中间过程无法从最终文件状态推断。`SOUL.md` 或 `MEMORY.md` 变化可在 Event 中提示,但冻结的 Session 快照不变(ADR-0092、ADR-0134、#350、#352、#464)。 - -## 3 · Host 启动、迁移与 recovery - -```mermaid -flowchart TD - Start["Host starts"] --> Lease{"Acquire profile writer lease"} - Lease -- "busy" --> ReadOnly["Fail closed
read-only diagnostics"] - Lease -- "owned" --> Open["Open botharness.db"] - Open --> Gen{"Schema generation supported?"} - Gen -- "newer / corrupt" --> Recovery["Recovery mode
no operational writes or wakes"] - Gen -- "current" --> Integrity["Integrity checks"] - Gen -- "older" --> Copy["Migrate isolated temp copy"] - Copy --> Verify["Verify schema + integrity"] - Verify -- "fail" --> Recovery - Verify -- "pass" --> Activate["Atomic replace"] - Activate --> Integrity - Integrity -- "fail" --> Recovery - Integrity -- "pass" --> Modules["Start deep modules"] - Modules --> Rebuild["Rebuild DSH-derived projections
reconcile bounded intents"] - Rebuild --> Ready["Enable admissions, wakes and commands"] -``` - -一个 DSH profile 同时只允许一个 BotHarness writer。所有 module migration 合并成单调递增的 Schema Generation;迁移只在临时副本上完成,校验后原子替换。打开、迁移或完整性检查失败时进入 recovery mode,不回退到 NDJSON、storage domain 或内存写入。 - -## 4 · Messaging 事务与外部副作用 - -```mermaid -sequenceDiagram - participant P as Provider adapter - participant M as Messaging command - participant DB as botharness.db - participant N as Post-commit notifier - participant O as Orchestrator - participant X as Provider service - - P->>M: verified event + account fingerprint + capabilities - M->>DB: BEGIN IMMEDIATE - M->>DB: append Source Event / Revision - M->>DB: Channel placement (optional) - M->>DB: evaluate exact Trigger + Wake Policy revision - M->>DB: create Inbox Admission / Attention facts - M->>DB: COMMIT - DB-->>N: committed fact ids - N-->>O: wake at policy-selected safe boundary - O->>M: explicit Channel reply or authorized Service Action - M->>DB: validate current revision + capability + grant and write Outbox Intent - M->>X: execute with stable idempotency identity - X-->>M: receipt / failure / unknown outcome - M->>DB: append attempt and outcome facts -``` - -Source Event 是内容唯一权威;Channel 和 Inbox 都只保存关系。Reply 使用可信 Reply Route 自动选择来源 provider;主动发布属于 Service Action,需要 Provider Capability,以及该 Bot 对应用可触达会话的会话条目,或作为后备的 Human 保存目标 Service Grant。SQLite 事务只覆盖本地事实;外部副作用使用 Outbox Intent、幂等标识和有界 reconciliation,不宣称 exactly-once。不可证明的结果进入 `unknown-outcome`,由 Human 处理。 - -Wake Policy 决定何时让 Orchestrator 看见新 attention:当前 step 完成后的安全边界、当前 turn 结束后,或 idle 时启动新 turn。普通外部消息不打断正在执行的 model/tool step;只有 DSH 明确支持且策略授权的控制路径才能 steer。就绪的 attention 按回合收割:忙碌期间新到的事件只把就绪集合置脏,当前回合结束(即空闲)时由一次 harvest turn 消费全部就绪项;主动 steer 只用于直接 @ 与 DM(ADR-0077)。 - -### Lark 接收与回答反馈(ADR-0149) - -新会话首条消息的 Admission 通知异步等待受校验的回复连接就绪,避免连接建立期间丢失接收反馈;不阻塞收件确认或模型唤醒。SDK HTTP 异常中的明确权限拒绝归为失败;修复不重写或补发既有未知尝试。 - -Messaging 在 Source Event 与 Inbox Admission 提交后异步尝试原消息的 `GLANCE`,只在该来源的 canonical Reply Outbox 成为 `provider-accepted` 后尝试 `DONE`。两个状态由应用定义;原内容、Admission、Outbox 继续是各自唯一权威。可选的 checked Provider Service Capability 重查账户指纹、Registration、独占 Consumer、来源会话/Thread/发送者及当前授权。旧 Provider 缺少能力时保持正常收发。 - -反馈 attempt 元数据保存在既有 Source Event payload,完成记录引用原 Outbox;管理 snapshot 暴露最近状态,不复制消息内容。写入前持久化 attempt,超时/未知/重连/重启均不自动重试;有界异步调用不阻塞接收、模型或回复。静音仍可产生 silent Admission,屏蔽阻止新 Admission;静默结束、委派完成、待确认问题及无关输出不表示已回答。平台接受不表示 Human 已读或样式已经验证。详见 [ADR-0149](../adr/0149-lark-feedback-follows-admission-and-accepted-reply.md)。 - -Slack 的 #1161 候选复用这条 application-defined Messaging 反馈链:checked Provider 把 `received`/`answered` 映射为原始 Human 消息上的 `eyes`/`white_check_mark`,回答后移除自身的 `eyes`。Slack 静音会话不产生反馈;Lark 既有 silent Admission 语义不变。Provider 仅串行同一来源的反应写入,不占用 Bot transition queue 执行网络写入;每次写入重查来源、账号、Consumer 和授权 fence,部分写入/清理不确定保留 unknown,不自动重试。本候选不提升现有 Provider pin;真实 Slack 频道与 DM 证据仍是独立交付门槛。 - -### 绑定的应用接收默认流量(ADR-0142) - -Application-defined IM DM commands (`/commands`, `/help`, `/status`, `/model`, `/effort`, `/stop`, `/new`, `/compact`) reuse this intake's Binding, sender admission, Source Event and deduplication. Only a whole text-only DM is a command; groups, attachments and unrecognized text retain their existing path. Claimed command Admissions are handled without model input or wake; `last_error` retains pending/unconfirmed command work for inspection rather than automatic replay. The Messaging owner serializes commands per PersonaBot across Bindings, rechecks current source authority and management pairing capabilities at execution, and passes a current-authority fence through asynchronous model validation and Session replacement. Model changes use the existing Orchestrator-only customization owner and preserve Assignment inheritance. Replies use the source-bound Outbox's intent, attempt, final-send fences and accepted/failed/unknown outcomes (ADR-0106/0129); a duplicate never executes or sends again. Weixin changes require an admitted paired-owner DM; other platforms require an approved management pairing with current approval capability, or refuse with Web guidance. `/new` and `deepseekbot bot-new-session` share the idle Orchestrator replacement runtime. `/compact`, the Bot DM composer's `/compact` and `deepseekbot bot-compact` share Idle Compaction's DSH `/compact` path: they refuse while the Bot has a running turn or pending Inbox Admission, and compact only a live Orchestrator Session. - -接收以外部身份 Binding 为键,而不是保存的目标。启用的 Binding 把应用的默认流量(私聊和合格的群 @)接收进绑定 Bot 的 Inbox。会话的第一条被接收的消息,会与其 Source Event、Inbox Admission 在同一事务中提交一个隐式会话条目(`messaging_grants` 中 `origin: 'implicit'` 的行),使 Outbox fence、群策略、Channel Bridge 路由和撤销仍挂在同一个锚点上。Binding 的新会话模式(`auto` 或 `ask`)及其上限(每小时 20 个新条目、500 个活跃、200 个等待)决定新会话被接收还是等待处理。屏蔽按 Bot、应用指纹、会话类型和 ID 持久保存;静音只推进 `preferenceRevision`,不改动条目的 Outbox revision。一个 Bot 可以绑定同一平台的多个应用,每个应用仍只属于一个 Bot。Client 中,应用的会话列表管理这些条目并显示已有的 Channel Bridge 路由(新建同步正在围绕外部连接器重新设计),「外部连接器」列出这些路由并保留保存目标的 Service Grant,作为 Provider 无法列出或直接发往会话时的高级后备。 - -外部应用的准备步骤由官网教程维护,既有 **绑定应用** 弹窗提供对应语言的教程链接,不再显示独立 Lark 配置引导卡片。Driver.js 保留为应用内部控件定位工具,不承载平台配置说明。绑定、接收与回复结果仍来自既有 Provider/Host 权威;打开教程不产生授权或发送,也不新增引导存储。 - -### 首条外部出站路径(ADR-0101) - -[ADR-0127](../adr/0127-product-artifacts-compose-an-independently-versioned-im-provider.md) 提议在开发用 fork 政策之外增加产品分发路径:打包的 `deepseekbot` 固定 Core、Client、Browser、Computer 和独立版本的 `@botharness/im-provider`,通过同一 Bundle Patch 激活全部六个组件([ADR-0160](../adr/0160-deepseekbot-default-bundles-browser-computer-and-im-provider.md))。Provider 保留 SDK、凭据、存储身份及公开 Service;Core 保留所有 application-defined authority。构建校验不可变输入、重建运行时来源和 tarball 完整性。原生优先从 CLI 安装位置解析 Bundle,因此产物验收使用独立安装的官方 CLI,避免误用开发链接。初始不连接账号;重复的 standalone Bundle 在启动前被拒绝。Provider 随产品更新。此构建路径不发布 npm、不自动资格验证其他平台,也不免除 Human QA 和生产启用门槛。参见[打包验收指南](../product-im-installation.md)。 产品 Provider 的来源、DSH 版本和运行时摘要独立于开发版固定值;改变产品输入需要递增自身版本并重做实际安装验收([#868](https://github.com/BotHarness/BotHarness/issues/868))。 - -[#1115](https://github.com/BotHarness/DeepSeekBot/issues/1115) 经 application-defined Messaging Provider 的可选可达群查询/checked first-post 契约扩展同一主动发送路径。Host 保留 Binding、会话屏蔽和 Outbox authority;首次发帖只创建既有 implicit 会话条目,不生成 Source Event、Inbox 或本地 DM。Generation 89 在 Binding 保存每小时发帖限额(默认 60,可关闭),以既有 Outbox attempt-start 计数,未知结果不重试。可选契约覆盖 Lark、Slack、Discord;实际平台权限、版本绑定的原生验收及 #1111 Provider 发布仍独立。详见 [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md)。 - -PersonaBot Profile 的 IM 连接经现有 Typert/API Gateway 选择账号和已测试目标,再由 Human 建立 Binding 与单目标主动发送 Grant。application-defined Messaging Provider Registration 由 Consumer Fiber 持有,Binding/Grant/Outbox 使用同一 `botharness.db`。接受和执行都复核活跃 Bot、Grant、Registration、认证账号 fingerprint 和目标内容 digest;删除、改址或账号变化要求显式重新授权。先提交 Intent 和 attempt-start,再调用 provider,结果只记平台接受、明确失败或未知;重启不重发 pending/in-flight,未知结果留待 Human 核对。 - -生产适配需要 dsh-im 公开、版本化的 `describeBot`/`sendChecked` 契约,以在账号 transition 中验证平台身份并冻结已授权路由。这是待上游接受的小型扩展;已发布 `4.32.0` 不满足该契约,BotHarness 默认禁用这条出站 authority。隔离开发可通过 `dev-instance --im-provider` 安装 [ADR-0104](../adr/0104-isolated-im-profiles-pin-a-qualified-temporary-provider-fork.md) 指定的临时 fork 完整 SHA,并在启动前校验运行时代码 digest;这不代表上游已发布或生产启用。dsh-im 仍持有 SDK/连接/凭据与原设置入口;BotHarness 不读取其私有 JSON,不接管 standalone Session 路由。#12 通过公开 `consumeInbound` 独占接收显式授权群内的文字 @:Messaging 先提交 bridge-message Source Event 与独立 Bot Inbox Admission,commit 后确认,再沿既有 group-mention harvest/steer 唤醒 Orchestrator。来源不需要本地 Channel placement,模型从 Inbox 了解接收身份、发送人及 group/thread/root/parent;`bridge_read` 读本地已保存来源,`bridge_reply` 仅接受来源 ID 与正文,Host 推导本 Bot 的有效身份与原回复路由,复用 Outbox 后调用公开 `replyChecked`,不镜像到 Human DM。撤销、归档、consumer 丢失、旧 Grant revision 都不能授权未来收件或尚未开始的回复;重启不重发未知 Outbox。外部来源 turn 不扩展 Memory acceptance 的来源授权,也不自动写入 Memory。详见 [ADR-0106](../adr/0106-exclusive-im-intake-commits-bot-inbox-before-acknowledgement.md)。 #612 的 `bridge_context` 通过同一 Inbox 来源和 Bot 身份调用公开 `historyChecked`,区分群列表、附近 ±5 分钟 Chat 时间窗与可信 Thread 列表;Host 按 JSON 预算仅将完整返回内容 reconcile 到 canonical Source Event,并记录有界读取 metadata。普通历史不建立 Admission、不触发 wake;已经存在且实际返回的 Admission 沿用当前 turn 的成功/失败处理。Provider 返回的显示名仅补充同一 canonical 身份;发送人和 @ 人员 ID 保持身份依据。Inbox 沿用 harvest 的 Message/Source Event 引用格式,上下文返回可用的姓名和 @ 关联,缺失姓名时回退 ID。Human 来源详情显示读取记录与最近一页,不自行读取远端。 - -### Lark 管理员配对(ADR-0136) - -[#1027](https://github.com/BotHarness/BotHarness/issues/1027) 的 application-defined 配对 owner 使用 Messaging 所属的 Operational Database,以当前 PersonaBot、外部身份 Binding、Provider 认证的真实用户及原始私聊路由固定授权范围。已绑定 Lark 身份在既有独占 Consumer fanout 上保留账号级控制入口:纯文本 `/pair` 先提交申请再确认,不创建普通 Source Event、Bot Inbox Admission、模型唤醒或 Memory 写入。普通消息仍需自己的接收授权;删除聊天路线不关闭配对入口,也不产生普通 DM 接收权限。Client 显示实际接收器是否就绪。 - -首次能力只能由认证 Web Human 显式勾选并按当前申请 revision 审核;定位码不能兑换权限,首位申请者不会自动成为管理员。能力仅覆盖当前 Bot 的批准、拒绝、回答及保存规则资格,不扩展 VPS、原生 DSH API、其他 Bot 或审批人管理。每次使用重查当前 Binding、Bot、真实 actor、能力和状态;暂停期间不可用,永久撤销后重新配对必须重新审核。该切片交付配对与 Web 审核,IM 审批/提问控件仍由后续切片接入原生权威。参数、恢复及审计边界见 [ADR-0136](../adr/0136-lark-pairing-is-reviewed-bot-scoped-operational-authority.md)。 - -### Lark 普通角色与自动申请(ADR-0165) - -Sender permission diagnostics validate the Source Event's original ingress authority before reporting that a platform has no Role policy (#1951). Grant-backed sources retain their Binding and Grant checks; Conversation Ingest sources record `ingressBindingId` in the canonical Source Event and validate that original Binding plus the current Ingest. A shared reader needs its own current Binding in the same Provider/account scope, and any resolved Role must belong to that checked Binding. Disabled, revoked, replaced, blocked, or unavailable authority remains a refusal. Legacy Ingest sources without an ingress Binding anchor return `no-current-scoped-identity`; a fresh message supplies the evidence rather than attributing old content to a replacement Binding. This adds no content copy, grant, listener, or schema migration. - -The #1415 candidate extends the same ordinary-chat authority to checked Slack DMs. The existing Bot-wide restriction covers Lark conversations and Slack DMs, remaining off by default; a Slack actor is identified as `slack-workspace-user-id` under the current Bot/App/Provider fingerprint and Binding. Unknown restricted DMs create only pairing metadata and a fixed notice; Web review assigns an existing Role, approval asks again, and only a fresh message enters the canonical Source Event/Admission/reply path. Current permission/directory reads reuse the same owner, and revoke/restart/rebind retain their fences. Slack group and native management parity remain separate. There is no new schema, listener, credential store or native RBAC. The isolated source candidate passed actual Web approval, fresh DM/current permission/directory reply, revoke refusal and restart with matched Source Event/Admission/Outbox facts; Human interaction feedback is accepted. Published product and Slack group/native management qualification remain separate. See [ADR-0174](../adr/0174-slack-dm-chat-pairing-reuses-current-binding-authority.md). - -#1415 候选将同一普通聊天权威扩展到 checked Slack 私聊。Bot 既有发送者限制覆盖 Lark 会话及 Slack 私聊,默认仍关闭;Slack 人员以当前 Bot/App/Provider fingerprint 和 Binding 下的 `slack-workspace-user-id` 标识。未知受限私聊只产生配对元数据和固定通知;Web 审核分配已有角色,批准后要求重问,新的消息才进入规范 Source Event/Admission/回复路径。当前权限与目录读取沿用同一 owner,撤销、重启和重新绑定保留各自 fence;Slack 群聊及原生管理能力仍由独立验收推进。没有新增 schema、监听器、凭据存储或原生 RBAC。隔离源码候选的真实 Web 批准、新私聊/当前权限/目录回复、撤销拦截与重启已通过,Source Event/Admission/Outbox 与原生结果一致,Human 已接受交互。已发布产品、Slack 群聊及原生管理能力仍独立验证。 - -[#1423](https://github.com/BotHarness/DeepSeekBot/issues/1423) adds application-defined Behavior permission items at the same Messaging owner. Groups reference Bot-scoped stable IDs with explicit Allow/Deny; current permission queries deduplicate overlaps, retain every Group revision/source and expose same-item conflicts with Deny priority. One primary behavior Role remains applicable. Settings provide a shared item table, separate edit/create dialogs, searchable Group checkboxes and affected-person previews; stale item/group revisions refuse writes and referenced-item deletion requires removing Group references first. The second slice adds bilateral authored exclusions: a shared pure resolver previews incompatible effective Allows, and the canonical transaction refuses item/group/member edits or Deny deletion that would introduce them. Reciprocal links and revisions remain in the existing item records, with no schema change. Generation 81 persists items in Operational Database; authenticated senderAccess commands and the existing bridge_sender_permissions Tool remain the sole write/read seams. Text guides model behavior, not arbitrary resource isolation or proof of semantic consistency. See [ADR-0173](../adr/0173-group-behavior-items-resolve-by-id-with-explicit-deny.md). - -#1423 的第二片沿用同一 Messaging owner:Human 按稳定权限项 ID 声明双向互斥。共享纯 resolver 预览最终同时有效的 Allow;Host 在既有事务中拒绝会引入不兼容的权限项、组、成员编辑以及 Deny 删除。明确拒绝仍优先;双方引用与版本保存在现有记录,不增加 schema。配置检查也覆盖暂时停用 Binding 的成员配置,避免重新启用暴露潜在冲突。任意文字或主 Role 的语义矛盾不由此自动识别。 - -[#1406](https://github.com/BotHarness/DeepSeekBot/issues/1406) adds application-defined External Authorization Groups and person exceptions at the same Messaging owner. One current Chat pairing can belong to multiple groups in its exact Bot/App Binding while keeping one primary behavior Role. Each current resolution unions Role, group, legacy management and person Allow, then removes any explicit Deny; private native delivery, decision and unused approval checks use this result. Group membership cannot approve chat or mint a management destination, and revoke/re-pair never inherits old member IDs or exceptions. Direct authenticated Web Human input retains default complete product authority and native execution approval. Generation 79 stores bounded revisioned group records in the Operational Database; existing authenticated senderAccess commands, snapshot and on-demand Tools carry the current sources without a second Consumer or cache. The Client adds Groups, scoped member selection and personal exceptions to pairing settings. See [ADR-0168](../adr/0168-external-groups-are-current-scoped-management-authority.md) for migration and boundaries. - -#1406 在同一 Messaging owner 增加应用定义的 External Authorization Group 与个人例外。当前 Chat pairing 可加入同一 Bot/App Binding 下的多个授权组,仍保留一个主行为 Role。每次解析先合并 Role、组、已有管理配对及个人 Allow,再扣除任意显式 Deny;私聊原生审批的投递、决定及尚未使用的批准都读取此结果。入组不批准聊天、不创建管理目的地,撤销后重新配对不继承旧成员 ID 或例外。直接认证的 Web Human 输入默认保留完整产品权限与原生执行审批。Generation 79 在 Operational Database 保存有界且按 revision 修改的组记录;沿用认证 senderAccess 命令、快照与按需 Tools 展示当前来源,不增加 Consumer 或缓存。Client 配对设置加入授权组、范围内成员选择及个人例外;迁移与边界见 ADR-0168。 - -人员、角色与申请表格的创建/修改时间来自 Messaging owner 持久化的 JSON 元数据,不由 Client 加载时间推断;读取和通知投递记账不推进修改时间。旧记录未记录的时间保持未知。时间字段是兼容的可选元数据,沿用 Operational Database authority,不增加 SQL schema generation。 - -[#1414](https://github.com/BotHarness/DeepSeekBot/issues/1414) extends the same owner with explicit `review-pairing` and a separately resolved Management Delegation Ceiling. A Lark group-only request can receive a public pairing card without a private-DM prerequisite; current reviewers with an observed trusted private route may also receive private copies. The qualified Provider retains its exclusive Consumer and a distinct `botharness/pairing-v1` callback namespace. Messaging checks the actual actor, current Binding and route, pending request and selected Role revision, and the reviewer's current use/delegation authority before a single canonical decision. Generation 80 stores bounded card receipts and actor-attributed selections; uncertain sends are not blindly repeated. Cards disclose review metadata rather than the blocked instruction. Approval only notifies the applicant to ask again; a new valid message must enter the normal Admission and model path. Group pairing cards do not change the private destination contract for native execution approvals or Questions. See [ADR-0171](../adr/0171-pairing-cards-check-current-review-and-delegation-authority.md). - -#1414 沿用该 owner,增加显式 `review-pairing` 与独立解析的 Management Delegation Ceiling。仅有群的 Lark 场景可直接收到公开配对卡片,不要求先建立私聊;已有可信私聊路由的当前审核人也可收到私聊副本。合格 Provider 保持独占 Consumer,使用独立的 `botharness/pairing-v1` 回调命名空间。Messaging 在唯一权威决定前检查真实操作者、当前 Binding/路线、待审核申请与选中 Role revision,以及审核人当前的使用/委派权限。Generation 80 持久保存有界卡片回执及具操作者归属的选择;未知发送结果不盲重试。卡片只显示审核元数据,不包含被拦截旧指令。批准只通知申请人重问;新的有效消息才进入既有 Admission 与模型路径。群配对卡片不改变原生执行审批或 Questions 的私聊目的地边界;详见 ADR-0171。 - -#1376 adds an explicit visitor exception on one existing allowed Bot/App group grant, independent of pairing and collection. Unpaired ordinary authors collected through authorized group/Bridge/thread/ingest paths remain passive (`silent` admissions, `context` scheduling evidence, no count/interval), never fill eligible wake thresholds, and may supply trusted author context to a later eligible request. The current requester alone supplies role-governed behavior; no background author union grants management powers. Closing, blocking and rebinding retain the independent authority fences. - -#1376 在已允许的具体 Bot/App 群授权上增加显式访客例外,与配对及采集独立。经授权的群、Bridge、线程或 ingest 采集到的未配对普通作者保持被动上下文(`silent` Admission、`context` 调度证据、无 count/interval),不填充有效请求的唤醒阈值,但可为后续有效请求提供带可信作者身份的上下文。角色行为以当前请求者为准,不合并背景作者的管理能力;关闭、封禁及重新绑定仍保留独立权限检查。 - -The #1375 application-defined Role mutation commands reuse this owner and authenticated sidebar seam. Revision-checked edits and single-role reassignment advance current policy revisions without rewriting historical Tool Results. The existing qualified private approval route reads current `approve`/`reject` Role capabilities for ordinary DM-origin pairings while preserving legacy explicit management grants. Group-origin pairing does not mint a management destination. Exact actor/Binding/pairing/receipt/native-lifetime checks remain Host owned; capability removal refuses delayed decisions and reassignment invalidates old routes. Natural-language behavior is not a resource ACL. - -The #1033 private Lark candidate adds independent `save-rules` management authority to that same resolver. Its distinct checked Provider callback previews the complete native operation and confirms a request/actor/private-receipt-bound nonce before the existing Channel Tool Approval owner can save and activate a rule. Generation 82 stores verified external creator provenance in the existing canonical Tool Approval Rule record. Each later match and final execution guard checks the original pairing and current `approve`/`save-rules` capabilities under the same Bot/App Binding. Authority loss permanently revokes the rule; re-enable and re-pair cannot restore it, while an independent still-effective grant preserves it. Web Human rules retain their prior authority. An IM-only approval creates a missing canonical Bot DM through the existing Channel Store and registry display-name lookup; it does not require opening Web or create a new Inbox Admission. Web and card status read the same canonical rule; fixed-text receipt-only cleanup may remove stale standing-rule claims after creator revocation without granting a destination or disclosing operation details. Native session-wide approval remains Web-only, and Workspace/Service grants are not enlarged. The Human accepted the actual exact-rule slice. The next candidate offers separately disclosed all-opaque scope only with Provider rule-card version 2 and its account capability; kind-bound nonces prevent an exact confirmation from granting that broader rule. External rules cannot auto-match Web-only native requests. The Human accepted the QA1 all-opaque first slice, and actual changed-input, Grant-scope, revoke/restart and final-card checks passed; an interrupted disabled-card update needed explicit idempotent receipt refresh. Published Provider qualification remains separate. See [ADR-0175](../adr/0175-external-tool-rules-retain-current-creator-authority.md). - -The #1041 source candidate observes Runtime-owned committed `sessionFailure` Channel messages and verifies Session ownership. Default-off event settings and bounded delivery metadata live in Messaging's Operational Database. Failure alerts resolve the same current private management route as approvals, qualify its genuine DM source through the checked Provider, then apply a final authorization/Registration fence. Only fixed failure/Session metadata is sent; error bodies and prompts remain canonical in Web. Delivery does not hold the failed turn, grants no decision capability, retries only known-unsent attempts within three tries and preserves Unknown Outcome without replay. Restart cancels pending intents and marks interrupted sends unknown. Bot DM sidebar controls use the existing authenticated Typert/API Gateway seam; group/additional Provider and Host-down monitoring remain separate. See [ADR-0176](../adr/0176-work-failure-alerts-use-committed-facts-and-management-route.md). - -#1041 源码候选观察 Runtime 已提交的 `sessionFailure` Channel 消息并校验 Session 归属。默认关闭的事件设置及有界投递元数据由 Messaging Operational Database 持有。失败通知沿用审批的当前私聊管理目的地,通过 checked Provider 核对真实 DM 来源,并在发送前校验授权及 Registration。仅发送固定失败/Session 元数据,错误正文和提示保留在 Web;通知不阻塞失败工作、不授予决策能力,仅对确定未发送尝试最多三次,未知结果不重放。重启取消等待意图并把中断发送记录为未知。Bot DM 侧栏经现有认证 Typert/API Gateway 配置;群、其他 Provider 和 Host-down 监控仍由独立功能推进。 - -#1375 的应用定义 Role 修改命令沿用同一 owner 和认证侧栏接口。按 revision 编辑角色或为人员重新分配单一角色会推进当前策略 revision,但不改写历史 Tool Result。现有合格私聊审批路线为从 DM 配对的普通人员读取当前角色的 `approve`/`reject` 能力,并保留既有显式管理授权;群内配对不创建管理目的地。真实 actor、Binding、配对、回执和原生请求生命周期仍由 Host 校验;移除能力拒绝延迟决定,重新分配使旧路线失效。自然语言行为不构成资源 ACL。 - -[#1373](https://github.com/BotHarness/DeepSeekBot/issues/1373) 沿用 Messaging owner 与 Operational Database,新增普通 External User Role、默认关闭的发送者限制和 `conversation` purpose 配对;既有 `management` purpose 的 `/pair` 显式能力保持独立。普通角色先支持自然语言行为权限与空管理能力,不新增 DSH 原生 RBAC,也不宣称隔离邮件、代码或任意 Shell 资源。配对按当前 PersonaBot、App Binding/fingerprint 与 Provider 认证的 Lark open ID 限定,可跨允许的 DM/群复用,但不跨 Bot/App/平台或重新绑定继承。Host 在默认接收、显式 Bridge、会话接入、共享来源及模型消费处逐 Bot 校验资格;不同作者的角色不合并。 - -未配对的受限 DM/群 @ 先持久提交有界审核元数据,不保存旧指令正文、不建立请求 Source Event/Inbox Admission、wake 或 Task。认证 Web Human 在 Bot DM Channel sidebar 经既有 API Gateway/Client seam 选择角色并按 revision 审核。批准先提交,再通过当前独占 Consumer 的受检查回复能力发送固定重问通知;只允许新的有效消息进入原有模型回复/Outbox 路径。通知尝试在外部调用前记录,未知结果不盲重试;接收回调先返回,避免与 Provider 账号队列死锁。撤销立即关闭后续资格及尚未执行的相关 Admission,既有历史保留。Generation 78、迁移和边界详见 [ADR-0165](../adr/0165-lark-chat-pairing-is-reviewed-current-binding-authority.md)。三个只读目录与完整首轮真人反馈仍由 #1374 交付,角色编辑/重新分配与显式开放群/被动上下文分别保留 #1375/#1376 的真人依赖。 - -The admitted Inbox context carries compact trusted sender/App/Source Event references rather than automatically injecting roles or permission snapshots. The application-defined read-only `bridge_sender_permissions` Orchestrator Tool calls the existing Messaging owner, resolves one accessible source and returns current pairing status, effective Role, policy revision and query time. It creates no authority or observation. Stable guidance calls for a fresh lookup of the actual requester before a new permission-sensitive operation, while ordinary chat need not query; unresolved lookup defers the governed operation. Historical Tool Results are dated evidence. Deterministic admission and supported management checks remain Host-owned. - -入箱消息只附可信发送者、App 和 Source Event 引用,不自动注入角色或权限快照。应用定义的只读 `bridge_sender_permissions` Orchestrator Tool 沿用 Messaging owner,解析可访问的规范来源,返回当前配对状态、有效角色、policy revision 与查询时间,不创建权限或观察记录。稳定行为指导要求对新的权限敏感请求重新查询真实申请人,普通聊天不强制查询;无法解析时暂缓受该策略约束的操作。历史 Tool Result 只是带日期的证据。确定性的接收资格与已支持管理动作仍由 Host 校验。 - -The #1374 `bridge_directory` Tool adds three bounded/pageable reads through the same Messaging owner and Operational Database: effective ordinary paired people/current Roles, known configured external conversations/states, and paired people observed in the conversation of an accessible trusted Source Event. Observations intersect current Bot/App/Binding pairing with authorized retained sources, including current joined shared placements, and exclude purged or previous-Binding evidence. At most 1000 recent matching sources are examined per query; query time, first/last observation times, truncation and incomplete coverage remain explicit. This is never a complete/current platform member list or a new destination/impersonation authority. Role-governed questions still resolve the actual requester with the current-permission Tool. Directory results are queried as needed, group replies remain visible to the whole group, and the complete first-flow Human feedback remains required before #1375/#1376. - -#1374 的 `bridge_directory` Tool 沿用同一 Messaging owner 与 Operational Database,提供三个有界可分页只读视图:有效普通配对人员与当前角色、已知已配置外部会话及状态,以及在可信可访问 Source Event 所属会话中观察过的配对人员。观察结果将当前 Bot/App/Binding 配对与授权保留来源取交集,包括当前加入的共享 placement,并排除已清除或旧 Binding 的证据;每次最多检查最近 1000 条匹配来源,明确给出查询时间、首末观察时间、截断与不完整覆盖。它不代表完整或当前平台成员名单,不授予新目的地或冒充权限。受角色策略约束的问题仍对真实申请人调用当前权限 Tool;目录按需查询,群回复仍全群可见,完整首轮真人反馈仍是 #1375/#1376 的门槛。 - -### Lark 私聊审批(ADR-0141) - -[#1029](https://github.com/BotHarness/BotHarness/issues/1029) 将已提交的工具审批发往 Web 显式选择的已配对管理私聊;通知路由及回执由 Messaging owner 在现有 Operational Database generation 60 保存,完整请求/决定仍归 canonical Channel 与 native Session。通知、决定接受、原生执行结果和卡片更新分别投影。现有独占 Consumer fanout 单独处理官方 SDK 卡片操作,不建立普通 Source Event、Inbox 或 Memory。公开的受检查 Provider capability 负责原始私聊/实际发送人、自己的卡片回执和平台写入前 fence;Host 校验实际点击者的当前 Bot/Binding/fingerprint/配对能力与 revision、目的地、request↔receipt、完整操作及原生执行者。允许一次/拒绝重入现有工具审批 broker,Web 与 IM 共用一位胜出者,执行前再次核验撤权与完整参数。回调确认不等于批准,批准不等于成功执行;重启使旧卡片失效,未知发送结果不重发。该切片不保存规则、不处理群审批/原生问题,也不释放 native wait。见 [ADR-0141](../adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md)。 - -### Bot 之间的 Channel 协作(ADR-0065) - -Orchestrator Session 中注册的 `group_attention_get` 与 `group_attention_set` 是 DSH model-facing Tool,消费 BotHarness Host 的 application-defined Channel capability;Host 从该 Session 所属 PersonaBot 确定 actor,并在读写时复核当前 Group membership。未保存的 Group 偏好只读为 digest 5 条/30 秒、revision 0;Human Bridge 写入和 Bot Tool 写入共用 Channel record 中的 `wakePolicies`,每次实质变更在同一 SQLite 事务里追加不可改写的 actor/时间/revision 审计事实。审计表只存历史,不作为第二套当前偏好;Admission 在消息提交时固定有效策略与 revision,不会随之后的编辑回写。 - -#366 把 Human DM、Bot DM、群内提及、普通群消息、入群邀请与申请流程、Assignment 报告及生命周期的内建来源规则写成每个 PersonaBot 本地的不可改写修订事实。Host 在建立新 Inbox Admission 的同一事务里读取对应规则并保存来源规则 revision/wake 快照;既有 Admission 不回填。普通群消息的来源默认值沿用 5 条/30 秒 digest,适用的 per-Channel `wakePolicies` 覆盖仍保留在 Group Channel record;Assignment 进度报告仍按报告状态与回复请求决定是否即时唤醒。资料页经 Host Bridge 查询所有有效规则、修订及最后修改者。来源类别修订与 Channel 覆盖分属不同作用域。 - -#370 的 `source_attention_get/set/reset` 是仅对所属 PersonaBot 可见的 Orchestrator Tools,Human Profile 通过 Typert/API Gateway Bridge 编辑同一 `bot_source_policy_revisions` 修订权威。v1.0 可编辑提醒强度的来源为 `assignment-report`(`conditional`/`immediate`)与 `group-ordinary`(`immediate`/`digest`/`mentions`/`silent`,汇总条数及间隔有界);两者始终 `admit`。`human-dm`、`bot-dm`、`group-mention` 始终接收并即时唤醒;Human Bridge 与所属 Orchestrator 的 `source_attention_set/reset` 可编辑/恢复 `delivery: steer | turn`,默认 `steer`。直接来源要求 `wake: immediate` 和显式 delivery,拒绝汇总参数;其他可编辑来源拒绝 delivery。Host 在投递唤醒时读取当前 delivery;Admission 保留入队时的 wake 与修订。`group-invite`、`group-join-request`、`group-join-decision`、`assignment-lifecycle` 保持即时工作流通知;其中与 completed Report 明确配对的成功执行通知只搭乘收割,不重复触发同一因果唤醒(ADR-0077);其余来源没有安全的延后/静默收割语义,因此规则只读,Bot Tool 与 Human Bridge 都拒绝其修改。删除可编辑覆盖以带 Human/Bot actor 的新修订恢复内建默认,不删除历史;后提交的 Admission 才读取新修订。Orchestrator 真正启动时为所纳入的来源类别追加不可改写的 wake-attempt 事实,近七天计数从这些事实投影,而非把 Admission 数或汇总阈值当作唤醒次数。Group Channel 的普通消息覆盖继续独立存于 Channel record,优先于 PersonaBot 来源默认值;冻结规则按 ADR-0076 延后。 - -Attention 已交付契约(ADR-0070/0074/0077、#364):四档偏好均为普通群消息保留该 Bot 的 Inbox Admission。`mentions` 不自行唤醒,但同群直接 @ 可带入有限的待处理上下文;`silent` 不自行唤醒,也不搭乘 @,只在 Bot 显式读取时进入本轮。群聊触发 harvest 时同时选择触发消息附近的上下文与最早待处理的一段;单群至多 100 条,并受整轮文本/token 预算约束。提示中明确省略数量与继续读取位置,未选中消息保持待处理,后续合格轮次继续从最早处推进。`channel_read` 只把实际返回并进入本轮的 Admission 纳入处理集合:进入本轮显示处理中,成功结束才已处理,失败显示需修复。内部 observed 保留审计用途,不新增常用“完成”工具,也不把 Human 打开 Channel 当作 Bot 处理。#362 的通用多来源 Inbox Trigger 与 Attention 聚合仍待后续切片。 - -当前 #47 首个高流量 Group 切片把每位成员的普通消息 Wake Policy 写在 Channel record;该 preference 归 Bot 所有,Bot 可用自己的工具读写(含 count/interval),Human 可在成员侧栏查看并覆盖;四档:`all`(每条普通消息即时成为 attention)、`digest`(N 条 / T 秒汇总;默认)、`mentions`(只有直接 @ 到达)、`silent`(记录但不唤醒、也不搭车,只能显式读取)(ADR-0074)。任一模式下,普通消息作为 Source Event 与该 Bot 的 `group-ordinary` Inbox Admission 在同一 SQLite 事务中提交,Admission 固定当时的 policy revision;即时模式逐条就绪,汇总记录阈值,mentions 与 silent 记录空阈值。未保存偏好时采用 digest 的默认阈值,保留已保存的模式与 revision。直接 @ 走独立的即时 Admission,不等待汇总。Host 在达到数量或时间上限后把该 digest 批次标记为就绪;忙碌期间新到的即时项不再各自排队,只标记就绪集合,当前回合结束或空闲时由一次 harvest turn 消费全部就绪项(ADR-0077)。直接 @ 与 DM 默认 steer:有活动回合时在下一个安全 step 注入,否则并入下一次 harvest。重启从 pending/retryable Admission 重建计时,收割只把实际纳入 Orchestrator context 的 Admission 标为处理中,回合成功结束才标为已处理;Bot 通过 `channel_read` 明确读取到的普通群消息也加入该回合,成功后标为已处理,失败则需修复,未返回的消息保持待处理;是否向 Channel 回复仍由 Bot 决定。静默收件为普通消息保留待处理 Admission,但不设置汇总阈值,不自动唤醒,也不进入 harvest;只有显式 `channel_read` 才会进入本轮处理,直接 @ 仍即时。归档中的 Bot 在消息提交时不产生新的 Inbox Admission;群消息与其他活跃 Bot 的收件仍独立提交。唤醒处理按 Source 类别而非平台分类:外部平台在 Bridge 边界归一化为 Source Event,运行时只认 Source 类别与 admission reason(ADR-0075)。Bot 自管自己的 attention policy(来源类别规则 + per-Channel 覆盖 + count/interval),Human 可覆盖;冻结规则按 ADR-0076 延后,安全闸门不属于 policy。通用多来源 Attention 聚合与 Inbox Trigger 仍待后续切片。 - -五个应用自定义 attention Tools 使用现有逐 Bot 的来源策略与 Channel preference Provider。`source_attention_set` 省略 `sourceClass` 时默认 `assignment-report`,仅允许 `conditional|immediate` 且不能传 digest 参数;`group-ordinary` 允许 `immediate|digest|mentions|silent`,只有 `digest` 接受 digest 参数。计数与间隔使用整数 schema,由 Host 强制执行 1–100 与 1–3600 秒边界,省略时保留有效设置。逐 Channel 的 `group_attention_set` 在所有模式下保留可选 digest 设置。Source reset 恢复内置规则,不清除 Channel override,也不改变历史 Admission。读取、编辑和重置返回有效值、修订、最后编辑者/时间及有界七日 source wake 计数;非法组合在策略写入前失败。 - -当前 Bot-scoped botAttention Bridge 查询直接从 Inbox Admission、Source Event、Channel placement 与 Assignment Directory 投影有界页,按 Source Event 时间与 ID 排序,返回状态、发送者、摘要以及可用的 Channel 消息或 Assignment Session 引用;它不另建收件内容。Assignment Report 与 Source Event 在同一事务中形成该 Bot 的 Inbox Admission,并带报告状态;报告进入 Orchestrator 回合时记录观察,回合完成后标记已处理,是否向 Channel 回复由 Bot 决定。重启后未观察的到期报告可唤醒一次,已进入回合却中断的报告显示需要修复。Human–PersonaBot DM 的 Channel sidebar 在有事实时显示 Bot Inbox entry,按来源 Channel 或 Assignment 分组,待处理项展开,已处理和明确忽略的历史折叠;Bot 在收到或读取某条 Channel 消息后可用 `inbox_ignore` 明确忽略,决定写在同一 Admission 上并保留消息历史;回合完成本身不再 resolve 任何发给 Bot 的条目:它们变为 open,直到 Orchestrator 用 `inbox_resolve` 标记 done 或 ignored(ADR-0188),阅读本身不自动写入长期 Memory。点击来源消息复用 Channel timeline 的 around 定位,点击 Assignment 报告打开事项详情。Group Channel 不显示该 entry;Human 查看侧栏不改变 Bot 的观察事实(ADR-0070、#47、#152)。 - -Human Inbox 的首个可运行切片在 Bot mode 左侧栏的 Messages 上方提供独立入口,默认显示待 Human 处理的群聊加入申请、仍存活的原生提问和工具审批;#546 已交付的未读视图将群聊及 Bot→Human DM 按 Channel 汇总,信息视图保留无 Channel 的事项完成报告。提问项只在 DSH 原生请求仍等待 Human 答复,工具审批项只在 BotHarness 审批请求仍存活、且 Channel 内没有答复、取消或审批决定时出现;打开后定位到对应私聊卡片。事项的最新报告为 `waiting-human` 且 open ask 仍存活,或报告为 `blocked` 且尚未解决时,Human Inbox 从 Assignment Directory 投影同一条按 Session ID 稳定标识的待办;状态升级为受阻时更新摘要与报告来源,不重复建项。Orchestrator 回复后事项运行期间暂隐藏该项;若事项再次空闲或出错但没有新的解除受阻报告,待办继续显示。打开后进入该 Bot 私聊并展开事项详情;完成报告或停止事项后待办消失。事项完成报告则按最新 Source Event 投影到“仅供了解”,Human 可打开来源或忽略该份报告;忽略决定单独保存 Source Event ID、决定与时间,新报告仍会出现,不复制 Inbox 内容。Host 从 Channel record 中的待处理申请、Source Event/Channel placement 以及 Human 的 Channel read position 投影列表,不另存 Inbox 内容;批准或拒绝沿用 Group 决策事务,已了解沿用 Channel 已读位置。查询按时间与稳定 ID 分页,并把游标绑定到分类、Bot/Channel 过滤条件与排序方向;Client 在切换范围时丢弃旧响应。Bot 的 Channel Admission 进入 needs-repair 时,同一权威按受影响 Bot 与 Source Event 投影一条待办;Human 可打开 Bot Inbox 或来源消息,来源 Channel 已删除时降级到 Bot Inbox,修复状态解除后待办消失。Workspace Grant 请求也从 Bot DM Source Event 投影为待办;新提交的回复只有带有效 Grant 引用才会清除,历史已存储的本地化授权文字回复仍按兼容规则识别;unknown-outcome、rebind 与 readiness 等原因仍待各自的 typed Attention facts(ADR-0071、#126)。 - -[#687](https://github.com/BotHarness/BotHarness/issues/687) 切片让事件展开、行动执行和准确来源跳转彼此独立。只有点击整行才打开 Inbox 详情;工作区行动把权威请求上下文载入既有选择器,回复、提问、审批和 Assignment 行动使用原生 Modal 回应表单。重新打开工作区选择器时重查权威状态;这些控件沿用既有 Host 命令,不把决定权复制到 Client。 - -#547 切片允许本地 Human 在 Human Inbox 查看捕获的群聊或 Bot DM 未读消息、展开相邻上下文并原位回复。Channel owner 按该 Human 的可见范围返回 timeline,并在发送时再次验证回复目标;Inbox 复用现有 `channelSend`,只提交一条带来源消息引用的 Human Source Event。草稿与重试身份仅是 Client 临时状态;Inbox 刷新或发送失败保留草稿,未修改内容的重试复用同一消息身份。看到具体来源内容推进其权威已读位置,展开未读摘要不推进。来源及已确认回复均保留准确的 Channel 消息导航。 - -#550 切片允许 Human 在 Inbox 同一来源上下文面板中查看并决定实时工具审批,复用 DM 审批卡及既有 `toolApprovalStatus`/`toolApprovalDecide` Bridge 命令。认证 Host 再次检查来源请求和实时授权范围,提交权威 Channel 审批决定,并只恢复对应原生调用。批准、拒绝或过期后该请求离开待行动投影,其他 Bot 的请求保持独立,默认最久等待优先。成功或过期失败后 Client 刷新 Inbox 与独立行动提示,并从保留的旧分页中移除已确认解决的项。有界附近消息与准确来源跳转沿用现有 Channel timeline 边界,不新增审批存储或生命周期;已处理历史由 #553 投影。 - -#553 从已提交的 typed Human 回应 Source Event,经 Channel placement 关联原始提问、审批、Grant 请求或 Assignment 报告,投影已处理历史。同一请求 Source Event 只保留第一份权威回应的历史行,携带请求、回答、回应时间、Bot 与来源 DM;不复制消息、不增加历史表。游标绑定类别、Bot、Channel 和排序;阅读消息或忽略完成报告不产生已处理行动。Assignment 原生 Inbox 接收答复后才移除 Human 待办;仅收到 Human DM 保留转发地址和未解除待办;旧 blocked ask 已有权威 Human 答复时,后续明确报告建立新请求。历史上下文独立查询准确回答,不依赖请求附近各两条消息或已过期的实时 broker 推断已处理状态。Client 丢弃旧筛选结果,每次刷新最多重查三页、每页 50 项;更多记录可沿刷新后的游标再次加载。Assignment 上下文高亮准确报告,「查看 Session」进入原始 DSH Session,「查看答复」定位所属 DM 的准确回答。 - -#551 在同一 Inbox 来源面板支持实时原生提问,复用来源 DM 提问卡的准确问题、选项与自定义输入,以及既有 `userQuestionStatus`/`userQuestionAnswer` Bridge 操作。拥有请求的 Channel question broker 校验实时 Orchestrator、目标与答案,提交一次权威回答并恢复原生请求。Client 与工具审批共享决定后的刷新,从保留分页移除已确认回答或过期请求,其他 Bot 保持独立。有界上下文与准确来源跳转沿用既有 Channel timeline,请求生命周期及已处理历史仍归各自既有边界。 - -### 本地 Human 名称目标设计(ADR-0103) - -本地 Human 在一个 DSH Profile 内有一个可选默认显示名,在 BotHarness 插件设置中编辑,未设置时使用 `Human`。Human 参与的每个 DM 或 Group Channel 都可用 **Human Channel nickname** 覆盖默认名,通过 Channel 头部菜单中的「我的昵称」编辑。清除昵称恢复继承;修改默认名只影响没有覆盖值的 Channel。这支持 Human 与不同角色聊天时使用不同称呼,本次只保存昵称,不增加角色背景、另一个 Human 账号或另一份 Human Inbox。 - -既有应用自定义 Messaging 权威拥有默认名与 Channel 覆盖值。Host 按 Channel 昵称 → Human 默认显示名 → `Human` 解析名称,浏览器 tab 不拥有独立副本。名字修改经既有 Typert/API Gateway seam 指向 Host-owned 本地 Human。既有成员记录中的默认 `Human` 不视为用户主动设置的 Channel 昵称。Channel 作者名、成员、提及选择器、收件明细及 Human Inbox 上下文使用同一有效名称;Bot 获得的 Channel 成员及新组装或明确查询的消息上下文也使用它。昵称不授予权限,也不提供角色扮演指令;既有 DSH SessionEvent 与已组装的模型输入仍保留执行历史。 - -Human 与 PersonaBot 的提及保存带类型的稳定目标,在显示时解析可见标签,包括历史消息。Human 名称取消息所属 Channel 的当前有效名称,Human Inbox 中也按来源 Channel 解析;PersonaBot 名称取其身份的当前名称。已知目标优先使用当前名字;目标不可用时可保留记录的标签作为展示回退,绝不按名字改指另一个目标。普通文本不会被重新解释为可信提及。Source Event 内容与原提及范围保持不变,显示标签长度变化不修改持久 offsets,也不产生 Source Revision、新通知、Bot Admission 或 wake。 - -名字允许重复,包括同一 Channel 中的 Human 与 PersonaBot 同名;成员与提及界面区分 Human/你和 PersonaBot,并保留目标 ID。Channel 昵称始终标记同一个 Human ID,阅读位置、行动与提及仍归同一份 Human Inbox。外部账号映射与多人登录留待后续 Bridge 工作。默认名路径使用 Messaging 的 `local_human_names` 记录及受信 `humanIdentity`/`humanNameSet` Bridge 操作;Channel 摘要投影当前 Human 成员,作者、回执和可信提及按其与 PersonaBot 当前身份解析名称。Bot 的 Channel 读取在原消息旁提供带类型的 `actorNames`,仍受既有输出预算约束。名称提交沿用 roster 实时通知,不产生 Channel placement 或注意力事实。各 Channel 覆盖使用 Messaging 的 `channel_human_nicknames`,以 Channel ID 与 Human ID 为键;`channelHumanNameSet` 检查当前 Human 参与资格,拒绝只读 Bot-to-Bot 观察。DM/群聊头部菜单经现有 Bridge 编辑或清除覆盖。明确昵称即使文字与默认名相同也独立保存;清除覆盖后恢复继承(#622)。按 ID 引用提及可参考 [Slack 官方提及语法](https://docs.slack.dev/messaging/formatting-message-text/);Channel 昵称覆盖来自本地 roleplay 使用场景。 - -### 活动中心目标设计(ADR-0098、ADR-0099) - -#2026 将活动中心收束到个人 Human Inbox,完整页面和 Dock 使用同一原位工具审批卡,移除总览切换及用量、活跃度等统计呈现。首个切片通过现有 Human Attention 查询定位所属 Channel 消息,再读取权威审批状态;每次提交仍由原有 Channel Tool Approval owner 判断是否待处理。卡片默认提供允许一次、精确规则与拒绝;全范围授权保留在来源聊天。上下文在卡片下展开,摘要不推进已读;批量操作仅在显式选择模式出现。成功后由权威列表移除驱动约 220ms 的不可交互退场,失败原位保留并可重试,不向下一项转移审批焦点。此切片不新增 Host API、持久化表或审批生命周期;三组折叠列表与其搜索排序留待首个切片的 Human QA 后继续。下文总览、Channel 活跃度及 #698/#703/#705 的 UI 描述记录旧交付;查询能力保留,但不再作为当前活动中心的入口。 - -#548 切片从已加入 Group 的 Source Event 与同 Channel placement 的 `replyTo` 关系投影「回复我」:只包含 Bot 对本地 Human 可见消息的直接回复,仍绑定该 Human 的成员可见范围。每条回复使用 Source Event ID 稳定标识,按最近活动排序并允许 Bot/Channel 过滤,已读后保留浏览并从权威读位置计算未读标识。个人回复不重复进入「其他未读」Channel 汇总,入口未读总数仍统计所有不同的未读 Source Event。两个 Client 窗口及 Host 重启均从同一查询重建。原位回复和准确来源导航复用 #547 路径;上下文按原始时间顺序展示作者、头像、时间及回复目标,可展开有界相邻消息,宽屏采用列表与上下文并列,窄屏堆叠。 - -#549 切片将个人视图扩展为「提及与回复」。Orchestrator 通过 `channel_list` 发现当前 Group Human 成员,再用 `channel_send.mention_human_ids` 指定其稳定身份。Channel owner 验证当前成员关系,在已有 Source Event payload 内提交 Human 目标与显示偏移;普通文本不提供身份。一条 Bot 消息同时提及并直接回复本地 Human 时,个人视图只列一项,未读总数只计一次。现有 `replies` RPC category 和 Source Event item 身份保持兼容,可信提及用 `channel-mention` 区分。两种原因共用最近优先排序、Bot/Channel 筛选、已读状态、有界时间序上下文、原位回复及准确导航。Human 提及元数据不改变 Bot Admission 或 Wake Policy;范围仍是单一本地 Human,不新增全体 Human 广播或账号配置。 - -#123 审批提示切片通过现有版本化 Activity 快照及实时流独立发布正数 `attention.approvalCount`,不改变执行状态。process-local 来源是拥有原生工具审批的 broker:仅在权威 Channel 卡片提交后计数,决定、取消、scope 失效或 Fiber 销毁时,先移除提示再恢复 Tool 答案。侧栏、输入框与总览头像共用这个有界数量;请求身份及原始载荷仍只留在所属审批界面。新 Host 没有 live 审批,重启不从历史请求卡片复活提示。原生问题 broker 同样只在卡片提交后发布 `questionCount`。限时问题卡按 Session 和调用定位,不依赖提问的 Agent:Agent 释放后仍计数;新 Host 会重新打开原 Orchestrator Session 仍持有原生问题的未决限时卡片,回答经 Bot 的轮次队列、以卡片自身的 Source Event 为锚点恢复该 Session([ADR-0187](../adr/0187-personabots-use-a-product-preset-with-timed-questions.md))。其他 attention 类别及明确 waiting-on-Assignment 留待后续切片。 - -首个总览切片(#541)通过 `botharness/activityOverview` 读取现有 Human attention 投影的完整明确行动数,并连接 PersonaBot Registry、根 Session 归属、当前 activity 和 DSH 原生 live Agent 状态。首个切片当时将原生提问/审批等待和空闲 Assignment 的未答请求显示为等待或受阻;当前总览则将 Human 待办与执行状态分开,如上文所述。只有 live 且 thinking/working 的根 Session 进入正在执行列表,重启后不把旧日志的执行状态当作当前运行。视图拥有每五秒的查询刷新及卸载清理,离开视图后的旧响应不会覆盖新状态;无新增数据库表或执行权威。 - -在 #2026 之前,Bot 模式用一个「活动中心」入口承载「总览」和个人「收件箱」两个视图;前述 Human Inbox 段落记录已交付的首批投影,以下是后续目标。总览给出未解决的明确 Human 行动数、各 PersonaBot 实时状态,以及正在执行模型或工具的 Orchestrator/Assignment Session;点击 Bot 进入其私聊,点击任一 Session 退出 Bot 模式并打开 DSH 原始模式中的对应 Session。等待、受阻与空闲不计为活跃 Session。今日 Channel 活跃度按已提交消息数统计,主图逐 Channel 区分 Human/Bot,展开后按发送者查看;全局及逐 Bot token 用量可看近七天趋势,不按 Channel 猜测归因;Memory 展示逐 Bot 近七天已提交变更次数和当前未提交提示。各卡片消费各自权威的读模型,不另建消息、用量或运行事实账本(#34、#39、#424)。 - -收件箱面向当前本地 Human;已读事实按 Human 身份寻址,为将来多 Human 留出同一语义,但本切片不交付多账号。明确待行动、提及与回复、其余未读、信息更新和已处理历史分开呈现。待行动只计仍需 Human 决定或解除阻塞的 typed 请求,按等待最久排序;提及及未读按最近活动排序。繁忙 Channel 的未读折叠为一个 Channel 摘要,提及可定位准确消息;原生 Channel 尚无 Thread 子对话,不按 Thread 分组。展开摘要不推进已读位置;实际看到具体消息,或明确标记已读时,才在权威 Channel 读位置推进到相应消息。活动中心入口数字只计去重后的未读 Source Event,另用独立提示表示未解决行动;同一消息即使关联多个 Bot、兼属报告或提及,也只在 Human Channel attention 里出现一次,多个 Bot 分别提出的真实请求则各有行动卡。 - -行动卡在收件箱内显示问题、选项或输入框与简短可展开来源上下文;Human 可沿用来源处相同的授权命令直接回答,也可跳转原消息。已失效或被他人解决的卡片不允许继续提交;处理后从待行动移入可筛选的已处理历史,仍链接权威请求与回答。群组可回应请求须显式指明合格 Human 范围,首份有效回答解决同一请求,不暗含全员提及。#126 下一条 tracer 先交付本地 Human 的 Group 未读汇总、准确来源定位和已读;Human 提及/回复与 Inbox 内回应分步扩展。当前 ChannelMention 只记录 Bot,Human 提及需新的可信目标身份。跨 Bot 总览由 #541 交付,Bot Inbox 仍只负责选中 PersonaBot 的事件时间线。 - -Human 在 Group 中选择「@所有 Bot」时,Host 在发送时把当前 Channel 的活跃 Bot 成员解析为逐个直接提及:只提交一条 Channel Source Event,每位目标 Bot 获得与手动单独 @ 相同的 Inbox Admission、attention 与 wake;发送前向 Human 显示实际目标人数。它既不访问其他 Channel 的 Bot,也不允许 Bot 使用该快捷广播,不引入新的 Wake Policy。此输入能力由 #542 单独交付,不改变上述 Human 未读去重。 - -Bot-to-Bot DM 是两个 PersonaBot 参与的真实 `dm` Channel。Bot A 通过可信 Session ownership 以自己的 Actor 身份向 B 发送消息;Messaging 在同一权威中提交 Source Event、Channel placement 与 B 的 Inbox Admission,Bot-hop guard 限制循环,A 不接收自己的输出。Human 可以只读打开此类默认不在 roster 显示的 Channel,但不会成为第三位成员。A 每次向非 Human DM Channel 发出已提交消息时,引起这次发送的 Channel(群、Human DM 或 Bot DM;不可用时为 Human–A DM)都会出现居中的 Channel Notice,指向这次发信与可查看的对话,而不复制正文;A 的 Bot Inbox 同时得到一条已处理、永不唤醒的记录(ADR-0154)。 - -Human–PersonaBot DM 是这个 Bot 的时间线(ADR-0182):Bot 在群或 Bot DM 中发言时,同一事务在它的 Human DM 写入一条只含 `{ channelId, messageId }` 的 `botChannelPost` Channel Notice,摘录在读取时从原 Source Event 投影,不复制正文;原消息不可用时显示不可用。起因在别处的 Memory commit 记录除了留在起因 Channel,也在 Human DM 增加一个指向同一 Source Event 的 placement,读取时以 `noticeOrigin` 标出来源。Human DM 内的发言、以及已落在 Human DM 的 `botDmAction` 不再重复写入。Client 在 DM 聊天顶部 ··· 菜单中按动作类型(发言、记忆更新)提供只影响本地显示的开关,默认全部显示;同一 Channel 的连续发言合并为一行。 - -应用定义的 `list_bot_contacts` Tool 在所属 Orchestrator 的 Agent Scope 中读取同一 PersonaBot Registry。`query` 对名称、完整简介及稳定 ID 做不区分大小写的子串搜索,最多 200 字;也可直接浏览,默认每页 20 位,`limit` 为 1–50。将 `nextCursor` 原样作为 `cursor`,保持 query 不变;游标绑定所属 Bot 和规范化筛选,结果按稳定 ID 的 ordinal 升序排列。分页读取当前资料:名单及匹配状态不变时续页不重复、不漏人,改名不改变 ID 排序,移除/暂停者不再返回;游标之前的新建或新匹配身份需要重新搜索。结果包含 `botId`、最多 128 字的名称和最多 160 字的简介预览,并显式标记截断;含续页信息的完整 JSON 页最多 12,000 个 UTF-16 字符,因此可少于请求条数。仅传 `bot_id` 可读取单个活跃同事最多 1,000 字的简介预览。资料始终是非可信数据,不提供 Soul、私有 Memory 或凭据;重名用 ID 区分。返回的 `botId` 可原样用于 `bot_dm_send`、群邀请与 mention,发送时仍由 Messaging 重查目标活跃状态及群成员权限;发现过程不引入第二份目录或权限权威([#568](https://github.com/BotHarness/BotHarness/issues/568))。 - -Human–A DM 里选中 `@B` 会在 Human 消息中持久保存 B 的稳定 ID 与文字范围;Host 在 A 的 Orchestrator 回合组装输入时重新读取 B 的当前名称与最多 400 字的简介,并把它们作为有界联系人资料提供给 A。重名靠 ID 区分,改名采用当前资料,已归档或失效的目标在发送时被拒绝;手打或粘贴的 `@名字` 只是普通文字。此操作不唤醒 B 或改变 DM 成员;只有 A 后续显式发送 Bot-to-Bot DM 消息才触发 B 的 Inbox。Group Channel 中 Human 或已加入的 Bot 可 `@` 已加入的 Bot;一条消息仍只有一个 Source Event,每位目标 Bot 独立获得 Inbox Admission。Bot 创建 Group 时,Host 从 Orchestrator ownership 确定创建者,初始成员只有该 Bot;创建者可邀请活跃非成员 Bot。邀请状态存于同一 SQLite Channel record,创建待处理邀请、无 Channel placement 的 system Source Event 和受邀 Bot 的 `group-invite` Inbox Admission 在一个事务内提交。邀请携带可信的 Bot 协作跳数和受邀 Bot 的创建时间,跨 Bot 唤醒不重置循环限制,删除后重建的同名 Bot 也不能继承旧邀请。受邀者收到自己的 DM 上下文中的邀请 prompt,但在接受前没有 Group read/send 权限;接受把 membership 与邀请状态同时更新,拒绝不增加成员。Bot 模式提供默认开启的 Group 邀请自动接受(ADR-0073):设置由原生 Profile Config `botharness-client.autoAcceptGroupInvites` 持久保存,Client Fiber 将其实时读取器绑定到拥有邀请事务的 Core;只影响新邀请。开启时同一事务写入成员关系、accepted 邀请、system Source Event 与 handled Admission,并记录 `respondedBy=profile-policy`,不启动唤醒、不伪造 observed;关闭时保留上述逐邀请决定,Bot 的接受或拒绝也在同一事务结束邀请 Admission。启动时依据已有 accepted/declined 邀请补齐旧 Admission 的终态,避免重投或重启重复唤醒。重复邀请与重复同向决定幂等,归档目标时取消待处理邀请,重启时恢复仍待处理的 Admission。创建者可改群名、移出其他 Bot 成员;任一已入群 Bot 可用 `group_leave` 自行退出,成员关系在同一 Channel record 中更新,读写权限立即撤销,待处理群消息不再唤醒它;若创建者退出,Bot 管理权消失、成员界面标明由 Human 管理,其余成员与历史仍保留。Bot 自主退出或任何现有移出成员操作,都会在同一事务中提交一条 Host 撰写的群内成员变动消息,分别记录“离开”和“被移出”的类型与正文,供留在群内的人查看,并给每位仍在群内且活跃的 Bot 建立独立的普通消息 Inbox Admission,记录各自当时的 Channel 注意力偏好;退出者没有 Admission。all 可即时唤醒,digest 到达数量或时间条件后唤醒,mentions 可随之后的直接提及进入回合,silent 仅在显式读取时处理。进入回合的成员变动提示标明 Channel system,Bot 可按需回复。提交后的界面实时发布与 Bot 唤醒彼此独立;唤醒通知失败时运行中的 Bot Runtime 按有界退避重试持久 Admission,重启后仍由启动扫描恢复。Human 的群聊侧栏将成员名单与群管理分区:成员区标题的加号打开可搜索的邀请弹窗,带待办数字的铃铛打开邀请与入群申请处理弹窗;成员行左键打开 Bot 私聊,右侧菜单或右键菜单提供私聊、消息提醒策略弹窗与移出群聊。群管理区只呈现群头像、群名,次级菜单提供解散命令;Human 裁切 1:1 头像后将有界 WebP 图像写入同一 Channel record,Host 仍校验图像。邀请仍通过同一 Channel 邀请事实及 Bot Inbox Admission;Human 独占整群逻辑删除命令。关闭自动接受时,Human 发起的邀请在 Bot 接受前不授予群访问权限;删除撤销成员访问,保留 Source Event 等运行证据而不作破坏性清除。首个 Group Human `@` 切片是 #254,后续协作切片由 #278 组织。 - -Human 在自己的 PersonaBot DM 输入框中从 `#` 候选选中 Group,消息才记录稳定 Channel ID 与选中文字范围;Human 已发消息中的引用可点击进入该 Group。Host 在该 Bot 的 Orchestrator 输入中重查当前名称,非成员只收到 ID、名称和是否已加入,不读取成员或历史,普通手打的 `#名称` 没有引用身份。Bot 可在该 Human DM 所触发的回合用 `group_join_request` 请求加入选中的 Group;请求本身不会改变 membership。待处理申请保存在同一 Channel record,Bot 创建者收到独立的 system Source Event 与 Bot Inbox Admission,Human 在成员区通知弹窗可批准或拒绝,创建者可用 `group_join_decide` 决定。首个决定与成员更新、申请者的结果通知在本地事务中提交;申请者收到结果后才能按现有成员规则使用 `channel_read` 和 `channel_send`。重试、重启、改名、同名群都使用稳定 ID;归档申请者或删除群会取消待处理申请(ADR-0069、#292)。 - -应用定义的 `group_rename` 与 `group_remove_member` 工具只返回已提交 Channel 的简短确认:`channelId`、当前 `name` 与 `outcome`(`renamed` 或 `member-removed`);移除成员还包含 `memberBotId`。确认中不含头像、邀请/入群申请历史、成员列表或提醒策略。所属 Host 命令仍检查当前 Bot 创建者及成员身份,通过 Channel authority 提交;Human bridge 继续返回完整呈现记录。改名持久化未返回记录时,工具明确失败。查看当前已加入的 Channel 与成员身份应使用 `channel_list`,不能把命令确认当作群快照。 - -`group_create` 确认新建的 `channelId`、当前 `name` 与 `outcome: created`。`group_invite_bot` 确认已检查的 `channelId`、`inviteId`、`inviteeBotId`,并以所属命令返回的实际邀请状态作为 `outcome`,不假定所有结果都为 pending。`group_invite_respond` 返回同样的引用与实际决定状态,并附当前群 `name`。这些确认不包含内部身份版本时间戳或无关的 Channel 呈现状态。拒绝邀请不授予成员、读取或发送权限;重复相同决定保持相同引用,冲突、旧身份、已取消或未获授权的决定保留所属命令的错误。 - -`group_join_request` 确认当前 Human DM 已选中的 `channelId`、`requestId`、`requesterBotId` 与所属命令实际返回的申请状态 `outcome`;`group_join_decide` 返回同样的引用和实际决定状态,并附当前群 `name`。申请确认不要求非成员群出现在已加入的 Channel 查询中,也不授予读取或发送权限。确认不包含完整群记录、成员列表、头像、提醒策略或内部身份版本时间戳;首个决定、成员更新和申请者通知仍由同一 Channel authority 提交。重复同向决定保留相同引用且不再次通知,冲突决定、已归档或旧身份申请者、失去管理权的创建者继续由所属命令拒绝。 - -Orchestrator 的应用定义 channel_list 工具从当前 PersonaBot 的 Session ownership 派生身份,只返回其已加入的 Group、Human DM 和 Bot DM;可按名称、Channel 类型、稳定 Bot 成员 ID 筛选并分页,结果带当前成员身份。它是 canonical Channel record 的授权查询 Consumer,不创建第二份成员目录。Bot 用返回的稳定 Channel ID 调用 channel_send;发送时仍重新检查当前成员资格,旧查询结果不会授予访问权。应用定义的 channel_read 查询在成员校验后对该 Channel 的完整有序消息历史应用正文、作者及日期过滤,再返回有界游标页;回复预览仍解析自原消息,不因过滤失去引用。`channel_read(scope=joined, text=...)` 对当前已加入的 Channel 进行跨频道正文搜索,结果按时间与稳定 ID 排序并分页;成员关系变化会使旧游标失效。原先仅扫描每个 Channel 最近 200 条的 `channel_search` 工具已移除,Bot 不再面对两个含义重叠的搜索入口。 - -应用定义的 Channel 查询工具枚举 `type: group|dm`、`scope: channel|joined` 和 `author_kind: human|bot|bridged|system`,所属 Host 仍防御非法值。`channel_list(channel_id=...)` 在全部过滤后没有可访问匹配时返回 `{channels: [], outcome: no-accessible-match}`;未知、不可访问或与其它过滤不符的目标使用同一确认,不泄露存在性,普通空搜索仍返回 `{channels: []}`。`channel_read(scope=joined)` 必须有非空白 `text`,不能同时指定 `channel_id`;`author_bot_id` 只能与省略或 `bot` 的作者类型组合。日期界限包含端点,日期形式 `YYYY-MM-DD` 的下界为 UTC 当日零时、上界包含整个 UTC 日;不可解析或倒序范围失败。游标仍绑定原过滤,跨已加入 Channel 查询还绑定当前成员集合;读取仅观察实际返回的消息。两个工具的 `limit` 保留 number 与既有兼容行为:默认 20,向下取整后夹取至 list 的 1–100 或 read 的 1–200。固定 DSH 0.2.0-rc.1 转换器支持 integer,但不支持 minimum/maximum;本票不收紧已有小数和越界输入。 - -模型侧 `channel_read` Consumer 使用 Host 所有的可行动消息投影,在精确 ID observation 前对完整序列化结果施加 12,000 个 UTF-16 code unit 的输出预算(ADR-0102)。投影去除 Human receipts、deliveries 和 Channel revision,保留完整正文、回复、可信附件及行动引用。因预算未返回的消息仍为 pending,并提供绑定原过滤的继续游标;首条超长消息提供 `message_id` 完整内容读取路径。有界 JSON 片段绑定当前投影的哈希及偏移,每次复查成员资格,只有同一活跃 turn 接收到完整连续内容后才加入消费集合。Human Channel / Inbox canonical records 和 bridge 呈现保持不变。 - -### 外部 CLI 的在线请求与回复 - -应用定义的在线 CLI 依照 ADR-0159,每次从当前 Host 启动令牌换取内存 cookie,经既有 DSH HTTP unary carrier 调用 Typert Bridge;不另设监听器、凭据缓存或数据库旁路。发送先由 Registry 查询确认 Bot,再由 Channel owner 建立 DM,使用 `human-UUID` 消息 ID 提交;重试去重仍归 Host。新增 `channelSendStatus` 只读查询复用 `source_events`、`inbox_admissions`、`channel_output_origins` 和 `session_ownership`,按原请求 Source Event、同一 DM 与 Bot 所有的 Session 返回已提交回复和当前处理状态,不以时间窗口或最新一条消息猜测对应关系。问题/审批卡片、通知及失败不算回复;超时保留回执供查询,不自动重发。工具审批、正式问题、工作区授权和发布状态沿用各自 Host owner;无新增耐久表或权限权威。见 [CLI 指南](../bot-cli.zh.md) 与 [#1317](https://github.com/BotHarness/DeepSeekBot/issues/1317)。 - -依照 [ADR-0162](../adr/0162-online-cli-management-keeps-explicit-host-authority.md),显式 Host 选择扩展到 Registry 创建/查询、Model Preset/Plan 与 Channel 发现。Zip/目录由 CLI 上传调用者本地字节,经已有 authenticated Bot Zip import owner 验证和创建;Git 获取仍在 Host 执行。创建和模型应用是分阶段 owner 操作,部分失败保留已知身份,未知结果不自动重试;连接失败不切换到离线 Profile。attention、Session 与 activity 查询只读既有投影,提供有界调试摘要;测试脚本经同一业务 CLI 驱动精确请求与持久回复,并可显式调用删除 owner 的 preview/confirm/retry 清理本轮身份,保留 Memory。writer lease 不构成 RPC 全局串行锁,各 owner 的版本/HEAD 与幂等契约保持独立。见 [#1363](https://github.com/BotHarness/DeepSeekBot/issues/1363)。 - -### CLI 的 IM 应用授权 - -应用定义的 CLI Consumer 通过既有 Messaging Service 的 `messagingApps` 发现已资格验证的 Provider setup descriptor,再经同一个已认证 DSH HTTP carrier 调用原生 Connection Fetch Registry 的 `dsh-im/app-setup` endpoint。该 Provider 拥有授权 attempt、过期和原生账号/凭据持久化;CLI 只保留一次调用的内存 cookie,不创建额外 store 或生命周期。飞书凭据和微信验证码仅从 stdin 进入目的明确的 `setup.credentials`/`setup.verify` payload;stdout 只投影经过校验的非敏感状态、二维码、账号引用与指纹。微信扫码由 Human 在手机确认,再用同一 attempt 查询;IM 应用授权不选择 PersonaBot 或建立 Messaging Grant。见 [ADR-0161](../adr/0161-cli-im-authorization-keeps-provider-attempt-authority.md) 与 [#1318](https://github.com/BotHarness/DeepSeekBot/issues/1318)。 - -### 配对设备的远程访问 - -[ADR-0199](../adr/0199-paired-devices-reach-the-host-through-a-same-listener-device-gateway.md) 已由 [#1652](https://github.com/BotHarness/DeepSeekBot/issues/1652) 实现:配对的手机不持有任何 DSH 凭据。应用定义的 Device Pairing 模块(`packages/core/src/devices/`)经 DSH 公开的 `webServer.register`/`registerUpgrade` 在同一 `dsh web` 监听器上提供设备网关:设备在 BotHarness 前缀下以 `Authorization: Device .` 调用与 Web Client 相同的 unary 信封和 Remote stream mux 帧;模块每次按运维数据库中的设备记录(只存哈希)校验并更新最近使用时间,再用 Host 内存中经 `connection.authenticatedUrl()` 自行换取的 loopback cookie 转发至原生 `/api` 与 `/api/remote.mux`。DSH 签名 cookie 的密钥本就跨 Host 重启保留,但 cookie 不标识设备,且 stream 升级不经过 `connection/request`,故不能把 cookie 交给设备。撤销立即拒绝后续请求(编码 401:`device-revoked`/`device-unknown`/`device-credential-missing`),并关闭该设备已打开的 stream socket。配对邀请、列表与撤销只在已认证的 `/api` carrier 上提供给 Human:Web Client「设置 → 设备」显示一次性、10 分钟有效的二维码(即 `deepseekbot://pair?p=…` 深链接,携带 Host 标识与按序路由),列出设备的配对与最近使用时间,并可撤销。见 [#1644](https://github.com/BotHarness/DeepSeekBot/issues/1644)。 - -托管 Connect(目标状态,尚未实现):[ADR-0241](../adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md)([#2387](https://github.com/BotHarness/DeepSeekBot/issues/2387))在路由列表里增加一条 Connect 路由,顺序为 局域网 → Tailscale HTTPS → Connect。账号复用 TOB 的 WorkOS 环境,只用于发现 Host 和经纪一次性配对凭据;本仓库新建的中继 Worker 为每台开启 Connect 的 Host 代管一条 Cloudflare Tunnel,ingress 只放行 `/botharness-device/*`,应用流量不经过 Worker。手机登录后无需在电脑上确认即可配对,新设备会在电脑和其他手机上提示并可一键吊销。先在中国大陆以外实现并验收,大陆可达性之后另找测试者验证,不阻塞实现。 - -### 手机推送 - -[ADR-0202](../adr/0202-phone-push-is-a-content-free-projection-of-the-human-inbox.md)([#1655](https://github.com/BotHarness/DeepSeekBot/issues/1655)):应用定义的 Push delivery 模块(`packages/core/src/push/`)是 Human Inbox 的投影,不另设通知权威。Human Inbox 表或 PersonaBot 的审批、提问、私密输入计数变化时(以及每分钟一次的兜底扫描),模块读取 `action` 项与未读的 `replies` 项,对每台已登记的配对设备只发送一次新项,并经 Expo Push Service 投递;推送标题为频道名(`#名称`,私信为 PersonaBot 名称),正文为所需操作加上工具与主要参数、问题或消息的简短预览(折叠空白,最多 140 字符);私密输入请求不含内容([ADR-0213](../adr/0213-phone-push-shows-the-channel-and-a-short-preview.md))。数据字段只含 Host 标识、Human Inbox 项标识、PersonaBot 标识与读取请求所需的 Channel/消息标识,并携带未处理 Human 操作数作为角标。设备经设备网关的 `/botharness-device/push` 登记 Expo push token、切换各类通知、发送测试通知;撤销设备或 Expo 回报 `DeviceNotRegistered` 时删除 token。发送失败有界重试,结果写入不含内容的 `push` 开发者日志。App 保存每台配对的 Host,点按通知先切换到对应 Host,再打开该审批、提问或私信。 - -## 5 · Orchestrator 与 Assignment control plane - -Human 不负责创建或选择执行 Conversation。Human–PersonaBot DM 是 Human 与该 Bot 直接对话的入口:消息先成为 Source Event,经 Bot Inbox 交给 Orchestrator;Orchestrator 再决定直接回复,或在授权与 capacity 内创建、复用和管理多个 Assignment Session。普通 Orchestrator assistant final 只留在 DSH SessionPersistence;只有显式 Channel messaging command 才产生 Human-facing Channel message。该 command 从可信 Session ownership 推导 PersonaBot Actor,并验证目标 Channel membership,不接受模型自报 bot id 或 author。右侧「会话」只投影明确归属该 PersonaBot 的独立 DSH 根 Session,包括 Orchestrator 与 Assignment,不展示 Subagent;归属与角色来自 Session Ownership,标题、工作区及实时运行状态来自 DSH 原生 Session 目录,不以 cwd 推断归属(ADR-0072)。 - -Workspace Grant 是 application-defined 的持久授权记录:Human 通过 DSH 的 Host 目录选择器或绝对路径输入添加一个现有文件夹,由 DSH Workspace registry 解析真实路径,再为 PersonaBot 创建 Grant。Orchestrator 的 cwd 始终是自己的 Memory Repository;它可读写 Memory,并可读取所有当前有效 Grant 的项目目录,只在 Human 为该 Grant 明确开启 Orchestrator 写入权限后可写项目目录,不能自行创建 Grant 或开启自己的写入权限。每个 Assignment 选择一个有效 Grant,固化 Grant ID、Workspace ID、单一 primary cwd 与权限快照,只能读写所选目录。撤销 Grant 会阻止两个角色后续的文件访问及该 Grant 的新事项创建、请求和恢复;历史 Session 不因重新授权而复活。授权列表中 Memory 是固定内部目录,项目目录可添加、撤销,撤销不会删除 DSH Workspace 或磁盘文件。DSH 原生 workspace-write 仅限制部分写入并不隔离读取,且可能允许临时目录写入。BotHarness 在最终 Tool guard 对 DSH 原生 read、read_image、write、edit、str_replace_editor、glob、grep 的路径按当前 Grant 校验:Orchestrator 可读 Memory 与所有有效 Grant、可写 Memory 与 Human 明确开启 Orchestrator 写入权限的 Grant;Assignment 只可读写固化的单一有效 Grant。Shell 与插件工具按 Session 自身的 DSH 沙箱模式与审批策略运行;DSH 发起的审批(例如 Shell 请求 `sandbox_permissions`)进入应用定义的 Channel Tool Approval owner。普通 Web 卡只有允许这次/始终允许/拒绝;新规则记住同一 Bot、根角色和当前授权范围内的当前工具,输入可变,旧精确与宽规则保持原义。每次原生询问仍获得独立的 `allowed-once` 审计;规则撤销或范围变化使其失效。工具批准不等于目录隔离,也不创建 Grant;Shell 可能触达目录之外,界面需说明。每个 PersonaBot 的 Access Preset 默认为 `workspace-write + ask`;Human 经既有风险确认可开启 `danger-full-access + ask`,按所选 scope 作用于当前 Orchestrator 和/或之后新建的 Assignment。已有 Assignment 保留其 mode/Grant/revision 快照;本次迁移只规范旧 Full Access 的 approval policy。Full Access 普通请求自动回答,识别出的权限/策略变更必须 Human 本次决定;撤销、当前身份和独立能力检查仍适用。详见下文三动作边界及 [ADR-0239](../adr/0239-three-action-tool-approval-and-full-access-use-native-ask.md)。 - -### Three-action tool approval and Full Access (#2080) - -Ordinary Web approval uses Allow once / Always allow / Reject. The application-defined Channel Tool Approval owner records `allowed-always-tool` with the Human decision, activates a revocable `tool` rule in the existing Operational Database, then returns DSH's native `allowed-once`. A tool rule matches the current Bot, root role, authority scope and tool name across changed inputs. Exact, all-opaque and Secret rules keep their old semantics; new ordinary cards do not mint all-tools authority. Current Grant/scope checks, execution guards and rule revocation remain authoritative. The Web Channel, Activity/companion reuse, online CLI and readable mobile history share that decision contract. - -Top-level Full Access composes its existing unconfined execution mode with native `ask`, replacing the earlier `never` combination. The same responder automatically answers ordinary requests only while current Full Access remains valid. Recognized permission/policy management always requires an individual Human decision and skips automatic rules and Reviewer; directory grants still use the authenticated Owner read/write command. Generation 104 preserves existing rules and updates only old Full Access Assignment approval snapshots, without changing mode, scope, Grant or preset revision. Delegated Subagent policy remains native `never`. This is application policy rather than an override of DSH's `never` semantics. See [ADR-0239](../adr/0239-three-action-tool-approval-and-full-access-use-native-ask.md). - -当所需项目尚无有效 Grant 时,Orchestrator 可通过专用工具在自己的 DM 提交一条持久授权请求卡;该工具只能请求,不能发放 Grant,也不会预建 Assignment。Human 在卡上明确确认文件夹读写后,认证 Owner 的共享命令核对当前身份与目录,在单个 Channel 事务中提交可写 Grant、关联 Human 决议和 Inbox Admission;提交并读回后才续接原任务。重复确认沿用已提交的决议,撤销后的旧请求不能重建权限。回复携带经 Host 核验的请求消息 ID 与有效 Grant ID;Human Inbox 从请求和回复 Source Event 投影一条待办,普通回复不解除,授权回复解除。包括历史请求在内,仅有关联有效 Grant 的已提交结构化回复解除授权待办;原始文本保留,普通“已授权”文案不作为授权证明。Inbox 与来源 DM 复用同一目录选择及授权操作,提交事务重新核验请求与 Bot Grant,两窗口只能提交一条有效回应。Orchestrator 重新列出有效 Grant,随后才用选中的 Grant 创建 Assignment;卡片和授权记录是不同的持久事实。普通 Assignment Ask 仍先回到 Orchestrator。Orchestrator 遇到 Human 未指明的 Memory 分支选择时,调用 DSH 原生 `ask_user_question`;BotHarness 在该 Agent 作用域的 `user-questions/request` waterfall 中提交持久 Channel 提问卡,Human 的选项或自定义回答经同一 DM 回到原生 Service,使同一 Session 继续。卡片保留来源 Session,回答和取消各有持久消息;停止、重启或旧请求不得再次回答。明确目标直接切换,不提问。Assignment 不直接向 Human 使用此原生工具,仍通过 Orchestrator 转问。 - -右侧是 Channel sidebar(ADR-0053):group Channel 显示成员与 Channel 管理 entries,Human–PersonaBot DM 显示该 PersonaBot 的 entries(会话、Memory、Bot Inbox、Computer 等);entries 由统一注册 seam 提供、可折叠、按声明顺序排列,注册描述符可提供 Lucide 图标名称与标题操作;会话、Bot 收件箱和记忆演化的显示设置放在各自标题右侧的齿轮里,会话范围/布局保持 per-PersonaBot 浏览器偏好、记忆术语保持全局浏览器偏好,未注册或不可用时直接不显示而不是占位。Chat 始终是中间的 Channel body。「会话」把 Orchestrator 固定在顶部,Assignment 按状态分为可折叠的「进行中」与「空闲」,按最近更新倒序并平滑过渡位置;「全部」保留已停止的历史 Session;Human 可切换按状态或按工作区折叠分组,每个 Bot 的范围、布局与折叠选择仅保存在本浏览器。点击行使用 DSH 原生 `UiWorkspace.openSession` 打开对应 Session,而不是维护一份只读事项详情。归属该 Bot 的根 Session 可通过原生标题栏及 Session 菜单返回 Bot 私聊;空闲时左侧标题前显示 Bot 头像,运行状态及日程标记仍优先显示。Assignment Directory 继续持有 Grant、续接、报告、停止、并发及审计事实。 - -Channel sidebar 的编辑模式只折叠当前呈现而不改写展开偏好,拖动/方向键只改变草稿;完成保存同类 scope 的顺序,取消丢弃草稿,恢复默认也属于草稿。浏览器本地分别保存所有 PersonaBot DM 共用的顺序与所有 group Channel 共用的顺序;未显示/未注册的稳定 ID 保留位置,新 entry 按注册顺序附加。切换选择或关闭侧栏丢弃编辑状态,授权撤销仍经原有 expandable seam 清除禁止展开的状态;排序不写 Host 或 Memory([#721](https://github.com/BotHarness/BotHarness/issues/721))。 - -桌面 Bot 模式(窗口宽 961 px 及以上)把 `main` 区域改为一个 dockview 布局([ADR-0203](../adr/0203-bot-mode-is-a-dockable-layout-that-plugins-extend-with-dock-panels.md)):Channel body 与每个 Channel sidebar entry 都是独立的 **Dock panel**,不叠成标签页、不浮动,只能拆分放到彼此旁边。BotHarness Client 用 `ctx.provide('dockPanels', …)` 提供 Dock panel Registry:其他插件 `inject` 后调用 `register({ id, title, icon, scope, defaultPosition, order, component, headerAction? })` 并获得释放函数,重复 id 抛错,排序按 `order` 再按 id。`scope: 'channel'` 的面板收到当前 Channel,没有 Channel 时显示空状态;`scope: 'global'` 的面板不随 Channel 变化。可选 `headerAction` 在同一面板标题栏渲染,并与正文接收相同的 Channel 范围;活动中心以此提供筛选与排序菜单,正文不再显示分类 tabs。`defaultPosition.zone` 只决定第一次打开的位置:`left` 为最左一列,`center` 在 Channel body 下方,`right`(默认)叠进右侧栏,`bottom` 在新工作区内放到 Channel body 下方,以保留全高控制列;旧布局仍为底部一行;`relativeTo` 指向的面板已打开时,紧接在它后面;Human 关闭过的面板回到它原来的位置。`channelSidebar.register` 现在同时把非设置类 entry 注册为 `channel` 面板,同一 tab 的 entry 默认上下相邻;窄屏遮罩侧栏仍直接读取 `channelSidebar`。同一 Service 还提供 `open`、`close`、`toggle`、`isOpen` 与 `openPanels`,由当前挂载的布局执行;Channel body 标题栏的 **Panels** 菜单与其他入口(例如侧栏快捷卡片)都走这组接口,菜单末尾是「重置布局」。布局只是浏览器本地呈现,保存在 `localStorage` 的 `botharness.bot-mode-layout`(版本 4),不经过 Host,因此没有 CLI 命令;插件自己的数据仍走它自己的 Service 与 Host Provider(#1789、#1791)。新工作区在控制列、旧侧栏回退在品牌行下方为每个已注册的 Dock panel(Channel body 除外)显示一张固定小卡片:卡片在面板打开时高亮,点击经 `toggle` 开关面板,Service 的 `available()` 告诉它当前是否挂载了布局。活动中心是内置的 `global` Dock panel(`id: 'activity-center'`),它的卡片取代原来的活动中心行并保留未读数与待处理红点;旧侧栏回退中没有挂载布局时,这张卡片打开完整的活动中心页面,其他卡片不可用(#1829)。 - -#2017 首片在符合 rc.2 AppFrame 结构的 Web/macOS 宽屏中启用工作区:一个不可关闭、可移到左/右/任意整列之间的 Workspace Control Rail(工作区控制列),以及独立的全局 Channel 列表面板(`bh:channels`)。列表沿用现有 Roster 与选中 Channel 的唯一状态。没有选中 Channel 时,控制列和全局面板仍可操作。`workspace-shell` 只对当前 AppFrame 标记后释放原生侧栏占位,临时将公开 `layout.toggleSidebar()` 命令接到 Channel 列表开关,退出、卸载或收窄时恢复原方法描述符并移除标记;不改原生 Store、宽度或折叠偏好。结构不匹配、命令无法接管或 Windows 标题栏布局使用旧侧栏回退。macOS 仅在红黄绿按钮覆盖的左上角避让,其他面板利用顶部空间,全屏取消避让。窗口拖拽与面板拖拽保持各自区域。 - -**两层布局(会话、记忆与定时任务已迁入,其余分阶段迁移)**:[ADR-0221](../adr/0221-global-and-channel-workspaces-own-panel-placement.md) 与 [#2048](https://github.com/BotHarness/DeepSeekBot/issues/2048) 在上述已交付的平铺布局之上定义 Global workspace → Messages panel → Channel workspace 的归属。#2049 首片把会话移入消息面板:沿用已有会话查询与快捷键命令,新增稳定 DOM 的四方向分割容器;版本 4 的原布局存储按稳定 Channel ID 保存开关、方向、比例和隐藏入口偏好,并保留升级前原始备份。旧 Work 迁移先执行;隐藏父面板保留子状态,会话快捷键恢复父子面板。最终 Control Rail 只管理全局面板和消息面板;频道子面板由消息 header 控制,右键该 header 以图标、名称和复选框管理开关显隐(隐藏开关同时关闭子面板,不设单独管理按钮),并在父面板内分割、移动、按频道保存比例与开关。隐藏父面板不清空子状态,也不改变执行或权限。群聊保留群资料和成员,不提供记忆文件、记忆演化、定时任务、Browser、Computer 或 PersonaBot Profile,即使会话显式查看成员 Bot 也不例外。共享的浏览器/电脑资源设置仍可属于全局;Bot 专属访问与视图不因物理资源共享而变成全局。 - -[#2049](https://github.com/BotHarness/DeepSeekBot/issues/2049) 实现首个可运行切片:仅把会话迁入消息面板,保留 ADR-0220 的 DM/群聊查询与访问校验,其他面板暂留外层。现有 Client 布局 owner 负责外层与每频道内层偏好、带备份的幂等旧布局迁移及首次使用种子;不新建 Host 数据权威。沿用原会话快捷键标识和原生绑定:父隐藏时恢复父子,父显示时仅切换子面板。正式目标只有两层,不引入递归工作区、标签页或浮窗。验收入口为真实 Web 升级旅程与 macOS Desktop,证据记录在该切片的 PR;其余子面板的迁移仍待后续反馈。 - -[#2067](https://github.com/BotHarness/DeepSeekBot/issues/2067) 将记忆文件、记忆演化和定时任务加入同一消息内布局:各分割容器保持稳定内容身份,分别保存方位与比例。方位由 Human 选择,消息面板变窄时不自动换轴或限制停靠方向;布局库的主题与拖放配置保持稳定,只在实际配置变化时更新,避免拖拽中的重排反馈。原布局 owner 的版本 5 在保留会话 v4 字段的同时,新增按 Bot DM Channel ID 保存的三个面板状态与首次使用种子;旧外层面板和 Rail 项移除前备份原记录。原快捷键标识与默认键(Primary+Alt+Shift+6 / E / 9)保持,父隐藏时恢复父子,群聊不响应 Bot 专属操作。右键消息 header 管理图标、名称、复选框形式的开关显隐;隐藏开关同时关闭子面板。所有变化仅涉及呈现,复用既有 Memory 和 Schedule 查询与权限 owner。[#2069](https://github.com/BotHarness/DeepSeekBot/issues/2069) 将浏览器/电脑加入同一 Bot 频道子面板集合:v6 单独备份旧记录并移除旧外层节点,保留 v5 记忆/定时任务状态;默认 Primary+Alt+Shift+B / 0 与自定义/解绑保持。未启用状态复用居中图标说明,启用仍使用原子面板 header 的显式电源操作;布局开闭不改变 Browser/Computer Access 或执行资源。 - -Control Rail switch visibility is a workspace preference (`hiddenControls`) in the existing versioned layout record. The rail context menu updates that preference and closes a hidden Panel through the existing Dock owner; checking it again restores the entry without opening content. A Panel header's Close action changes only open layout state. Explicit shortcuts can still toggle Panels independently of rail visibility. Tooltip keycaps project the effective native shortcut catalog, including customized bindings, and render outside the rail's clipping boundary. - -Application-defined pointer context menus share `ds/PointerMenu`: callers supply viewport coordinates, while the design-system component owns the body portal, measured coordinate conversion under CSS zoom, native frame clearance and viewport edge avoidance. Regular control-anchored dropdowns keep the native DSH Menu. This ownership prevents each Panel or Channel surface from inventing a separate offset. - -频道导航不会重建工作区和 Dock(ADR-0203)。`scoped-state.ts` 按选中频道及视图代次重置聊天临时状态和资源引用,并拒绝上一代次的异步回写;各频道草稿仍由现有草稿模块保存,Host 持久化不变。 - -布局版本 2 在同一浏览器记录中保存按容器尺寸恢复的比例几何、关闭面板的位置、开关顺序、群成员分割比例与开关,以及 0–16px 连续间距(默认 4px);版本 1 保留现有相对布局迁移。背景恢复原生 base 深色,面板采用 85% base/15% surface 混色和小圆角,仅略亮于背景;macOS 红黄绿区域同背景色,header/body 间隙为 `min(4, gap / 2)`。退回旧布局不会覆盖已有新工作区记录。消息正文在工作区中是可关闭的唯一「消息」面板;群成员从消息 header 展开为内部左右分割(20–60%),不再占全局开关。控制列按钮支持拖拽及 Alt+上下排序;选中时仅保留蓝色图标和底色,不显示边框。底部把手在悬停或键盘聚焦时出现,位于齿轮之上;工作区布局通过既有 Bot Settings section Slot 提供,Portal 保留当前 Dock 的设置动作,不创建第二份偏好存储。面板快捷键注册到 DSH Shortcuts Service,由原生键盘设置自定义,离开工作区或弹窗内不触发;不另建配置存储。Dockview 保持 Portal 内容常驻,控制列移动用 reuseExistingPanels 恢复布局,避免重新挂载;Bot 小人拖拽后的 click 被消费,不再误导航。完整平台矩阵及 Windows 原生 caption 留待后续验收。 - -项目显示偏好与排序共用浏览器本地 scope 和编辑事务:完成以一次偏好更新保存二者,取消一起丢弃,恢复默认布局按注册顺序显示全部可用 entry。隐藏项目仍列在编辑模式中,可通过显示按钮恢复;全部隐藏时设置入口仍可访问。正常模式不挂载隐藏项目的内容,但保留 header 的权限监听,让授权撤销继续清除禁止展开的状态;未注册 ID 保留显示偏好,新注册 entry 默认显示。隐藏不改变 Host 权限、执行或 Memory([#809](https://github.com/BotHarness/BotHarness/issues/809))。 - -显示设置二级菜单临时只展开对应 sidebar entry 供预览;连续选择保持菜单打开并立即更新原有显示偏好,关闭菜单恢复先前的展开呈现而不写入展开偏好,权限 gate 仍然生效;切换选择丢弃预览([#807](https://github.com/BotHarness/BotHarness/issues/807))。 - -Memory files 与 Memory evolution 的读取缓存仅属于 Client 的 Bridge action owner 和 Channel,最多保留 30 个 Channel;关闭/展开及后台刷新继续展示上次成功结果,首次成功之前使用 skeleton。成功空结果也是已加载状态;失败通过轻量提示与 Retry 恢复,重试中保留提示,成功后清除。切换 action owner/Channel 时重新挂载读取资源,旧请求不得更新新 scope;缓存不成为持久 Memory/Git 权威([#719](https://github.com/BotHarness/BotHarness/issues/719))。 - -Human–PersonaBot DM header 菜单或频道列表的「查看资料」直接打开 **PersonaBot Profile view**(ADR-0085,#2017 修订),临时替换消息面板内的 Chat 历史与 composer;返回或 Escape 恢复 Chat。从频道列表进入时先打开消息面板;资料导航在新 Dock 布局恢复后再次确保消息面板存在并激活,避免旧的关闭状态覆盖导航,沿用原布局位置。Profile popover 及其 client-local pinned-card 呈现状态已移除;所有可用的 **Profile Card** 直接在详情中展示,Display name 与 Avatar 仍在此编辑。Profile Card 继续通过有序、可增删、scope-aware 的 client 侧 Cordis registry 注册,Host 读模型不变。Group header 也直接进入 Group Profile view,保留有限数量的实时成员活动预览及 Group scope 卡片:Host 从该 Channel 已提交消息分页聚合近 26 周的每日消息数与按 author 分组的活跃度,经 Typert/API Gateway 交给 Client;Human/Bot 作者分别呈现,群管理仍在独立 Channel sidebar。DM Channel 只使用 PersonaBot Profile(#424)。自定义 Avatar 是有界 data URL 存在 owning Registry 的 SQLite 记录中(旧 `bot.json` 仅用于一次性迁入)(512×512 WebP、解码 ≤128 KiB、magic-byte 校验、拒绝远端 URL),经带缓存校验的认证读取 route 提供,缺失或损坏时确定性回退 identity-seeded Blobatar(ADR-0086)。创建 Bot 以及 Display name、岗位或 Avatar 变更后,Registry 把同一份呈现写进该 Bot Memory 的 `.botharness/bot.json`(像素配方、上传图片 `.botharness/avatar.*` 或 seeded 配方),只提交 `.botharness/`,其余键保留;Host 启动时为缺少该文件的 Bot 补齐。分享后 Bot 市场读取它,显示与侧栏一致(#966)。 - -DM composer 的固定 `/` 快捷动作与模型/工作区 chips 仅消费既有模型编辑器及 PersonaBot-scoped Workspace Grant 流程;目录预览是只读操作,授权确认显示 Bot、解析后的目录与访问范围,不暴露权限模式切换。快捷动作移除自己的输入片段,保留正文、引用与附件,不发送模型消息。图标加文字的模型和工作区数量居左,紧凑 token 用量居右。只读总量与 header Profile 使用同 Bot、同 26 周 activity snapshot 及缺失/部分数据语义,在已提交消息变化、打开 Profile 与有界轮询时刷新;从聊天 header 或频道列表「查看资料」直接进入消息面板内的 Profile view,不再经过资料浮层,也不再固定卡片到浮层;从频道列表进入时先开启消息面板。DM-scoped `profileSessionUsage` RPC 选择最新的所属 Orchestrator root 和最多五个最近更新的 Assignment roots,由既有 Usage owner 按需读取 native Session Query 日志,复用 token bucket 口径及继承前缀/所有权/删除校验,每个 root 最多纳入二十个 descendants;这些只读行不形成新的持久统计权威,部分和不可用日志明确标示。Window Companion 的显示/移除沿用原有 owner、生命周期与 Profile-local 偏好存储;入口保留在频道菜单,聊天 header 不再显示图钉,不新增权限权威(#1432、#2017)。 - -模型配置目标在 Profile 顶部提供紧凑的预设切换,详细的单 Bot 快照编辑置于活动图表下方的折叠区或弹窗;用量图表默认显示最近 7 天,可在现有 26 周查询范围内切换时间范围(#592);独立的长期累计与筛选查询由 #507 提供。Profile 的实际模型用量与配置的可用模型分开呈现,后者不冒充已经发生的调用(#488、#39)。 - -Computer 是 profile 级共享资源(ADR-0051、ADR-0082):Profile 的 **Computer Target** 在 Bot settings 中选择本机或容器,新 Bundle 默认本机,旧的无 target 配置保留 Docker 行为。Computer Provider 管理内部目标策略,官方 `ctx.computerUse` 的单一 Provider 注册不变(ADR-0079)。本机目标在 macOS、Windows 和 Linux 上都使用 Host 上按平台校验固定版本与 SHA-256 的 `cua-driver mcp --direct --embedded`,完全不探测 Docker;Human 显式点「检查权限」完成安装与就绪检查:macOS 读取辅助功能/屏幕录制权限(继承运行 DSH 的应用的 TCC 身份),Linux 需要可达的 X11/Wayland 显示(没有 DISPLAY 的服务器由本机目标启动私有的 Xvfb、D-Bus 会话和窗口管理器;DISPLAY 指向无桌面的裸 X 服务器(例如与 Bot Browser 共用的 Xvfb)时复用该显示,只补上窗口管理器和缺失的 D-Bus 会话;关闭驱动时一并停止),Windows 无需授权。Human 直接在自己的屏幕上完成登录。本机没有 Selkies viewer、归档迁移或空闲停机,手机通过 Host 的截图流查看并接管本机桌面(`/api/computer/frame` 长轮询驱动的 `get_desktop_state`,画面不变不重发;`/api/computer/input` 走桌面范围的点击、滚动、输入和按键;接管时先通过 `/api/computer/takeover` 暂停 Bot(拒绝所有 Bot 的 Computer 工具调用,包括截图;帧轮询和输入续租,手机离开 30 秒后自动失效),ADR-0210);容器仍使用 `docker exec` stdio MCP、Selkies viewer、持久卷与导出/导入。目标通过原生 configForms 持久化,volatile 更新关闭旧驱动并停止旧目标,清空工具目录与会话授权;等待 Human 的审批绑定目标版本,不能批准另一个目标的桌面操作。 - -手机远程控制容器 Computer 和容器 Browser 时可以走 WebRTC([ADR-0209](../adr/0209-selkies-webrtc-is-switched-on-per-phone-call-over-the-tailnet.md),[#1799](https://github.com/BotHarness/DeepSeekBot/issues/1799))。Host 有 tailnet 地址时,新建的容器默认仍用 WebSocket,另外配置好 WebRTC:ICE-lite、一个公布在 tailnet 地址上的 UDP 端口(Docker 只在 loopback 发布),以及空的 ICE 服务器列表。手机经设备网关打开信令路由后,Host 在 tailnet 地址上绑定该端口做转发,把容器切到 WebRTC;最后一个通话结束或设备被撤销时切回 WebSocket 并关闭端口。不使用 STUN 或 TURN;不可用时状态路由返回拒绝码,手机继续用 WebSocket 画面。 - -只有 Human 为某个 PersonaBot 打开 **Computer Access** 时,精选的观察/动作/验证工具与目标对应的指引才注入它的 Orchestrator 与 Assignment 会话作用域;每个会话的首次动作经 DSH 原生审批询问 Human 一次(profile 开关可自动允许),每次观察与动作都以脱敏的 **Computer Audit** 记入 `logs.db`(ADR-0080)。Computer 未准备好或权限被拒绝时工具返回可读错误,不威胁 Host 启动。 - -Browser 同样是 profile 级共享资源(ADR-0089):随 `deepseekbot` 默认安装的 `@botharness/browser` Bundle 运行受管的 **Bot Browser**(每个被分配的 browser profile 一个实例;默认共用一个 profile,命名 profile 按需启动、独立空闲停,ADR-0096)——优先复用机器上已装的 Chrome/Edge 加专属 profile(`$DSH_HOME/botharness/browser`),机器上没有可用浏览器时按需安装 version-pinned Chrome for Testing(`$DSH_HOME/botharness/browser-chromium`),CDP 端点收在 loopback,Human 可在窗口里登录一次。只有 Human 为某个 PersonaBot 打开 **Browser Access** 时,工具与指引才注入它的 Orchestrator 与 Assignment 会话作用域;只读的 `browser_open` 与 `browser_observe` 已交付,每个会话的首次动作走与 Computer 相同的原生审批(profile 开关可自动允许),每次观察与动作以脱敏 **Browser Audit** 记入 `logs.db`;每个 Bot 拥有自己的 **Bot Tab**(共享窗口里的后台标签;标签归属是可见性作用域,不是安全边界,ADR-0095)。Browser entry 已提供该 Bot 标签列表与焦点预览、`browser_screenshot`(只作为 model attachment,绝不进审计)与 Human **Browser Pause**(暂停该 Bot 的动作与模型截图,Human 始终可直接操作窗口);模型截图从入队到原生附件处理完成都检查该 Bot 的控制版本,Pause/Resume 或 profile 切换会拒绝未完成的旧截图;交互工具(click/type/press_key/scroll/wait)与多标签管理(`browser_tabs` list/open/select/close、后台标签、空闲收窗)已交付,ref 过期以"重新观察"错误收场;原生鼠标与键盘输入在 CDP Session 内准备焦点,不唤起前台窗口;导航与动作后的页面就绪检查要求相隔一个轮询间隔的两次完整状态,使用 15 秒截止时间,持续未就绪时返回错误而非成功,并要求先观察再决定是否重试可能已执行的动作。浏览器未运行时工具返回可读错误;profile 导出留待后续阶段。 - -Local Browser 的 `localDriver` 可显式选择默认 `current` 或试用 `agent-browser`([ADR-0124](../adr/0124-local-browser-drivers-share-host-authority.md)):两者共用 Browser Provider、专属持久 browser profile、Chrome 启停与 Human 窗口。候选仅在私有 IPC 内以锁定版本的原生进程连接这个受管 CDP 端点,按 profile 串行选择精确 target,ref 绑定当前 DOM 身份,绝不通过同名元素或相邻标签恢复。Browser Access、拥有者 Session、原生审批、Pause、Audit 和模型附件仍由原 Provider 管理;切换驱动撤销旧授权并等待旧实例清理,取消会停止候选与其 Chrome。Container 可独立选择 `containerDriver=current`(默认)或 `agent-browser`(#768):同一个 Host 原生候选进程通过现有专属 Container CDP 中继连接,转发原有已认证共享 Viewer;有界上传 staging、截图、Human 输入前 Pause 和全屏仍遵守原有权限。正常停止 Container 时先有界请求 Chrome 正常关闭,再清理已验证归属的桌面,以保留刚写入的 profile 数据;清理失败仍阻止替换。Stop 在清理前撤销共享该 profile 的全部 Bot 注册与授权;原生审批绑定具体 Session 注册,晚到的 Human 预览由 Bot 失效版本拦截。空闲标签关闭撤销对应 Bot,空闲 runtime 关闭撤销整个 profile;清理失败保留共享执行屏障,直到后续空闲清理重试或 Human Stop 成功。日常浏览器模式保留各自驱动。Local 与 Container 的真实 PersonaBot 对比结果分别记录在 #767、#768,默认值不变。 - -Managed Browser 观察在原有精确引用旁提供有界的非敏感字段值及适用控件状态。可选 Local 候选驱动保守压缩完整 AX 骨架并保留页面/弹窗语义;包含原始控件值时保持 AX 原文,避免误删多行值。两种表示均不改变 Browser 授权和引用身份 ([#787](https://github.com/BotHarness/BotHarness/issues/787)). - -Browser Target 通过原生 Profile 设置选择 Local、Container 或 Daily Browser(ADR-0114/0116),默认仍为 Local。Container 使用固定 digest 的官方 Chrome 镜像、资源上限、命名 profile 独立且带所有权标签的 volume 与内部 CDP,独立于 Computer。Browser 和 Computer 的独立 Client Bundle 编译复用 `packages/client/src/client/remote-viewer/` 的纯展示组件,无 Computer runtime 依赖:容器画面在侧栏默认只读,Human Open 展开同一连接的全屏 Viewer,复用接管、状态、缩放和收起交互。Browser 确认 Host Pause 后才允许输入;关闭操作或收起全屏保持 Pause,恢复 Bot 需要明确 Resume。共享 Viewer 全屏期间,侧栏 overlay 提升层级,避免侧栏开关遮挡全屏收起控件。切换 target、profile、viewer 地址或关闭 Access 均撤销当前 UI 输入权限。Target 切换撤销 Session 授权、待审批作用域、ref 与标签归属,等待 runtime 成功停止后才允许新动作;显式上传经有界文件复制,空闲停止保留数据,不挂载 Host 目录。日常浏览器的只读借用切片见下一段。 -本地 Browser 接管复用同一套 Viewer 家族([ADR-0163](../adr/0163-local-browser-takeover-reuses-computer-viewer-family.md),#1322):Host 以 `/botharness-browser/viewer/local/` 直供本地帧缓冲(截图轮询成流,CDP screencast 只留给 Bot 侧 observe/screenshot),Client 仍走共享 `remote-viewer`。点击、滚动与键盘输入经 Viewer 端点进入当前 Bot Tab(提交后的文本经 `Input.insertText`,IME 在 Human 设备上组字),输入要求 Pause;小视口默认触控板模式(滑动移动虚拟光标、轻点点击),可切直点。验证步骤走 ask-permission 接管:`browser_takeover` 先暂停并签发一次性链接(至多 10 分钟,带说明),Human 在 Viewer 横幅里 Done / Could not finish,Bot 在 `await` 里做服务端页面状态校验后 Resume(旧引用失效,必须重新观察)。键入的秘密只记字符数,审计(mint/accept/input/complete/expire 含完成原因)与会话录制只读、无删除口。 - -Daily Browser([#741](https://github.com/BotHarness/BotHarness/issues/741),[ADR-0116](../adr/0116-daily-browser-tabs-use-explicit-ephemeral-borrowing.md))通过 MV3 扩展显式借用一个 Human 标签页,仅注册 `browser_observe`,保留 Browser Access、可信 Session ownership、原生首次动作审批和既有审计。Browser Service 拥有进程内的一次性配对码、绑定扩展 Origin 的令牌、固定 tab/URL 的临时 lease 与 observation deadline;不建立持久授权库。认证 Client 创建 Bot 配对码;扩展经仅 loopback 的 WebServer prefix 兑换,先展示 Bot/当前页面,再经 Human 单独 Share。只有批准的观察才通过 activeTab/scripting 读取 main document 的有界可见文字和控件名称,不读输入值、Cookie 或其他标签页,URL/documentId 拒绝跨文档结果。侧栏显示当前只读借用与 Return;归还、任何导航/刷新、关闭标签、Access 撤销、Target 切换、Host 关闭或失联会拒绝 pending observation、撤销动作审批,重启不恢复借用;Human 标签保留。扩展只使用 loopback host permission、activeTab、scripting、session storage 和 alarms,不依赖 Computer 或启动 Human 默认 profile。[安装与操作指南](../daily-browser.zh.md)。 - -日常 Chrome 的明确控制是独立的 `daily-control` Target(ADR-0121,#766),不升级 `extension` 只读借用。现有 Browser Tool Provider 仍拥有 Access、可信 Session ownership、原生审批、Pause、Audit 和注册生命周期;独立 Subprocess 运行固定版本官方 Playwright 扩展连接器。Human 先在官方扩展选择一个已有页面,再在 Browser entry 单独允许控制该文档。Bot 只得到 observe、type 和 ref-based click;每次观察的 refs 绑定原始 DOM 节点,Resume 必须重新观察。连接捕获的 Page 不跟随标签组或其他页;主文档导航/刷新、关闭、断连、归还、Access 或 Target/profile 变化、Host 退出撤销进程内授权和在途结果,重启不恢复,Human 标签保留。Computer 授权独立;[安装与操作指南](../daily-browser.zh.md)。 - -显式 `profile-control` 通过第一方受限扩展持久配对一个 Human Chrome Profile;Browser Access 与原生 Session 审批分别生效。只能操作普通网页;配对哈希持久化,但引用、选择、待执行命令和 Session 授权不恢复。多个 Bot 操作串行;导航使引用失效,暂停等待已发出操作结束。见 [ADR-0123](../adr/0123-daily-chrome-profile-control-is-an-explicit-persistent-pairing.md)。 - -Browser entry 的 header Access 开关控制展开:关闭即折叠并锁定,开启在同一次交互中展开。平面标签列表置顶 Provider 当前 Bot Tab,并显示标题与 URL。跟随开启时预览 Bot 当前工作页;关闭时固定当前画面所对应的 target,直到 Human 选择另一个已归属标签。预览只读取 observation,不改变 Provider 的当前标签与 Agent 控制;Pause 仍是独立的 Host command。 - -Human 的「打开 Bot 浏览器」通过现有进程内 per-Bot 标签页 Provider:唤起仍存活且归属此 Bot 的预览页(或当前页),恢复最小化窗口;Human 预览其他页时不改变 Bot 当前页指针。已关闭的归属页被清理,优先复用仍存活的归属页,否则创建一个归属此 Bot 的空白 Human 标签页。重复打开与 Bot 操作共用 per-Bot 队列并复用该页,不唤起或登记共享浏览器 profile 中其他 Bot 的页。Human 前台聚焦与 Agent 后台操作保持独立。 - -临时关闭 Browser Access 会撤销 Agent Scope 工具注册并取消该注册的在途调用;正在等待的调用及时结束,已发出的 CDP 操作等待自身完成后返回撤销错误,队列中的旧调用即使权限重新开启也不会执行。调用者取消信号同样保留,不放弃底层尚未完成的操作;Provider 的进程内标签归属与当前页指针保留,重新开启后工具可继续使用原工作页。显式停止/重置会清空这些记录;此连续性限于同一个 browser profile,标签归属不跨 Host 重启持久化。 - -修改 browser profile 分配时,Core 通过应用定义的 Host Service 调用现有 Browser Provider reset command。Client Profile combobox 合并 PersonaBot record 中已分配的名称与 Browser observation 返回的已存 profile 目录名;目录读取不创建 runtime、不跟随符号链接,也不另建目录清单存储。输入已有名称可选择,新名称通过明确的创建项分配;失焦与 Escape 不保存。Profile 名称先在既有 Host 保存接口验证;保留路径段 `.` 和 `..` 在写入 PersonaBot record 与调用 reset 前被拒绝,当前标签与 Pause 不变。runtime 对已存无效名称继续回退默认 profile,正常名称中的点仍被允许。切换的 Bot 在使用新 runtime 前清空旧的进程内当前页/标签归属记录与 Pause 状态;Browser Access 与 Session 授权仍各自独立。其他 Bot 的标签归属与旧 profile 的浏览器数据保留。重置记录有界生命周期诊断,切回原 profile 时不持久化或重新登记旧 target。 - -同一 browser profile 的并发首次调用等待同一次 Browser 启动;启动失败后可重新尝试,不生成第二个实例。Human 关闭当前 Bot Tab 后,下一次工具调用清除失效的进程内归属与当前页,并提示使用 `browser_tabs list` 选择另一页或 `browser_open` 恢复(#463)。 - -`browser_upload` 使用 Host 文件向当前 Bot Tab 附件字段挂载文件:文件 input 的 ref 精确选择该字段,上传按钮的 ref 在 CDP Session 内拦截原生 chooser,并使用实际 `Page.fileChooserOpened.backendNodeId`,不会猜测其他附件字段;省略 ref 使用第一个 `input[type=file]`。chooser 监听按 CDP Session 隔离,超时或失败后解除监听与拦截;文件不存在、无输入或按钮未打开 chooser 均返回可读错误。Browser Audit 只记录 ref、文件 basename 与大小,失败摘要隐藏传入的完整 Host 路径;发布前仍须 Human 明确确认。 - -Browser Tool Provider 在同一调用完成路径中记录成功与失败,包含授权、Access、Pause 及 Resume 后重新观察检查的即时拒绝;队列入口拒绝与运行失败仍各只记录一次。Browser Audit 使用注册时的 Bot、Session 及可信 Session ownership 的角色归属,写入现有 `logs.db`;输入只记录字符数、上传只记录文件名,不另建日志存储或读取接口。 - -Pause/Resume 在现有进程内 Provider 状态中失效该 Bot 的可操作观察。页面点击(ref 或坐标)、输入、按键、滚动及上传,必须先完成一次在当前控制状态变更后开始、且 Pause 未开启时成功返回的观察;读取失败、Pause 期间的读取以及旧的在途读取均不能满足此要求,截图也不能替代观察。Open 与标签管理保留以恢复缺失页面;Access 开关保留重新观察要求,显式 Profile 重置则重新开始标签归属。其他 Bot 各自独立。 - -```mermaid -flowchart LR - Inbox["Bot Inbox / Attention"] --> O["One active Orchestrator Session"] - O -->|"list / inspect"| Dir["Durable Assignment Directory"] - O -->|"create_assignment"| Gate{"Global active Assignments < limit?
default 8"} - Gate -- "no" --> Error["Structured + LLM-readable failure
no queue, no intent"] - Gate -- "yes" --> Runtime["Assignment Runtime"] - O -->|"send_assignment_request / stop_assignment"| Runtime - Runtime <--> W1["Independent Assignment Session A"] - Runtime <--> W2["Independent Assignment Session B"] - W1 -->|"report_to_orchestrator"| Report["Assignment Report Source Event"] - W1 -->|"completed Turn paired with this Report"| Completion["Host Completion Notice
paired completion #194"] - W2 -->|"confirmed stop / native cancellation / native error / Host recovery"| Notice["Host Lifecycle Notice
stop / cancellation / error #194
Host recovery: unconfirmed outcome"] - Report --> Inbox - Completion --> Inbox - Notice --> Inbox - W1 -.-> Sub["DSH Subagents
aggregate-only"] -``` - -Assignment Session 是 DSH independent root,以 DSH `sessionId` 为 canonical identity;Continuity Key 只是 PersonaBot-local alias。Orchestrator 通过六个工具 `list_assignments`、`inspect_assignment`、`create_assignment`、`send_assignment_request`、`wait_for_assignment`、`stop_assignment` 管理它们。Assignment Agent 只能用 `report_to_orchestrator` 向 Orchestrator 回报;其 Agent Scope 没有 Channel send capability,普通 final 也不会写入 Channel。v1 没有 Assignment-to-Assignment 直连、广播或等待队列。 - -带地址的答复只在原生 Inbox 接收后清除所捕获的原问题;pending followup 不等于投递成功。可证的准备失败保留问题、权限/模型快照和空闲重试路径;原生投递结果不明或重启保持修复可见、不重放。仅收到 Human DM 不隐藏未解除待办或生成已处理历史;可证失败后可在原来源明确重试。旧答复不能清除新问题(#812)。 - -Assignment Runtime 的并发上限覆盖整个 Host 的所有 PersonaBot(默认 8),同时约束新建、按 Session 恢复空闲事项和按 Continuity Key 复用。恢复前先同步占用原有 Assignment Directory 的 working 名额,再交给 DSH;运行中的事项接收更新不增加名额。满额时返回 `assignment-capacity`、当前数量、上限和可重试标记,不启动执行、不清除待答问题、不改变模型或权限快照。事项停止确认前仍占名额;释放名额后,Orchestrator 可重试同一 Session,不引入等待队列(#811)。 - -Orchestrator 通过 `archive_assignment` / `unarchive_assignment` 显式归档和恢复 Assignment(ADR-0188)。归档调用 DSH Workspace Registry 原生的 `archiveSession`,不带 `stopActivity`:事项仍在执行、停止中或等待 Inbox 接收时直接拒绝(`assignment-active`),由 Orchestrator 先停止再归档。成功后 Assignment Directory 记录 `archived_at` 作为原生归档集合的镜像,并清除 Continuity Key;归档进行中和归档后的 Assignment Request 都会被拒绝。不会自动归档。`list_assignments` 支持文本搜索(用途、Continuity Key、最新报告)、活动筛选、`include_archived` 和 opaque cursor 分页,默认不含已归档事项。 - -哪些 event 进入 Bot Inbox、哪些唤醒 Orchestrator 仍由现有 wake 与 source policy 决定;被成功回合观察到的 Inbox Admission 记录 `opened_at` 并保持 open,不再按是否 @ Bot 或来源类型另设规则。`inbox_resolve` 在同一 Admission 上写入 resolution、note 与 Orchestrator Session,按 id 或过滤条件批量处理;过滤条件先返回数量、样例与基于命中集合的确认 token,只有带同一 token 的重复调用才落库。`inbox_list` 按 Channel 对话或 Assignment 分组列出 open 条目,每轮的 `[Open Inbox]` 块只给出数量与前几条一行摘要。attempt state 仍是执行事实,open 由这些列派生,不改写历史已处理记录。`inbox_snooze` 在同一 Admission 上记录 `snoozed_until`、`snoozed_at` 与 note;同一 Channel 对话或 Assignment 的更新 Source Event 被 Bot 实际观察后才提前返回;未唤醒 Bot 的普通群消息不能取消到期唤醒,再次 snooze 不会提前。Host 在启动和每次 snooze 后为每个 Bot 设一个定时器,到期时以最早返回的条目为锚运行一次 Orchestrator 回合,列出返回条目与 note;失败则延后五分钟重试。每次成功回合后若仍有未 snooze 的 open 条目,Host 排入一次 `[Triage]` 回合;某一轮没有让 open 数量下降就停止,直到出现不在这批停滞条目中的新 open 条目才再次触发(ADR-0188)。 - -#1037 有界切片只在无后代、无执行中工具且无已批准待执行调用的 Assignment 等待 Human 审批时释放 live 运行名额。持久 activity 仍为 working,process-local executionWait 区分 waiting-human 与 waiting-capacity;最多 32 个等待中的 Assignment Session 使用独立上限。Human 决定提交后,原生 outcome 或下一模型 Step 必须先取得当前运行名额,实际工具执行前再次检查归属、Grant、原 Source Event 与准确操作。新建和空闲恢复仍在满额时立即拒绝;中止、停止与冷恢复均不重放原调用。同一 Orchestrator 的权限 continuation 与群聊隐私仍单独保持 gate。见 [ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md#live-approval-waits-and-running-permits)。 - -Human 在 Bot 模式设置中将此 Profile 级上限调整为 1–32。DSH 原生 Settings schema 的 Volatile field 由 Profile Config Editor 持久化;UI Plugin 的 Host Fiber 将 live reader 绑定到 application-defined Assignment Runtime,并在 dispose 时释放绑定。Client 只展示 Host 确认的保存值;Runtime 在每次新建或恢复空闲事项的准入时读取当前值。保存后立即影响后续准入,重启后保留;降低上限不中止已有执行,直到使用量低于新上限才允许启动新工作(#825)。 - -待处理 Workspace Grant 请求通过同一持久 Human-action 查询进入独立 Activity attention。只有经过校验的 Grant 关联 Human 回复、Inbox 忽略或来源 DM 移除才清除计数;普通授权文字不会清除。重启从已提交 action 重建计数,不伪造执行状态。 - -报告超过 2 KiB 时,Assignment Runtime 在提交 Inbox Admission 前把完整文本交给 DSH Spill Service 保存,并只将前缀预览、字节数、不透明 locator 与 provider 的检索提示写入 Assignment Directory、Source Event 和 Bot Inbox;单份报告上限为 1 MiB。Orchestrator 的 `inspect_assignment` 可通过 DSH Session Query 读取该事项最近四条原生日志事件(最多 12,000 个字符),也可指定 `report_offset` 从已接收报告的原生 `tool/call` 事件每次读取至多 2,000 个字符,并通过 `nextOffset` 继续;两种读取均先核实事项归属,返回查询条数、实际读取量与估算 token 成本。Spill Service 仅负责保存且 locator 对消费者不透明;Orchestrator 即使不能直接读取本地 Spill 路径,也能通过 Session Query 找回该报告,不额外建立一份全文报告数据库。 - -Agent 可自行选择发送内联报告,或先写入其工作区文件并报告路径;超长内联报告由 Host 自动处理,不触发额外提问。 - -当前 `stop_assignment` 通过 DSH `Agent.cancel({ kind: "user" })` 取消活动回合并清空待执行输入;BotHarness 先持久记录「停止中」,待 Agent 静止后在同一事务里记录「已停止」和 Host 来源的 Lifecycle Notice。通知进入该 PersonaBot 的 Bot Inbox,Orchestrator 可在私聊报告停止结果;未观察的通知在 Host 重启后恢复,已交给 Orchestrator 但在运行中中断的通知标为 needs-repair,以免重复执行不确定的副作用。停止的事项不再接收请求或迟到报告,其 Continuity Key 可用于新 Session;取消不销毁 DSH Session 历史。成功完成路径读取可信 DSH `turn/start` 的编号并记录在独立 Assignment Report 的 payload 中;原生 `turn/end` 确认成功后,Host 仅为同一所属 Session、同一 Turn 的 completed Report 追加一条幂等完成通知,保存该报告 Source Event ID 与结束 seq。报告保持 Bot 来源,通知保持 Host/system 来源;Bot Inbox 只读查询返回 Turn 和关联报告 ID。配对通知搭乘报告触发的收割或下一次真实 Turn,不单独唤醒,也不因报告已处理而伪造通知已观察/已处理;重启仍保持这一规则,正文同时保留报告语义与 Host 完成事实及各自来源引用([ADR-0077](../adr/0077-turn-time-harvest-consumes-the-ready-attention-set.md))。progress-only Turn 不产生完成配对通知。Human 通过 DSH 原生 Session 控制取消活动 Assignment 时,adapter 仅从已提交 reason=aborted 的 turn/end 读取可信 Turn/end seq;Runtime 在同一事务内投影 error、释放 Continuity Key,并按所属 Session/Turn/native-turn-aborted cause 幂等写入 Host/system Lifecycle Notice 与 Inbox Admission。它沿用即时收割,保留原 Report,不伪造成功配对或自动重试;通知与原报告分别可导航,已处理通知重启不重放。Orchestrator stop 路径抑制原生取消回调,继续只发已确认 stop 的通知([ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md))。已提交的原生 error Turn 沿用同一失败结算事务,以 failed/native-turn-error 区分来源;adapter 只传可信 Turn/end 身份,不把原始错误写入通知。Runtime 释放 Continuity Key,保留进度 Report,独立即时收割一次,不生成成功配对、自动重试或替代 Assignment;已处理事实冷重启不重放。Host 启动时把仍为 working/running 的 Directory 行在同一事务内转为 error、释放 Continuity Key,并沿已保存来源策略纳入一条 System interrupted/host-recovery 通知。正文说明旧执行结果未确认,不推断原生 Turn/end 身份或报告配对;保留原报告,既有收割可唤醒 Orchestrator 一次,但不自动恢复 Assignment。状态转换和已处理事实再冷重启不重复通知或收割。准入前失败仍是独立边界,#194 尚待完成的视觉验收记录在 issue。 - -Assignment Request 的 `context-update`、`next-step`、`next-turn` 分别映射到经过验证的 DSH inject、steer、followup seam;普通请求不 cancel 当前 step。跨 SQLite/DSH 边界只保留最小 Assignment Delivery Intent,重启时有界 reconciliation;歧义进入 `needs-repair`,不扩张为通用 workflow engine。 - -统一 **Sessions panel** 保留 `sessions` 身份并退役 `group-work`([ADR-0220](../adr/0220-one-sessions-panel-with-explicit-bot-or-channel-scope.md),#2034)。Header 的范围选择是 Client 本地偏好:DM 固定当前 Bot,Group 默认当前频道,或由 Human 明确选择可访问的成员 Bot。Bot 范围复用 `sessions(slug)` 与原生 Session catalog;群内选择 Bot 使用面板本地查询结果,不覆盖当前 conversation 的共享 Sessions 状态。频道范围复用 `channelAssignments(channelId)` 的 Host 成员权限与原始 Source Event 归属检查,最多 200 条;达到上限显示截断提示。两种范围复用 Session 行状态投影并打开同一原生 Session ID;报告状态单独显示,空闲不等于完成。迟到响应在范围卸载后丢弃,失效 Bot 选择清除并回退频道范围。会话面板默认跟随当前频道时不重复显示范围标签;群聊范围选择收进视图选项菜单,仅显式指定 Bot 时在标题栏显示 `Bot · 名称`。 - -布局 v1/v2 升到 v3 前保留一次本地备份 `botharness.bot-mode-layout.before-sessions-v3`。已有 Sessions 位置优先;只打开 Work 时原位替换;两者关闭时不自动打开,并重写停放邻居和控制列偏好。快捷键通过 DSH 原生 revision-checked 配置接口迁移有效且无冲突的 Work 自定义绑定,保留 Sessions 显式覆盖(包括解绑)。冲突或失败指向快捷键设置;旧 Work 命令不再注册,原生接口不允许删除未注册命令的覆盖,因此保留其休眠配置以便回退。迁移完成标记防止用户后续重置 Sessions 时再次导入。旧版本回退需要恢复布局备份;Host 数据与 Session 生命周期无迁移。 - -### 5.1 · PersonaBot activity、事件与 renderer - -Web 的选中 Channel、Roster 与 Activity 消费在每个文档内共用一个受认证的 EventSource;既有 `/api/botharness/stream` 在选中 Channel 时携带 `channelId`/`after` 与 `roster=1`/`activity=1`,从 owning Channel live hub 组合原有 Channel revision 流、roster 通知、撤销基线与同一 Activity Projection 的有界快照。没有选中 Channel 时复用 roster/Activity 流。各逻辑消费者独立清理,最后一个离开时关闭物理流;Channel 选择变化调整查询,替换构造失败保留健康旧流,Host 合成 adapter 断开时取消两个原流读取者;重连错误清除可回放 Activity 基线,迟到消费者保持 stale 与 HTTP 恢复直到新快照到达。慢消费者只保留当前 Activity 更新,背压时跳过心跳;Channel revision/回放和 Owner 认证不改变。这是过程内传输复用,不创建新的 Activity 或内容权威([#1451](https://github.com/BotHarness/DeepSeekBot/issues/1451))。 - -DSH 失败的 `turn/end` 仍是执行事实权威;BotHarness 在所属 Orchestrator 或 Assignment 回合结束并判定失败后,由 Host 向对应 PersonaBot DM 提交一条带 role、Session ID、错误码、可用 HTTP status、简短错误详情和事项上下文的 application-defined failure notice。该消息是面向 Human 的持久通知,不把 SessionEvent 全文或工具日志复制成第二套执行历史;Client 把它渲染成可读的失败卡,刷新后从 Channel authority 恢复。Orchestrator 的 Human Source Event 仍依原有 retry/reconciliation 语义处理,失败通知不等于完成该 Source Event。 - -DSH SessionEvent 是 durable execution authority;BotHarness 不复制 tool call 或 assistant output 为第二套 Session fact。explicit Session Ownership 把 Session 归属到 PersonaBot 及 `orchestrator` / `assignment` root role,PersonaBot module 再把这些事实与 live liveness 折叠成一个可重建的 Activity Projection。Browser 首先经 Typert/API Gateway 查询 projection,随后消费带单调 revision 的 live update;revision 断档时重新查询,而不是由 Client 自己推导状态。 - -每个页面共用的 Channel/通讯录/Activity 实时流按 ADR-0181 由原生 Remote 流承载:Host 的 `botharness/liveFrames` 流式 Remote 方法打开既有 live hub(与 `/api/botharness/stream` 相同的查询、回放与拒绝语义),把它的事件帧逐条作为流项产出;Client 挂载对应 contribution,以 EventSource 形状的适配器经 Gateway 流监督器消费,断线后携带最后的事件 ID 续接,Host 拒绝(未知 Channel、修订超前、参数非法)时进入关闭态。过渡期的事件帧隧道 `botharness/liveFrames` 已在 #1502 移除;Remote 无法挂载流方法时仍回退到原 SSE 路由。通讯录与 Activity 进一步各用一条带类型的流(`botharness/followRoster`、`botharness/followActivity`):每代先发完整基线再发增量,Client 经官方 `RemoteSnapshotStream` 消费,帧流只剩所选 Channel(#1498)。所选 Channel 随后也有自己的带类型流 `botharness/followChannel(channelId, after)`:`opened` 给出本代起始修订号,`entry` 按修订号顺序给出已提交消息,`notification` 承载回执、已读位置与草稿预览;Host 的 Channel 订阅是唯一的帧源,流方法与 SSE 路由只是两种编码,Client 的 Channel 消费逻辑与传输无关,时间线分页仍按 ADR-0061(#1500)。按 ADR-0179,窗口贴着最新消息时由官方 `RemoteJournalStream` 负责:每代以游标加最新一页开场,条目必须紧接上一修订号,缺口由 `channelJournalPage` 读取限定修订号的一页修复,向上翻页经官方类的 prepend;Client 把整体替换合并进已显示的窗口。定位在已读位置或旧消息附近的窗口仍走 `channelTimeline`(#1503)。Window Companion 的订阅也改为带类型的流 `botharness/followCompanion`:Host 把既有 feed(租约、恢复与背压不变)投影为 baseline、selection、activity、message 帧,Client 经 `RemoteSnapshotStream` 消费,监督器重开时带上最后的 consumer 标识;订阅更新仍是独立的 POST。此后每个页面只剩原生 HMR 一条长连接(#1501)。 - -PersonaBot 活动通过公开 `activitySnapshot` 查询与认证 `scope=activity` SSE stream 交付同一份 Host 拥有的快照(`generation`、单调 `revision`、每个 Bot 的聚合状态)。每次连接先发送完整 baseline,实际聚合状态改变后推送完整快照;重连无需重放第二套活动历史。Client 原子地向置顶、普通行、rail 头像及 DM 输入框应用同一快照,拒绝同 generation 内的旧 revision,并防止过期 roster 响应覆盖当前活动。退出 Bot mode 或隐藏页面时关闭活动连接。 Activity Client 在进入时查询 baseline,修订跳号时重新查询。stream 失败或不可用时,每 10 秒最多一次重试并查询既有 `activitySnapshot` 权威,单次查询时限 5 秒;收到有效 live frame 后停止备用刷新。慢 Activity stream 只保留一个排队的完整快照,后续变化合并到最新快照,不重放原始执行事实。退出 Bot mode、隐藏页面或 Plugin disposal 会取消定时器、请求和订阅;旧 Host generation 的迟到响应不会覆盖新的 live baseline(#121)。执行状态由显式 Session Ownership 和现有 DSH SessionEvent Projection 提供,不来自 roster 轮询或本地发送标记(#120/#536)。 Channel stream 建立连接时还补发已见 cursor 前最多 100 条消息的当前回执,补足 HTTP 快照与实时连接之间的窗口;不引入轮询或第二套 Admission 权威。 - -application-defined `botharness/personabot/activity` Cordis Event 在 projection 改变后以 `emit` 发出,供 Host 内的 Live2D、3D 或其他 Plugin 同步。Orchestrator 活跃时负责呈现;当它明确 `waiting-on-assignment` 时,活动来源切换为 Assignment:同类 tool kind 使用对应 effect,多类并行回退到通用 `working`。waiting、blocked、approval 与 informational attention 单独投影,不进入可配置 priority。 - -PersonaBot Output Committed 已由 canonical Channel writer 的成功提交回调提供:可信 Runtime 传入本次调用的 Session/Source origin,Host 校验显式归属后发布应用定义的 `botharness/personabot/output-committed`。载荷是冻结的公开正文/格式与 PersonaBot、Session、Channel、消息、修订及回复/来源引用白名单,不复制工具/审批卡或私有上下文;origin 不持久化、不进入 SSE。重复/冲突、回滚、模型增量和重启重建不通知。当前 Cordis emit 不隔离 callback 错误,因此同一 EventsService 的 emit dispatch 逐消费者安全调用,Promise 不等待且失败仅记录固定诊断;Consumer Fiber 销毁自动清理,不另建 listener registry、日志权威或 browser API。订阅方法见 [Output guide](../dev/guides/personabot-output.md)。 - -Tool activity notification 只广播 `toolKind`、可选 `toolName`、SessionEvent reference 与 Tool 显式声明的 `publicDetail`;完整 arguments/result 由受控 Capability 按引用读取。Channel output commit 后另发 PersonaBot output notification,TTS 与说话动画消费该 public output,而不是任意 Tool 参数或尚未提交的草稿。 当前 `publicDetail` 是 Tool Provider 在其实际注册的 DSH Tool Definition 上通过应用定义 `withPublicToolDetail` 显式提供的同步声明;Host 不从原生 call-card title/rawInput 推断公开文本。文本限制为 160 字符的非空单行,控制字符与双向格式符拒绝,失效或抛错时省略;并发声明不一致时省略,已有 revisioned Activity 与认证 Gateway 传递同一字段。浏览器标签页 Provider 只返回固定操作文案,不公开 URL、标题或 tab identity。 - -工具 Activity 的首个切片(#122)由待执行 `tool/call` 与配对的 `tool/result` 投影事实,经 Agent Scope 内已注册工具的 presenter 解析类别。Activity 快照只保留有界类别与已注册名称;presenter 标题、原始输入、路径、diff、参数和结果均留在原生 Session。并发同类保留效果,不同类别回退通用工作;一个结果不会隐藏尚未结束的调用。每次投影变更先提交一个 revision,再发布应用定义的 process-local `botharness/personabot/activity` Cordis 通知与完整 SSE 快照,同状态的工具变化也会通知。侧栏 hover/focus 与输入框读取同一安全摘要,键盘展开在每个活跃 Session 的独立卡片内显示最新安全活动,展开区不重复 Bot 名称;既有 motion 偏好控制相同效果。最新记录由同一 Host 投影拥有,携带进程内 opaque 显示 key、可信角色、可选的原生 `session/title` 名称、Host 观察时间与 revision,经 snapshot/SSE 同步;名称最长 1,024 字符,拒绝控制或双向格式字符,创建、改名和重建都读取相同的已提交标题事实。输入框展开区是透明浮层,只有全宽 Session 卡片保留背景;桌面名称与状态同行,窄屏换行,Assignment 使用原生名称并省略过长文字。按实测展开高度给最新消息留出空间,避免遮住审批操作,较早历史仍可滚到透明区域后方。同角色 Session 仍分别显示,完成或销毁后移出,idle 时不留记录,Host 重启后原子重建当前基线。显示 key 不是原始 Session ID,也不是完整详情查询授权;不保留八条历史,不持久化或回放 Session transcript,不包含参数、结果或 reasoning。授权 opaque 完整详情通过 Host-only `botharnessActivityDetails.read(reference)` Capability 读取,角色优先级聚合仍归 #123。 当前执行来源由已记录的 Session Ownership 的 root role 与 Subagent provenance 推导,Activity 聚合字段公开主会话、任务会话、子代理三个类别及各类正在执行工具的会话数,展开区的 per-Session 最新记录另以 opaque key 区分各个活跃 Session;同一会话多个工具只计一个来源。角色分布通过现有 revisioned snapshot、Cordis 通知和相同安全摘要交付,原始 Session 标识、路径和工具数据不会进入该来源字段。该来源信息不改变当前状态或效果选择,角色优先级仍由 #123 完成。 - -活动中心总览读取与侧栏/消息框相同的 Host Tracker 执行选择及有界工具摘要。原生问题/审批卡片和其他 Human 待办保持独立的 `hasAction`/待办数投影,不改写 SessionEvents,也不以 waiting/blocked 覆盖执行。活跃归属根 Session 卡片沿用原生执行事实和运行中根过滤。原生问题的 live owner 在请求卡提交后向同一 Tracker 发布 `questionCount`,与 `approvalCount` 分别保留;Avatar 数字显示两者之和,悬浮说明区分来源。回答、取消、Agent dispose 和 Plugin close 从 owning pending map 清除,Host 重启不重放历史待办。Assignment waiting-human/blocked 待办复用 Human Inbox 的同一权威查询、回复/隐藏/停止条件,事务提交后派生到 Tracker 的 `waitingHumanCount`/`blockedCount`,不把报告状态写成 Session 执行状态;冷启动重建未解决的持久化报告。所有计数使用同一个 Avatar 数字和独立本地化说明。application-defined `wait_for_assignment` Tool 通过 Runtime 等待已提交报告或执行结束,使用原生 Tool AbortSignal,限时 1–120 秒。当 Orchestrator 的全部活跃 Tool 调用都属于明确等待操作时,共享执行选择呈现所属 Assignment;若 Orchestrator 仍有其他工作则保持优先。等待 lease 为 process-local,由报告、完成、取消、超时或 Runtime close 释放,不从历史 Tool 名字推断或重放。其他共享 attention 仍属于 #123 后续切片。 - -Client 通过一个 Avatar module 在侧栏行、响应式 Pin Grid、消息、顶部和 composer activity row 中呈现同一 projection。默认 Blobatar media 可在 thinking/working 时运动;自定义图片保持静止,由外层 Activity Frame 表达状态;group Channel 可用最多三个头像与 `+N` 的 facepile。Human Inbox 则统一投影 Channel Attention 与 PersonaBot Attention,并按 action-required / informational 分类;#546 从持久 Channel placement 与按 Human 身份保存的 read position 投影未读数;同一 Source Event 只计一次,入口另示待行动提示。 - -Tool detail Capability 默认拒绝所有 Consumer。部署 Human 在 `botharness-core.activityDetailConsumers` 显式列出受信任的 Host Plugin runtime name;Cordis Service caller Context 提供实际调用方 Fiber,只有处于 ACTIVE 的允许 Consumer 才可读取。它是受信任 Host Plugin 之间的部署授权边界,不是同进程恶意代码沙箱。服务不注册 Browser RPC 或 Fetch 详情端点;opaque reference 本身不授予权限,也不是 Session 显示 key。每次读取审计 Consumer、结果与字节数,不记录 reference、原生 Session ID、参数或结果。 - -共享状态与 Tool 摘要优先选择活跃 Orchestrator Session,安全的展开列表仍保留双方 Session;Orchestrator 结束后恢复展示剩余已归属 Session 的活动。#123 首片不推断等待标记或建立新的 attention 生命周期;明确等待现由 Runtime 拥有的有界操作提供;其他正交 attention 仍属后续契约。 - -引用表只保存指向原生 SessionEvent 的 process-local locator,最多 256 项,有效期五分钟;请求时校验当前归属及归属模块的进程内单调 repair revision,再从存活 Session 的 canonical snapshot 读取精确 Tool arguments 与配对 result(包括原生 meta),返回独立 JSON 副本,完整数据超过 64 KiB 时拒绝。归属改变、原生事实消失、Turn 边界、Session disposal、projection rebuild 或 Host disposal 均使旧引用失效;无持久 payload 副本或 replay。已完成 Tool 的 Consumer 可在当前 Turn 内使用此前收到的引用读取结果,Activity 则继续只显示当前待执行工具。 - -### 5.2 · 可编辑 Avatar(已接受设计,待交付) - -[ADR-0118](../adr/0118-editable-avatar-appearance-is-independent-of-activity.md) 记录 Human 已确认的设计目标:人物插画与抽象小角色两个 Avatar Family 共用真实 Bot-state 语义与过渡规则,各自适配部件和姿态。保存的有版本 Avatar Appearance 配方归 PersonaBot owning module;执行与 Human attention 消费现有 owning projection;逐帧姿态和过渡时间归 Client renderer,不写 SessionEvent、不改保存造型,也不另建状态聚合。首版编辑目标包含细分发件与有界五官几何参数;抽象角色可短暂变成有界点阵/符号,配件暂时收起、稳定姿态恢复时完整回来;小头像的动作更短、更克制,大形象更丰富。保存配方与派生静态快照;缺少兼容部件/协议版本时保留配方、显示原外形快照,并明确暂不可编辑/播放角色动画,独立活动提示继续消费真实事实。候选渲染路线是现有 React Client 内的受控 SVG、稳定节点与有界姿态采样,不预先引入新动效依赖。首个切片先验证编辑、保存、重读、真实工作动作与独立 attention(依赖 #123 owner 已交付的共享合同),第二家族复用语义合同;完成/错误动作后续另定结果作用域。shared native Human attention 继续由 #123 owner 提供,explicit waiting-on-Assignment 不由头像推断。 - -[#751](https://github.com/BotHarness/BotHarness/issues/751) 的首条人物路径使用 Registry 的 `setAppearance`:受控、严格版本化的部件与十六进制颜色配方和 Host 由同一 SVG 派生的 512×512 PNG 在一次原子写入内保存,SHA-256 revision 绑定配方与快照。DTO 只携带配方/revision 与既有快照 URL;图片覆盖或移除会清除 composed 配方。Client 的 Profile 草稿在 Save 前不写入,侧栏和大图消费同一保存配方;稳定 SVG 节点上的 Web Animations 只持有有界局部姿态,真实 activity 切换会重新定向,减少动画、隐藏、离屏和卸载取消资源。Host 光栅化依赖仅在保存时执行,不进入 Client Bundle;第二家族、细分发件/五官和缺版本兼容回退仍由后续切片验收。 - -### 5.3 · 窗口伙伴(已接受设计,待实现) - -[ADR-0144](../adr/0144-window-companions-consume-owned-activity-and-scoped-output.md) 与 [规格 #1135](https://github.com/BotHarness/BotHarness/issues/1135) 记录 Q1–Q23 共识。Human 主动从 Bot 行右键或 Channel header 的具体 Bot 入口选择“显示为窗口伙伴”,与 Channel 置顶独立;通过官方 RC 的 shell overlay Slot 跨 Harness 页面显示既有半身 Avatar。Client companion owner 持有当前 Client × DSH Profile 的选择、每 Bot 来源/范围/漫游偏好,以及所有伙伴共享的卡片层数/保留数量设置;不更改 Registry 的身份/外观、Memory 或 Channel pin。伙伴像素视图省略画布底色与方形头像框背景,呈现透明人物轮廓;既有侧栏头像与保存配方不变,透明区域仍保留可用 hit area 和独立 attention。图片/缺失 rig 快照不自动抠图。底部漫游、拖拽、受窗口边界约束的位置和锚定像素气泡属于呈现层,逐帧姿态不跨 RPC。 - -消费路径为 owning Registry/Activity Projection/canonical Channel → 认证 Typert/API Gateway 与有界 live/query adapter → 独立 companion Client owner → 既有 Avatar 与卡片。当前 Activity Client 退出 Bot mode 会关闭消费,当前 SSE 只有单 Channel 输出流;伙伴的独立生命周期和按 Bot 跨 Channel 输出合同是待实现工作,不由现有页面组件顺便提供。Host 内其他 Plugin 继续查询同一 Activity Service 并消费 application-defined Cordis 通知;不存在伙伴 UI 到 Activity authority 的反向写入。 - -每 Bot 独立开关 Activity 标签、DM、群聊消息,默认开、开、关。Companion Visibility 默认“Bot 和我都在的 Channel”,另有“仅我和 Bot 的 Channel”与“所有 Bot 加入的 Channel”;Bot–Bot DM 在第三档归 DM 开关。只展示当前 Bot 的已提交输出并标识来源 Channel/DM 双方。第三档需要面向受信 Human Consumer 的 Host-owned Bot 输出只读合同,范围限制在被选 Bot 的 authored output;不能删除通用 timeline 成员检查、授予模型新权限或读取其他作者的完整历史。点击原 Channel 仍检查 owning 读边界,不能访问时解释限制。 - -首次选择/Client 或 Host 重启建立 Host 一致的新消息基线,不重播旧输出,直接查询当前 Activity;短暂后台/断线仅有界恢复本轮基线后尚未播出的消息,canonical identity 去重并拒绝旧 generation 响应。关闭来源清卡片与待播,重新开启或新合格 Channel 从当前基线继续。进程内 output-committed 只是通知,不能充当持久 cursor;查询成本与队列都必须有界。气泡到期/移除不更新 Channel read position。独立 Human attention 即使所有播放关闭仍保留并导航到 owning 活动/Human Inbox,不从计数编造正文或审批控件。 - -每 Bot 一张当前活动卡加独立并行逐字消息卡,默认折叠 3 层、最多保留 20 张未到期卡;全局设置可调整。hover/focus 展开稳定列表,暂停漫游与到期,已显示文字继续播放,新增内容只计数、退出后有界合并;长文预览、列表滚动,基本气泡避让。无自动 +N 折叠或钉选数量上限,Human 通过拖动、暂停漫游、移除调整。归档保留静态伙伴与标记、删除清选择;reduced motion、freshness、上传图片静态 media 和版本快照降级沿既有 Avatar 合同。先完成真实 Host→Client 的一个像素伙伴 Activity/DM 与操作/恢复切片,Human 验证后扩展多个 Bot、群聊与三档范围;闭合/半开/张开文字节奏嘴型作为随后 BotPixel 兼容扩展。独立桌面窗口、fork 分发与全身动作不属于此目标。 - -[ADR-0150](../adr/0150-window-companion-requests-use-their-live-owners.md) 记录 Human 在 #1178 接受的 attention 扩展:伙伴显示真实 owning Tool Approval 的当前待处理请求并可直接决策,独立于普通输出的播放开关和范围。审批正文来自现有 live owner 的已提交请求,不由 attention 计数或旧 Channel 历史重建;原规格保留历史边界。 - -生产 feed 通过 Fiber 拥有的可释放 attachment 观察 `ChannelToolApproval`,在已确认选择快照中投影当前已提交请求;首次钉选、重连都不需要重播历史。`CompanionRequests` 将待处理请求放入有总数提示的常驻滚动区域,独立于装饰气泡容量;普通消息暂缓逐字与到期,显示等待数量。复用 `ToolApprovalCard`、原生控件和既有审批 RPC,只有明确匹配 Bot/DM/Session/call 且 live 的 companion target 可脱离当前 Channel;普通聊天选择检查保留。提交前重新读取状态,Host 仍验证规范请求和 native caller;竞争决定只能一次生效。断线禁用、状态读取失败可重试,拥有者结算或撤销后快照移除请求并恢复键盘焦点,Host 重启不从耐久请求历史重建 live 权限。 - -依赖切片 #1179 将既有 `ChannelUserQuestions` owner 接入同一 feed,并在 `CompanionRequests` 中增加正式提问变体。`UserQuestionCard` 与 Chat、Human Inbox 共用,保留全部问题、选项说明、单选/多选和自定义文字。当前回答草稿不受普通消息和悬浮影响,不另建持久化;明确匹配 Bot/DM/Session 且 live 的目标在提交前重新读状态,可在其他 Channel 仍被选择时直接回答。规范拥有者校验、提交且只接受一次,再恢复原 native request。普通问句不生成交互控件,新 Host 不从历史重建待答问题;审批 attention 与导航保持独立。 - -### 5.4 · 形象物种与自绘部件(部分已实现) - -[ADR-0148](../adr/0148-avatar-species-and-custom-parts-extend-one-pixel-rig.md) 在不新增 rig 的前提下扩展像素 Avatar Family。Avatar Species(人类、精灵、哥布林、矮人、兽人、猫、狗、狐狸、兔子、熊、花)是像素胸像骨架上的基底:轮廓、耳朵、鼻子或吻部、建议身体色与可接受部件;动作、锚点、说话嘴型与伙伴透明轮廓保持共享。头发拆为可分别上色的分片,新增穿过头发的头饰槽(前后两层)。当前物种无法显示的选择保存并隐藏,不被替换。 - -Custom Part 是有界像素网格,每格引用外形颜色或固定色并带明度档位,因此随外形换色。BotPixel 负责其类型、校验、渲染与纯函数绘制算法;PersonaBot 所属模块把副本嵌入 Avatar Appearance,DSH Profile 中由 Host 管理的 Part Library 以内容哈希身份和来源保存可复用部件。导出的 PersonaBot 携带其部件;单个部件以内嵌数据的 PNG 流转。眼睛、眉毛和嘴不可自绘,眨眼与说话帧仍由 rig 负责。 - -新 PersonaBot 记录全域名字种子版本;没有该记录的 PersonaBot 保持仅人类的种子。使用物种、新槽位或 Custom Part 的 recipe 提升 asset/schema 版本,旧 Client 显示保存的快照;既有 recipe 渲染不变。首个切片验证哥布林、左右侧发分片与一个自绘头饰,从编辑器到 Part Library、再到 Window Companion,并经过导出/导入。 - -目前已实现:哥布林、精灵、矮人、兽人和花物种,左右侧发分片,胡子,中世纪服装与头饰,全域种子(#1210、#1212–#1214),第一条 Custom Part 路径(#1211),自绘头发(#1238),自绘其他部件(#1240)、部件文件分享(#1215),完整绘制工具(#1216),每片头发单独配色与内置头饰(#1217),普通 PNG 导入和派生编辑(#1219),以及带毛色花纹的动物物种(#1218)。asset version 4 新增前发、后发和呆毛颜色、单根呆毛,以及与配饰并存、可放内置头饰或 Custom Part 的头饰位;只有 Human 编辑这些项时编辑器才把 recipe 升到 version 4,并把作为配饰戴着的头饰移入头饰位。自绘的前发、侧发或后发会替换对应的内置部件,原来的选择仍会保留;其中发色的像素会像内置头发一样上阴影,所以编辑时从当前样式压平成的格子开始。自绘头饰的 recipe 是 asset version 3。Part Library 是 `avatar-part-library` 模块的 `avatar_part_library` 表(schema generation 73),以部件内容哈希为键,并保存名称、来源和父部件。Client 编辑器通过 `partLibraryList`、`partLibraryAdd`、`partLibraryExport` 和 `partLibraryImport` 四个桥接方法使用它;`part-file` 把单个部件编码为 ×8 预览 PNG,部件数据放在 `botharness-part` tEXt 块中,整个部件库导出为这种 PNG 的 zip,导入时校验每个块的 CRC 并按内容去重;导入 Bot 时其外形穿戴的部件以 `imported-bot` 来源加入部件库,作者为该 Bot 的显示名;编辑器提供带变亮/变暗明暗笔的铅笔、橡皮、四连通填充、按 Shift 锁定角度和正方形的直线与矩形、限定在区域内并带 4×4 或 2×2 Bayer 抖动的明暗渐变、可重新随机的 ±1 明暗杂色、Alt 点击或长按吸管、所有工具的中线镜像、前后两层、每次操作一步撤销(触屏双指撤销、三指重做)、触屏偏移光标和 1× 预览;每个工具的格子都由 BotPixel 计算,结果仍是外形颜色加明暗档位。外形嵌入部件副本,因此部件会随 `.botharness/bot.json` 和 Bot Zip 一起带走。`part-image` 解码部件尺寸(或整数倍放大)的普通 PNG,经 `partLibraryImport` 报告颜色数,`partLibraryImportImage` 保留最常见的颜色(最近色、不抖动),以 `imported-image` 来源存为固定颜色的格子;带部件库父部件保存的部件来源为 `derived`,并以 `parentAuthor` 记录父部件作者。 - -### 5.5 · Bot 主色(已实现) - -[ADR-0177](../adr/0177-bot-primary-color-is-portable-identity-not-theme-state.md) 将 application-defined Bot Primary Color 定义为 PersonaBot 的视觉身份来源,而不是 DSH-native Service、SessionEvent、Client 偏好或全局主题品牌 token。现有 PersonaBot Host owner 管理自动/自定义模式与可移植兜底来源;Client 共享来源解析,再针对实际背景与 light/dark 派生名字颜色。自动模式采用当前 Avatar 的主 `hairColor`,没有可用主发色(上传图片、无头发、line family 或不支持的外形)时使用按 PersonaBot ID 确定的兜底色;自定义覆盖独立于 Avatar Appearance。主题切换不修改 Avatar 或身份来源。 - -已实现(#1471):PersonaBot Registry 记录的可选 `primaryColor` 字段保存来源策略(缺失即自动,无 schema generation 变化);`botPrimaryColorSet` bridge 方法沿用 DM 范围的既有授权,校验后保存,自动模式保留已携带的兜底来源。`primary-color` 模块提供 Avatar renderer 与主色解析共用的外形解析,二者不会各自实现默认头像算法。Bot Profile 的头像弹窗提供独立的 Bot 主色设置(自动/自定义、预设、取色器、HEX)与本地 light/dark 预览,Host 保存成功后才更新共享呈现;取消或失败不发布草稿。DM 的 Bot 作者名按实测背景派生名字色:保持色相与色度,只调整明度,直到对比度不低于 4.5:1,黑白灰保持无彩色。群聊(#1472)沿用同一来源解析:群聊与 DM 的 Bot 作者名、@ 候选名、输入框中选中的 Bot 提及和已发送的 Bot 提及各自按自身实测背景(含半透明提及底色与对方气泡)派生名字色;只给名字文字着色,提及底色、气泡、正文、Human 名字、Channel 引用和“所有 Bot”保持原样,主题切换时重新测量。分享与导入(#1473)沿用既有的 `.botharness/bot.json` descriptor:导出与每次保存主色时写入 `primaryColor`(模式、自定义来源,以及已携带或按来源 PersonaBot ID 得出的自动兜底色),不写任何主题派生色;Git 与 Zip 导入在创建新身份后读取它,新 ID 不改变携带的兜底色,自动模式仍是自动并在有可用主发色时跟随发色。没有该字段的旧 descriptor 按新身份的自动策略处理;字段不合法时沿用 descriptor 既有校验,整份 descriptor 被忽略,不写入 Registry。删除后的归属(#1474)沿用 Registry 保留的历史记录:PersonaBot Deletion 受理时把当时解析出的来源色写入该记录的 `primaryColor.retained`,不新增存储,也不做逐消息或逐统计时间点的颜色快照。`list` 对已删除身份返回带 `retained` 的策略,没有该值的旧记录在读取时按 PersonaBot ID 兜底且不回写;`overviewUsage` 的每个 Bot 行返回 `primaryColor` 来源色(现存 Bot 为当前解析值,已删除为保留值)。Client 用同一套名字色派生给已删除 Bot 的历史作者名、已发送提及和用量列表名字着色;现存 Bot 忽略记录上残留的 `retained`,继续跟随当前来源。按 Bot 的用量图表(#1476)读取 `overviewUsage` 行上的来源色:以图表实测背景派生对比度不低于 3:1 的图表色,再按固定层级(缓存读 100%、其他输入 62%、输出 36%,与背景混合)和固定顺序绘制 token 子类,分项不可用的用量保持中性色;色调图例和 tooltip 身份标记说明含义。概览的每日总用量、Profile 的按模型视图等以 token 类型或模型为主要比较对象的图表保留分类色;单个 Bot 自己的 token 图表(popover 的迷你图和 Profile 的每日总量视图)与按 Bot 的条形使用同一组色调和色调图例;消息框右下角 token 用量弹层里的 Bot 名字和各 Session 占比条也使用该 Bot 的名字色与图表色。颜色只取决于该 Bot 的来源色和背景,不随排序、分页、时间范围或参与的 Bot 改变。单 Bot 活跃度热力图(#1475)沿用同一来源:Profile 与 popover 中的事件活跃和 Memory 提交热力图把按实测背景派生的图表色写入该热力图的 `--bh-heat-color`,四档强度仍按原有比例与中性底色混合,计数、分档和 tooltip 不变,零活动保持主题中性色;主题切换时重新派生。Group Channel 的聚合热力图没有单一 Bot 身份,继续使用品牌强调色。 - -ADR-0177 的六个切片(#1471–#1476)至此全部实现;外部 IM 客户端的渲染不在范围内。这些仍是 ADR-0177 的已接受目标,不是运行时事实;完整范围与验收见[设计记录](../research/2026-10-12-bot-primary-color-design.md)。 - -## 6 · 持久化、导出与恢复边界 - -```mermaid -flowchart TB - subgraph Profile["One DSH profile"] - DB[("botharness.db
operational authority + attachment bindings")] - Files["Optional Memory repositories
Markdown · Git authority"] - Attachments["Attachment files + identity records"] - CAS["Pending legacy attachment / Soul CAS bytes"] - DSHS["DSH SessionPersistence
transcripts · execution"] - Creds["DSH credentials / settings"] - end - - Barrier["Manual Export Profile
backup barrier + consistent snapshots"] - Package["one compressed
.botharness-backup"] - Stage["Import Profile staging
validate · migrate · dependency check"] - Target["v1: new stopped environment only
cold + suspended authorities"] - - DB --> Barrier - Files --> Barrier - Attachments --> Barrier - CAS --> Barrier - DSHS -.->|"adapter-supported facets"| Barrier - Creds -.->|"declarations only; never secrets"| Barrier - Barrier --> Package - Package --> Stage - Stage --> Target -``` - -| Data | Authority | Portability | -| --------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| operational facts | `$DSH_HOME/botharness/botharness.db` | consistent SQLite snapshot inside manual profile backup | -| operational logs (debug timeline) | `$DSH_HOME/botharness/logs.db` (lightweight owner, rebuild-empty) | excluded from backup; emailable as-is | -| optional Memory repositories | Git-backed Memory Provider | selected SoulSnapshot / PersonaBot Export / profile backup | -| attachments | Host-managed files, identity receipts and Messaging bindings; pending legacy CAS | current referenced files and identity mappings | -| Soul bytes | content-addressed files | dependency-closed selected bytes | -| Session transcript / execution | DSH SessionPersistence | only through a verified DSH export adapter; otherwise declared omitted | -| credentials and DSH settings | DSH services | never copied; restore creates suspended rebind requests | - -v1 只有两个备份动作:Export Profile 生成一个 self-contained `.botharness-backup`,Import Profile 选择一个文件。没有自动备份、scheduler、catalog、retention 或 incremental chain。Restore 总是在隔离 staging 中验证;成功后 PersonaBot 仍为 cold,provider authority suspended,Workspace/model/plugin dependencies 必须在目标机重新解析并由 Human 明确激活。 - -#886 的 v1 实现使用 application-defined Portability coordinator;各 owning module 提供历史 Memory 定位、当前附件引用闭包与恢复时的挂起转换。Database owner 在现有 Profile Writer Lease 下阻止事务写入并执行 `VACUUM INTO`;`ContentPurge.withCheckpoint` 覆盖同步快照与受管文件捕获。文件在捕获前后核对身份、时间、大小和 SHA-256,集合变化、缺失引用、超出资源/屏障时限均拒绝发布;屏障释放后压缩并完整校验。包保留 deleted identity 的 retained Memory,只有 canonical erasure proof 才允许省略;源 Git 授权配置替换为安全默认值,恢复后 hooks 隔离。当前界限为 DSH `0.2.0-rc.1`、60,000 entries 与 512 MiB;底层 Session 内容无已验证 adapter,明确标为 unsupported。 - -Settings 只经现有 Connection Fetch seam 预览、下载、检查文件与操作目标就绪度,不接收目标路径或替换正在运行的数据。受信任本地 `botharness-profile restore` 命令仅接受不存在的新目标;在 sibling staging 中校验路径/hash/SQLite/Generation、取得 Writer Lease、应用真实 Purge checkpoint、重定位 Memory,再一次提交。可执行 Bundle/Profile 配置、credentials、Workspace 文件和 debug logs 不导入;目标由固定版本 DSH 与已安装 Bundle 启动。Replace Existing 和 Managed Transfer 不属于此版本的运行入口。 - -Disaster Restore 将全部原生 Session 引用设为内容不可用,保留历史归属;中断 Assignment/Inbox/Outbox 均不重放。Bot、触发器、外部绑定、Service Grant、Workspace 与持久工具授权挂起,Browser/Computer access 关闭。目标 Human 配置明确 Model Plan、本地凭证并授权该 Plan revision,再确认同身份双活风险后显式激活;新 Orchestrator ownership 与恢复收据同一事务建立,执行使用新原生 Session。后续 Plan 变化再次阻止执行,所有实际模型调用仍经当前 catalog 验证,不能静默回退。激活仅开放本地 Bot 执行,外部 authority 与 Workspace 仍须独立重新授权。[操作与限制](../settings.md#complete-environment-backup-and-restore)。 - -Diagnostic Bundle([ADR-0185](../adr/0185-diagnostic-bundles-are-local-redacted-read-only-exports.md),#1533)是另一条只读导出:用户在时间线多选消息(消息菜单“多选”,右键或触屏长按打开)并在“导出”菜单里选择诊断包后,经 Connection Fetch 预览并下载一个本地 zip,范围从第一条到最后一条所选消息(只选一条时到现在)、前后各多 30 分钟,所选消息在 manifest 与时间线中标记,带上参与 Bot 的 Human DM、所导出 Channel 内的 Inbox 收件、其拥有的 Orchestrator/Assignment/Subagent Session 原生事件(起点处正在运行的 turn 从 `turn/start` 算起)和合并时间线。`DiagnosticsExport` 只通过 Channel store、Inbox history、Session Ownership 与原生 `sessionQuery` 读取,标准脱敏后只在内存中保留预览结果,不写盘、不建表、不上传;Memory、Workspace、附件内容、凭据与其他 Channel 不进入导出。 - -图中的 Attachment files 表示新附件真实文件及记录,Messaging 绑定将已转换旧引用解析到当前真实文件,legacy CAS 仅服务未转换依赖。hash 相同不恢复共享,缺少归属或含糊的旧调用明确失败。后续 Backup/Export 要包含当前被引用的文件与身份映射,引用感知清理及显式 Purge 也必须涵盖这些真实文件;一次明确导出保存当前字节,不建立持续附件版本归档。 - -### 删除与清除(已接受设计,待实现) - -[ADR-0130](../adr/0130-deletion-preserves-history-and-makes-memory-erasure-explicit.md) 与 #138 将 PersonaBot/Channel 的结束参与和物理内容清除分开。删除 PersonaBot 的确认框展示实际 Memory Repository 与依赖,提供“打开记忆文件夹”及默认不勾选的“同时删除记忆文件”;打开文件夹不改变勾选或确认状态。未勾选时保留记忆及 Git,勾选后也只能清除经 Host 重查、专属且归属明确的仓库。共享、路径变化或无法证明归属时不可清除;Workspace、原生 DSH Session、远端 Git、外部导出与备份不随之删除。普通删除先关闭执行/收件/授权入口,等待所拥有的 AgentHandle 执行树静止,再保留 deleted identity、历史 Session Ownership 和仍保留记忆的持久位置;它不同于可恢复归档。Channel 删除保留消息及因果归属,只结束其成员和路由,不删除外部会话或其他路由;Hidden Channel 仍可恢复。另行确认的 Content Purge 才清除选中的 Source Event 正文,附件需检查共享引用,并覆盖 legacy CAS 与当前真实文件绑定。 - -现有 roster stream 在提交后和重连时投影不含正文的清除位置选择器。Client 清理当前和缓存会话、侧栏和 attention 摘要,拦截延迟响应回填,并显示清除标记。这些内存选择器来自 Host,不构成第二份持久权威。 - -Purge Ledger 是应用定义的 Host 深模块权威,必须单调持久并位于可恢复数据库快照之外。清除先接受 ledger,再幂等应用 Messaging 清除与受管文件清理;中断时已接受的范围不可重新显示、投递或用于新出站效果,文件失败需明确报告尚未清完。#886 必须依赖真实 ledger/checkpoint 实现,不能用空占位代替;恢复在 Messaging 可读前合并并应用 package/destination union,独立离线旧备份只保证其自身 checkpoint。备份包括保留的 deleted-identity 记忆仓库。设计验收不等于运行功能已交付。 - -### Channel 内容清除运行路径(#897) - -Generation 70 的应用定义 `ContentPurge` Host owner 通过已有 Typert/API Gateway 提供已结束 Group 的只读历史、所选 Source Event 预览和单独确认;不注册模型 Tool。已结束 Group 的身份保持占用,同名新群聊不覆盖旧历史。本地和外部来源可以共享到仍活跃的会话,但全部位置在对应 revision 上均须仍在 Human 当前可读范围内。普通删除只结束所选 Channel 的路由/摄入,保留其他 Channel/Inbox 目标及外部会话。[ADR-0146](../adr/0146-content-purge-retains-file-selectors-and-settles-issued-effects.md) 记录文件、效果与恢复边界。 Memory 通过自己的查询和 retained/deleted 仓库定位提供已记录的来源衍生提交;Workspace Grant 提供可能的副本位置。这些引用参与确认前重验证,不写入清除账本,也不声称扫描过未追踪的文件或导出。 - -独立 `botharness/purge/ledger.db` 由同一 Host 生命周期拥有,SQLite FULL 同步事务先接受无正文的事件身份、作者/时间/因果、Human 审计及托管文件选择器,再移除运行正文;该文件不属于 `botharness.db` 快照。最小墓碑及 placement 保留,secure-delete 后截断 WAL;应用失败关闭运行数据库进入 recovery,启动先应用事实再创建 Messaging Consumers。Attachment owner 重查全部保留来源、迁移/获取绑定和 Outbox 引用后,仅移除已审阅的独占当前文件和 legacy CAS;共享文件保留,受阻清理可见且重启继续。待投递意图脱敏并取消,已发出请求在活跃 Host 中按真实证据结算,冷中断保持结果未知。持久栅栏和原生 Tool Guard 拒绝重复回填、旧命令与新的内容依赖效果。 - -`checkpoint()` 导出已验证事实;`withCheckpoint` 在同步或异步快照回调完整结束前持有清除屏障,Profile Backup 在其更广的 Backup Barrier 内调用。冷 `restoring` 验证 v1/v2 包检查点,单调 union package/destination 事实并保留目标审计,再应用、开放 Messaging。新目标只可从已验证包初始化;已建立账本缺失、损坏/不支持的数据或 union 中缺少运行快照引用事实均关闭入口。v2 包含托管文件身份及外部/系统作者。备份 Settings 与本地恢复入口由 Portability module 提供。 - -预览列出所选来源、全部 placement/Admission、文件移除/保留及依赖效果;info 控件披露仍保留的 Assignment/其他因果来源正文、未改写的 Human Memory/Workspace/导出、原生 DSH Session 提示/结果、外部副本、Git 远程及离线备份。旧独立文件只执行其包含的检查点。[验收指南](../agents/qa-channel-purge.md) 覆盖真实 SQLite/文件恢复及 DSH 共享文件/中断清理。 - -### PersonaBot 删除运行路径(#896) - -Registry 在 Generation 66 持久化不可恢复的身份关闭状态和确认时的记忆位置。活动查询排除已接受删除的身份;历史查询保留身份、Session Ownership、Report、消息和用量归属。Human Profile 确认框复用 Typert/API Gateway 与原生 Host 文件打开能力。“同时删除记忆文件”默认不勾选,清除需要专属 Git 仓库、已记录的目录身份,以及与其他保留仓库和所有注册 Workspace 均无重叠。新建自定义仓库可以记录同样的归属证明;已有自定义路径本身不足以证明独占归属。Host 共享存储也受到保护。 - -删除 owner 先接受持久身份关闭状态,再停止该 Bot 所拥有的原生 AgentHandle 执行树、撤销 Workspace/工具规则,并停用其 Messaging 绑定。已发出的原生/provider 效果保留实际结果。可选文件清理具有明确接受的范围和未完成状态;重试只使用原目录身份,不能选择新路径。DSH Session Persistence 与共享 provider 账号保留。冷启动仍排除已删除身份,即使清理需要修复。后续 Profile Backup 必须枚举历史 Registry 记录及删除位置,区分主动清除的记忆。Channel Content Purge 与支持恢复的 ledger 仍属于 #897。 - -### Bot Marketplace(已接受设计,待实现) - -目录查询将 GitHub topics 与 `.botharness/bot.json` 的 tags(兼容旧 `roles`,存于 D1 的 `roles` 列)一起用于文字搜索、`topic` 筛选和 `/v1/topics` 统计;同一仓库的同名标签只计数一次,统计排除收录标记 `botharness-bot` 及未上架仓库。FTS5 同时索引两类标签,迁移会从已存数据重建索引。每小时刷新不依赖 push 即更新 GitHub topics;检测到 push 后重新读取 README 和 bot.json,并替换旧标签。 - -[ADR-0131](../adr/0131-bot-marketplace-starts-as-a-github-indexed-catalog.md) 与 #18 让 Bot Marketplace 先作为 GitHub 索引目录上线:作者给公开仓库加 `botharness-bot` topic 即同意收录,也可在 Marketplace 贴链接立即抓取。独立的 Cloudflare Worker 与 D1 每日按 topic 切片发现、每小时用 GraphQL 批量刷新,README 进入 FTS5 索引;浏览用 keyset cursor,搜索只取前 200 条。harness 的 Marketplace modal 展示 README 详情;安装复用 #298 的 Git URL 创建路径,确认框显示最新提交并提示第三方仓库风险。贴链接与一键举报共用 ALTCHA 和限流。不建账号、不计下载。[ADR-0135](../adr/0135-a-bot-moves-between-people-as-a-zip-of-its-memory-files.md) 撤销了基于账号的第二阶段:把单个 Bot 交给别人或换设备,改为在 harness 里导出 Bot Zip(默认只含 Memory 文件和 `.botharness/bot.json`,整包导出时可选带 Git 历史),对方从创建菜单的「从 zip 导入」得到新的 PersonaBot。 - -[ADR-0240](../adr/0240-marketplace-listings-localize-author-first-with-generated-summaries.md) 让列表按读者语言(中文/英文)显示:作者在仓库根目录放 `README.zh-CN.md`/`README.en.md`,在 `.botharness/bot.json` 的 `i18n` 里写翻译后的名称和简介;Worker 推送后一并抓取,`lang=zh|en` 时优先返回。某语言没有作者简介时,Worker 在每小时刷新后经 OpenCode Go 的免费模型按 README 生成一句简介(按输入哈希缓存、每轮限量,标记 `bioGenerated`),作者文本总是优先;README 正文不做机器翻译。Host 的 Marketplace modal 与 CLI 按界面语言传 `lang`。 - -## 7 · 关键边界 - -- 正常运行只认 explicit Session ownership;`cwd` 只可作为迁移/修复提示,不能决定 PersonaBot 身份。 -- DSH Session 状态是执行权威;BotHarness 只投影 activity/last-run,并将 semantic Assignment Report 与 Host Lifecycle Notice 分开。 -- Provider capability 不等于授权;发现一个飞书频道也不自动授予向它发消息的权限。 -- UI 不直接读文件或数据库,不自己推导业务状态;它消费 Host read models,并把 command 交回 owning module。 -- PersonaBot archive 先关闭 admissions、wakes 和外部 actions,再停止 Orchestrator、Assignment 与 owned Subagents;purge 是单独的破坏性动作。 -- Browser 与 Host 是两个 Cordis 应用;Host service 不跨进程 inject,统一走 `/api` client bridge。 -- Memory 跨设备同步是 Orchestrator 的原生 Git 行为,不是 Host 服务;BotHarness 不持有远端仓库或凭据,也不提供一键同步,非 Git 所有者经 Portability 迁移记忆(ADR-0084)。 -- Roadmap Project #1 保持 private;文档同步只用 `read:project` 读取显式 `In Progress` 和 Artifact,经过 fail-closed 白名单投影后才提交公开 JSON。Project notes、private items、assignee、backlog 与 ETA 不跨越这条发布边界。 - -### 产品发布身份(已接受设计,执行门禁待交付) - -[ADR-0216](../adr/0216-dsb-product-versions-identify-immutable-package-sets.md) 将电脑端 DSB 软件身份与 npm 产品包身份统一;Core/UI/Browser/Computer 同号,DSH/独立 Provider/手机保持各自版本。既有准备流程的冻结 Release Plan 绑定源码与完整产物,而不是新增版本服务。交付 QA 后版本与字节不可替换;Beta 转正式须验证新的正式包集合。Host 的现有 Release Service 投影实际运行身份和独立的待生效版本,经既有认证 bridge 同时提供 CLI/Web;缺失发布元数据不能伪装源码构建。发布按依赖核验后推进产品入口,失败恢复同一计划/字节,不宣称多包原子性。规则详见[版本规范](../agents/version-policy.zh.md),实施由 [#1979](https://github.com/BotHarness/DeepSeekBot/issues/1979) 的三个切片负责;本节不表示全部发布门禁已实现,也不授予标签迁移、发布或共享设备使用权限。 - -#1980 的运行身份沿用 Release Service:四个第一方 Plugin 在模块加载时将不可变包名、版本与真实目录快照附在各自 callback 上。Core 捕获产品依赖组合,随后从原生 Registry 的 ACTIVE Fiber 读取实际 callback 快照并比对;等待组件启动时返回 `composition-unverified`,关于页面允许重新读取身份,不能只凭已安装清单宣布发布身份。运行版本和待生效状态在当前 Service 内保留;后续请求继续校验实际 callback,组件被替换后身份可失效,但不提前宣布新版本。配置重新应用复用同一 Core 模块捕获的组合;磁盘安装不能提前改变运行版本。该快照是应用定义的进程诊断,不是新持久化版本库,也不是对任意可执行 Plugin 的安全认证。源码构建使用编译时 Git/dirty 身份;具体安装、重启与 Human QA 证据仍由 issue/PR 记录。 - -#2173 的应用定义升级发现仍由同一个 Release Service 负责。Bot 模式进入时通过认证 bridge 查询运行身份、真实 DSH 版本及 registry 版本清单;兼容性复用当前 Host 的原生检查器,覆盖产品与精确依赖组件。绝对最新版本不兼容时继续寻找最新兼容版本;元数据缺失、搜索超时或网络失败保持未知并短期重试,不宣称没有更新。客户端按本地 Profile、发布通道和精确目标保存提醒关闭记录,与已安装版本更新说明分开。确认安装前再次核验兼容性,再进入既有原生安装和权限边界;已暂存版本仍独立提示重启,不提前改变运行身份。CLI `release-check --force` 与 UI 共用该 Service 并绕过查询缓存。 - -## 8 · 实现顺序与可并发范围 - -1. #77 验证 pinned DSH 的 Agent/SessionPersistence/Subagent seams;#79 建立 operational database owner。这两项可并行。 -2. #80 在 #77 与 #79 后实现 explicit Session ownership 和 activity projection。 -3. #81 在 #77、#79、#80 后先交付最小 DM → Orchestrator → Assignment → report → DM reply tracer bullet,并同时提供可验收的事项列表/详情;#47 的后续 Assignment coordination 在该切片通过后扩展。 -4. #78 可与上述工作并行研究 Feishu provider contract,但 #48 的 adapter 实现受它约束。 -5. #74 的 Memory 工作在上述主链通过后,以独立 optional Provider tracer bullet 推进;#75、#76 保持 focused design/grill,避免阻塞首个可体验闭环。 - -## 9 · 如何维护 - -- 模块、数据流、事务边界或 authority 发生结构变化时,同步本文件、英文镜像和 `docs/architecture/diagrams/*.mmd`。 -- 运行 `pnpm diagrams` 提交 light/dark SVG;`scripts/sync-docs.mjs` 将本文和图同步到 `apps/docs`。 -- 配套:BotHarness 产品术语 `CONTEXT.zh.md`(英文为 `CONTEXT.md`);取舍与理由 `docs/adr/`。Platform Spec 与 App PRD 已归档为历史工作草稿,不再作为并列设计权威。 - -## 附件文件操作切片 - -[ADR-0105](../adr/0105-attachments-use-native-file-operations-under-source-authority.md) 沿用现有 Attachment owner 管理原件当前内容。`channel_attachment_save` 校验当前 Channel 成员权限与准确的消息/fileId 归属,再将原始字节流另存到明确允许 Orchestrator 写入的 Grant;已有目的文件不覆盖。原生文件工具处理另存文件,Shell 使用该 Grant 的 `workdir` 并保留 Human 审批。Agent Scope 中复用原生 Tool 注册,通过隔离的 application-defined Policy Provider 在每次 `tools/execute` 选择单一获准根目录,不改 Memory cwd 或全局 Provider。写入权限变更使旧审批规则范围失效;已开始的 Shell 可能完成。 - -`channel_attachment_import` 明确选择当前 Memory/Grant 读取权限内的 canonical 普通文件,交给既有 owner 创建独立可下载 Attachment,再经 `channel_send` 当前发送权限回发。另存与导入不解析文件,不授予 Shell 权限。#632 交付本地 ZIP/CSV 链路。#633 的 `channel_attachment_open` 从精确来源消息与文件生成仅当前回合有效的原生访问选择:`read` 只允许指定原件路径;`edit-original` 另需既有 Human 工具审批或匹配的已保存规则。原生 guard 每次复查当前来源权限与文件可用性,拒绝其他路径,包括同目录兄弟文件。隔离 Policy Provider 为原生修改选用该附件已有的 data 目录,不授权整个附件仓库或元数据目录;不透明 Shell 保留审批。该选择在回合结束时清除,不是持久授权。原生写回修改既有文件,共享身份在刷新/重启后读取当前字节,独立上传仍然独立。不增加 Source Revision、文件变化 Inbox Admission、唤醒、应用锁或版本档案。仅 Inbox 的 Lark 文件链路由 #657/ADR-0107 集成;Slack 仍是后续 provider 切片。 - -[#657 的外部文件切片](../adr/0107-external-files-use-trusted-source-capabilities-and-existing-owner.md) 为既有 dsh-im Service 增加可选公开文件能力。真实文本 @ 仅解析其准确父文件元信息;canonical Source Event/Inbox 保留来源,不产生 Channel placement。首次访问重新校验原账号、群/话题及资源关联,将有界字节流交给既有 Attachment owner;普通 receipt 让稳定文件身份在重启后复用,缺失原件不重建。来源详情下载与 `bridge_attachment_save` 共用该权限。原生文件工具和经审批的 Shell 处理可写 Grant 中的独立副本,当前回合通过 `channel_attachment_import` 明确选择结果后才能 `bridge_reply_file`。既有 Outbox 在外部效果前记录文件引用,再复查当前 Grant/来源,经 provider 原生上传与准确话题回复。不确定结果不重试,不增加文件目录、解析器、SDK 连接、转录存储或文件自动唤醒。 - -### Human Inbox 中回应 Assignment - -等待/受阻卡片以 Assignment Session 聚合,读取准确报告 Source Event 和至多前后各两条报告。Human 在 Inbox 回答时,消息通过现有 Bot DM authority 提交,并携带 `assignmentReply: {sessionId, sourceEventId}`;事务核验所属 Bot、运行状态、当前请求或空闲受阻报告,以及尚无已提交 Human 回应;若之后的权威接收前失败记录指向同一未解除 ask 且事项空闲,可明确重试。新的 Human 回应消费这次机会,并发提交仍只接收一份。此消息由 Orchestrator 接收并转交 Assignment,不直接恢复事项或清除 ask;完成、失败或停止后的报告拒绝新回应。同 ID 重试返回原提交。普通进展保留未解决 ask,较弱等待报告不覆盖较强的受阻 ask。点击 Bot 打开私聊,点击 Assignment Session 切换到 DSH 原始 Session(ADR-0071)。 - -Human 回应目标及时间使用 Messaging 拥有的 SQLite 索引。列表超过 150 项时明确暂停自动轮询,保留正在浏览的旧记录;可见的「刷新回到当前列表」按钮保留筛选条件,重查最多三页并获取新游标。「加载更多」不截断当前记录,成功行动命令仍刷新权威状态。 - -## 共享 Channel Bridge — #634 - -[ADR-0108](../adr/0108-shared-channel-bridge-places-canonical-external-sources.md) 为授权 Lark Grant 增加明确选择已有 Group Channel 的收件位置。Messaging 在 ACK 前将同一个外部 Source Event、canonical Channel placement 与绑定收件 Bot 的 Inbox Admission 原子提交(#634 验证 @;普通文字见 #613)。原生时间线和授权成员读取投影有界的外部发送人/时间/正文/来源;其他成员获得可见性,不复制消息、身份、Admission 或唤醒。既有 harvest/steer 路径以该本地 Channel 作为入站上下文,只有明确使用自身身份的 checked reply 才回到 Lark。当前成员、绑定与 Grant revision 控制收件、唤醒、读取和未开始的回复;退出/撤销保留已有共享事实。默认仍仅入 Inbox;该切片保留每 Grant 一个目标、每 Source 一个 placement,重投不移动历史。多目标、话题跟进与协作继续由 #629 的后续 tracer 交付;普通文字收件沿用下述 #613 策略。 - -```mermaid -flowchart LR - IM["Authorized Lark group
verified Human @"] --> Provider["dsh-im public Service
exclusive Consumer"] - Provider --> Commit["Messaging transaction
canonical Source Event"] - Commit --> Placement["One existing Group Channel placement"] - Commit --> Admission["Addressed bound Bot Inbox Admission"] - Placement --> Members["Current Human and Bot members
native timeline and bounded reads"] - Admission --> Runtime["Existing harvest / steer
one Orchestrator"] - Runtime --> Reply["Explicit own-identity reply
current grant and source checks"] - Reply --> Outbox["Existing durable Outbox"] - Outbox --> Provider - Provider --> IM -``` - -## 外部群收件与唤醒 — #613 - -[ADR-0109](../adr/0109-external-group-collection-is-separate-from-wake.md) 将每个授权 PersonaBot/账号指纹/具体 Chat 的普通文字收件与唤醒分开。Messaging 保存不可改写的策略版本与编辑者;Human Profile 和所属 Orchestrator 的 scoped Tools 可选择仅收 @ 或普通文字全量,以及数量/时间 digest、下一轮 immediate、随本群 @ 阅读或 silent。开启全量要求当前 exclusive Consumer 确实收到一条该群非 @ 消息;仅验证时不保留该消息正文或 Source Event。接收租约与 Host 重启会重置能力验证,已保存策略保持不变,平台缺失的事件不回填。 - -新收件在 ACK 前原子提交 Source、已有目标 Channel placement(如配置)和绑定 Bot 自己的 Admission,固定当时的 Messaging revision 与阈值。Bot Runtime 复用现有有界 harvest 和计时;普通消息在回合边界入队,不 steer 正在执行的模型/工具步骤。@ 保留既有 steer/turn 规则,可同批阅读本群的待处理 digest/mentions 上下文;silent 只在显式读取时进入本轮。重启从 pending Admission 的历史阈值恢复,编辑只影响后续消息,重投不重新分类。是否回复仍由 Bot 独立决定;其他 Channel 成员的普通消息 attention 由 #638 交付,话题跟进由 #614 交付。 - -## 外部话题参与 — #614 - -应用定义的 Messaging Service 为已授权 Lark 群提供明确的话题跟进。PersonaBot 从自己 Inbox 的可信 Source Event 选择 Thread;Host 检查当前 Grant、Consumer、账号 fingerprint、Chat/Thread/root,以及该 Consumer 是否实际交付过本话题的无 @ 回复。发过一次回复不自动跟进。按 Grant/Thread 追加不可变策略版本,Bot 选择跟进或沿用群规则;Human 可跟进、排除普通回复或交回群默认,明确的 Human 覆盖在恢复继承前优先于 Bot 修改。 - -普通消息按 Thread 覆盖再按群 collection 判断,唤醒默认继承群 ordinary wake,亦可提供有界覆盖。直接 @ 继续走原 addressed 路径。退出恢复群规则,不等于在全量收件群中禁收;不回填、不重写历史。canonical Inbox Admission 在 ACK 前冻结 group/Thread revision 和实际 wake/count/interval,后续 harvest 沿用现有安全 turn/steer 边界。持久策略重启后保留;实际投递资格证据属于当前 Consumer 生命周期。撤销、归档、关闭 Consumer 与过期来源/Grant 始终优先。 - -PersonaBot Profile 展示最多 50 个 Inbox 锚定话题的收件方式、修改者和管理 Modal;无话题能力的平台不生成控件。DSH Tool Registry 与 Typert/API Gateway 分别承载 Bot 和 Human Consumer,应用权限仍由 Messaging Host 持有。参见 [ADR-0110](../adr/0110-external-thread-following-is-scoped-and-explicit.md)。 - -### Human Inbox 详情与移除(#687 QA) - -手机 Activity 通过认证 Bridge 读取同一应用定义 Human Attention owner。查询版本 1 在分组分页前执行安全摘要搜索、未读消息筛选,个人消息默认提及优先或显式按时间排序,游标绑定当前筛选。消费者分别加载可见分组;已处理历史需显式筛选。没有 Activity 专用持久化或第二套请求生命周期。悬浮顶部栏仅持有临时筛选、搜索和排序状态,折叠偏好仍按设备与 Host 保存。旧 Host 明确拒绝,避免静默返回未筛选结果。 - -消息窗口上下沿的等宽箭头分别增量读取历史/较新上下文;下沿到达此前末尾后仍能查询后来消息。Channel 沿用原时间线游标,Assignment 报告通过同一个有界查询继续读取边沿。每条消息在悬停或键盘聚焦时显示精确来源按钮,触屏保持可用;Assignment 原始报告则打开所属 DSH Session。常规手动刷新与底部来源按钮移除,失败可重试且保留回复草稿。 - -Human Attention 在原 operational database 拥有 `human_inbox_dismissals`,只保存 Human、item、Source Event key、可选 unread placement revision 和时间,不保存另一份消息。Host 验证可见原来源后提交 Inbox-only Dismiss;它不答复、审批、授权或推进已读,不创建已处理记录。查询分页和待行动计数排除已移除项;入口未读总数仍由原已读位置决定。Channel 未读汇总只隐藏当时 revision 及之前的批次,之后的消息重新出现;新的 Assignment 报告也不被旧决定隐藏。多窗口与重启共享同一状态,原 Channel 卡片仍能处理,随后真正回应的已处理历史仍引用原权威。再次点击同一行只收起详情,不移除。 - -### 总览行动与 Session 行卡(#698) - -总览默认显示非 idle 状态或有权威 Human 待行动的 PersonaBot;「显示空闲 Bot」可查看其余 Bot。等待/受阻工作显示为状态或行动,不计为正在执行。每张 Bot 卡复用 Human Inbox 的原生行动表单与选择器,以独立的 Bot 过滤 Client 查询缓存读取同一 Human Attention Bridge,最久等待优先、有界刷新并沿原游标继续分页。决定与移除后刷新该列表及总行动数;沿用 Inbox 的移除排除规则,不新增持久表或请求生命周期。正在执行的根 Session 用紧凑行卡显示原生 DSH Session 列表的当前 displayTitle;总览打开/刷新时加载该公共列表,订阅名称更新,并用图标区分 Orchestrator 与 Assignment。只有原生名称不可用时才回退用途/角色;点击仍退出 Bot mode 并打开准确原始 Session。 - -## 今日 Channel 活跃度(#703) - -application-defined `botharness/channelActivityToday` 查询由 Channel owner 在既有 SQLite 中聚合 Host 本地日内可见 placement 的不同 Source Event,尊重 Human 成员与可见修订边界,排除已删 Channel、Bot 私聊及其生成的注意力通知。响应给出准确的 Human/Bot/其他计数、当前 Channel Human 昵称、Registry Bot 名字、桥接发送者标签,以及日期/时区/半开时间边界。总览使用同一尺度的紧凑堆叠条,可展开发送者明细并跳转 Channel;默认渲染 20 行且提供继续查看,完整总量不截断。视图拥有每 30 秒、午夜和手动刷新及卸载清理;查询失败显式呈现,不推进已读、不生成 wake,不增加 schema 或第二套计数存储。 - -总览优先排列需 Human 行动的 Bot,并先展示 canonical 行动表单,再显示执行中的 Session。显式全部已读命令先固定每个 Human 可见 Channel 的消息位置,再推进既有已读游标;之后到达的消息仍未读,不改变请求解决、Inbox 移除或 Bot attention。统计复用锁定版本的 TanStack 图表与主题,以前端偏好记忆折叠状态,并保留可访问的发送者明细。 ([#705](https://github.com/BotHarness/BotHarness/issues/705)). - -总览 token 统计(#709)通过应用定义的 `overviewUsage(period, after?)` Typert 查询消费既有保留 Usage 权威。今日或含今日的七个 Host 本地日期返回完整整体总量/每日分项,与有界 Bot 明细分页独立;缺失报告不再让整段合计变成未知:每个桶返回已上报部分的合计,只有完全没有上报值的桶才是空;总量缺失的行用其已上报分项作下限,并以 `partial` 标记该合计不完整,Web 与手机显示为“N+”;核对和历史基线状态明确。当前 Registry 名称标记行;不在当前 Bot 列表中的保留统计仍可见,但不虚构身份或 Profile 入口。Client 复用 Profile UsageChart/主题,以可清理的轮询读取已结算事实,并通过既有 DM 导航打开当前 Bot Profile。不增加账本、Channel token 归因或模型/wake 策略。 - -总览 Memory 统计(#716)通过应用定义的 `overviewMemory(after?)` Typert 查询读取现有 Memory Service 与 Registry。每页至多十个当前 Bot;每个仓库的所有非恢复/stash 引用可达的普通 Git 提交按 committer 时间归入含今日的七个 Host 本地日期,共享提交仅计一次。当前 staged/unstaged/untracked 状态单独显示,不推断文件作者或审批需求;缺失、无效、超时或读取失败的仓库显示不可用,不伪装为零提交或 clean。仓库 Git 查询有超时并异步执行,避免阻塞共享 Host;查询关闭可选 Git 锁与 fsmonitor,不 stage、commit、reconcile 或建立 checkpoint;沿用 ADR-0068 的仓库权威,不增持久统计账本。Client 复用 Profile 紧凑卡片及 TanStack 主题;可展开每日值,保留已加载分页刷新,折叠 Statistics/退出视图时清理查询资源。 - -### 外部身份独立生命周期(#699) - -[ADR-0111](../adr/0111-external-identity-lifecycle-is-independent-of-grants.md) 将应用定义的 PersonaBot 外部身份与会话 Grant 分开。Messaging 既有 bindings 表持久保存启用偏好、本地名称及 revision;仅绑定通过可信 dsh-im 的认证账号元信息,不创建 Grant 或 listener。Profile 以独立身份表/Modal 管理;Group 没有身份表。暂停停止该身份的 Consumer lease,并由同一个 Host 权限门禁拒绝未开始的 Client/Bot 外部效果;原有路线、Source Event 和政策快照保留。恢复校验同一账号和原目标 digest,不扩大范围。撤销单个 Grant 不解绑身份;明确解绑使该身份所有 Grant 失效,保留可检查配置和消息,不删除 Provider 凭据。#700 继续交付 Channel Bridge 表。 - -Messaging 的 bindings/grants 事务提交,以及进程内 Consumer lease 开始、停止、连接成功或失败,均使既有认证 roster 状态流失效。已打开的 Profile 身份与 Channel Bridge 视图收到通知后重查同一 Host 权威;通知不携带身份、凭据或授权内容,也不保存接收连接状态或重放外部事件(#855)。 - -### Channel Bridge 管理(#700) - -[ADR-0112](../adr/0112-channel-bridge-intake-is-managed-at-the-existing-grant.md) 将有 revision 的收件偏好嵌入既有 Messaging Grant(Generation 50),由 Group Profile 的 Bridge 表/Lark Modal 管理。Human 命令校验当前 Human/收件 Bot 成员资格、Grant/配置版本以及原授权账号和目标。暂停保留独占 Provider lease,在 canonical 持久化之前丢弃后续收件;已收来源仍按当前权限读取和回复。删除移除收件范围/目标、递增 Grant revision 并关闭 lease,拒绝旧来源未开始的效果,同时保留历史、身份和独立发送范围;不会自动回退 Inbox。管理界面的添加/恢复持久记录 Provider 发送时间边界;早于边界的迟到消息仅确认、不投递,重启后仍有效。这依赖已验证 Provider 的发送时间与对齐时钟;迁移路线在管理界面激活前保留既有语义。恢复不请求回填或重复 listener。收件条件不替代每个 Bot 的 attention/harvest/wake,收件身份不授权其他成员借用发言身份。旧收件接口更新同一权威;多来源/DM 投递和成员自身身份回复仍由后续切片交付。 - -### 共享普通消息的成员 Attention(#638) - -[ADR-0113](../adr/0113-shared-external-traffic-uses-member-channel-harvest.md) 将明确收件并首次放入 Group Channel 的普通外部来源,在同一 canonical 事务中按当前活跃成员各自的频道覆盖/Bot 默认策略建立 Admission。重投不会补发给后来加入的成员或重写策略快照;直接外部 @ 仍只走接收身份的既有提及路径。每成员使用既有 Channel count/time digest、有界最旧优先 harvest、安全 turn 排队和恢复;已放入 Group 的普通来源不再走身份专属外部 digest。接收 Bot 明确设置的话题 wake 覆盖保持独立分区,不影响其他成员。来源文本保留发送者、平台、外部消息 ID 和 Source Event;共享收件不授权借用身份。群频道侧栏的唤醒策略项按成员卡片读取 Host 的实际有效策略与继承来源,编辑沿用既有审计 owner;频道连接器仍只控制收件。无远端离线回填、新队列或共享 Inbox 存储。 - -## Composer footer preferences and generation speed(#1958) - -The application-defined Client footer owns a browser-local display preference (`botharness.composer-footer.v1`): visible item order and left/right placement. Defaults show model, Workspace and token usage; speed is opt-in. Preferences never grant access or cross devices. A visibility panel opened by right-click, long-press or the `/footer` composer shortcut and a temporary pointer/keyboard arrangement mode share this preference. The panel stays open across toggles. With no rendered items, the footer collapses completely; `/footer` remains available for recovery. Desktop labels stay on one line with ellipsis; phone-width Web keeps its compact layout. - -The Host observes committed DSH `assistant/message` SessionEvents and keeps one process-local measurement per Session until the next step, reply consumption or Session disposal. A completed reply may carry optional `perf` metadata through the existing Channel/Source Event persistence and timeline projection; old messages require no backfill. Provider output counts are used when usable, otherwise reply text yields an estimate. Both reply and footer labels use the compact `36 tps` format; estimation metadata remains available in the message data. The footer uses its own inline size for responsive presentation: at 480px or less, model/workspace labels collapse to named, titled icons while numeric usage/speed remains visible, without resize subscriptions or preference writes. Phone-width Web uses the same applicable controls and visibility/order preferences, with compact 28px-high buttons on all pointer types. This is diagnostic metadata, not billing authority. The Client only loads averages while enabled: at most four pages/200 messages for up to 20 measured replies, refreshed on a completed measured reply, without polling or per-token scans. Hidden or purged content is excluded from displayed measurements. - -## Client-local composer recovery(#1459) - -DM 与 Group 的未发送撰写上下文由 application-defined Client recovery owner 保存于浏览器 localStorage(ADR-0184;原设计编号 0160 已被其他决策占用),不进入 Source Event、Session Persistence、Bot Memory 或 telemetry。Host/Profile 通过 Host bridge 返回持久 opaque identity,组合可信 Human 与 canonical Channel ID 隔离草稿;身份未就绪或切换时不展示旧范围输入。正文、明确 mentions/Channel references、reply ID 和已上传附件引用逐次保存,不依赖卸载事件,也不保存 File 字节。 - -恢复先经现有 Typert/API Gateway seam 校验当前 Human 可见回复及 Attachment owner 引用,再结合当前 roster 校验目标;失效引用保留正文并提示,未完成上传不能伪装 ready,`@all` 需重新选择。不可变版本及因果时钟保留并发标签页的双方输入,显式选择后重新校验;空闲视图同步,正在编辑的内容不被静默覆盖。存储损坏/配额/不可用明确提示,保留有效旧记录与内存输入。此切片不提供跨设备同步。 - -提交快照与输入召回(#1460)在同一恢复范围下增加一份有界的提交尝试列表(`composer-submission-store`,localStorage `botharness.submissions.v1:`)。通过本地校验的提交先以原 client message ID 保存正文、明确 tokens、reply ID 与已上传引用,再清空输入框;提交前被拒绝的输入撤回记录、仍是草稿。状态为 pending/sent/failed/unknown/restored:Host 明确拒绝为 failed,请求没有得到回答为 unknown(界面显示「结果未确认」,不称未发送),页面关闭时遗留的 pending 按 unknown 处理。unknown 只通过向 owning Host 按原 message ID 查询来核对:已提交则记为 sent 并由规范消息取代本地气泡,明确不存在则转为 failed 并可放回输入框,查询不到结果则保持 unknown 且不允许放回;不自动重发,也没有重试队列。Host 的 `channelSend` 对相同 ID 加相同内容返回已有消息,因此 ACK 丢失不会产生重复消息。空输入框按 ↑ 召回本 Channel 的提交尝试(含失败与未确认),↑↓ 浏览,越过最新一条回到召回前的草稿;召回只填入正文与仍有效的明确 tokens,不发送,不带回附件与回复对象并给出提示;编辑后方向键恢复光标移动,IME 与候选菜单优先。普通历史保留最近 100 条且不超过 256 KiB,按最旧裁剪;failed 与 unknown 不受该预算淘汰。这份列表是恢复数据而非消息日志,规范消息仍由 Host 拥有。 - -## Human 群聊全部 Bot 提及(#542) - -群聊组合器的 `@所有 Bot` 是临时的 Human 输入意图,预览显示当前群内已加入且未暂停的接收 Bot 人数;DM 不提供该选项,粘贴文本不携带选择权威。现有 Channel Store 使用 Registry 与成员事实生成包含名单及显示名的预览 revision。提交前及实际提交边界校验同一预览,名单改变(即使人数相同)或人数为零时返回更新后的可信预览,经既有 Typert 错误 details 显示新人数、保留草稿,并等待 Human 再次发送。 - -Host 将选中的单一 token 展开为普通逐个 @Bot 的正文及稳定 ID/范围,提交一个 Source Event 和 placement,再复用每个 Bot 的普通 group-mention Admission、attention 与 wake policy;普通消息 silent 不屏蔽明确提及。重试相同 messageId 在当前成员校验前复用已提交内容,重启保留普通消息及各接收者状态。该预览不写入消息或引入 broadcast Source Class;Bot 的 Tool 不暴露此快捷方式。见 [ADR-0099](../adr/0099-human-all-bot-mention-expands-to-direct-mentions.md)。 - -### 明确分享自己的 Inbox 来源(#636) - -[ADR-0115](../adr/0115-explicit-inbox-sharing-adds-canonical-placement.md) 向当前 Orchestrator 暴露 `bridge_share`。Messaging 在执行时检查自己的 Inbox 来源、当前接收身份/Grant 与已加入的 Group,再原子提交一个 canonical placement 和各成员普通收件记录。接收 Bot 保留原 admission,其他成员沿用自己的 Channel Attention 与数量/时间 harvest。Channel 渲染直接读取 canonical 来源内容,保留发送人、平台和外部 ID。分享不改变后续收件,不镜像到 Human DM,不对外发送,也不授权成员借用接收身份。同目标重试返回已提交结果,不补收给后来成员;首个切片拒绝另一个目标、DM 和已经投递到 Channel 的来源。撤销授权会阻止新的副作用,但保留已共享历史。多 placement 与独立接收路径由下述 #635 切片交付。 - -[ADR-0117](../adr/0117-external-only-reports-use-owned-outbox-correspondence.md) 增加明确请求的纯外部报告。`bridge_targets` 返回自己已有的授权群 Grant,`bridge_post` 沿用 Outbox,不产生 Channel placement 或 admission。可选 Provider 回执保留原生消息/会话 ID;`bridge_outbox` 提供有界预览或一条 canonical 报告,撤销后仍可读。真实收件的 parent/root 只关联同 Bot、账号 fingerprint 和会话的原报告。Profile 与来源 Modal 读取 Outbox 投影。可选且认证的自身回传仅补充已有对应关系,不产生收件提醒;不假定真实 Lark 必定回传。未知结果不自动重试,沿用 @/跟进和 dispatch 前撤销检查。 - -### External platform behavior defaults (#701) - -[ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) assigns qualified Lark collection, ordinary-message harvest and identity enabled preferences to the existing Messaging database owner. Immutable global revisions cross the authenticated Typert/API Gateway; native Bot settings and Profile tables display effective values and inheritance origin. Legacy configurations remain custom; new qualified configurations inherit, and restoring inheritance uses current defaults. Explicit Thread/source collection overrides platform collection; a member's Channel wake override precedes its explicit PersonaBot source rule and then external-platform defaults. Local messages retain local defaults. Sources and per-member admissions save revisions and thresholds; digest buckets partition those snapshots. Identity pause reuses the existing dispatch gate and Consumer lease lifecycle; resume revalidates the same authorization and fences old events without backfill. No new identity, Grant, listener, automatic reply or Inbox store results from a defaults change. - -[ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) 将已资格验证的 Lark 收件、普通消息汇总与身份启停偏好归于既有 Messaging 数据库权威。全局不可变版本经认证 Typert/API Gateway 供 Bot 设置、Profile 与 Bot 工具读取;旧配置保持自定义,新配置明确继承,恢复继承使用当前默认值。话题/来源收件覆盖平台默认;成员 Channel 唤醒覆盖 PersonaBot 显式来源规则,再回退外部平台默认。本地消息保持既有默认。Source 与各成员 Admission 保存版本及阈值,汇总按快照分桶。身份暂停复用既有发送门禁和 Consumer lease,恢复校验原授权并拒收边界前的迟到事件,不回填;修改默认值不产生新身份、Grant、listener、自动回复或 Inbox 存储。 - -### Multiple source and target routes (#635) - -[ADR-0120](../adr/0120-multiple-bridge-routes-retain-canonical-sources.md) keeps multiple explicit Channel/Inbox routes under the existing authorized Messaging Grant. One qualified Provider Consumer lease per verified account fans out to conversation-scoped Grants. A canonical external Source Event has one content authority and multiple per-Channel placements; Inbox-only has no transcript. Immutable per-Bot/per-route snapshots retain authorization, collection and wake evidence beside one admission per Bot/source. Any ready live path can select that source once; handling one Bot cannot consume another member's admission. Current membership and identity gates protect intake and reads; context and attachments retain their own receiving-identity boundaries; explicit shared-source replies use the responder-owned authorization described below. Route pause/delete/reenable do not affect surviving routes or backfill accepted sources. Profile tables expose explicit DM versus Inbox-only destinations, independent switches and native add/edit/delete Modals. Generation 52 requires a pre-upgrade Profile backup or forward recovery, rather than code-only rollback. - -[ADR-0120](../adr/0120-multiple-bridge-routes-retain-canonical-sources.md) 将多条明确的 Channel/Inbox 路由保留在既有 Messaging Grant 下;同一已验证账号的一个 Provider Consumer lease 向各授权会话分发。外部 Source Event 保留一份内容权威和各 Channel placement,Inbox-only 不生成聊天历史。每 Bot/路径的不可变快照独立记录授权、收件与唤醒证据,每 Bot/来源仍只有一条 admission;任一有效路径就绪时只选择来源一次,处理一个成员不会消耗其他成员的工作。收件及读取检查当前成员和身份权限,上下文与附件仍遵守接收身份边界;明确的共享来源回复使用下述回复者自身授权。单条路由暂停、删除或恢复不影响其他路径,也不回填旧来源。Profile 通过独立 Switch 和原生 Modal 管理路由,明确区分 DM 投递与 Inbox-only。Generation 52 需升级前 Profile 备份或向前修复,不能仅回退代码。 - -### Own-identity replies to shared external sources (#637) - -[ADR-0122](../adr/0122-shared-source-replies-use-responder-owned-authorization.md) keeps source content and receiving identity canonical while allowing a current Group member to inspect its shared placement without creating an Inbox Admission. An explicit reply requires that member's own enabled external identity and one verified Grant for the original external group. The optional dsh-im checked reply-context contract resolves the exact source under the responder's application, retaining conversation/thread/root/parent while translating only the app-scoped sender identifier. The existing Outbox records a per-responder/source intent, owned identity, qualified route, receipt and honest outcome; Profile projects these details. Membership, Binding/Grant revisions and Provider Registration are fenced again after remote validation and immediately before SDK dispatch. Missing authority, mismatched routes and unknown outcomes never borrow the receiver, fall back to the group mainline or retry blindly. Text replies add no source copy, automatic ownership lock, wake or schema migration; shared remote context/files remain separately qualified. - -[ADR-0122](../adr/0122-shared-source-replies-use-responder-owned-authorization.md) 保留一份来源内容和原接收身份,让当前 Group 成员读取已共享的 placement,而不新增 Inbox 收件。明确回复必须使用该成员自己启用的外部身份和原外部群的唯一已验证 Grant。dsh-im 可选的回复核验契约以回复者应用读取原消息,保持会话/话题/root/parent,只解析该应用范围内的发送人 ID。既有 Outbox 按回复者/来源持久记录独立请求、身份、路线、回执与真实结果,Profile 展示这些事实。远端核验完成后、SDK 发送前再次检查成员、身份/Grant 版本与 Provider Registration。缺少授权、路线不符或未知结果时,不借用接收身份、不回退群主线、不盲目重试。文本回复不新增来源副本、自动抢单锁、唤醒或数据库迁移;共享远端上下文和附件仍需各自的资格验证。 回复核验前,既有 Consumer fanout 可在收件关闭时取得仅供回复的账号 lease;外部来信确认后丢弃,不新增 Source Event 或 Inbox,自己的准确回显仍可关联 Outbox。Profile 仍显示收件关闭,身份/Provider 生命周期会取消该进程内连接。 - -[ADR-0125](../adr/0125-nearby-context-combines-time-coverage-and-count-minima.md) 更新外部 `bridge_context` 的 nearby 选择:先通过有界分页覆盖来源前后各五分钟内的可见 Human 文字消息,再为不足的一侧补齐默认前 10/后 5 条;Bot 可指定每侧 0–20 条,条数是保底而不是密集窗口上限。Lark Provider 依次遍历时间窗口、最近更早消息、最近更晚消息,按精确时间过滤秒级边界;不等待未来消息。Messaging 的进程内 opaque continuation 绑定 Bot、来源、Grant revision、范围与条数参数,每页续期 30 分钟,每次仍重新校验当前权限与 Consumer/Provider 生命周期。输出预算、略过记录和续页均明确呈现;普通历史仅形成既有 Source Event 观察,不创建 Inbox Admission 或新的消息权威。 - -[ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md) extends the existing checked DSH Service Provider/Consumer seam to Slack Human text mentions. The native Socket Plugin verifies the workspace, Bot and App pairing, persists exclusive Consumer mode, and acknowledges only accepted canonical intake; loss of a lease does not create a standalone Session. Slack channel IDs, message timestamps and event delivery IDs remain distinct. Replies use the original native thread timestamp (or root message timestamp to start a thread), current own-Bot channel/source qualification, the existing pre-dispatch fence and an explicit native receipt. Client identity/connector/source views reuse their existing projections with platform-correct labels. Explicit Slack source context reads now use the same checked Provider: every page requalifies own account/member/source/lease, separately adapts newest-first channel history and chronological native reply chunks (including root reservation/deduplication and time-bounded cursors towards older chunks), and persists only returned canonical Source Events without historical Inbox Admission or wake (#819). Nearby reads paginate the inclusive five-minute window and supplement nearest visible Human text to default 10 before / 5 after, each configurable 0–20. Signed runtime-local continuations retain bounded native IDs/counts, not message bodies; channel-history coverage is distinct from complete native-thread coverage. The #831 file tracer separately opts into one hosted attachment on a Human mention: exact native source-file association is rechecked at read time, safe ID/name/type/size metadata reaches canonical Source Events, and bounded private streams reuse the Attachment owner. Explicit result files use the existing Outbox with a fence before native upload completion in the original thread. Private URLs remain Provider-local; redirects, revoked leases and changed sources refuse. The #837 ordinary-text Consumer opt-in uses native message.channels events with current account/App/public membership/lease checks; own mentions remain on app_mention only. Explicitly authorized groups retain default mention-only collection, real-delivery qualification and existing count/time or immediate wake. Profile reuses the same compact collection editor; no new store, queue, Session or mandatory reply is introduced. #854 extends the existing explicit thread policy to Slack native `thread_ts`: root and thread match, while no parent ID is fabricated. A real unmentioned child (`ts != thread_ts`) on the current lease qualifies follow; a mainline root does not. Source/account/Grant checks, Human overrides, per-member Admission snapshots and count/time wake remain unchanged. Migrated connector Grants retain the Profile thread table and policy Modal. Ordinary files remain separately qualified; #843 extends the existing platform defaults after intake qualification. - -Slack external-only reports (#863) use the same canonical Outbox as Lark: an explicit own-identity `bridge_post` persists text and checked native `channel`/`ts` receipt without a Channel placement or self admission. An eligible Human thread reply associates through native root identity under the same account/fingerprint/conversation; existing connector and wake policy determine intake. Publication does not imply topic follow. Provider account/member/Consumer/Registration checks precede a single non-retrying send; ambiguous outcomes remain unknown. See [ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md). - -[ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md) 将既有 DSH Service Provider/Consumer 契约扩展至 Slack 的 Human 文字 @。原生 Socket Plugin 核验工作区、Bot 与 App 对应关系,持久保存独占 Consumer 模式,仅在 canonical 收件接受后确认;lease 丢失不创建独立 Session。Slack 频道 ID、消息时间戳和事件投递 ID 分别保留。回复沿原生话题时间戳(主线消息则用其时间戳新建话题),重新核验自身身份的群成员资格及原消息,经过既有发送前门禁并保存原生回执。身份表、频道连接器表和来源详情复用既有投影,按实际平台显示名称。Slack 来源上下文通过同一 checked Provider 主动读取:每页复查自身账号/成员/来源/lease,分别适配频道历史倒序和原生话题页内正序/向旧消息块续页,为附加 root 预留空间并去重,只持久化返回的 canonical Source Events,不生成历史 Inbox Admission 或唤醒(#819)。附近查询完整翻页读取前后各五分钟窗口,稀疏时补齐最近的前 10/后 5 条 Human 文本,两侧可各设 0–20。运行实例签名的续页只含有界原生 ID/计数,不含消息正文;频道历史的覆盖范围区别于完整原生话题。#831 单独为 Human @ 消息启用一个 hosted 附件:读取时复查原生消息与文件关联,安全 ID/名称/类型/大小进入 canonical Source Event,有界私有下载流复用 Attachment owner。明确导入的结果文件通过既有 Outbox 回传原生话题,在上传完成可见前执行门禁。私有 URL 留在 Provider 内部,拒绝跳转、已撤销 lease 和已变更来源。#837 通过 Consumer 明确 opt-in 接收原生 message.channels 普通文字,复查当前账号/App/公开群成员/lease;自己的 @ 只走 app_mention。已授权群保留默认只收 @、真实普通投递验证及现有数量/时间汇总或逐条排队唤醒,Profile 复用紧凑收件策略编辑器,不增加存储、队列、Session 或强制回复。#854 将既有显式话题策略适配到 Slack 原生 `thread_ts`:root 与 thread 相同,不制造 parent ID。当前 lease 上真实无 @ 子回复(`ts != thread_ts`)才证明可跟进,主线 root 不提供该证明。来源/账号/Grant 校验、Human 覆盖、每成员 Admission 快照和数量/时间唤醒保持既有契约;迁移后的频道连接器 Grant 保留 Profile 话题表与策略 Modal。普通文件仍需独立资格验证;#843 在收件资格验证后扩展既有全局默认设置。 - -### 已资格验证平台的默认行为 - -[#1156](https://github.com/BotHarness/BotHarness/issues/1156) qualifies two QQ applications through the existing independent binding and Channel Bridge model. Each app-scoped conversation is explicitly mapped; neither matching text nor a shared local Channel equates external locators or lends passive reply proof. The canonical Source Event projects its receiving app reference/name into QQ Channel source labels and details, and Group Profile activity retains app identity. These optional presentation fields add no store or sending authority; earlier projections remain readable. Native acceptance receipts and observed Provider Echo remain distinct capabilities. - -Native own Echo remains unqualified: accepted send receipts and visible original-group replies do not establish a platform callback of the responding App's own message. The current dual-App slice is partial #1156 delivery. - -The optional developer Provider's maintained SDK 1.0.4 build patch observes the WebSocket message boundary before decode/JSON, links bounded transport and Gateway metadata by process-local connection/frame counters, and exposes start/end totals through existing connection health. Diagnostics remain process evidence; they create no Source, Admission, Outbox or Echo authority and are separate from the managed product Provider pin. - -自身原生 Echo 尚未通过验收:发送接受回执与原群可见回复不能证明平台回推了响应应用自己的消息;当前双 App 切片属于 #1156 的阶段性交付。 - -可选开发 Provider 的维护版 SDK 1.0.4 构建补丁在 WebSocket message 进入 decode/JSON 前观测,按进程内 connection/frame 计数关联有界传输与 Gateway 元数据,并通过既有连接健康状态提供首尾累计数。诊断仅为进程证据,不产生 Source、Admission、Outbox 或 Echo 权威,与托管产品 Provider pin 保持独立。 - -[#1156](https://github.com/BotHarness/BotHarness/issues/1156) 沿用独立应用绑定与 Channel Bridge 模型验证两个 QQ 应用。各自应用范围的会话分别显式映射;相同文字或共同本地 Channel 不会合并外部定位,也不会借出被动回复证明。Canonical Source Event 把接收应用 reference/名称投影到 QQ Channel 来源标签和详情,群 Profile 活动保留应用身份。这些可选展示字段不增加存储或发送权限,旧投影仍可阅读;原生接受回执与 Provider Echo 观察保持独立资格。 - -[#1153](https://github.com/BotHarness/BotHarness/issues/1153) exposes Group Channel sync from the received QQ conversation through the existing Channel Bridge command. Inbox Admission and optional Channel placement share the canonical Source Event; disabling placement retains history. Messaging stores bounded reception metadata by Bot, Provider and authenticated app fingerprint in its own database. Local controls and public account inspections record distinguishable boundaries, while restart continuity remains explicitly unverified; no second transcript or remote backfill is introduced. Same-app rebind preserves blocks and mute preferences without restoring revoked Channel authority. See [ADR-0152](../adr/0152-qq-reception-intervals-record-local-observations.md). - -[#1153](https://github.com/BotHarness/BotHarness/issues/1153) 通过既有 Channel Bridge 操作,在已接收的 QQ 会话里提供群 Channel 同步。Inbox Admission 与可选 Channel 落点共享 canonical Source Event,停止落点保留历史。Messaging 在自己的数据库里按 Bot、Provider 和已认证应用 fingerprint 保存有界接收元数据,区分本地控制与公开账号检查的边界;重启连续性明确标为未验证,不引入第二份消息记录或远端补收。重绑同一应用保留屏蔽和静音,但不恢复已撤销的 Channel 权限。见 [ADR-0152](../adr/0152-qq-reception-intervals-record-local-observations.md)。 -The [#1159](https://github.com/BotHarness/DeepSeekBot/issues/1159) incoming-voice extension extends the same checked Service with independent transcript/audio opt-ins and the existing strict native quote proof. Native platform ASR or explicit unavailable provenance reaches canonical Source/Inbox and the existing Orchestrator text reply. Original audio remains source-bound canonical media; explicit playback acquires a separate WAV through the shared bounded SILK Worker. WeChat encoding checks remain platform-specific. There is no second voice store, automatic ASR, ordinary reception or native voice output. One maintained-runtime application qualified native quoted ASR → model → own-app original-group text reply (Human confirmed), original download and actual Client keyboard playback; other application/codec/modern-Host combinations remain independent qualifications. - -[#1159](https://github.com/BotHarness/DeepSeekBot/issues/1159) 语音入站扩展沿同一受检 Service 增加独立转写/原音协商,并复用严格原生引用证明。原生平台转写或明确不可用来源进入 canonical Source/Inbox,沿现有 Orchestrator 在原群回文字。原音保持来源约束,显式播放通过共享有界 SILK Worker 获取独立 WAV;微信编码校验仍由自己的平台边界负责。不新增第二份语音记录、自动 ASR、普通消息接收或原生语音输出,一个维护版运行应用已通过真实引用语音转写 → 模型 → 本应用原群文字答复(Human 确认)、原音下载及实际 Client 键盘播放;其他应用/编码/现代 Host 的组合仍需独立验收。 - -The [#1158](https://github.com/BotHarness/DeepSeekBot/issues/1158) file candidate adds explicit generic-file negotiation to the same QQ Service. Direct native files and qualifying embedded type-103 file quotes belong to the current mention Source Event; a single raw file block, matching message/group/author and consistent native reference indices are required. No adjacent-message matching, history cache or historical Source is introduced; private download tickets stay in Provider memory. Shared Channel projection adds a file card whose download uses the existing source/identity/Grant checks, canonical AttachmentStore and forced attachment response. Processing reuses an independent Workspace working copy and imported result. Upload type 4 uses the same final account/source/lease/Core fence and native receipt as image output; actual application permission remains unqualified. - -[#1158](https://github.com/BotHarness/DeepSeekBot/issues/1158) 文件候选在同一 QQ Service 增加显式普通文件协商。直接原生文件及合格的内嵌 type-103 文件引用均归属当前 @ 消息 Source Event:要求唯一原始文件块、消息/群/作者身份吻合、原生引用索引一致,不关联相邻消息、不增加历史缓存或历史 Source,私有下载票据保留在 Provider 内存。共享 Channel 投影增加文件卡片,下载复用现有来源/身份/Grant 检查与 canonical AttachmentStore,并强制附件响应。处理复用独立 Workspace 工作副本及导入结果。类型 4 上传复用图片输出的最终账号/来源/租约/Core 检查及原生回执;实际应用权限尚未验收。 - -The [#1157](https://github.com/BotHarness/BotHarness/issues/1157) QQ image candidate extends the same checked Service with opt-in image intake, bounded private source tickets and acquisition, and native image output. Canonical Source Event/Inbox and AttachmentStore remain the authorities. Human preview uses the original implicit account Grant plus its current Channel route and canonical placement when no independent reception-path record exists; membership, identity, scope and revocation checks still apply. Preview creates no Observation or wake. Actual model access reuses working-copy save and native `read_image` on an image-capable calling route. Image upload never sends a message itself; account/lease/source and Core authority are checked again immediately before the single final native POST. Its public native message receipt settles the existing Outbox; unknown is not retried. Caption-first presentation preserves attachment association without inventing native interleaving. Real QQ/model/Client acceptance remains separate evidence. - -[#1157](https://github.com/BotHarness/BotHarness/issues/1157) QQ 图片候选沿同一 checked Service 扩展显式图片收件、有界私有来源票据与获取,以及原生图片输出。Canonical Source Event/Inbox 与 AttachmentStore 继续拥有权威。没有独立接收路径记录时,Human 预览使用原 implicit 账号 Grant、其当前 Channel 路由及 canonical placement,仍检查成员、身份、范围和撤销。预览不产生 Observation 或唤醒。实际模型访问复用工作副本保存及支持图片的当前调用路由上的原生 `read_image`。图片上传自身不发消息;最终单次原生 POST 前再次检查账号/租约/来源和 Core 授权。公开原生消息回执结算既有 Outbox,unknown 不重试。说明在前的展示保留附件关联,不虚构原生穿插顺序。真实 QQ/模型/Client 验收证据独立记录。 - -The [#1152](https://github.com/BotHarness/BotHarness/issues/1152) QQ group-text candidate registers through the existing dsh-im Service Provider/Consumer seam. Official application authentication supplies the account fingerprint; trusted group mention text commits into the existing Source Event and Bot Inbox, wakes the existing Orchestrator and replies through its own application in the original group. Reply-only qualification does not require proactive capability. Exact source proof is bounded and process-local: five minutes, five attempted replies, no group-history reread or durable resume cursor. A pre-dispatch authority fence and native acceptance receipt preserve the existing Outbox; unknown results remain unknown without automatic retry. See [ADR-0151](../adr/0151-qq-group-replies-use-authenticated-apps-and-process-local-source-proof.md) and the [QQ guide](../qq-connection.md). Real group/model qualification and public release are separate delivery evidence. - -The [#1154](https://github.com/BotHarness/DeepSeekBot/issues/1154) QQ delayed-result slice reuses [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md)'s shared posting policy through an explicitly authorized saved group target when the Provider lacks QQ discovery. Known reply expiry retains the original failed Outbox intent; an explicit proactive report uses a distinct request identity, own-app/target correspondence, final fence, shared hourly quota and native receipt. Unknown replies do not become proactive reports, and repeated report requests after restart read the existing outcome. This adds no QQ-specific policy, automatic retry, local Human DM placement or additional durable result store. Real current-app delayed-task qualification remains separate from source regression coverage. - -[#1154](https://github.com/BotHarness/DeepSeekBot/issues/1154) 的 QQ 延迟结果切片在 Provider 缺少 QQ 群发现能力时,通过已显式授权的保存群目标复用 [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md) 的共享发送策略。明确的回复过期保留原失败 Outbox 意图;显式主动报告使用独立请求身份、自己的应用与目的地一致性、最终门禁、共享每小时限额和原生回执。不确定回复不会转为主动报告,重启后的重复报告请求读取既有结果。没有 QQ 专属策略、自动重试、Human DM 落点或第二份持久结果存储。当前应用的真实延迟任务验收与源码回归分别记录。 - -The #1155 ordinary-text extension uses the same exclusive checked QQ inbound consumer. Its software capability enables forwarding, while actual ordinary-delivery qualification belongs to the current Core receiver lease and follows the existing account/conversation intake boundary. A mentions-only probe supplies bounded process evidence without canonical ordinary collection. Explicit collection uses the existing Source, Observation, harvest and Channel policies; intentional external speech still requires independent posting authority. Neither a stored collection preference nor a blocked-interval event establishes current eligibility. No QQ history store, permission flag or standalone receiver is added. - -#1155 普通文字扩展沿用同一 QQ 独占受检消费者。软件能力允许转交事件,真实普通投递资格则归属于当前 Core 接收器 lease,并遵循已有账户/会话接收时间边界。只收 @ 模式下的探测提供有界进程证据,不进行普通内容的 canonical 收集。显式收集复用已有 Source、Observation、harvest 与 Channel 策略,主动外部发言仍需独立发送权限。保存偏好及屏蔽期间的旧事件均不证明当前资格,不新增 QQ 历史存储、权限标志或独立接收器。 - -[#1152](https://github.com/BotHarness/BotHarness/issues/1152) 的 QQ 群文字候选沿用现有 dsh-im Service Provider/Consumer 边界。官方应用认证提供账号 fingerprint;可信群提及文字提交到既有 Source Event 和 Bot Inbox,唤醒既有 Orchestrator,并使用自己的应用身份在原群答复。仅回复资格不要求主动发送能力。准确来源证明有界且仅存在于进程内:五分钟、五次回复尝试,没有群历史重读或持久恢复游标。发送前授权门禁和原生接收回执沿用既有 Outbox,unknown 不自动重试。见 [ADR-0151](../adr/0151-qq-group-replies-use-authenticated-apps-and-process-local-source-proof.md) 与 [QQ 指南](../qq-connection.zh.md)。真实群/模型资格与公开发布分别记录交付证据。 - -[#843](https://github.com/BotHarness/BotHarness/issues/843) 按 [ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) 将既有全局偏好扩展至 Slack 群文字。Bot 设置可选择 Lark 或 Slack,各自保留草稿与独立不可变版本;认证读取 RPC 可指定已资格验证的平台,旧调用仍读取 Lark。Generation 53 保留全部旧默认值与显式覆盖,仅扩展允许的平台。新绑定的 Slack 身份默认继承,旧身份保留原选择,由 Human 明确恢复继承。修改只作用于后续事件及仍继承的收件/汇总/身份行为;群授权、真实投递验证、Admission 快照与恢复接收时间边界仍由原模块管理,不新增账号、Grant、消息、队列或回复权威。 - -[#855](https://github.com/BotHarness/BotHarness/issues/855) adds a **development-source qualified mention/reply** Discord checked adapter through the same Provider/Consumer and canonical Inbox seams. App/Bot identity is authenticated and checked against Gateway READY. An explicitly external-only Profile avoids standalone bootstrap; consumer loss preserves external ownership. A public thread maps to its verified parent conversation plus exact child-channel `threadId`, without manufactured message ancestry. Replies check current native permissions/source, run the authority fence and validate native author/destination/receipt without fallback or retry. See [ADR-0128](../adr/0128-discord-checked-replies-preserve-native-child-channel-routing.md). The real channel/thread model, native identity/guild boundary and refusal/recovery checks passed under agent-operated E2E acceptance; [verification](../dev/verification/discord-855-mention-reply.md) bounds the qualification to that development runtime. Product Provider promotion and other Discord capabilities remain separate. - -[#855](https://github.com/BotHarness/BotHarness/issues/855) 通过相同 Provider/Consumer 和 canonical Inbox 边界增加**开发来源提及/回复已取得资格**的 Discord adapter。App/Bot 身份由认证 API 与 Gateway READY 校验,显式 external-only Profile 避免先进入独立 Session,consumer 消失仍保留外部所有权。公开 thread 使用已验证的父会话与准确子频道 `threadId`,不虚构消息祖先。回复复查原生权限/原消息,执行授权门禁并验证原生作者/目的地/回执,不转发、不重试。见 [ADR-0128](../adr/0128-discord-checked-replies-preserve-native-child-channel-routing.md)。真实频道/thread 模型、原生身份/服务器边界与拒绝/恢复检查已按 agent-operated E2E 指令通过;[验证记录](../dev/verification/discord-855-mention-reply.md) 将资格限定于该开发运行时。产品 Provider 晋升与其他 Discord 能力另行处理。 - -[#937](https://github.com/BotHarness/BotHarness/issues/937) extends that candidate Provider through the existing `bridge_context` / `historyChecked` seam: bounded group and existing-public-thread Human-text pages, native App content visibility plus view/history checks before and after the HTTP page, and runtime-local signed cursors bound to account, original source route, scope and limit. Canonical context retention creates no Admission or placement; a conflicting retained source rolls back the page and reports `source-conflict`. Native edits never replace the earlier source evidence. [The exact candidate checkpoint](../dev/verification/discord-937-context-reads.md) separates real single-page model success, direct native cursor checks and restored-permission/restart refusal from still-pending model continuation/conflict acceptance. Nearby, ordinary subscription, files and product dependency promotion remain outside this slice. - -[#937](https://github.com/BotHarness/BotHarness/issues/937) 沿既有 `bridge_context`/`historyChecked` 边界扩展该候选 Provider:有界 group/已有公开 thread Human 文本页,HTTP 页读取前后核对原生 App 正文可见性及查看/历史权限,运行时内签名 cursor 绑定账号、原来源路由、范围和页限。Canonical 上下文留存不创建 Admission 或 placement;留存来源冲突使整页回滚并返回 `source-conflict`,远端编辑不替换早期来源证据。[准确候选检查点](../dev/verification/discord-937-context-reads.zh.md) 区分真实模型单页成功、直接原生 cursor 检查及恢复权限/重启后的拒绝,与仍待完成的模型续页/冲突实测。Nearby、普通订阅、文件和产品依赖晋升不属于本片。 - -[#845](https://github.com/BotHarness/BotHarness/issues/845) qualifies Slack sources through the existing shared Channel routes and member Attention, without a second store. Group Profile exposes separate effective Lark/Slack wake previews; the legacy Lark RPC field remains compatible. The bilingual [IM Provider integration guide](../dev/guides/im-provider-integration.md) records the checked identity, intake, native routing, bounded context/file and acceptance boundaries for future providers. - -[#845](https://github.com/BotHarness/BotHarness/issues/845) 使用既有共享 Channel 路由与成员 Attention 验证 Slack 来源,不增加存储。Group Profile 分别展示 Lark/Slack 的有效唤醒策略,保留旧 Lark RPC 字段兼容。[IM Provider 接入指南](../dev/guides/im-provider-integration.zh.md) 汇总后续平台需要复用与验证的身份、收件、原生路由、有界读取/文件及验收边界。 - -[#878](https://github.com/BotHarness/BotHarness/issues/878) adds a **WeChat paired-owner DM slice with source and installed-product E2E verified and Human QA approved**, through the existing checked Provider/Consumer, canonical Source/Inbox and own-identity Outbox. The QR-paired owner is an explicit native DM Grant, with no fabricated mention/group/thread or local DM mirror. The Provider keeps the original private continuation outside public snapshots and advances polling only after canonical acceptance; absent leases remain external-only. Checked text replies use the source-bound continuation and a pre-send authority fence. Their receipt is explicitly a client acknowledgement, not a native message ID or read/delivery proof. The #903 file slice reuses the same Source Event/Inbox path: one native file is retained as safe metadata, privately downloaded into canonical attachments on demand, and an explicitly selected result replies to the original DM. CDN tickets/AES keys never enter public DTOs; downloads stop at 25 MiB, and a required current-authorization fence runs after upload before the final send. Declared oversized files remain inspectable but not downloadable. The #904 image candidate separately negotiates native image intake, derives format from bounded decrypted bytes, and reuses canonical attachments plus own-identity image replies with a post-upload fence. The Client preview is authenticated and disposable; model understanding additionally requires the actual DSH model route to support images. Installed-product image intake, preview and actual DeepSeek Flash image understanding are verified; the Human confirmed the native image result in the original WeChat DM. Receiver-side byte equality and a public image release are not claimed. The #905 candidate preserves platform voice-transcript availability, native item ID and duration in the existing Source Event and exposes provenance in Inbox/Source UI and the Orchestrator input. It does not implement ASR or audio playback; a real native voice transcript reached the model and the Human confirmed its own-identity text reply in the original DM. The #906 raw-audio extension additionally negotiates a private source-bound audio attachment and optional native codec metadata, retaining the unchanged original in canonical attachments. Explicit playback or working-copy selection runs bounded SILK decoding in a Worker and acquires a separate mono 24 kHz WAV through the same AttachmentStore; authenticated preview resources are disposed on close, and current source/account/Grant fences remain in force even for cached output. Unsupported codecs remain downloadable originals. This adds no ASR, automatic model audio input or native outgoing voice. Fresh raw-audio E2E qualification is tracked separately from the #905 transcript evidence. Remote context, groups and canonical proactive reports remain unavailable in that earlier slice. See [ADR-0129](../adr/0129-wechat-owner-dms-use-private-source-continuations.md). - -[#996](https://github.com/BotHarness/BotHarness/issues/996) extends the existing canonical DM admission/reply path to explicitly authorized Lark private chats. A checked user Target retains its outbound `openId` and an optional exact private `chatId`; only a qualified Provider accepting that locator exposes a DM receive scope. The same PersonaBot can retain Discord and Lark identities, with private messages admitted as `human-dm` into its existing Inbox and source-bound replies through the Lark identity. Missing private-chat locators remain outbound-only; private DM reception is excluded from Group Channel Bridge controls. This adds no local DM mirror, Session store, permission broadening or second Bot. - -### Human Bridge media presentation - -[ADR-0217](../adr/0217-im-conversation-mirroring-is-an-opt-in-projection.md) adds independently opted-in IM conversation presentation and local-DM forwarding. Both default off and share one effective preference per Binding/private conversation, across implicit and explicit Grants. Incoming presentation places the canonical Source Event without another Admission; accepted outgoing presentation carries its Outbox and original-source lineage and participates in Content Purge. Only the new mirror placement reduces media to safe metadata; ordinary connector placements keep their original media checks. Enabling starts a prospective epoch; current epoch and Binding checks fence projection and bounded recovery. Forwarding uses the exact opted-in Binding, existing Outbox and final Provider send fence, including every long-message part. A closed switch or off/on cycle stops unsent work. WeChat qualifies this first slice; the other IM adapters remain separate issues under #1990. - -[ADR-0137](../adr/0137-human-bridge-media-uses-channel-source-authority.md) places Human external-image access in the existing Messaging and Attachment owners. The Source Event retains trusted native descriptors and order; realtime and historical Channel placement projections expose media references without resource keys. The existing attachment route under the authenticated API Gateway rechecks Human Channel access and independent recorded reception paths using Channel, Source Event and attachment IDs. The Provider verifies the original message/resource association before acquisition. Visible images acquire automatically, reuse the deterministic Attachment identity and enlarge in the native Modal. Reception stop refuses new acquisition; unbind/revocation refuses acquisition and cached serving through that path. Missing or purged originals are not recreated. Human viewing creates no Observation, Inbox Admission, model image input or send permission. #1021 is the Lark image tracer; files, audio/video and other providers extend the contract in subsequent slices, with real platform qualification tracked in each issue/PR. - -#1022 沿同一权威接通 Lark 经核验的单个引用文件:投影与取得共享媒体资格判断,未知 MIME/大小不作推断。Human 明确下载才取得有界原件;支持原生打开的 Host 复用既有应用菜单,`channelMediaTarget` 每次按当前 Channel/来源/文件授权解析 canonical Attachment 目标。CLI 提供同一授权路径的目标和下载命令;没有新增 Provider、消息存储或 Bot 注意力。直接文件、多文件/混排与真实平台验收保持独立资格边界。 -[#908](https://github.com/BotHarness/BotHarness/issues/908) extends the same WeChat Source Event authority with opt-in one-level native quote evidence: server message ID, item ID, embedded text, display summary and partial-reference metadata remain distinct. Missing bodies resolve only from readable canonical sources in the same currently authorized account/private conversation, or accepted Outbox records with genuine native server receipts; WeChat client acknowledgements never qualify. No Provider transcript store or standalone Session lookup is introduced. `bridge_context(retained|retained-nearby)` returns bounded canonical local records with explicit `retained-local-sources` coverage, current own-identity/Grant/lease inspection, initial row-boundary snapshot and authorization-bound opaque keyset cursors. These reads audit the anchor Source Event without creating Admissions or wake; unavailable references do not imply remote deletion or a Thread. Client source details and Orchestrator input distinguish native quoted text, local resolution and unavailable content. Remote WeChat history/search remains unavailable. - -[#909](https://github.com/BotHarness/BotHarness/issues/909) routes the explicitly authorized WeChat paired-owner DM through existing Channel connector records: local DM/Group placements and Inbox-only paths reference one canonical Source Event. Group members retain their independent Attention/harvest policy; private Inbox and explicitly connected local DM paths retain the `human-dm` policy. The native origin remains `dm`, never a fabricated WeChat group or Thread. Shared source readability does not transfer the receiving identity or its private continuation: each external reply still requires the acting Bot's own enabled identity and Grant. Existing intake boundaries, placement deduplication, membership checks and dispatch fences apply to switches, deletion, revocation and restart. No schema, Provider transcript store or new Session lifecycle is added. Fresh installed-product qualification is recorded on #909. - -[#910](https://github.com/BotHarness/BotHarness/issues/910) extends the canonical external-only report path to qualified paired-owner WeChat DMs ([ADR-0139](../adr/0139-wechat-external-reports-use-private-owner-context.md)). The Profile form and `bridge_post` share owned Outbox authority. The Provider explicitly negotiates receipt and final-fence support, keeps current owner context private with fingerprint/order/retention checks, and invokes the application's current Binding/Grant/Registration fence immediately before one native send. Genuine server IDs stay separate from client acknowledgements. Missing context and native rejection offer explicit fresh-message recovery; unknown outcomes never trigger automatic retry. No local DM mirror, new Inbox delivery, scheduler or standalone Provider Session is introduced. - -[#911](https://github.com/BotHarness/BotHarness/issues/911) qualifies a process-local checked typing lease for the authorized WeChat owner DM ([ADR-0145](../adr/0145-wechat-typing-follows-owned-processing-leases.md)). Canonical Messaging resolves own source, Binding, Grant and Registration authority; actual Orchestrator/accepted steering and related Assignment work share one bounded native lifecycle. The Provider keeps tickets and continuations private, renews only under a current fence and cancels on the last processing owner or invalidation. Identity persistence stores only the preference; live activity resets on restart and never becomes a SessionEvent, Outbox delivery or read receipt. Windows native visibility, stop and recovery passed Human QA on #911. - -[#912](https://github.com/BotHarness/BotHarness/issues/912) extends the existing immutable defaults owner to WeChat identity enablement and typing with independent inheritance markers ([ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md)). Generation 69 preserves old choices as custom; new bindings inherit. Global typing changes invalidate only inherited live leases, and identity resume retains the existing receive-after fence. The owner-DM editor omits unsupported group/Thread controls; Channel connectors keep separate routing and member policies. No new authority or message store is introduced. Native #912 fresh-message QA remains pending. - -### 窗口伙伴(#1138–#1142) - -EventSource 在暂时 HTTP 失败后进入终态 CLOSED 时,集合 owner 用单一重试计时器重新建立连接,间隔从 1 秒退避到最多 8 秒。最后确认的过程消费 ID 通过 `?resume=` 携带;新连接随后原生自动重连时,`Last-Event-ID` 优先于查询参数。两条路径共享同一认证、有界 Host 租约与规范资格重查。成功基线重置退避;移除最后一个选择或卸载 Plugin 清理计时器和恢复标识。Client 重启不保留恢复标识。 - -Client 独立的 `CompanionMotion` 拥有有界拖拽姿态、速度采样、连续重力、横向阻尼、轻微地面回弹与落地收敛。View 继续拥有既有帧循环;带阻尼的二阶角弹簧在拖拽反向、停留与松手时保留角度和角速度,气泡跟随同一位置。有界积分步长和倾角上限避免延迟帧使姿态失稳。调整窗口保留当前运动并重新收敛边界、协调拖拽原点,指针取消只匹配活动捕获。最终落地只保存归一化横向位置;减少动效时清除弹簧能量、直接回到底部并关闭姿态效果。这些呈现动力学独立实现,参考 Coopanion 的拖拽/空中/落地交互概念,不引入其源码或美术。 - -集合共用一个 Client-owned `CompanionSound`,消费既有文字播放循环新显示的已提交字素,以及 `CompanionMotion` 的抓起、快速拖动、快速释放与首次触地反馈。拖动音效冷却 500ms,地面小反弹不重复播放;取消捕获与减少动效不补播。沿用既有 `speechSound` 存储字段保存统一伙伴音效偏好,避免重置已选设置。全局伙伴声音设置按 Client/Profile 持久化,默认关闭;Web Audio 只在可信 Human 操作时按需创建,受限或不可用不影响消息。短合成音节随 Bot 身份和文字变化,标点与空白静音,并发与频率有界,不排队补播。静音、隐藏/离屏、断线、归档、移除和销毁停止相应声音。该呈现不新增模型工具或 TTS,不持久化音频资源或播放进度([#1173](https://github.com/BotHarness/DeepSeekBot/issues/1173))。 - -Avatar 为伙伴消费者提供可释放的锚点读取器:每次形象替换只采集一次受支持头部的局部顶部中心,既有可见帧循环只读取该局部组当前显示的屏幕矩阵,包含外层旋转、缩放及过渡中的变换原点。像素 Activity 覆盖使用相同头部区域点与 SVG 根矩阵;图片、未知 rig 或不可用的 SVG 几何使用可见 Avatar 盒子顶部中心。不逐帧扫描整个 SVG 边界,也不向 Host 发送姿态 RPC。`CompanionBubbles` 保留原始投射点,让直立卡片受视口、工具栏与多伙伴避让约束,偏移后用像素式连线保持来源关系。阅读延续既有位置与离开宽限;隐藏/离屏停止采样,形象替换/销毁清除读取器([#1177](https://github.com/BotHarness/DeepSeekBot/issues/1177))。 - -应用定义的 Window Companion owner 跟随 Client Plugin 生命周期,位于 Bot 模式页面之外,通过官方 `shell.overlay` Slot 渲染。本地偏好按 Client origin 与原生 `profileContext.dir` 的不透明哈希隔离,消息卡片不落盘。受认证的 Connection Fetch `/api/botharness/companion` 提供 Profile 上下文及只处理未来消息的 SSE 基线;消费者在同一 Host turn 同步注册并读取快照,再投影 Registry 形象、现有全 Bot Activity 和拥有者已提交的 Human–Bot DM 输出。Channel store 与 Session ownership 保持权威;草稿和历史查询不进入首个 feed。 - -应用定义的 `WindowCompanions` 集合拥有 Profile 的钉选集合和共用容量,组合每个 Bot 独立的播放 owner 与运动 View。侧栏菜单与各 Bot 的 header 按钮均操作此集合,与 Channel 置顶无关。偏好从单选 v1 迁移到 v2;只持久化形象选择、来源/范围、位置、走动与容量。多个伙伴复用一条受认证 SSE,通过原生随机的临时 consumer ID 和同路径 POST 原子替换消费集合;控制句柄随流关闭释放,既不是身份也不是耐久权威。递增选择版本阻止迟到的基线初始化被替换的消费;增加或移除其他 Bot 不重置正在阅读的卡片。 - -选中伙伴时,共享 feed 同时供给现有 Client Activity store,替代其独立 Activity SSE。慢消费者分别合并 Activity 和选择快照,按 Bot 有界保留消息并轮流排出。容量默认层叠 3/保留 20,层数 1–10、保留 1–100 且层数不超过保留;每个 Bot 的 Host 待发队列、Client 保留卡片与阅读到达队列各受保留数量约束。阅读中最多两倍保留数量的卡片,离开时合并最新消息;已有文字按完整字素继续播放。View 做基本气泡避让与视口夹取,不设钉选数量上限或自动折叠人物。移除一个 Bot 只释放自身播放,最后一个移除后关闭共享流。透明半身使用 PixelFigure 公共 body/head 结构,仅伙伴 surface 去掉底板,普通头像配方与背景保持原样。每个 Bot 的 DM、群聊与 Activity 标签独立开关;消息开关与 own-dm/shared/all-bot 范围相交。Channel owner 的 `observeOutput` 单条规范查询返回安全正文、作者、当前成员关系与独立的 Human 读取判定,Feed 验证所选 Bot 后推送,队列在范围变化及排出时重新资格判断。来源或范围变化只移除不再符合条件的显示及排队卡片,同一 Host 的确认基线保留仍可见内容与阅读进度,新启用来源不回放禁用期;点击来源先按需刷新 owner 会话列表,再检查锚点 timeline 读取权限,失败在卡片解释。卡片标明 Channel、Bot DM 双方和原会话可读性,不自动加入或改变已读位置。宽范围仅新增所选 Bot 的输出观察,既有通用 timeline 和模型能力不扩权。同一 Host 的 EventSource 断线重连通过 `Last-Event-ID` 识别过程租约;最多保留 60 秒、128 个已断开的租约,每 Bot 的规范消息引用受保留数量约束。Channel owner 的 `outputCheckpoint` 读取最新 placement 位置,`observeOutput` 返回单条位置;首次选择及新开启来源建立 Host 基线,延迟的旧通知不能成为新消息。四类来源各有 Client 过程 epoch,离线开关或范围变更后只恢复连续启用且仍合格的引用;补播重新查询规范正文和成员关系。恢复 GET 原子接管持续收件,等待 POST 确认或握手再次中断时仍有界留存;重复规范引用不占用额外队列容量。订阅确认先于补播;Client 保留同 Host 的阅读进度,按 Channel/message 标识去重。租约或 generation 失效、Client 重启均开始未来基线;无全历史扫描、耐久消息副本或共享已读写入。页面隐藏取消帧采样并暂停文字和到期时间。生命周期扩展仍按规格 [#1135](https://github.com/BotHarness/DeepSeekBot/issues/1135) 交付。 - -Companion Feed 通过一条可释放的订阅直接观察已提交的 `bot-registry` 通知,独立于 Bot 模式。归档保留带标签的静止形象;规范的暂停/重新启用变化重置有界消息引用和来源检查点。过程生命周期标识随流恢复保留,让合并过的归档/重新启用变化仍可被 Client 识别,不新增耐久身份状态。确认删除通过已确认选择快照发送移除 ID;collection 只释放对应子项、偏好和卡片,旧 revision 不能恢复已删除项。当前 owner 的 attention 事实仅用于导航。上传图片保留原媒体与整体交互;不支持的 rig/schema 保留配方和带版本的保存快照,并明确说明动作限制。图片 owner 拒绝过期版本 URL,不以另一形象替换该版本。键盘焦点与 hover 同样展开阅读;菜单键/Shift+F10 打开原生菜单,Escape 返回焦点,关闭气泡选取存活控件。减少动态效果立即归位,离屏/隐藏停止采样,销毁释放 observer、帧及 morph/fade 动画;旧动画完成回调不能再排入动作。性能验收仍须真实多 Bot 帧、内存、资源测量及 Human 同意的预算。 - -支持的像素伙伴通过 BotPixel 的可选 `mouthLayers` 公共合约预绘制 saved/closed/half-open/open 四层,普通头像与 Host 保存快照保持默认输出。`WindowCompanion` 从既有安全字素播放进度派生一份嘴型,不引入说话 Activity、第二个消息队列、计时器或耐久表情状态。生产 Avatar 仅在 companion surface 请求这些层,独立 `AvatarSpeech` 资源在既有 SVG 内做短透明度过渡,不重建人物或重启眨眼/转头;说话时暂时显露脸部,外层 Activity 和 attention 仍反映真实事实。标点闭嘴,播放结束、取消、归档、断线及销毁恢复保存表情;减少动态效果、隐藏与离屏由现有共享策略立即归静。多卡片选择第一条仍在逐字呈现的卡片驱动一套有界嘴型。上传图片、不支持的 family/版本保留原有降级,不拼接替代部件。该呈现不需要 TTS、音素、模型动作标记或 Tool 私有内容([#1143](https://github.com/BotHarness/DeepSeekBot/issues/1143))。 - -### Own-Bot Inbox history (#1278) - -The application-defined `inbox_history` Orchestrator Tool reads only the calling PersonaBot's canonical Inbox Admissions, including handled messages and self-records. Generation 77 adds a rebuildable FTS5 trigram projection over Source Event bodies, notice labels, commit subjects and changed paths. Source Event insert/update/delete and Purge Ledger application maintain the index in the same transaction; both the projection and every returned page exclude purged source/cause content. FTS5 secure-delete is enabled alongside the Purge owner's SQLite secure_delete setting, so purge removes old index entries rather than retaining recoverable delete keys. This query neither observes work nor changes Attention Decisions. The native Tool Consumer obtains Bot identity from trusted Orchestrator ownership; it does not accept a target Bot argument. Channels, Inbox history and Memory remain separate authorities for conversations, the Bot's diary, and distilled memory. - -Pages contain at most 50 items and 300 Unicode characters per snippet. Literal searches require at least three Unicode characters. Chronological pages use a timestamp/id keyset and an initial Source Event rowid boundary. Relevance pages preserve their first FTS order in a bounded process-local snapshot containing only Source Event ids, then rejoin current canonical data on each read so purged, removed or no-longer-matching rows disappear. Ranked cursors expire after ten minutes or Host restart; the cache holds at most 64 searches with at most 10,000 matches each, requiring narrower filters for larger result sets. Neither the cache nor the index creates a second transcript or resumable work queue. New self-record payloads persist their actual cause id for cause filtering; older records without that id remain searchable without inventing provenance. See [ADR-0154](../adr/0154-bot-self-records-keep-memory-commits-and-bot-actions-in-the-timeline-and-inbox.md). - -应用定义的 `inbox_history` Orchestrator Tool 只读取调用者 PersonaBot 的规范 Inbox Admission,包括已处理消息和自我记录。Generation 77 新增可从 Source Event 重建的 FTS5 trigram 投影;事件写入、更新、删除与 Purge Ledger 应用在同一事务维护索引,查询排除被清除的来源及起因内容,同时启用 FTS5 secure-delete 与 Purge owner 既有的 SQLite secure_delete,避免旧索引段残留被清除词条;不观察待办、不改变 Attention Decision。Bot 身份来自可信 Orchestrator ownership,工具不接受目标 Bot 参数。频道、收件箱历史和记忆分别保有对话、Bot 日记与提炼后记忆的权威。 - -每页最多 50 条、摘要最多 300 个 Unicode 字符,字面文本搜索至少需要三个 Unicode 字符。时间排序按时间/id 翻页,并固定首次读取的 Source Event rowid 上界;相关性排序以进程内、有界的 id 快照保留首次 FTS 次序,每次重新联接当前权威内容,移除已清除、已撤回或不再匹配的项。相关性游标十分钟或 Host 重启后失效,最多缓存 64 次搜索,每次最多 10,000 个匹配,超过时要求缩小筛选范围。快照不保存正文、不成为第二份记录或工作队列。新自我记录保存实际起因 id;旧记录缺少 id 时不推测起因。 - -### AX Client 启动证据 - -应用定义的 Client 诊断 collector 扩展既有 ADR-0063/0064 开发者日志边界。在明确启用的 Host 中(隔离 AX launcher 只为自己的子进程启用),Host Cordis Fiber 通过原生 `webServer.tapIndex` 在 Client 导入前安装 observer,并在 `/api/botharness/client-diagnostics` 注册精确、经过认证的 Connection Fetch route。浏览器报告白名单错误/生命周期 code、顺序化文档 attempt、固定保留的首次失败与 shell 挂载记录及有界遗漏;Host 保留有界实时 read model,并将同一过程证据写入既有 `logs.db`。它不创建 SessionEvent、产品状态、额外 SSE 或操作权威。API 健康、已提交且可见的 shell 观测、新鲜度、真实浏览器 console/DOM 核验分别成立;未知、后台延后及失败证据不能隐式变成成功。见 [agent 核验流程](../dev/guides/client-startup-diagnostics.zh.md) 与 [#1184](https://github.com/BotHarness/DeepSeekBot/issues/1184)。 - -### Provider-owned inline app setup (#1111) - -The optional application-defined `setupVersion: 1` / `describeSetup(channel)` capability on the public same-Host Service supplies only a versioned settings endpoint descriptor. The browser calls the Provider-owned `dsh-im/app-setup` management transport directly; BotHarness Host has no credential or QR-session command. Provider-owned bounded attempts retain setup state, create the account in `external-consumer` mode before its first connection, and return only its opaque account reference and authenticated `describeBot` projection. The Client hands the existing Binding command only provider/account reference and fingerprint; the existing identity owner revalidates it and acquires the exclusive intake Registration. Closing the dialog leaves the browser handle resumable for the Provider attempt's lifetime; cancellation prevents a not-yet-committed account, while an account already created remains available to bind. Older Providers keep the existing Settings-and-return path. This adds no operational schema, credential store or second message receiver. The candidate covers Lark credentials and WeChat QR/code setup. QR attempts use the same Provider-owned transport and bounded lifetime, refuse replacement of existing configured accounts, and are revoked with their originating Registration. Lark native DM/group replies are qualified on the earlier packaged candidate; native WeChat pairing and final visual/recording qualification remain required before #1111 is complete. - -### Mobile contextual creation (#2195) - -The mobile app uses its existing Expo Router tab and root Stack owners for an adjacent contextual creation action ([ADR-0235](../adr/0235-mobile-creation-shares-the-existing-tab-navigator.md)). iOS bottom controls use SwiftUI Button and native iOS 26 glass styles; the expanded creation menu uses a native rounded glass backdrop with an opaque accessibility/older-OS fallback. Android retains OCAT controls; navigation state still belongs to Expo Router. A presentation-only menu resolves stable Computer or cloud account/Organization keys; All asks for a target. Computer creation remains in the existing Host full-client flow. Cloud creation rechecks account scope and owner access through CloudClient, then uses the existing idempotent public mutation and opens its returned Channel. No mobile permission store or second messaging authority is added; native evidence is tracked separately from source checks. - -### Mobile presentation (#2028) - -Android chat Markdown has a separate, disposable geometry module. `MarkdownViewportContext` supplies the existing chat viewport and scroll values; the UI thread reports coarse visible-window changes, while the renderer retains measured block heights and minimum bubble width. Offscreen text becomes equal-height spacing and restores through the same Markdown path. Width/font changes discard geometry, native media remains mounted, and screen readers use the full document. This module owns no message data, persistence, playback cursor or Host command. - -The block parser also retains its disposable lexer result with exact source provenance. Android rendering and window-support checks reuse that result; repaired tails and source-only blocks reparse, while document-wide references retain their complete parse context. Append-only boundary lookback preserves unchanged block identities. The [single-paragraph Arena](../research/2183-single-paragraph-arena.md) measures the CPU benefit separately from native drawing limits; this cache owns no conversation state. - -The local Android Expo `StreamReveal` module decorates that existing React Native paragraph with bounded appearance-only spans and a native `Choreographer` clock. Parsing, text, line layout, selection and accessibility retain their existing owners; no React update or text setter runs on animation frames. The shared reveal budget currently limits animation to 48 graphemes, with overflow visible immediately. Corrections, reduced motion and detach settle spans and remove callbacks. Older binaries without the module keep the JavaScript fade, and installing the native module requires a rebuild. The [matched Release Arena](../research/2028-stream-arena.md) records the selected implementation and its performance limits. While an active Android paragraph exceeds two screen heights, the wrapper can draw the same untransformed TextView into a screen-bounded software bitmap; this reduces repeated hardware text recording without changing the child's layout or text. The bitmap is a disposable rendering cache, released when normal drawing resumes or the wrapper detaches. Returning to normal composition invalidates the child's hardware display list. Transformed, translucent or otherwise unsupported child composition retains the normal view path. - -The Expo client keeps profile read caching and reply presentation separate from Host authority. One native drawer stack owns the handle and nested settings/history/edit routes; editor components reuse their Host command ports without creating another modal. Profile usage exposes the same reported lower-bound total and partial marker as global usage, while the underlying strict usage query keeps unknown totals distinct. A bounded TanStack Query resource is scoped to each authenticated Host client and Bot/DM identity; profile navigation opens the native shell before data arrives, reuses successful sections during refresh and retains them on partial failure. Conversation publishes stable timeline snapshots and separately subscribed live draft bodies. A disposable conversation-level playback owner retains a separate grapheme cursor and input reserve, controls semantic release cadence, and carries the same row through draft-to-commit draining without changing canonical messages. Its ticks notify only the active bubble; historical messages render directly. Reading history holds the visual cursor until a completed return gesture or explicit jump. Covered Chats views detach roster/Inbox view subscriptions and catch up on focus while the shared transports keep receiving events. A device-local Streaming / Complete bubbles preference controls whether draft playback is shown. Android renders supported Markdown as stable, memoized source blocks using synchronous React Native text/layout, with enriched-renderer fallback for images and native-only extensions; iOS retains its existing renderer. Neither cache nor preference changes Channel persistence, draft ingestion or model execution. Native text visibility and frame timing require their own device evidence; see [ADR-0219](../adr/0219-mobile-presentation-keeps-caches-and-stream-buffers-local.md). - -### Mobile chat backgrounds (#2118) - -The Expo app owns a device-local default and optional saved-Host-ID/Channel-ID overrides, separate from Host appearance and PersonaBot Profile Banner. Its transactional editor commits versioned local metadata before publishing a new effective choice. Normalized owned JPEG basenames rebase under the current document container; image reclamation protects committed references, live drafts and in-flight imports. Unreadable metadata falls back safely without collecting images. One stable canvas/image/theme-veil layer sits outside scrolling and keyboard-responsive content. This introduces no Host API, model action, Memory or message authority; see [ADR-0228](../adr/0228-mobile-chat-backgrounds-are-transactional-device-local-presentation.md). - -### Optional mobile statistics (#1935) - -The application-defined Mobile Telemetry owner observes the existing Expo saved-Host, Host client and Conversation boundaries. Pairing success follows redemption and saved-Host commit; connection readiness follows authenticated live Roster loading; first-chat acceptance follows Host acceptance or reconciliation of the same outgoing message. Bot-message observation requires a subsequent new committed entry in the foreground conversation. PushSession retains notification authority and gains no telemetry in this slice. - -A local default-on preference preserves saved opt-outs. The switch lives at the bottom of Settings with collapsed details. A once-per-phone informational notice mirrors the Web client, with privacy/source/settings links and a persisted dismissal; collection does not wait for acknowledgment. The owner persists the preference, notice-seen marker, a random Mobile Install ID, three activation markers and a sanitized bounded queue; it never receives Host/Channel/message identifiers or content. One lazy, memory-only official React Native SDK adapter transports the approved events through the existing first-party proxy, with automatic collection and remote configuration disabled. No new Host API, persistence domain or RPC interceptor is introduced. A 30-second connection observation deadline never terminates Host reconnection. See [ADR-0214](../adr/0214-mobile-statistics-use-explicit-consent-and-an-independent-install-id.md) and [privacy/qualification runbook](../mobile-statistics.md). - -手机统计新安装默认开启,保留已有关闭选择,开关独立于 Host 并位于设置最底部,说明默认折叠。首次说明沿用 Web 客户端:可查看隐私、源码和设置,点击知道了后记住已读状态;这是信息告知,不是采集门禁或条款同意。生产资格仍需独立验收。已有配对、Host client、Conversation 和 PushSession 保留产品权威。统计只接收粗粒度结果;关闭后清除本地标识与待发队列,不能撤回服务端已收到的事件。生产配置需真机、PostHog 收数、公开隐私说明和 Human QA 分别验收,不能以自动化测试或 CI 替代。 - -### DSH 原生会话列表的 Bot 文件夹(#1997) - -首个 tracer 增加统一的 DeepSeekBot 产品分组,下面按 Bot/角色建立子组,以显式 PersonaBot ownership 收纳 Orchestrator 与 Assignment 根 Session,与 cwd 无关。两级标题复用 Channel sidebar 的 PanelHeader,并各自保存 Client 本地折叠偏好。现有 botSessionIds 查询在旧 ID 列表之外返回分组元数据;标题、执行状态和导航继续使用原生 Session 事实及 UiWorkspace。分组不修改 Workspace 成员、Grant 或 Session header。RC2 没有附加分组 Slot,因此有界的应用适配层在原生树中增加自有 portal 挂载点,并仅在挂载期间遮蔽重复行;找不到结构时保留原生列表。原生搜索仍独立工作;新增分组行中的原生菜单留待首个 tracer 的 Human 反馈后扩展。 - -### Searchable Bot Settings - -The application-defined Bot Settings modal keeps a single active settings Slot mounted. Its localized search index resolves only currently registered pages, then selects the page and focuses the matching settings row or section; searching never mounts hidden providers or starts their requests. Appearance contains a nested layout Slot so the live workspace retains ownership of gap/reset state. DeepSeekBot appearance is an application-defined Client-local authority: independent light/dark/system selection, separate per-mode palettes and Primary overrides persist per browser/site or Desktop application, without a Host key. Its disposable palette scope covers only owned roots and modal/menu surfaces. Per-mode global Header/Body material overrides resolve against one of four presets, painting Dock header/content surfaces with background alpha and backdrop blur while keeping message/control surfaces independent. Stable functional Panel overrides are stored per mode and edited only from Header context menus, with per-property global inheritance and target-scoped reset. An authenticated, ephemeral Host delivery lease lets the CLI address one online Client and use the same local owner; it stores no preference mirror. Language remains a DSH-native preference through `locale.setLocale`. See [ADR-0236](../adr/0236-deepseekbot-appearance-is-client-local-and-scoped.md). Notifications and Keyboard shortcuts are first-level pages. The shortcuts page reuses existing Bot bindings and the DSH-native Panel command catalog/edit protocol, including revision checks, conflicts and native recording interception. The General tutorial describes the current Panel workspace and retains the narrow-layout fallback. - -### Channel workspace split topology (#2076) - -The Client owns a per-Channel binary split tree inside Messages. A leaf is message content or one registered Channel subpanel; a branch stores its axis and first-child fraction. Hidden/unavailable leaves collapse only in the rendered projection, leaving the saved tree intact. Bot-only availability and shortcut dispatch still use the existing ChannelDock owner. No Host capability, data authority, or execution permission changes. - -Version 7 retains the old per-panel open/hidden-control records and migrates legacy side/fraction geometry lazily into an equivalent tree. It backs up the prior raw record before upgrade. Explicit dragging splits the destination leaf; resizing only updates the selected branch. Stable keyed portals render into leaf-owned DOM containers, which move between layout slots without remounting content. Width changes never rewrite topology. See ADR-0222. - -## DSH compatibility admission - -For the 1.4.0 candidate after the 0.2.1 API migration, application-defined product packaging declares `>=0.2.0-rc.2 <0.3.0-0` in first-party DSH engines. Host-provided peers follow that policy and additionally name the qualified 0.2.1 prerelease tuple for npm default SemVer matching. The DSH-native Bundle/Profile preflight owns admission and includes prereleases; no application exemption bypass is introduced. Exact development and native-patch pins remain reproducible, while `productDshVersions` enumerates only real versions selected for artifact qualification. The application-defined compatibility adapter chooses native legacy or activation-based Subagent APIs before execution, keeps native delivery/cancellation ownership, and validates Owner identity using that Host’s trusted invocation. Native shell Plugins come from the running Host; the bundled scoped file Plugin retains its read_image fix and adapts the older Session cwd locally when the workingDirectory service is absent. The preset uses the common native baseline and the native ComputerUseRegistry child Plugin owned by the product Computer Plugin; its pinned implementation is identical across both qualified releases, without a second incompatible native-package installation. Forward admission does not establish future runtime compatibility. See [ADR-0087's current correction](../adr/0087-dsh-compatibility-is-a-semver-range-with-a-verified-floor.md) and the [1.4.0 qualification plan](../agents/dsh-1.4-release-qualification.md). - -### Mobile cloud service sessions (T1 UAT) - -The phone can enter **Cloud services** without a paired Host. Its application-defined `CloudClient` owns trusted public configuration, the live PKCE transaction and separately stored cloud credentials; `HostSession` continues to own local Host pairing. Strictly validated public JSON from a QR or explicit paste is reviewed by the Human, compared with credential-free discovery, then pinned. System-browser WorkOS login and the service's verified `/api/me` establish instance admission. SecureStore rotation precedes readback; local-only logout is visibly distinct from remote-confirmed revocation. Cold OAuth callbacks cannot resume a transaction. See [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md) and [native UAT gates](../agents/tob-mobile-uat.md). Cloud workspaces use the same client owner: create sends a stable request ID saved before the network call, selection rotates the independent WorkOS session for the target organization, and the service verifies current membership. The phone displays owner/member from the verified response; only owner can submit versioned description updates. Workspace settings live in TOB Org DO storage, not the local Host. With TOB authenticated-user policy enabled, the client accepts explicit admin or user roles and retries server-owned default-workspace onboarding without granting instance administration. An ordinary user can create and select additional workspaces; instance admission and current organization membership remain separate checks. UAT Simulator ordinary-user creation, CLI agreement, restart/refresh and logout isolation are recorded in the multi-user evidence; the next HTTP channel slice adds owner creation, organization-wide channel discovery and durable text through the same CloudClient. Its pending operation is bound to identity, organization and channel before I/O, then retried with the same key after uncertain failure. Channel SQLite remains TOB-owned; The next invitation slice uses this same owner: owner send/list/revoke and verified-recipient inbox/accept, with a user/org/email-bound pending operation persisted before issuance. Accepted members explicitly select the workspace through the existing token refresh; invitation IDs grant no authority. WorkOS owns membership activation and TOB Org DO journals issuance and first active observation for new-member history. Direct channel guests use a separate inbox/channel entry in the same CloudClient; acceptance does not select or join the host organization. Channel DO owns the user-bound grant and exact message-sequence floor. Guest sends persist a distinct channel-bound retry scope. The initial path targets existing verified users without email-delivery claims. Its native/UAT qualification, Agents, WebSocket and physical-device qualification remain separate gates. - -Mobile cloud Agent management uses the TOB channel authority independently of Host pairing. The existing SecureStore vault retains one explicit credential handoff; public snapshots omit its secret. Copy revalidates owner rights, and revoke removes remote access. This does not grant local Bot work authority; see ADR-0229. Members can request a fixed health check through the same CloudClient. SecureStore retains the exact target, deadline and operation before transmission; restart retry cannot extend authority. TOB Channel DO stores the canonical request/result, while the Agent operator separately grants local work. Local retry deletion does not cancel an accepted request. The current phone path reloads history explicitly; Host forwarding and WebSocket remain separate increments. - -### Cloud Agent Host connections (#2155) - -A channel-scoped TOB Agent can be explicitly imported into a Host and bound through the existing Messaging account/Bot surface. `CloudConnections` keeps public identity, policy and cursor in the Operational Database; the DSH native Credentials Provider alone stores the secret. Discovery precedes bearer transmission, and public identity qualification precedes activation. `botharness/tob` consumes bounded HTTP history into the existing Source Event/Admission/Inbox; it preserves remote Agent authorship and original-Human work provenance. Current cloud work policy is rechecked at Messaging admission and source consumption, above ordinary-message and bridge wake settings; context sharing does not transfer execution authority. Canonical commit precedes the transport checkpoint, so interrupted reads replay rather than skip. Existing Outbox operation IDs supply cloud send idempotency. This application-defined connector is separate from the protected local steward Tob. The application-defined `CloudWork` owner and generation-101 intent/result ledger expose the same bounded health operation through an Agent-scoped native Tool and authenticated direct CLI. Canonical source/current Human policy supply authority; durable execution claims prevent silent reruns and a fixed one-hop forwarding intent prevents execute fallback after uncertain delegation. Transport, checkpoint replay and fixed model-mediated A-to-B health work are qualified on two UAT Hosts, including both Host restarts with no repeat execution. A fresh mobile-to-two-Host run and WebSocket remain separate gates; see [ADR-0230](../adr/0230-cloud-agent-connections-use-native-credentials-and-messaging.md). - -### Mobile workspace directory (#2161) - -The root shell renders the existing drawer once across computer and cloud screens. HostSession retains paired-computer authority; CloudClient retains cloud configuration and sessions. An in-memory, paginated directory combines verified organization memberships with organization identity obtained from authorized channel-guest listings. Membership wins per instance/organization. The directory is presentation only: guest selection calls filtered guest routes and cannot acquire organization membership or sibling channels. Opening refreshes current grants; closing invalidates old responses. See the unified-navigation extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). Source tests do not replace native switch/revoke/large-text acceptance. - -### Mobile mixed conversation entry (#2179) - -The application-defined mobile conversation directory combines the selected Host roster and the authenticated cloud account's paginated organization/guest Channel listings. It is a client projection: Computer messages remain Host-owned; cloud messages remain TOB-owned. Cloud directory reads borrow and restore organization token scope under the existing CloudClient lock without changing the selected workspace. Account/config changes discard late directory results. Host routes bind a Host ID and cloud routes bind an account/config scope, refusing an old destination after a source change. Host caches and unsent text are source-namespaced; offline Computer sends are disabled and text drafts have no automatic retry transport. This first slice retains one active Host; parallel Hosts and Web/Desktop follow under #2180. Cloud server read/pin/recent-message summaries follow under #2181, and ordinary cloud wake policy under #2182. Source checks are not native/UAT qualification. - -### Mobile Chats environment paging (#2194) - -Chats shares its persisted device-local conversation filter with Activity. The native source pager derives tabs from verified Organization listings, including empty memberships and authorized guest labels, in enrolled-source order. Source/account-qualified keys preserve identity while swiping; tab selection does not switch Host management authority. Unsupported Cloud Activity has an explicit notice and no borrowed Computer badge; All discloses partial Computer-only coverage. This first slice retains one active Computer. See the Chats environment extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). - -### Browser Remote Hosts and Host Feed (#2500) - -Remote Bot Browser and Computer viewers reuse `apps/mobile/src/host/browser-viewer.ts` and `computer-viewer.ts`. The Web adapter owns one mounted viewer against the selected Host client, projects its state through `useSyncExternalStore`, and uses the existing viewer title bar and design-system controls. Frame polling and input use device-authenticated HTTP; the Host Feed mux retains the one-time stream-ticket handshake. Closing or disconnecting aborts polls and input, waits for pending takeover requests, then releases control on the original Host. Replacement viewers wait for that cleanup. Inputs belong to one explicit control generation: resuming the Bot, losing control or losing frames invalidates that generation, even while the Host Feed remains connected. Recovered frames remain read-only until another explicit takeover. Shared Browser/Computer frame loops discard the cursor after a failed frame request, or a 204 received while not live, and fetch the current frame through the existing route; unchanged screens can therefore recover without a new sequence. The Web panel localizes recovery guidance while retaining the raw notice in the viewer snapshot for diagnostics. Removing a Host first closes its viewers and forgets the local entry, then waits up to five seconds for pending takeover/release cleanup before revoking the pairing; an unresponsive Host does not block removal indefinitely, but cleanup after that deadline may require the Host operator to resume Browser. The Computer remains the profile's shared desktop, while Browser scope includes the Bot slug. No Remote Host full-client fallback or new Host route is introduced. - -The application-defined browser `RemoteHosts` owner keeps its pairings and selected Host tab in device-local storage. Each Remote Host reuses the phone's Host client and conversation module through the device gateway; This Host never relays the Remote Host's credential. The shared `createInbox(client, { activity: true })` supplies each Remote Host's Human Inbox and unread snapshot. The first browser subscriber starts the client and Inbox, and the last stops them while retaining the last Inbox snapshot. Authenticated avatar fetches publish an immutable URL-to-object-URL map; removal, roster replacement and final unsubscribe release obsolete objects and discard late fetch results. The storage-key rename imports existing browser pairings and the selected tab. See [ADR-0242](../adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md) and [ADR-0243](../adr/0243-every-client-reads-every-host-through-one-host-feed.md). - -Remote conversation presentation retains the source Channel Journal entry through the shared conversation and message-display modules, then uses the existing Web message body and request cards. A conversation-scoped adapter supplies the source Host client and validates the active Host/Channel before decisions. Attachment reads resolve through that client's `resource(path)` and device credential, with no local-store or local-URL fallback; changing conversations aborts reads and releases object URLs. Activity and conversation cards share `createHostRequestActions`, with request references from the Inbox and Journal respectively. Only a Host-accepted decision completes a submission; stale decisions trigger the existing cards' status reload. Decisions reuse the shared attention actions and remain subject to the source Host's ownership and permission checks. - -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. Device Notification Preference remains a subsequent device-owned slice; the browser has no closed-page notification path. - -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. - -### Version-bound Memory image previews (#2214, first Web slice) - -The application-defined Memory Service exposes bounded PNG representations alongside its existing current-file and Git-diff queries. Metadata identifies worktree/index/exact reachable commit plus a SHA-256 byte revision; authenticated image transfer rechecks that owner, repository selector, path and revision before returning `image/png` with no-store semantics. It does not accept arbitrary blob IDs, revision expressions or Host paths. Current reads retain path containment, control-directory exclusion and safe-open checks; Git reads accept regular-file blobs only. A changed mutable source returns an explicit stale refusal rather than current bytes under an old version label. - -The Web reader and shared diff renderer display PNGs, with before/after panels that stack on narrow screens and reuse the existing image gallery for enlargement. Staged compares HEAD/index, unstaged index/worktree, current HEAD/worktree, and historical commits their first parent. Image bytes stay outside text/JSON diffs and are requested lazily by the browser. `memory-image` exports the same representation through the authenticated Host to a new file, with a JSON receipt. The preview budget is 25 MiB, 16,384 pixels per axis and 40 million pixels; existing text and download budgets remain separate. The second Web slice below adds Markdown rendering; other local image formats, editor/file-card previews and mobile remain in #2214. - -### Web Memory Markdown previews (#2214, second tracer) - -The application-defined Memory Service returns bounded full Markdown documents alongside existing current-file and working/commit diff results. Git remains the durable authority. A document descriptor carries its selected `source`, SHA-256 `revision`, status and original UTF-8 body. The shared safe-open / regular-Git-blob reader preserves containment, reachable-commit, mutable-read and byte limits. Each document is bounded at 4 MiB; commit queries share a 4 MiB budget across document bodies. Existing line-diff and image limits remain independent. - -Web readers render `.md` and `.markdown` by default with a source toggle. Working and historical diffs default to lines and offer labeled rendered before/after documents, stacking on narrow Web viewports. The document renderer uses bundled `react-markdown` / `remark-gfm` because the pinned native Markdown component cannot override HTTP(S) image loading before its request; it keeps DSH tokens and the existing image gallery. Raw HTML remains literal text. Local file links stay inert; ordinary external HTTP(S) links use browser navigation. - -Visible inline local PNGs use the existing authenticated Connection Fetch image route with the document path/source/revision and authored `reference`. The owning Memory query resolves a directory-relative or repository-root path, validates the document revision before and after image reading, and applies the same bounded PNG checks as standalone previews. The Client receives a temporary Blob URL and aborts/releases it on selection changes or source mode. Index and commit references read their actual Git blobs; missing historical images never substitute current files. Mutable images are observations of the selected source at load time; refresh reloads their dependencies. - -External HTTPS images require a per-image click and load directly in the browser with anonymous cross-origin access and no referrer; CORS/network restrictions may refuse them. These are live external references, including in historical documents. No Host proxy, new durable store, SessionEvent or preview-triggered Bot wake is introduced. The existing `memory-image` CLI offers the identical local-image query through `--image-reference`. Other local formats, editor and message-file previews, and native mobile remain later slices. - -## 原生 Panel Plugin 开发目标(ADR-0234) - -首片实现入口见 [Panel Plugin 开发指南](../panel-plugin.md):独立 VRM Bundle 消费 UI 提供的公开 `dockPanels` / `botPresentation` Service;Activity 仍由现有 ClientStore 投影维护,已提交 DM 输出由按 Bot 共享的文字时钟呈现,固定实例元数据扩展现有布局文档。 - -[ADR-0234](../adr/0234-panels-are-native-plugins-with-independent-binding.md) 记录已确认目标;以下为整体目标,已实现的 T1 范围以首片指南与验收记录为准。第一方与独立作者的面板复用原生 Plugin/Fiber 和 Bundle/Profile 组合。领域 owner 的 Host Service 与具名实时 Cordis Event 独立于面板显隐;浏览器 Consumer 沿用认证后的 Typert/API Gateway 与支持的 stream。状态型展示需恢复当前状态,首版不建设通用离线事件补发。后续 Memory 观测要覆盖无对话时的外部工作树变化,保留既有 Observation/Admission owner,并与 Git commit 区分。 - -实现、实例、绑定与工作区位置分开。普通实现允许多个实例,重复安装不重复创建。消息面板内的 Bot-bound Panel 跟随 PersonaBot 私聊;群聊不显示内容和打开入口,也不增加成员选择器。显式绑定的全局工作区实例保留并清楚标明所选 Bot/Channel;Group-channel-bound 复用 Channel 绑定并限制为群聊。现有频道子面板不因此获得跨工作区拖放,绑定不授予数据访问或代码隔离。 - -必需系统角色保留其唯一性、已注册的第一方兜底和独立恢复入口。替换优先复用原生 Slots 的优先级与有界渲染错误退让;这不等于 HMR 回滚,也不是现有应用 Registry 的重复 id 拒绝已经具备的能力。 - -首个纵向切片 [T1 #2222](https://github.com/BotHarness/DeepSeekBot/issues/2222) 为使用 Three.js/three-vrm 的独立 **VRM+Activity+说话表现面板**。动作消费已有 Activity 事实;说话表现独立消费真实、已提交的 Bot 输出,允许边工作边说话。首版使用文字节奏驱动表情、嘴型与轻手势,不要求音素级口型或新增 TTS。验收原生安装、两个 Bot 绑定、群聊无入口、当前状态与实时更新、重连不重播旧话或重复发声、减少动态效果、资产/渲染失败降级及 GPU 资源释放。此方向替代仅复用旧角色 renderer 的提议,不要求整体迁移浮窗交互或构建生活模拟世界。 - -[T2 #2223](https://github.com/BotHarness/DeepSeekBot/issues/2223) 将办公室切片改为无 Bot/Channel 绑定的 **全局 Task Kanban**,管理当前 Profile 内共享的 Task。Human 卡片操作与 Bot 查询/创建/更新工具使用同一授权 Service、持久记录和提交后通知;关闭面板不影响工具工作。演进 #1792 已有 to-do owner 并兼容旧记录,不增加第二份存储。Task 状态与进度独立于 Session 内计划和 Assignment 执行,创建、移动、完成或重开卡片不自动启动执行。验收重启/重开一致性、显式并发冲突及直接 CLI/工具 parity。 - -[Spec #2219](https://github.com/BotHarness/DeepSeekBot/issues/2219)、T1–T7、[T8–T15 迁移父工单 #2234](https://github.com/BotHarness/DeepSeekBot/issues/2234) 及其子工单统一规划在 [Panel Plugins Project #21](https://github.com/orgs/BotHarness/projects/21) 的 v2.0。T1 已进入首片实现与隔离 Host 验证;Human 验收尚待反馈,仍是扩展门槛。交付状态与依赖以工单为准:T2–T6 依赖 T1,T7 依赖 T6,迁移父票与 T8–T15 各自依赖 T1。迁移与 T2–T7 同属 T1 后的扩展分支,子票不等待协调父票完成,也不隐式等待 T7;并与 #1791/#1792 作者能力、#2096 布局 CLI 协调。 - -T4 保留 ADR-0220 的 Sessions 范围:DM 中为 Bot 所有的 Sessions,Group 默认按原始 Source Event 查看 Assignments,并保留已有显式成员 Bot 筛选。群聊不显示 Bot-bound 面板不等于移除该筛选,也不改变 Session ownership。T5 的 Members 在 DM 消息内隐藏,但全局固定 Group 实例不受当前聊天切换影响。Panel Consumer 释放与卸载领域 Provider/整个 Bundle 分开;后者遵循原生生命周期,能力缺失须明确显示不可用。 - -Nested appearance surfaces use the same Client-local owner and functional `dock:` identities as top-level panels. Memory detail views share one identity, while file lists and evolution remain separate. Direct surface paint preserves global inheritance independently of parent customization; real registered Plugin contributions receive the generic wrapper without new domain or layout authority (ADR-0236, #2261). - -DeepSeekBot's image wallpaper has a separate Client-local media owner and one atomic IndexedDB Blob/crop record. One disposable layer spans the enabled workspace behind the sidebar and panels; original bytes stay on the current browser/site or Desktop application. The CLI transfers bounded chunks to one explicitly selected online Client and commits through that same owner, while the Host remains a transient transport (ADR-0236, #2262). - -Video wallpaper extends the same local media record with an original video and generated poster. A disposable Client playback controller observes focus/visibility, reports actual playing/paused/poster/inactive state to the same owner, and unloads sources on exit or replacement. Failure displays the persisted poster without changing media authority or DSH preferences (ADR-0236, #2263). - -Appearance recovery composes the existing local preference and media owners without a third store. Current-mode clear affects only its functional Panel map. Explicit full reset preflights both revisions, saves defaults, then deletes the atomic media record; partial media failure is reported and retryable rather than presented as a cross-store transaction. A transient resource generation invalidates previews on accepted media changes, including removal from an already-empty store (ADR-0236, #2264). - -### Desktop whole-window zoom - -[ADR-0237](../adr/0237-desktop-window-zoom-is-client-owned.md) defines an application-owned Client -zoom Service on the stable desktop origin. Settings, native Shortcut Registry commands and an -explicitly addressed CLI share its versioned local default/current-primary-window document; -the authenticated Host route is an ephemeral delivery relay with no preference copy. A pinned, -MIT-preserving downstream theme composition leases only the original font-shortcut Fiber, -then returns it on disposal. The product build carries that licensed theme and a portable ESM -schema module. Bundle patches retain the original `ui-theme` row and canonical Settings -namespace. An explicit `deepseekbot-zoom-setup enable` command installs one Profile-local -package link through the selected Desktop's bundled package manager and requires a restart. -The macOS path and Windows candidate require Desktop 0.2.1-rc.2, refusing other overrides. -Windows reads executable FileVersion, checks live processes with CIM, and validates owned -junctions under the same Profile writer lock. The Client checks a matching platform lease -before registering the same native Shortcut commands; Ctrl+0 restores the saved default. -`disable` removes only this dependency and its exact owned link, preserving font preferences; -run it before product removal. Installing the Bundle alone does not activate compatibility. -Windows native qualification remains in #2270; Linux remains disabled. -It does not edit the installed desktop shell. Ordinary Web zoom is unchanged. Native-input, -packaged-install and Human QA evidence remain separate from source coverage; numberpad, -broader reliability and other desktop platforms are tracked in #2269/#2270. - -Bot-mode Dockview content overlays and splitter movement convert viewport coordinates to local CSS units through the repository-pinned Dockview compatibility patch. The patch is bundled in the Client; it changes no DSH executable and preserves persistent panel instances and the existing layout store. The macOS workspace corner separately retains the native 88×40 viewport-pixel traffic-light footprint and converts measured group intersections into local header padding; zoom, layout and fullscreen updates do not change that physical footprint. - -Bot-owned choice controls use the shared `ds/Select` → `PointerMenu` geometry owner under -window zoom. It keeps the native Menu renderer and keyboard owner inside the owning Modal, -converts viewport anchor coordinates through effective CSS zoom, clamps to the safe viewport -and returns focus after dismissal. No second shortcut router or Panel state is introduced. +# BotHarness 架构与数据流 + +#2080 第一条切片让 Web 请求卡与在线 CLI 共用 application-defined Workspace 请求批准命令。Grant owner 在 Channel owner 的现有提交事务内写入读写权限,规范决议与 Inbox Admission 同时提交;成功读回后再沿原运行时处理原任务。新目录与旧只读升级均需 Human 明确确认,不由 Client 串联多个独立成功动作。Orchestrator 与 Assignment 的原生文件写入都检查当前 Grant 写权限;Assignment 仍只使用其原 Grant 和单 cwd。普通目录设置、移动端交互和完整重启恢复矩阵仍按后续切片交付,不能从本次 Web/CLI 路径推定已完成。见 [ADR-0238](../adr/0238-workspace-request-approval-commits-write-authority-with-the-channel-decision.md)。 + +#2053 的本地 Group 发送复核试点由 Channel owner 在正式 Source Event / placement 追加的同一事务内比较实际观察依据。 +#2054 复用该 owner:held 标出实际返回的同伴正式消息;Bot 可引用这些消息显式让话,或提交独立补充。Host 只核对引用是否真实观察且仍有效,不判断语义重复,也不自动结清 Inbox;让话依据随现有草稿 JSON 保存。 +待复核草稿保存在现有 Operational Database,仅正式提交进入 Channel/Inbox;仅 drop 不解决 Inbox;显式 `resolve_inbox_ids` 可在 drop 成功后结清所选事项,过期或重启不自动恢复。 +原始依据与修订草稿归同一 owner,purge 清除派生内容。默认关闭,按群 opt-in;CLI 与模型使用同一契约。 +#2056 的显式恢复复用该记录:`review_action: inspect` 只返回当前授权下的有界元数据;重启后通过 `recover` 携带新观察与修正正文,保留原过期时间与预算。成功提交在同一事务留下消息 ID/意图指纹回执并清空草稿正文;重放只确认规范消息,不新增 Inbox 或恢复旧 Tool Promise。权限、来源与附件检查先于回执读回,详见 [ADR-0232](../adr/0232-group-recovery-retains-authorized-commit-receipts.md);本票真实模型验收仍在进行。 +组合两票时,复核记录保存观察所属 Host,避免跨重启比较进程内序号;新观察仍须覆盖全部必读消息并通过当前内容/附件指纹检查。恢复不重置版本、额度或已用的显式继续机会。显式继续与普通发送共享同一原子提交回执。 +重启重新准入本地群任务前,Host 复查当前成员资格;已撤权且未开始副作用的可重试 Admission 记为 Host 策略忽略,保留原因,不伪造 Bot 观察或完成结论。不确定副作用继续待修复。新的 Human DM 进入独立授权回合,原撤权任务的跨 Channel 发送 fence 保持有效。 +附件 owner 的有界当前内容摘要参与观察指纹,等长文件修改也会触发重新复核;摘要不进入公开消息,不改变附件身份,也不锁住外部文件写入。 +held 工具结果复用 Channel 的有界模型读取投影直接返回新增正文与显式观察凭据;仅完整返回的内容计入观察,省略内容仍需读取,后续提交仍在同一事务重查。其持久边界和保守取舍见 [ADR-0227](../adr/0227-group-send-review-shares-the-channel-commit.md),效果与 Human 验收仍由试点评估决定。 + +忙群最多进行两次复核,耗尽后明确 drop、既有 Inbox snooze 或一次显式继续申请(freshness_override)。显式继续仍原子核对当前消息;出现未读变化则拒绝本次申请,保留责任并延后,不能跳过新纠正。 +Override 绑定 owner、原 Group、当前草稿版本与实际观察;只越过自动复核次数上限,不越过当前消息检查,保留停止、权限、过期与 purge fence。 +耗尽预算时的 `suggestedSnoozeUntil` 是 Host 时间之后 30 秒的延后建议,复用现有 Inbox 调度,不自动安排唤醒;Human 的更晚时间、等待条件与取消约束仍优先。 +选择理由、观察与提交 ID 留在原草稿记录;不新增 wake scheduler 或公开系统消息。见 [ADR-0231](../adr/0231-busy-group-review-has-an-explicit-progress-boundary.md)。 + +Group composer 的 `/workspace` 和工作区 chip 复用 DM 的目录选择器与授权确认组件。默认选中当前未暂停、未删除的全部 Bot;Human 可以取消勾选,只授权指定几个。Group 只是逐 Bot 授权的入口,不持有权限;预览固定确认目标,后来加入的 Bot 不自动继承 Grant。共享目录预览相对于权限是只读的:native Workspace controller 可以登记、解析一个 DSH Workspace,但确认前不会建立任何 Workspace Grant。确认展示每个 Bot、canonical 目录、既有 Assignment mode 和 Orchestrator 写入状态;这些权限设置保持不变。逐 Bot 调用既有 `grantCreate`,可选的 application-defined Group scope 仅约束当前 Channel 成员与确认目录,并在异步 Workspace status 检查后、没有后续 await 的实际写入边界再次校验。SQLite `workspace_grants` 仍是唯一授权权威;无 Channel Workspace Binding、Group Grant 或统一 cwd。成功、有效 Grant 复用、拒绝和未知结果分别展示;失败/未知成员重试前通过同一个 `grants` readback 验证成员、撤销状态与当前目录,读回失败不发起写入。原侧栏与 chip 使用既有逐 Bot 变更通知刷新,正常委派仍通过原 Channel、Orchestrator 与 Assignment seam(#1433)。 + +#1386 已在 Human 授权的隔离 Linux Profile 中复验既有 QA2 PersonaBot:真实 Lark → Owner Web 多字段输入 → 既有审批 → 固定 CLI/MCP 引用认证 → 原私聊回帖;普通容器 CLI/MCP、真实 Spill、跨 Bot 拒绝、轮换/撤销及停止活跃使用、重启与自然到期 Schedule 均保留 native/canonical 证据。模型 wire 正文及运行记录扫描未发现私密 canary;结论限于记录的实际安装版本和两个固定消费者,不推导任意 CLI/MCP。Linux 接收器已停止并恢复原 Windows QA2,正常回帖已核对。当前仍为 opt-in,Linux 工作区文件权限需显式准备。详见[复验记录](https://github.com/BotHarness/DeepSeekBot/blob/main/docs/research/2026-10-12-private-input-linux-qualification.md)与[双语 Linux 指南](https://github.com/BotHarness/DeepSeekBot/blob/main/docs/dev/guides/private-input-linux.md)。 + +#1385 让当前已授权 IM Source Event 发起同一个 Owner Web Chat 私密卡;Host 经既有 Messaging Outbox 向原会话投递仅含 Chat/卡片定位 ID 的 Web 链接,实际 Owner Peer 仍是唯一提交 authority。进程内 source/reply lease 关联真实 Session 当前轮次、Bot 创建身份及 Binding,来源变化过期卡片并停止使用,账号替换/撤销禁止迟到结果投递;最终回复在 native Provider 开始前重新验证。受保护 Profile 仅放行实际注册的四个非私密 Messaging Tool Definitions,不增加第二个 receiver、持久表或 Pairing 策略。独立 Lark QA2 私聊已验证真实 Bot、多字段 Web 输入、现有允许一次、引用 CLI 认证 HTTP 200 与原私聊回帖;其余路径不从该结果推断。详见 [ADR-0172](../adr/0172-im-private-input-keeps-owner-web-authority-and-source-reply-fences.md) 与[双语可运行指南](../dev/guides/private-input-im.md)。 + +#1384 新增 application-defined ProtectedMcp Consumer,复用 native MCP Tool 适配器及官方 SDK stdio Client。模型与既有审批仅携带账号及字段引用;同一个 PrivateInputs owner 核对真实 Bot、当前版本和 Channel configured proof 后,才在私有 MCP tools/call 管道内提供原值。固定 Host 进程只执行一次认证 GET,丢弃正文、头部和错误,Host 在 native 结果持久化前只接受有界状态投影。普通 MCP 保留 native Client Plugin 与隔离 Linux 工作区;任意第三方 MCP 不自动获得凭据。详见 [ADR-0170](../adr/0170-private-mcp-consumption-uses-fixed-server-and-bounded-projection.md)。 + +#1383 复用既有 application-defined Bot Schedule → Source Event → Bot Inbox → Orchestrator 路径。Schedule 只保存引用;重启后仍由 native Credentials 及当前账号绑定核验真实消费,既有执行与 Channel 发送授权分别生效。受保护 Profile 允许 owning Orchestrator 使用四个 `bot_schedule_*` 管理工具。新建私密输入卡还需 Host 当前轮次的真实 Human Source Event;旧 Session 历史、目的 Channel 或模型自述不能提供此证明。无人值守缺少有效配置时立即返回安全 Owner-action 错误,不创建 waiter;详见 [ADR-0169](../adr/0169-private-input-interaction-follows-current-turn-source-events.md)。 + +#1382 在同一个 PrivateInputs owner 内加入 PersonaBot 创建身份与账号范围的 native Credentials 版本记录。Owner 从既有 Web Chat 卡片打开空白替换卡或撤销本地访问;新 grant 激活及 canonical Channel configured resolution 都成功后才返回引用。每次真实消费在账号串行队列内核对当前 UUID,登记 AbortSignal 后释放队列;替换/撤销写入新版本或持久 tombstone,并尝试停止既有使用。宽执行授权不能越过版本检查,停止结果区分已停止、已完成与不确定,401/403 只返回状态及安全修复建议。启动恢复先于 Owner 写入,撤销跨重启保留。详见 [ADR-0167](../adr/0167-private-account-versions-fence-consumption-and-revocation.md);旧程序不理解 tombstone,回滚需恢复完整升级前备份或向前修复。 + +#679 将入口收束为 Bot 模式设置左侧的紧凑图标/未读 Chip;折叠侧栏时则在 Bot 模式下方以相同尺寸对齐。展开时仅显示未读数字 badge,最多为 99+;有未读时入口常显,没有未读时仅在 Bot 模式开启时悬停或键盘聚焦才显示,与设置按钮一致;模式关闭时 hover 不显示,也不进入 Tab 顺序;折叠时入口仅在 Bot 模式开启后显示,图标右上角红点表示有未读或待行动;模式关闭时入口隐藏且不进入 Tab 顺序。无障碍名称保留完整未读数量与待行动提示。活动中心始终直接进入个人 Human Inbox;历史总览导航偏好不再改变入口(#2026)。 Web 页面与 Dock 共用三组可独立折叠的 Activity 投影,浏览器按 Host origin 保存折叠偏好;每组拥有独立分页与错误状态。标题栏搜索、排序和筛选经既有应用自定义 `humanAttention` 查询;查询版本 1 将搜索与未读过滤作用于分页前的合资格内容,并支持提及优先排序。Client 丢弃旧查询结果,首次加载与后台刷新分离,后台读保留已加载的空态或卡片。Host 在规范事务提交或原生请求 owner 变更后合并失效通知,经已有 roster RemoteSnapshotStream(HTTP SSE 兼容入口为 `human-attention/changed`)传递;消息列表与活动中心仍查询同一 Source Event、Channel placement、已读位置和请求 owner,不复制持久通知数据。Client 在订阅后读取基线,重连后重新查询,合并突发通知并补读查询期间的变更;频道列表的 latest 预览按 25ms 窗口合并为一次状态发布,保留各 Channel 最新消息与每个 Bot 的最大活动时间,退出或销毁时清除待发布队列;健康连接不轮询,仅断线时使用 10 秒恢复读取。最后一个展示面关闭后释放订阅和计时器。稍后提醒由 Host 调度最近到期时间,通知后重读规范查询。共享查询实现沿用移动端 #1954 / PR #1966 的实现。 #2231 第一切片沿用 Host Roster 的 Human Channel 免打扰偏好,在 Human Attention 消息查询的搜索、聚合、分页前排除静音 Channel(含已读保留的提及/回复);全局未读同口径,Channel 原始未读位置与明确 Human 请求不变。Web 共用 Activity feed 观察已有 Roster 状态变更,重查可见分组并丢弃旧偏好下的在途结果;全局状态同步重查,不新增连接或轮询。control rail 的独立红点范围由浏览器按 Host origin 单独保存,支持全部、待处理、个人未读、其他未读或关闭,同源窗口通过 storage 事件同步。既有 Human Attention 状态查询可请求完整分类摘要;先按 Source Event 去重再分区,不从面板首屏推算。状态失败或旧 Host 缺少分类时显示未知提示,关闭偏好始终生效;Activity 列表筛选与真实状态不受红点设置影响。 + +BotHarness 是 DSH(DeepSeek Harness)之上的插件层,给 Agent 持久产品身份:**PersonaBot**。PersonaBot 用一个 Orchestrator Session 管理 Inbox,并可同时管理多个独立 Assignment Session;Memory 是 optional capability,Persona 是其中的 optional 内容;两者都不是聊天或执行的前置依赖。DeepSeekBot 是首个应用,提供 roster、Bot Inbox、Assignment Directory、委派和 IM 接入。 + +#2290 修正私密输入的本轮自动来源判定:已接受且仍有效的本地 Human DM/Group Source Event 沿用本地 Owner 交互,混入的 IM 批次或 steering 不再强制选择 IM 来源。显式 IM 选择与账号本轮已记住的来源继续受原有 lease、歧义及撤销检查约束;历史消息不提供交互权限。详见 [ADR-0172](../adr/0172-im-private-input-keeps-owner-web-authority-and-source-reply-fences.md)。 + +#1380 的历史 opt-in 私密输入由 application-defined PrivateInputs owner 关联真实 Orchestrator、PersonaBot 创建身份、账号及多字段 Channel 卡片;Web 与 CLI 经现有 Typert/API Gateway,以真实 Owner Peer 提交原值,Host 原子写入一个 native Credentials Service grant。Channel 与模型只接收声明、状态和引用;既有执行审批先批准引用调用,固定 trusted CLI 才解析并消费原值,只投影 HTTP 状态。Human 已认可卡片交互与真实认证成功,#1380 已合并。原生 `bash` Consumer 由 application-defined LinuxShell Provider 经 native Subprocess argv 启动 digest-pinned 容器,挂载当前授权工作区并保护 `.git`;普通 MCP 保留 native MCP Client Consumer,其 SDK 管理的 stdio 进程也由受控 Docker argv 启动。容器不挂载 Host 凭据/socket、不继承 Host 环境;其他未验证的 Host Capability 仍被整个 Profile 拒绝。该 opt-in 不是默认架构;固定秘密 MCP 消费已由 #1384 交付,实际 Linux 安装验收及限制见 #1386 复验记录。详见 [ADR-0164](../adr/0164-private-input-candidate-separates-native-storage-from-qualified-execution.md) 与[可运行验证路径](../dev/guides/private-input-cli.md)。 自 ADR-0189 起,私密输入在普通 Profile 默认可用:`ask_secret`、固定的 `protected_cli` 与 `secret_http` 对每个 Orchestrator 注册,原值仍只进 native Credentials Service 并绑定发起的 PersonaBot。`secret_http` 由 Host 子进程发出请求,目标是 Owner 在卡片中填写的地址,Bot 只决定其下的路径、方法和 `{{字段}}` 占位符。凭据存储的保护有两层:Host 改写 native Sandbox Provider 返回的命令行,为每条 Shell 命令加上“不可读取该存储”的规则(Seatbelt 规则或 bwrap 遮盖),并在审批之前拒绝针对它的文件工具与点名它的命令。`danger-full-access`、Landlock 与 Windows 下只有后一层。历史 opt-in 的 `privateInputExecution` 曾采用 ADR-0164 的容器边界。上述容器 Shell、容器内 MCP 与 `privateInputExecution` 已由 [ADR-0192](../adr/0192-the-docker-protected-profile-is-removed.md) 删除:密钥输入在所有 Profile 默认可用,使用方为 `protected_cli`、`secret_http` 与 `secret_cli`,凭据存储由沙箱读取拒绝和命令文本拦截保护。 + +#1381 让每张私密卡片持有独立 live authority 与十五分钟 Host deadline;Human Inbox/Bot attention 仅投影已提交且仍有效的请求,并跳转原 Web Chat 卡片。配置、取消与过期共享一个幂等 Channel resolution ID,提交在 canonical SQLite transaction 内重新检查 live authority 后才成为最终 winner。版本二 native grant 必须具备完整且匹配的 configured resolution 才能消费,跨存储失败的孤儿 grant 不可用;启动恢复将丢失 waiter 的卡片持久标为 expired 并清理孤儿 grant,保留已配置引用。私密 resolution 不创建 Inbox Admission,不以恢复或过期唤醒停止的 Bot。Host disposal 排空 pending settlement 与启动恢复;Client 清空输入并拒绝旧 mount/迟到 pending poll 覆盖最终状态。详见 Proposed [ADR-0166](../adr/0166-private-input-settlement-commits-before-continuation.md)。 + +本文描述 #71 确认后的目标架构。M1 registry、M2 Memory 与 #66 roster storage 已实现;#77 已验证 DSH runtime seams,显式 Session ownership、Messaging、Assignment Runtime、统一 operational database 和可移植性按 #79–#81 分阶段落地。更新:2026-10-03。 + +Bot 模式首次体验由 application-defined Onboarding owner 在现有 operational database 保存 Profile receipt。五步 Desktop 引导(#2192,[ADR-0233](../adr/0233-desktop-onboarding-follows-tob-creation-receipts.md))依次为模型配置、通过受保护 Tob 的原 DM 创建 Bot、创建包含该 Bot 的群聊、面板布局教学,以及实际点击 Home 标题关闭首页并进入原 Tob 私聊。沿用 Home 与共享输入框,示例只填写草稿。可信 scoped CLI adapter 在执行前保存 operation 关联,owner 从既有 Bot/Channel 创建回执与实际成员关系恢复进度;拒绝、预留和模型文字均不算创建成功。布局教学完成记录属于当前设备,Host 只保存教程生命周期与必要结果引用。旧完成/跳过状态保持安静,旧未完成状态暂停等待主动重开;升级不重排布局、不修改快捷键或草稿。新 Bot 与 Tob 不再写入默认欢迎消息,既有消息与旧欢迎消息渲染保持兼容。旧 first-reply 证据仍保留作为历史事实,而非新版创建步骤的成功条件。 + +历史欢迎消息保留兼容显示,提供能力介绍、每日晚间问候与十分钟定时测试示例,点击后沿用正常 Human DM 请求与现有执行能力。新闻选项由 Host 在 onboarding 查询中投影当前 native 搜索配置和所选 Bot preset 的 `web_search` 可见性,只返回就绪布尔值,不保存第二份配置或凭据。固定 RC1 的已知 DeepSeek 搜索 Provider 与其独立凭据已配置时提供带真实来源链接的今日 AI 新闻;未配置、无法确认或不支持的搜索组合显示通用工作规划示例。配置就绪不保证网络请求成功,实际搜索失败、空结果和不可用必须如实说明。模型保存和问题发送是两次独立操作:单独选择模型不创建问题;因配置受阻的请求保存模型后进入单独的发送确认,关闭或恢复均不自动发送。引导模型配置复用 native model/credential Services,经 Typert/API Gateway 保存并读回 Profile 默认模型;默认勾选后当前 Bot 继承全局,取消勾选则写独立 Model Plan。新 Bot 无独立 plan 时继承全局;保留 model-plan revision 以校验返回继承的编辑。全局修改只影响之后的继承请求,不覆盖固定计划或运行中的 Assignment。模型失败后的“重试这条消息”沿用原 Human 消息与 Admission,经既有 retryability/side-effect gate 拒绝不安全重放,不再追加 Human 消息。 + +当前 Client UI 由独立 `@botharness/ui` Bundle 挂载,源码仍在 `packages/client`;RC2 的插件图把结尾 `/client` 解释为导出子路径,因此包身份依 [ADR-0066](../adr/0066-rc2-client-bundle-identity.md) 避开该后缀。Client HMR 只暂存当前 Bot/Channel 选择以恢复视图,不复制 Host 中的 PersonaBot、Channel 或消息权威。 + +产品术语以根目录 [`CONTEXT.md`](/zh/dev/design/context) 为唯一词表;[BotHarness Runtime 架构](/zh/dev/design/bot-runtime) 单独展开 PersonaBot、Bot Inbox、Orchestrator、Assignment 与 DSH execution 的关系。DSH/Cordis 本身的术语和 Plugin 开发决策位于 `/zh/dsh`,不在这里重复定义。 + +迁移阶段保持可验证:#79 建立 `botharness.db` owner;#885 的 Schema Generation 56 已将 #66 的 roster arrangement 单向迁入 owning SQLite module。旧 `botharness_roster` Storage Domain 仅作一次性输入,校验失败时 roster 只读且返回 `storage-unavailable`,不会当成空排列;成功后排列与 import marker 同一事务提交,旧源保留供恢复参考,正常运行不再打开或读写该 domain。Session ownership 保持既有数据库权威;#886 的 Schema Generation 72 增加恢复收据、逐 Bot 模型授权/激活记录与 Session 内容可用性。 + +#56 and #137 extend the current roster global slot to `{ pins, hidden?, sectionOrder, topOrder? }`: `pins` canonically orders Channel IDs for both group Channels and PersonaBot DMs, `hidden` omits Channels only from roster navigation while retaining their placement, and `topOrder` mixes section blocks with loose Channels while membership remains owned only by section records. The unary client bridge now has ten arrangement methods, including `topReorder`, `hiddenSet`, and bounded `rosterBatch` (one Host completion notice and one final Client snapshot for multi-select); #885 preserves this order, hidden presentation state, and single-membership invariant in the owning `roster_arrangement` singleton; each command mutates a transient draft and commits the complete arrangement atomically before publication, without dual writes. A missing import marker refuses edits until a valid source is imported; an already populated target without a marker is never overwritten. Sorting remains native DSH Settings, and collapse remains per-Client. 已提交的 roster mutation 会在 Host commit 后发送 `roster/changed` live invalidation;其他窗口只重读权威 roster,不接收也不复制拖拽中的预览状态。新提交的非通知 Channel 消息另以 `roster/latest`(类型化流为 `latest` 帧)推送该 Channel 的最新消息,各窗口只更新对应行的预览与时间,不为每条消息整体重读 roster(#1530)。 + +BOT mode 的折叠 rail 复用这份 Channel 排序读模型:置顶项在分隔线上方,其余 DM 与 group Channel 按 section/未分组的扁平顺序排列。`botharness/channels` 额外投影可选 `latestMessage` 供 hover 摘要使用;该字段从当前 Messaging authority 派生,不成为新的持久化权威。“最近更新”模式下,每段相邻的未分组 Channel 按最新可见消息时间排序,section 位置不变;“手动排序”保留存储的扁平顺序。客户端打开或更新某个 Channel 不会单独改变它的位置(ADR-0031 2026-10-13 更新,#1530)。 + +置顶格也是独立的排序 scope:默认继承全局最近更新/手动模式,可用 `ui-bot-mode.sortModes.pinned` 覆盖;拖动置顶卡片会把当前完整顺序(包括被搜索或隐藏过滤的置顶项)冻结为手动模式,通过 `pinsSet` 持久化,不影响 section 归属或其他 scope 的排序。折叠 rail 使用同一置顶顺序。 + +Hidden Channel 是 application-defined 的可逆 roster presentation state:它会从展开列表、pin grid、搜索与折叠 rail 中消失,但不会改动 Channel、消息、PersonaBot、Memory 或原 placement。破坏性删除仍受 ADR-0037 的 dependency report 与独立确认约束(#138)。 + +当前 Channel Chat 的实时显示遵循 ADR-0054:正式消息先写入当前持久化权威,再以该 Channel 的单调 revision 发出进程内通知。#143 的时间线读取遵循 ADR-0061:Host 的 `channelTimeline` 用不透明游标提供 latest / older / newer / around 窗口,Client 仅保留一段连续的按提交顺序排列的可见消息;SQLite Channel placement 的游标解释留在 Host。首次打开 Channel 取最新页;有上次已读锚点时,重开会在锚点附近分页并定位;向上补页维持视口锚点,定位旧消息可前后补页并高亮,并可继续向 newer 分页直至最新消息;用户离开底部后,新消息只更新未读提示,不强制滚到底,也不把实时消息拼接进尚有后续页的旧窗口。旧 `channelMessages` 端点暂作兼容。已选 Channel 通过经过 DSH 认证的 `/api/botharness/stream` SSE 接收已提交消息;重连可回放、缺口则从 Host 修复。同一连接还传送 Orchestrator 显式 `channel_send` 工具参数产生的进程内草稿预览:`channel/draft` / `channel/draft-settled` / `channel/draft-abandoned` 带 attempt ID 与独立于持久消息的进程内草稿 revision;连接时先发送完整 baseline,断档则重读 committed。草稿不从历史回放,不进入 Inbox,提交后由正式消息替换,放弃时移除;呈现层可消费草稿,任何不可逆操作只认已提交消息(ADR-0054)。Human send 使用 Client 生成的幂等 message ID;网络失败时本地气泡保留失败状态,Human 可把原文和附件恢复到 composer 后再次确认发送,而 Host 对响应丢失后的同 ID 重试只接受一次 durable append。Orchestrator 普通 final 与 Assignment 输出不等于 Channel 消息;当前正式 Channel 消息由 SQLite Source Event 与 placement 事务提交,旧 NDJSON 历史只在首次升级时单向导入。Group 中经候选列表选中的多个 Bot mention 在同一事务形成独立 Inbox Admission;Bot 处理状态经同一 SSE 流独立投影。已入群 Bot 的 channel_send 可提交稳定 ID 列表;Host 从可信 Orchestrator ownership 得到发送者,读取当前成员与名称生成 mention 标签,并在同一事务为不同目标建立独立 Admission。Bot-authored Group 消息保留因果根和跳数;同根同目标只投递一次,过限消息保留在群里但不再唤醒目标。收件 Bot 可以在同一群回复,重启后 pending/retryable Admission 仍可恢复。 Group 之外,Bot-to-Bot DM 使用确定性的双成员 Channel ID;Orchestrator 的 `bot_dm_send` 或该 DM 内的 `channel_send` 以可信 Session ownership 确定发送者。每次发送在同一事务中提交 Bot DM Source Event、placement、收件 Bot Admission(受因果根去重与跳数上限约束),以及只链接原消息的无正文 Channel Notice。Notice 放在引起这次发送的 Source Event 所在 Channel(群、Human DM 或 Bot DM),该 Channel 不可用时回到发送者 Human DM;同一事务为发送者写入一条已处理的 `bot-action` Bot Inbox 记录。若引起发送的正是这个 Bot DM 本身(两个 Bot 来回回复),消息已在原对话中可见,不再写 notice 或记录。Channel Notice 不计入 Human 未读、回执、活动统计与 Channel 预览,也不为其他成员建立 Admission(ADR-0154)。重启时恢复 pending/retryable 收件投递;Bot DM 默认不进入 Human roster,但在隐藏频道管理器中可发现、只读打开,Human 无发送或重命名权限。 +Group Channel 的气泡收件圆环只投影该消息已有的 PersonaBot Inbox Admission,分母是实际收件者而不是群成员总数。入队是「已投递」;运行时 claim 本身仍显示已投递,直到 observed_at 标记消息进入 Orchestrator 回合上下文才显示「处理中」。若 Bot 通过 Channel 查询主动读到消息而没有进入处理该消息的回合,Admission 保持 pending 但 observed_at 非空,投影为独立的「已读」;回合结束是「已处理」,并不意味着发出了回复。明确忽略、可重试失败和需修复失败各有独立状态。每次权威状态变化通过 Channel revision/SSE 通知并重读该投影,不新建第二套已读存储。Bot 发送者不产生自己的 Inbox Admission,也不进入收件人数。Group 的本机 Human 成员以 Host 固定身份、显示名、加入时间及首个可见 revision 保存在 Messaging 权威中;Human 阅读位置按 Channel ID 与 Human ID 共同持久化。既有 Group 从首条消息起可见,旧 Channel 阅读位置迁移到本机 Human。Host 仅对有可见权限的非作者 Human 投影已读/未读,Bot 状态仍只取 Inbox Admission;同一圆饼与明细展示两种语义,不再从浏览器 tab 或作者推断已读(ADR-0078、#347)。Human 已读推进只广播一条携带阅读 revision 的 Channel SSE;重连时从权威已读位置发送 baseline,Client 用消息的 placement revision 更新当前可见窗口,避免阅读大量历史时逐条广播。当前只支持本地一个 Human;跨账号多人 Channel 仍需独立身份与授权设计(#377)。 +Channel 的消息引用(#145)只保存同 Channel 的已提交目标 ID;Human 发送与 PersonaBot 显式 `channel_send` 由同一持久化权威校验,跨 Channel/缺失目标不能产生消息。引用作者和截断摘要在时间线读取时由当前历史投影,不复制为另一份持久内容,也不为每条消息单独读取。目标后来不可见则降级为不可点击提示;点击有效引用复用 `around` 窗口和高亮,不改变相邻消息分组与时间语义。 + +新 Channel 附件(#576,ADR-0100)保存到 profile 管理的独立真实文件,消息持久化 `{fileId,name,mime,size}` 引用。Host 在 append 前校验 profile 归属,查询时从当前文件投影 MIME 与大小,不改写 Source Event envelope;保留的旧 `{hash,name,mime,size}` 通过 generation 39 的 Messaging 绑定解析到独立真实文件,转换前后均校验字节且不改写 envelope;失败保留旧对象可读状态并给出有界修复日志。Composer 的固定上传 key 与消息 ID 分别保障传输和发送重试幂等。认证 Fetch 上传仍走 `/api/botharness/attachment/upload`,新下载以 `channelId + messageId + fileId` 验证归属并用 `no-store` 返回当前字节。Human 文件/图片菜单复用现有 DSH 原生打开能力;Orchestrator `channel_read_image` 使用 `attachment_id`(fileId)或 legacy `hash`,先验证当前成员资格、消息引用、当前 MIME 和大小,再传给 DSH attachment service,模型不接触 Host 路径。引用感知清理保护所有保留 Source Event 的文件身份;不启用自动调度或保留期。详见[双语文件指南](../file-open.zh.md)。 + +拥有该 Session 的 Orchestrator 可复制 `channel_read`(含通过 `message_id` / `content_cursor` 完整读取的内容)或旧版授权读取结果中的最多 10 个可信附件引用,四个字段原样保留:`{fileId,name,mime,size}`。#577 后旧消息投影迁移后的文件身份;过期 `{hash,name,mime,size}` 结果必须先重新读取所属消息再转发,旧 hash 图片读取仍须带所属消息以解析当前字节。Orchestrator 还可通过 `channel_attachment_import` 明确选择并导入已授权的本地结果文件;不能猜测身份与元数据。DSH schema 将 `size` 声明为整数;Host 继续校验 0–9007199254740991 的安全非负字节范围。固定版本的 DSH schema 不支持 `maxItems` 或数值上下界,因此数组上限由描述与运行时校验共同表达。Group 最多接受 20 个提及 ID,每个都必须是其他活跃现成员。`channel_send` 只有在 Messaging 权威接受写入后才返回紧凑的 `{channelId,messageId}` JSON;省略目标时使用入站 Channel。该结果取代原文本确认;附件身份、profile 归属、当前文件语义、回复目标、delivery-key 重试与因果循环边界继续沿用([#570](https://github.com/BotHarness/BotHarness/issues/570))。 + +应用定义的 `group_leave` Tool 只在规范成员移除提交后返回 `{channelId,left:true,outcome:"changed"}`;Bot 当前不是该群成员时返回 `{channelId,left:false,outcome:"unchanged",reason:"not-member"}`。无变更既包括重复退出,也包括从未加入的群;不推断历史成员身份,不返回未加入群的名称或名单。缺失 Channel 抛出 `group_leave: channel-unavailable`,非群聊目标抛出 `group_leave: group-required`,替代旧的含糊 `left:false` 成功结果;Tool 异常保持为失败。既有 `{channelId,left}` 字段及 Core 返回结构保持兼容,调用方须处理新的无效目标失败。ADR-0073 的同一事务仍承担创建者移交 Human、待处理 Admission 撤销、单条持久离群通知及剩余成员注意力策略;提交后的实时通知警告不否定已提交变更。读写权限立即撤销,因此 Bot 应通过仍可访问的 Channel 向 Human 报告([#571](https://github.com/BotHarness/BotHarness/issues/571))。 + +The reply Consumer can compose an explicit `resolve_inbox_ids` selection with a successful send/drop, using the existing Inbox triage owner afterward; completion failures remain separate from delivery. For an exhausted drop without observed peer-yield evidence, the draft owner identifies the original Source Event and Group Message IDs as keep-open work; the Consumer excludes them from composed completion while still resolving selected FYI. A later explicit cancellation/completion decision remains with the Inbox owner. A revoked initiating opted-in Group prevents that run from continuing in another Channel. A later independent DM remains available (ADR-0227). + +Home/Tob(#2082,[ADR-0223](../adr/0223-tob-is-a-protected-local-personabot.md))复用现有 PersonaBot、Memory、Workspace、Channel、审批和 native Settings owners。全局 Home 不保存第二份对话;提交进入 Tob 的同一个 Human DM,消息面板继续展示。空 Profile 的 onboarding 使用 Tob,已有普通 DeepSeek Bot 保持原身份和历史。Generation 96 的本地单例引用决定 Tob 身份,名字、Soul 或导入内容不授予权限。首片的 Assignment 并发上限路径在 #2088 扩展为明确的 CLI allowlist:Host 默认模型、Bot 模型计划、模型预设创建/读取/更新/应用、四个 native runtime 字段、telemetry、Git 可用性/安装,以及带显式确认的发布安装/重启。受限 release-install/release-restart 每次必须由 Human 新作决定,full access、已记住规则或自动审批均不能替代;此规则不为 Git 安装新增确认参数。各操作继续使用其已有 owner;preset 删除尚无 owner,凭据仍经原生私密输入,其余设置不因 CLI 已存在就自动授予 Tob。Tob 通过 DSH-native bash/pwsh Tool → 隔离的 Shell Service → DeepSeekBot CLI 发起操作;application-defined CLI broker 为每次 Shell 执行提供短期 loopback capability,不向模型提供 Human 管理凭据。Host 从真实 ToolExecution 绑定受保护 Tob 的 root Orchestrator 与准确的已准入 Human DM Source Event 集合;不接受 CLI 自报身份。解析后的设置操作沿用 native Settings owner 和现有审批,审批前后及写入时重验来源、旧值和 revision,并读回结果。Shell 完成、取消或 Plugin 释放时撤销 broker;原生 Assignment 工具不变。详见 [ADR-0224](../adr/0224-tob-management-uses-scoped-native-shell-cli.md)。布局 v8 保留 v7 子树与比例,并备份升级前原文;Home 默认快捷键使用独立稳定 ID。 + +#2089 扩展同一受限 CLI seam 到空白 Bot 创建、资料展示和生命周期([ADR-0225](../adr/0225-bot-creation-receipts-and-reviewed-profile-lifecycle.md))。Generation 97 的 `bot_creation_operations` 在原 operational database 保存 operation key、输入指纹与预留身份;Registry/Memory 和 Channel owners 仍分别创建规范 Bot、受管 Memory Repository 与 Human DM。同 key、同 payload 的响应丢失重试核对原身份;不同 payload、已归档/删除身份均拒绝。目录已创建但 Registry 身份缺失时保留现场并返回 `creation-memory-conflict`,不假装文件系统与数据库是原子事务。受限资料写入绑定完整 Registry record 的 `profileFingerprint`,审批后重新检查并读回;元数据 patch 仅允许名称、简介和标签。删除复用原 owner token、依赖和准确路径,默认保留 Memory,显式擦除和重试均不能改变已审核范围;受限删除确认/重试每次需 Human 新作决定。Tob 保护身份不变,导入与跨 Bot 既有 Memory 编辑仍为后续独立能力。 + +#2090 首个切片将同一受限 CLI seam 扩展到群创建和邀请([ADR-0226](../adr/0226-group-management-receipts-preserve-channel-authority.md))。Generation 98 在既有 operational database 保存稳定操作回执,绑定输入、Bot 身份代次与预留 Channel/invitation 身份;Channel 和 invitation owners 仍是群与成员状态的权威。创建时显式指定的初始 Bot 按现有 UI 语义直接入群;后续邀请沿用默认开启的 `autoAcceptGroupInvites=true`:管理操作审批通过后,同一事务记录已接受邀请与成员关系,受邀 Bot 立即入群,无需额外邀请决定。仅当 Human 已关闭自动接受时才保留原有逐邀请决定分支;本片不新增 UI 开关,Tob 报告实际状态。邀请的审核指纹包含频道元数据、成员及邀请策略,不含消息流或未读计数;审批后与写入前重验,拒绝过期审核。同 key、同 payload 重试核对原结果,已完成的受限重放只读回,不再次申请变更审批;删除、身份变化或 payload 冲突不回退到新建。Tob 始终从已准入 Human DM 管理其他 Bot,不加入群,群内容不授予管理权限。#2090 下一切片通过同一 seam 加入群名称/头像、成员普通消息唤醒策略与成员移除,不新增模型 Tool、数据库迁移或 UI。所有受限编辑必须携带已观察的 `expectedChannel`,审批绑定旧状态、准确目标和新值;执行时重验 Human 当前群访问权限、频道快照及目标 Bot 身份。指纹也包含每个成员的有效唤醒策略与继承状态,因此 Bot 默认策略变化会使旧审核过期。移除成员保留 Bot、Memory、其他频道与群历史;移除 owner 同时清除其管理身份,转由 Human 管理。这些编辑不使用操作回执;结果不确定时先读回,仍需修改则依据新快照重新审核,不盲目重试。已认证 Human 命令可选指纹以保持兼容;Tob 不能省略。后续待处理决定切片沿用同一 seam,为 Tob 开放 `group-invite-cancel` 和 `group-join-decide`。两者必须携带已读取指纹,绑定准确的待处理邀请/申请及目标 Bot 身份代次,审批后重验 Human 当前群成员资格、频道快照与记录仍待处理。取消不能撤销默认自动接受的邀请,也不移除现有成员;接受/拒绝申请由原 Channel owner 持久保存状态与适用成员关系,原决定通知只记录一次。受限入口只处理待处理记录;Human 入群决定命令未携带指纹时保留原有同向终态的幂等读回。结果不确定仍先查规范状态,不增加操作回执、数据库迁移或模型 Tool。个人展示设置、删除群与外部成员管理仍为后续范围。 + +## 原生 Human 等待的验证边界 + +[#1036 的有界实验](../research/1036-native-wait-experiment.md) 在固定 DSH +`0.2.0-rc.1` 上证明:独立 Assignment 等待原生工具审批时,Orchestrator +可真实处理并回复无关 Inbox 消息,但没有释放 Assignment 运行许可。 +Orchestrator 自己的原生审批或正式提问仍占有当前 Step;Admission 已接收、 +`steer` 已排入下一 Step 不等于模型已处理,原调用决定并返回后才处理无关输入。 +准确 Session/call 归属及当前 Grant 校验仍由既有 authority 承担,变更/撤销 +范围拒绝原操作。[ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md#native-human-wait-qualification-2026-10-09) +记录本次资格边界,不改变 ADR-0035 的单一 Orchestrator root 约束。 +[#1220](https://github.com/BotHarness/DeepSeekBot/issues/1220) 追踪缺失的受支持原生 +continuation 机制;#1036 保持阻塞,#1037/#1038 生产实现尚未交付。 + +后续[隔离 RC2 实验](../research/1220-native-timed-question-experiment.md) 验证原生 timed +question 可先真实返回 pending,问题保持可回答;同一 Session 可回复无关消息,再通过 +带原 call 身份的 qualified Inbox 输入处理稍后答案。这是 Tool 已结束、问题仍持续的机制, +不是挂起未结束调用。[DM 卡片适配](../research/1220-timed-question-card-e2e.md) 以原 Session/call +关联卡片:只有 `ASK_TIMED_OUT` 结束前台等待,卡片仍查询原生 Projection;稍后答案通过 +原生 `userQuestions.answer` 提交,显示已提交,直到原生接收后才写入 Channel 回答记录。 +稍后提交进入既有 per-Bot Runtime 队列,为同一 live Session 建立新的 application run,保留 +原 Source Event 上下文并重验当前归属、DM 与源内容 fence;run 持续到原生 idle,让模型能使用 +既有 Channel 工具,不追加普通输入或 Inbox Admission。 +进程内映射仅关联已存在的 live Agent/卡片,不是第二个问题权威,也不实现冷恢复。 +生产依赖仍为 RC1,timed 模式仅在显式配置的隔离 RC2 Profile 验证。权限审批、容量释放/重获和同群隐私验收 +继续阻塞,不从这次 DM 能力实验推断完成。 + +## 1 · 系统上下文 + +新附件已遵循 [ADR-0100](../adr/0100-file-open-actions-target-real-host-files.md):独立上传彼此独立,显式复用身份才共享编辑结果。原生打开指向真实目标;后续消息读取、预览和下载使用当前内容,上传源独立。保存不保留附件历史,不生成 Source Revision、Inbox Admission、通知或 Bot wake。目标缺失则报告不可用,不自动重建。旧 CAS 迁移(#577)按 Source Event 附件出现位置预留可重启恢复的身份,仅在保留依赖未转换时继续保护旧对象;其他 CAS 数据与 Memory Git 行为保持各自语义。 + +```mermaid +flowchart LR + Human["Human
DSH Web / IM"] + External["Feishu / Lark
webhook / future providers"] + + subgraph Browser["DSH Web Client"] + UI["DeepSeekBot UI
Roster · Chat · Human Inbox · Assignments · Settings"] + end + + subgraph Host["DSH Host · single profile writer"] + API["Client Bridge RPC"] + Identity["PersonaBot identity"] + Memory["Optional Memory Service
Service Definition · Git Provider"] + Messaging["Messaging
Source Events · Inbox · Outbox"] + Assignments["Assignment Runtime
Orchestrator · Assignment Directory"] + Transfer["Portability
Export · Backup · Restore"] + DB[("botharness.db")] + end + + subgraph DSH["DSH-owned runtime"] + Sessions["Agent / SessionPersistence
Orchestrator · Assignment Sessions · Subagent"] + Credentials["Credentials · profile settings"] + end + + Human --> UI + External <--> Messaging + UI <--> API + API --> Identity + API -.-> Memory + API --> Messaging + API --> Assignments + API --> Transfer + Identity --> DB + Identity -. attachment .-> Memory + Messaging --> DB + Assignments --> DB + Transfer --> DB + Identity <--> Sessions + Messaging --> Assignments + Assignments <--> Sessions + Assignments -. scoped Consumer .-> Memory + Messaging -.-> Credentials + Transfer -.-> Sessions +``` + +浏览器只通过 RPC 访问 Host read models 和 commands。Provider adapter 只负责验证、规范化和执行能力;它不拥有 Inbox,也不能直接唤醒 Agent。DSH 继续拥有 Agent 执行、SessionPersistence、Subagent 与凭据;BotHarness 不复制这些 runtime 权威。 + +Assignment 完成报告通过既有 Human Inbox 最新报告、忽略和来源级隐藏事实派生 `informationalCount`,进入同一有 revision 的 Activity snapshot。仅有信息时显示中性的 `i`,但不会单独让输入框活动行常驻,该行只在执行中或有待处理事项时出现;红色待处理数字不包含信息更新,同时存在时悬浮/聚焦摘要分别说明数量。打开报告本身不代表确认;忽略、隐藏,或主会话已处理这条汇报的 Inbox 投递时清除提示,汇报本身仍列在收件箱中;重启可从权威事实恢复,执行状态不变。 + +## 2 · Deep modules 与所有权 + +```mermaid +flowchart TB + Root["Host composition root
lifecycle · dependency wiring"] + DB["Operational Database Owner
writer lease · schema generation · transaction"] + + subgraph Modules["BotHarness deep modules"] + Bots["PersonaBot
identity · lifecycle · Session ownership"] + Memory["Optional Memory Service
repositories · Git commits · events"] + Msg["Messaging
events · channels · inbox · triggers
grants · outbox"] + Assignments["Assignments
directory · capacity · requests · reports"] + Usage["Usage
retained daily model tokens · read model"] + Portable["Portability
Soul · export · backup · restore"] + Views["Read models
RPC · UI projections"] + end + + Root --> DB + Root --> Bots + Root -. optional Provider .-> Memory + Root --> Msg + Root --> Assignments + Root --> Portable + Root --> Usage + Root --> Views + DB --> Bots + DB --> Memory + DB --> Msg + DB --> Assignments + DB --> Portable + DB --> Usage + Bots -. attachment .-> Memory + Bots --> Assignments + Msg --> Assignments + Bots --> Views + Msg --> Views + Assignments -. scoped Consumer .-> Memory + Memory --> Views + Assignments --> Views + Usage --> Views + Portable --> Views +``` + +| Module | Owns | Does not own | +| ---------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| PersonaBot | Host-owned ID、display name / role badges、lifecycle、explicit Session ownership | DSH Session lifecycle、Memory 内容 | +| Memory | generic Git-backed repositories、semantic commits、operation events | PersonaBot lifecycle、Inbox、Session | +| Messaging | Source Event、Channel placement、Inbox Admission、Attention、Trigger/Wake Policy、Service Grant、Outbox | Agent execution、provider credentials | +| Assignments | Assignment Directory、Assignment Request/Delivery Intent、capacity admission、report/lifecycle routing | DSH transcript、Subagent runtime | +| Usage | per-PersonaBot/执行类型/实际模型的保留日统计、usage read model 契约 | DSH Session 日志、价格表、金额总账 | +| Workspace Grants | Human 对 DSH Workspace 的授权与撤销、Assignment 创建时的权限快照 | DSH Workspace registry、Session 权限实现 | +| Portability | SoulSnapshot、PersonaBot Export、Profile Backup/Restore/Transfer 协调 | credentials、可执行插件、DSH 私有格式 | +| Read models | 查询、分页、PersonaBot Activity Projection、Human Inbox、UI-friendly projection | 业务事实与写入规则 | + +`botharness.db` 是 BotHarness core 的物理事务宿主,不是共享的 generic repository。Memory 内容与 commit 由 optional Git-backed Provider 掌管;每个 deep module 只通过自己的接口拥有表和不变量,跨模块流程由显式 command/port 协调。 + +Usage 是 application-defined 的保留统计:从 DSH durable SessionEvent 中按实际请求提取 provider/model 与 provider 报告的 input、output、cache-read、cache-write token,按可信 Session ownership 归属 PersonaBot,分别汇总 Orchestrator、Assignment 和 DSH Subagent;同一 Turn 用多个模型时分别入桶,缺失用量标为未知。`botharness.db` 按 `(bot, day, execution role, provider, model)` 保留日汇总,日界取 Host 本地时区;增量折叠必须幂等,普通 Session 删除后不可用剩余日志全表重建或重复计数,彻底清除 PersonaBot 才删除其可识别统计(ADR-0094,#39)。金额由价格表在查询时另行估算,1.0 不落金额总账(#35 留 v1.1)。Browser 只经 read model 查询,不直读 Session 日志或投影表;PersonaBot Profile 的 token 卡是当前消费者(#34 Decisions、#428)。 + +Usage generation 40 在同一 Operational Database 事务中写入匿名 HMAC 去重凭据与日增量;校准只补记未见调用,不清空保留汇总。凭据不保留原始 Session 标识或逐调用内容。旧汇总迁移为 Bot 级时间基线,升级前无法确认的补计明确留诊断;新调用持久幂等。归档保留用量;Registry Purge 清理汇总、凭据与基线,当前 Bot 创建身份与可信根 ownership 防止旧 Session 重新填入。 + +首个可运行切片(#499)扩展现有 26 周有界 `profileActivity` 查询,返回实际 provider/model 的日用量、可为未知的报告分项,以及即使某缓存分项缺失仍可确定的 provider 总量。Profile 按 Host 本地日期查看用量,与允许调用的 Model Plan 独立。逐调用切片(#503)独立结算每条追加的 Assistant 调用事件,成功调用使用实际 source,失败调用使用已记录的请求路由;Profile 区分可信 ownership 对应的 Orchestrator、Assignment 和子代理。Session 序号去重实时通知与重放快照,消息替换不产生新用量。Session 删除后的保留统计仍由 #502 完成。 Profile 在现有 26 周公开查询范围内,以一个有界时间选择联动每日用量、实际 provider/model 用量构成和缓存比例图,默认最近 7 天。模型/提供商切换按选中的实际模型 ID 或提供商 ID 汇总,不在行内嵌套另一维度;Host 原始行和折叠的执行详情保留两个标识。模型/提供商行紧凑地显示选中维度的名称与报告总量;图表悬浮提示展示总输入(未缓存输入加缓存读写)、缓存读/输入及输出/报告总量的比例;缺失分项或零分母的比例保持未知。执行类别默认折叠于详细信息(#592)。 + +筛选查询 `profileUsage`(#507)由 Usage 深模块拥有,经 Typert/API Gateway 提供:真实且非未来的日期最多覆盖 182 天;模型/提供商与执行类别条件同时作用于每日记录与累计汇总,累计值仅忽略日期。Profile 默认近七天,执行类别筛选位于折叠详细信息中;首次打开、改变筛选或手动刷新时查询,不轮询或按时间自动过期,同条件刷新失败明确标为过期,旧条件迟到的响应不会覆盖新筛选。响应包含查询/核对时间、正常/核对中/降级状态、可空未知用量,以及显式明细/选项上限(2,000 行/每维度 1,000 个实际选项);总量完整,截断图表隐藏。Session 来源是否可用不决定保留统计是否存在(#502)。 + +Model Preset 的当前模板存储由 Profile SQLite 的 owning module 管理,Schema Generation 54 将旧 JSON 模板一次性校验迁入,保留 ID/revision,并写入包含空源的切换标记;成功切换后旧文件仅保留为恢复参考,不再读写(#883)。Schema Generation 55 将 PersonaBot Registry 的身份、外观快照、访问开关与独立 Model Plan 一次性校验迁入 `persona_bots`,导入标记与记录同一事务提交;旧 `bot.json` 与 Soul 文件保留,但正常读写仅使用 owning Registry module/Profile Writer Lease(#884)。无效登记使 Core 启动失败并释放 lease,不会把坏数据当成空名单;修复保留的旧源后重试。Generation 72 的完整 Profile Backup 保存可复用模板与独立 Model Plan 的现值和 revision,恢复不会重新套用模板(#886)。 + +Model Preset 是部署本地可复用模板;Human 在 Profile 应用时,PersonaBot 保存独立 Model Plan 快照,后续模板编辑不传播到已应用的 Bot。Plan 固定 Orchestrator 的 provider/model/reasoning effort,并定义 Assignment 可用的精确模型、各模型允许及默认的 effort 和默认模型。Host 在每个执行入口按当前 Plan 验证选择,而 DSH SessionEvent 记录实际调用:Orchestrator 的变更在当前 Turn 结束后生效,已有 Assignment 保留当前路由,之后的显式切换按最新 Plan 校验;新建 DSH Subagent 默认继承仍获允许的父路由,否则选当前 Assignment 默认并告知父 Agent。不可用或有歧义的路由停止请求,交由 Human 修复,不静默回退。Model Preset 与 Model Plan 可进入保持身份的 Profile Backup,不进入 SoulSnapshot 或 PersonaBot Export(ADR-0027、ADR-0093,#488)。 + +快捷模型/effort 切换只修改此 PersonaBot 的 Orchestrator,并在下一 Turn 生效;DM 不猜测某个 Assignment 作为目标。Model Plan 可以同时省略 `assignmentDefault` 与 `assignmentModels`,表示任务继续继承 Profile 全局模型;既有固定任务配置不变。Registry 的 `modelPlanCustomize` 用 revision 校验创建或修改这种独立配置,Web、移动端和 CLI 共用该 Host 操作。已有 Assignment 仅使用自己保留的路由;没有保留路由时继续继承全局,不补用后来出现的 Bot 任务默认值。完整模型设置显示继承状态,选择完整模板或任务模型才建立固定任务策略([ADR-0218](../adr/0218-quick-model-selection-only-customizes-the-orchestrator.md),#2000)。 + +当前命名模板保存在 `botharness.db` 的 `model_presets` 表(#883,取代 #498 初始 JSON 存储),应用后的独立快照及修订号保存在 owning Registry 的 SQLite 记录中(#884),后续模板修改不会自动替换。Profile 经现有 Host Bridge 读取 DSH 当前注册的 provider、model 与 effort 能力,并在创建及应用时校验确切路由;Orchestrator 的 Agent-scoped Model Selection 在每个新 Turn 前读取最新快照,不修改 DSH 部署默认值。DSH 请求头仍是实际调用路由的事实来源(#498)。 + +后续切片允许 Human 修订命名模板而只改变未来应用的值与模板修订;Profile 的紧凑切换按当前模板复制新 Bot 快照,手动改一个 Bot 的 Orchestrator 路由则清除模板来源标记、保留 Assignment 默认值并增加 Bot 计划修订。Host Bridge 在写入前验证现有 DSH 路由,正在执行的 Turn 持续使用其已组装选择,下一 Turn 才重新读取 Bot 快照(#501)。 + +新建 Assignment 的模型选择由当前 Bot Model Plan 管理:Human 在 Profile 保存精确 provider/model 集合、各模型允许及默认的 effort 和整体默认路由,Host Bridge 在写入前对照 DSH 当前 Model Catalog 校验并递增 Bot 计划修订。Orchestrator 通过 `list_assignment_models` 读取该集合,再在 `create_assignment` 中省略选择以使用整体默认值,或指定获准的模型与 effort;Host 在认领 Session 和启动工作前拒绝越界选择。选定路由随 Assignment ownership 记录保存在 `botharness.db`,并通过该 Assignment 独有的 DSH Agent-scoped Model Selection 进入真实请求;读取 Assignment 或 Host 重启不会把选择改为后来更新的 Bot 默认值。这个切片不修改 Orchestrator 路由或 DSH 部署默认值(#504)。 + +PersonaBot 的 Agent scope 提供 `bot_subagent`、`bot_subagent_fork` 和 `list_bot_subagent_models`;Host 在每次启动子代理时从该 Bot 的最新 Model Plan 验证路由,并用 DSH LLM Service 预检可用性,再交给 DSH Subagent Service 创建真正的子会话。Bot scope 拒绝绕过计划的原生 Subagent 工具调用。省略路由时,获准的父路由被继承;已从当前计划移除的旧 Assignment 路由改用当前 Assignment 默认值,并在工具结果中告知父 Agent。子代理沿用根会话的 Workspace Grant 与工作目录;DSH 子会话原生的 `never` 审批策略不放宽根会话的模式约束(#508)。 + +Model Route Readiness 在 Profile 查询及 Orchestrator/Assignment 新 Turn 入口读取同一 PersonaBot Registry。旧 `model` 字符串只有在 DSH Model Catalog 唯一匹配且路由配置有效时才迁移为独立 Model Plan;有歧义或缺失则保留旧值并等待 Human 选择,绝不拼接部署默认 provider。修复状态即时计算,不是第二套策略存储。请求前校验精确 provider/model/effort;provider 在真正执行时拒绝凭据则通过 DSH `turn/end` 和现有 Channel Session failure 通知给出修复指引。成功应用 Model Preset 后移除旧字段,保持 Agent preset 不变,下一次 Turn 使用修复后的路由(#500)。 + +application-defined Memory Service 使用 `Consumer → Service Definition → Provider` capability seam。每个 PersonaBot 都有一个 Git-backed Memory Repository,Orchestrator Session 固定以它为 working directory。当前检出的工作树文件立即是记忆,无论是 Markdown、代码还是二进制文件。Agent 通过原生文件、搜索、Shell 与 Git 能力读写仓库;没有面向模型的 Memory CRUD 工具。新建 PersonaBot 时可选择空白记忆,或由 Host 检查 Git 并以 HTTPS/SSH 地址在暂存目录克隆远端仓库;克隆成功才建立 Bot,远端当前分支及文件立即成为记忆(#298)。私有仓库使用 Host 已配置的 Git 凭证,不经创建页面收集密钥;已有 Bot 仍通过原生 Git 整合远端仓库。外部仓库应合并、替换或放到其他位置若有歧义,Orchestrator 经 DSH 原生 Ask Question 询问 Human。分支、合并与冲突由 Git 决定;Git 改变工作树后,同一 Session 立即看到新文件,但该 Session 已冻结的 Soul 与 Core Memory prompt 不追溯更新(ADR-0060)。Memory 根目录的 `SOUL.md`(Soul)与 `MEMORY.md`(Core Memory)在 Session 首次组装 prompt 时冻结为同一份快照注入,只在 compaction 边界整体刷新;各自受每 Bot 可配置的字符上限约束,超限时截断并明确提示。旧 `PERSONA.md` 在每次 Host 启动时幂等迁移为 `SOUL.md`,读取仍兼容旧名(ADR-0134)。跨设备延续沿用同一路径:Orchestrator 按 Human 请求用原生 Git 与 Human 自配置的远端同步,新设备以 Git URL 创建新的 PersonaBot;不操作 Git 的所有者使用 Profile Backup,而不是 Host 持有的同步服务或一键同步(ADR-0084)。 + +Idle Compaction(#2310):当前 Orchestrator Session 没有进行中的轮次、也没有待处理的 Bot Inbox Admission 时,Host 按最后一次模型回答的时间,在该线路提示缓存寿命的一半(最少 15 分钟)经 DSH 命令注册表为该 Agent 执行 `/compact`(`compactNow`;compaction 服务隔离在每个 Agent 的 preset 组内,Host 无法直接取得),只压缩超过该线路 DSH 默认自动压缩阈值一半的会话;摘要格式与 `compaction/end` 刷新 Soul/Core Memory 的行为不变,期间到达的 Human 消息由 DSH 排队到压缩结束。缓存寿命依次取 Host 设置中的每线路值、模型目录、观测(请求前空闲间隔与该请求缓存命中率)与 DeepSeek 的 2 小时默认值,不足 15 分钟或未知则跳过。Assignment 与审批复核会话不参与,聊天中不显示;每次执行写入 `usage_idle_compactions` 并在 Bot 活动中返回。开关与每线路寿命是 Profile 级 Host 设置,Web 设置页与 `deepseekbot idle-compaction*` 共用同一 bridge 方法。 + +[ADR-0140](../adr/0140-the-host-falls-back-to-a-managed-git.md) 规定 Git 的来源:Host 每次启动时解析 Git,可运行且不低于 2.28 的系统 Git 优先;否则使用 Profile 中的 Managed Git。找不到、macOS 占位程序与版本过低都算不可用。进入 Bot 模式时先检查,不可用时只给一个「安装 Git」按钮并禁用创建与导入。Managed Git 是按 deepseekbot 版本固定版本号与 SHA-256 的便携构建,先从 `media.botharness.ai` 镜像、再从 GitHub 下载,无需管理员权限;使用它时 Host 把其目录加到 DSH 进程 PATH 最前,让 Orchestrator 的 Shell 也能用同一个 Git。SSH 导入地址克隆失败时,Host 自动转换为对应的 HTTPS 地址重试一次并告知 Human。 + +Memory Service 拥有 repository identity 和 lifecycle、可信 Session ownership、受限的 UI 查询,以及审计/恢复检查点。成功回合后,它可以把观察到的状态与可信 Source Event、Session 上下文记录下来;观察不会暂存、提交或隐藏工作树文件,也不推断 Git 内容的作者。当前文件与历史以 Git 仓库为权威,数据库 ledger 只是辅助记录。Memory Service 为每个 PersonaBot 保存一个 commit 游标:回合开始时静默对齐当前 HEAD,回合成功收尾后,把 HEAD 可达而游标不可达的每个 commit 交给 Channel 写成一条 `memoryCommit` Channel Notice(放在该回合的 Cause Channel,不可用时回到 Human DM),并为该 Bot 写一条已处理的 `memory-commit` 记录,随后推进游标;同一 Bot 的同一 sha 只记录一次(ADR-0154)。起因在群或 Bot DM 的 commit 记录同时放入该 Bot 的 Human DM(ADR-0182)。PersonaBot DM 的 Channel sidebar 分为「记忆文件」和「记忆演化」:前者以可展开目录树展示当前工作树,选中文件后在 Channel body 只读查看文本或二进制提示;后者展示所有本地分支及可达提交的 Git graph,选中 commit 查看完整 diff。当前差异默认按「新记忆/已有记忆的更新」对文件去重,切换并持久保存 Git 术语后,显示可折叠的未暂存、已暂存和未跟踪分组及状态标识。打开视图时定时读取、窗口重新聚焦时立即读取外部编辑,侧栏折叠标题栏也有刷新按钮;查询不暂存或提交(ADR-0088)。Human 文本保存服务仍先比较当前 HEAD,再显式生成 Git commit;普通导航不提供页内编辑。Memory 文件查询阻止访问 `.git` 控制路径及指向仓库外的符号链接,但不限制 Git 可保存的文件类型。旧 Repair 归档与检查点记录仍保留兼容;普通未提交改动不会阻止下一回合或强制修复(ADR-0068)。 + +恢复检查点额外记录分支、HEAD、暂存区与工作树,并用隐藏的 Git ref 保留对象。观察记录区分 Host 扫描、Agent 会话上下文和明确的本地 Human 命令;后者使用与 Channel membership 相同的 Host-owned `local-human` 身份,不能区分共享 Host 凭据的多人。Human 在记忆演化页选择检查点并确认恢复;Host 校验当前状态与检查点引用,在副本中准备目标状态,完整归档原仓库后切换。未被观察的中间状态无法恢复;Git ignored 文件留在完整归档中(ADR-0097)。界面上这份列表放在记忆齿轮菜单的「从自动备份恢复…」里。Human 也可以把记忆「恢复」到 Git graph 的任意提交:Host 先强制记录检查点,有未提交改动时把工作树提交为一个备份提交(可顺带用 annotated Git tag 给恢复前的版本起名),再把所选提交的文件树作为新子提交(分支以 compare-and-swap 前移),然后把工作树重置到该提交;历史不被改写,之后的提交仍可再次恢复(ADR-0190)。 + +Memory 外部打开沿用 ADR-0100 的交互,#574 实现当前 Memory 首片:显示路径的区域可点击弹出菜单,密集文件树提供右键菜单和键盘/触屏可到达的入口,合适位置提供带 Tooltip 的图标按钮。普通文件选择仍进内置阅读,目录选择仍展开。首片覆盖 Workspace 的 Memory Repository 路径及当前 Memory 文件/目录,#575 扩展已授权的普通 Workspace 路径,后续扩展 PR #435 的消息附件 chips,不识别任意正文路径。Memory Service 在 Host 从 PersonaBot 仓库根解析相对路径,沿用 `.git`、越界与符号链接检查;其他来源由各自 owning module 解析,不开放通用任意路径 authority。Client 沿用 DSH Typert/API Gateway seam 消费原生打开能力,不运行拼接 Shell 命令。目录菜单取 DSH 探测到的应用,文件菜单取默认和注册关联应用,首片不加自定义程序或持久默认应用设置。 + +默认打开遵循 [ADR-0178](../adr/0178-file-open-defaults-are-explicit-client-preferences.md)(#1461):Memory 当前文件阅读器、聊天普通文件卡及 Workspace 的 Memory Repository/已授权目录行共用一个 application-defined split button——应用图标与「打开」主按钮加独立下拉,下拉即原文件菜单。主动作优先 Human 的显式偏好,其次 DSH 报告的系统默认;pinned DSH 只为文件关联报告系统默认,目录在 Human 设置前没有可信默认项,此时主按钮说明原因并展开选择,不使用列表首项。下拉里的一次性选择不修改偏好;「设为默认」「恢复系统默认」写入同一个 Client 偏好 owner(`file-open-preferences`),文件按扩展名、目录单独,存于浏览器 `localStorage`,并以 Host 提供的 Profile 身份作键;身份未就绪时不读取任何偏好,写入失败如实报错。偏好只保存 Host 曾报告的应用标识,不授予路径权限、不修改 OS 关联、不跨设备同步。每次手势都由对应 file owner 重新解析目标并重新查询可用应用,已卸载或不再报告的应用不会被启动,也不会被静默替换。派发成功只表示请求已发送到 Host,不代表窗口已出现。记忆演化的 commit diff 与未提交差异在每个文件行提供「打开当前文件」(#1462),复用同一 split button 与偏好 owner,提交 PersonaBot slug 与该行路径,由 Memory Service 按现在的仓库状态重新解析;它不读取所选 commit 的内容,也不暂存、提交或修改仓库。Host 的变更列表不做 rename 检测,rename 表现为旧路径删除加新路径新增,因此新路径行打开新文件、旧路径行只检查该路径当前是否存在。历史上标为删除但后来重建的同路径文件可正常打开;当前不存在时提示「这个路径现在没有文件」,仅当该行本身带删除标记时附上「此提交/当前工作区变更将该路径标记为删除」,不从历史内容重建,也不追猜后续 rename。点击按钮不折叠/展开 diff。 + +普通 Workspace 路径复用同一 Client 菜单,但提交 PersonaBot slug 与 Grant ID,由 application-defined Workspace Grant Store 的 `requireActive` 在 Host 检查拥有者、撤销状态和当前 DSH Workspace Registry 身份,重新解析 canonical 目录。显示路径不承担 authority;失效、移动、未注册或缺失目录会拒绝。打开前再次校验,菜单只列 Host 探测到的目录应用,能力不可用时只保留复制 Host 路径。打开不修改 Grant、Session cwd 或访问权限,也不创建 Session;不提供目录下载(#575)。 + +原生打开始终作用于 DSH Host 所在电脑,Tailscale/Cloudflare Tunnel 只提供连接而不证明 Client 与 Host 同机。菜单明确目标和能力不可用原因;文件另提供下载到浏览器设备及复制 Host 路径,二进制/超大文件不因内置预览受限而失去打开和下载能力。下载后的编辑不自动回写远端,首片不做目录下载或历史 Memory 文件导出。 + +手机端 Memory 文件阅读器同样消费现有 Memory Service 的 `memoryFile` 表示和认证 PNG 字节入口(#2214)。应用定义的 `MemoryPreviewIO` 是客户端进程内的字节与临时文件适配器:本地图片引用携带文档的 source/revision,Host 继续拥有路径、Git 版本和陈旧检查;手机只在原生可见区域相交后读取有界 PNG,把校验过的字节写入临时缓存供 `expo-image`、既有缩放查看器和系统分享使用,离开时取消请求并清理。Markdown 使用既有原生文本/表格组件,图片节点单独呈现,原始 HTML 不执行;外部 HTTPS PNG 经逐图点击才读取,不携带 Host 认证或 Cookie,并拒绝重定向。文件树和普通资料行不加载媒体依赖;原生 Git diff 导航、其他格式及附件预览仍是后续切片。 + +Memory 格式表示进一步支持 JPEG/JPG、GIF、WebP 和安全静态 SVG(#2214)。纯客户端可用的 `media/image-format` 统一检测原始字节 MIME、尺寸和处理预算,Host Memory 入口及手机临时文件适配器消费同一规则。SVG 通过有界 XML/CSS 解析,仅允许无外部资源和主动内容的静态图片子集;仍以图片资源呈现,不注入页面标记,也不在 Host 转码或读取 SVG 引用文件。版本 SHA-256、原始字节和独立原文件下载语义保持一致;GIF/WebP 在帧数/总画布像素预算内保留原动画。 + +Memory Service 在 Host 启动、Orchestrator 回合前,以及本地 DM 或群提及并入活动回合前比较各 Bot 当前分支、HEAD、未提交文件内容和 Git index,与数据库中的每 Bot 观察检查点求净变化。首次观察只建立基线;之后在单次事务中写入有界路径摘要的 `memory-change` Source Event、该 Bot 的 Inbox Admission,并推进检查点。事务失败不推进基线,重启后重试;相同状态重复扫描不重复投递。启动扫描只入 Inbox,不主动唤醒 Agent;下一次普通回合在同一 Inbox 上下文领取并处理。Event 不推断编辑者,也不复制文件内容;Agent 需要时用原生文件和 Git 工具查看。并入消息时发现的净变化随消息在下一个安全步骤进入同一回合的 Inbox 上下文,成功投递才标记已观察,并随回合成功或失败处理;拒绝并入时保留待处理,供后续普通回合领取。回合结束更新剩余状态的基线,不额外通知,也不推断回合中变更的编辑者。离线期间改动又复原的中间过程无法从最终文件状态推断。`SOUL.md` 或 `MEMORY.md` 变化可在 Event 中提示,但冻结的 Session 快照不变(ADR-0092、ADR-0134、#350、#352、#464)。 + +## 3 · Host 启动、迁移与 recovery + +```mermaid +flowchart TD + Start["Host starts"] --> Lease{"Acquire profile writer lease"} + Lease -- "busy" --> ReadOnly["Fail closed
read-only diagnostics"] + Lease -- "owned" --> Open["Open botharness.db"] + Open --> Gen{"Schema generation supported?"} + Gen -- "newer / corrupt" --> Recovery["Recovery mode
no operational writes or wakes"] + Gen -- "current" --> Integrity["Integrity checks"] + Gen -- "older" --> Copy["Migrate isolated temp copy"] + Copy --> Verify["Verify schema + integrity"] + Verify -- "fail" --> Recovery + Verify -- "pass" --> Activate["Atomic replace"] + Activate --> Integrity + Integrity -- "fail" --> Recovery + Integrity -- "pass" --> Modules["Start deep modules"] + Modules --> Rebuild["Rebuild DSH-derived projections
reconcile bounded intents"] + Rebuild --> Ready["Enable admissions, wakes and commands"] +``` + +一个 DSH profile 同时只允许一个 BotHarness writer。所有 module migration 合并成单调递增的 Schema Generation;迁移只在临时副本上完成,校验后原子替换。打开、迁移或完整性检查失败时进入 recovery mode,不回退到 NDJSON、storage domain 或内存写入。 + +## 4 · Messaging 事务与外部副作用 + +```mermaid +sequenceDiagram + participant P as Provider adapter + participant M as Messaging command + participant DB as botharness.db + participant N as Post-commit notifier + participant O as Orchestrator + participant X as Provider service + + P->>M: verified event + account fingerprint + capabilities + M->>DB: BEGIN IMMEDIATE + M->>DB: append Source Event / Revision + M->>DB: Channel placement (optional) + M->>DB: evaluate exact Trigger + Wake Policy revision + M->>DB: create Inbox Admission / Attention facts + M->>DB: COMMIT + DB-->>N: committed fact ids + N-->>O: wake at policy-selected safe boundary + O->>M: explicit Channel reply or authorized Service Action + M->>DB: validate current revision + capability + grant and write Outbox Intent + M->>X: execute with stable idempotency identity + X-->>M: receipt / failure / unknown outcome + M->>DB: append attempt and outcome facts +``` + +Source Event 是内容唯一权威;Channel 和 Inbox 都只保存关系。Reply 使用可信 Reply Route 自动选择来源 provider;主动发布属于 Service Action,需要 Provider Capability,以及该 Bot 对应用可触达会话的会话条目,或作为后备的 Human 保存目标 Service Grant。SQLite 事务只覆盖本地事实;外部副作用使用 Outbox Intent、幂等标识和有界 reconciliation,不宣称 exactly-once。不可证明的结果进入 `unknown-outcome`,由 Human 处理。 + +Wake Policy 决定何时让 Orchestrator 看见新 attention:当前 step 完成后的安全边界、当前 turn 结束后,或 idle 时启动新 turn。普通外部消息不打断正在执行的 model/tool step;只有 DSH 明确支持且策略授权的控制路径才能 steer。就绪的 attention 按回合收割:忙碌期间新到的事件只把就绪集合置脏,当前回合结束(即空闲)时由一次 harvest turn 消费全部就绪项;主动 steer 只用于直接 @ 与 DM(ADR-0077)。 + +### Lark 接收与回答反馈(ADR-0149) + +新会话首条消息的 Admission 通知异步等待受校验的回复连接就绪,避免连接建立期间丢失接收反馈;不阻塞收件确认或模型唤醒。SDK HTTP 异常中的明确权限拒绝归为失败;修复不重写或补发既有未知尝试。 + +Messaging 在 Source Event 与 Inbox Admission 提交后异步尝试原消息的 `GLANCE`,只在该来源的 canonical Reply Outbox 成为 `provider-accepted` 后尝试 `DONE`。两个状态由应用定义;原内容、Admission、Outbox 继续是各自唯一权威。可选的 checked Provider Service Capability 重查账户指纹、Registration、独占 Consumer、来源会话/Thread/发送者及当前授权。旧 Provider 缺少能力时保持正常收发。 + +反馈 attempt 元数据保存在既有 Source Event payload,完成记录引用原 Outbox;管理 snapshot 暴露最近状态,不复制消息内容。写入前持久化 attempt,超时/未知/重连/重启均不自动重试;有界异步调用不阻塞接收、模型或回复。静音仍可产生 silent Admission,屏蔽阻止新 Admission;静默结束、委派完成、待确认问题及无关输出不表示已回答。平台接受不表示 Human 已读或样式已经验证。详见 [ADR-0149](../adr/0149-lark-feedback-follows-admission-and-accepted-reply.md)。 + +Slack 的 #1161 候选复用这条 application-defined Messaging 反馈链:checked Provider 把 `received`/`answered` 映射为原始 Human 消息上的 `eyes`/`white_check_mark`,回答后移除自身的 `eyes`。Slack 静音会话不产生反馈;Lark 既有 silent Admission 语义不变。Provider 仅串行同一来源的反应写入,不占用 Bot transition queue 执行网络写入;每次写入重查来源、账号、Consumer 和授权 fence,部分写入/清理不确定保留 unknown,不自动重试。本候选不提升现有 Provider pin;真实 Slack 频道与 DM 证据仍是独立交付门槛。 + +### 绑定的应用接收默认流量(ADR-0142) + +Application-defined IM DM commands (`/commands`, `/help`, `/status`, `/model`, `/effort`, `/stop`, `/new`, `/compact`) reuse this intake's Binding, sender admission, Source Event and deduplication. Only a whole text-only DM is a command; groups, attachments and unrecognized text retain their existing path. Claimed command Admissions are handled without model input or wake; `last_error` retains pending/unconfirmed command work for inspection rather than automatic replay. The Messaging owner serializes commands per PersonaBot across Bindings, rechecks current source authority and management pairing capabilities at execution, and passes a current-authority fence through asynchronous model validation and Session replacement. Model changes use the existing Orchestrator-only customization owner and preserve Assignment inheritance. Replies use the source-bound Outbox's intent, attempt, final-send fences and accepted/failed/unknown outcomes (ADR-0106/0129); a duplicate never executes or sends again. Weixin changes require an admitted paired-owner DM; other platforms require an approved management pairing with current approval capability, or refuse with Web guidance. `/new` and `deepseekbot bot-new-session` share the idle Orchestrator replacement runtime. `/compact`, the Bot DM composer's `/compact` and `deepseekbot bot-compact` share Idle Compaction's DSH `/compact` path: they refuse while the Bot has a running turn or pending Inbox Admission, and compact only a live Orchestrator Session. + +接收以外部身份 Binding 为键,而不是保存的目标。启用的 Binding 把应用的默认流量(私聊和合格的群 @)接收进绑定 Bot 的 Inbox。会话的第一条被接收的消息,会与其 Source Event、Inbox Admission 在同一事务中提交一个隐式会话条目(`messaging_grants` 中 `origin: 'implicit'` 的行),使 Outbox fence、群策略、Channel Bridge 路由和撤销仍挂在同一个锚点上。Binding 的新会话模式(`auto` 或 `ask`)及其上限(每小时 20 个新条目、500 个活跃、200 个等待)决定新会话被接收还是等待处理。屏蔽按 Bot、应用指纹、会话类型和 ID 持久保存;静音只推进 `preferenceRevision`,不改动条目的 Outbox revision。一个 Bot 可以绑定同一平台的多个应用,每个应用仍只属于一个 Bot。Client 中,应用的会话列表管理这些条目并显示已有的 Channel Bridge 路由(新建同步正在围绕外部连接器重新设计),「外部连接器」列出这些路由并保留保存目标的 Service Grant,作为 Provider 无法列出或直接发往会话时的高级后备。 + +外部应用的准备步骤由官网教程维护,既有 **绑定应用** 弹窗提供对应语言的教程链接,不再显示独立 Lark 配置引导卡片。Driver.js 保留为应用内部控件定位工具,不承载平台配置说明。绑定、接收与回复结果仍来自既有 Provider/Host 权威;打开教程不产生授权或发送,也不新增引导存储。 + +### 首条外部出站路径(ADR-0101) + +[ADR-0127](../adr/0127-product-artifacts-compose-an-independently-versioned-im-provider.md) 提议在开发用 fork 政策之外增加产品分发路径:打包的 `deepseekbot` 固定 Core、Client、Browser、Computer 和独立版本的 `@botharness/im-provider`,通过同一 Bundle Patch 激活全部六个组件([ADR-0160](../adr/0160-deepseekbot-default-bundles-browser-computer-and-im-provider.md))。Provider 保留 SDK、凭据、存储身份及公开 Service;Core 保留所有 application-defined authority。构建校验不可变输入、重建运行时来源和 tarball 完整性。原生优先从 CLI 安装位置解析 Bundle,因此产物验收使用独立安装的官方 CLI,避免误用开发链接。初始不连接账号;重复的 standalone Bundle 在启动前被拒绝。Provider 随产品更新。此构建路径不发布 npm、不自动资格验证其他平台,也不免除 Human QA 和生产启用门槛。参见[打包验收指南](../product-im-installation.md)。 产品 Provider 的来源、DSH 版本和运行时摘要独立于开发版固定值;改变产品输入需要递增自身版本并重做实际安装验收([#868](https://github.com/BotHarness/BotHarness/issues/868))。 + +[#1115](https://github.com/BotHarness/DeepSeekBot/issues/1115) 经 application-defined Messaging Provider 的可选可达群查询/checked first-post 契约扩展同一主动发送路径。Host 保留 Binding、会话屏蔽和 Outbox authority;首次发帖只创建既有 implicit 会话条目,不生成 Source Event、Inbox 或本地 DM。Generation 89 在 Binding 保存每小时发帖限额(默认 60,可关闭),以既有 Outbox attempt-start 计数,未知结果不重试。可选契约覆盖 Lark、Slack、Discord;实际平台权限、版本绑定的原生验收及 #1111 Provider 发布仍独立。详见 [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md)。 + +PersonaBot Profile 的 IM 连接经现有 Typert/API Gateway 选择账号和已测试目标,再由 Human 建立 Binding 与单目标主动发送 Grant。application-defined Messaging Provider Registration 由 Consumer Fiber 持有,Binding/Grant/Outbox 使用同一 `botharness.db`。接受和执行都复核活跃 Bot、Grant、Registration、认证账号 fingerprint 和目标内容 digest;删除、改址或账号变化要求显式重新授权。先提交 Intent 和 attempt-start,再调用 provider,结果只记平台接受、明确失败或未知;重启不重发 pending/in-flight,未知结果留待 Human 核对。 + +生产适配需要 dsh-im 公开、版本化的 `describeBot`/`sendChecked` 契约,以在账号 transition 中验证平台身份并冻结已授权路由。这是待上游接受的小型扩展;已发布 `4.32.0` 不满足该契约,BotHarness 默认禁用这条出站 authority。隔离开发可通过 `dev-instance --im-provider` 安装 [ADR-0104](../adr/0104-isolated-im-profiles-pin-a-qualified-temporary-provider-fork.md) 指定的临时 fork 完整 SHA,并在启动前校验运行时代码 digest;这不代表上游已发布或生产启用。dsh-im 仍持有 SDK/连接/凭据与原设置入口;BotHarness 不读取其私有 JSON,不接管 standalone Session 路由。#12 通过公开 `consumeInbound` 独占接收显式授权群内的文字 @:Messaging 先提交 bridge-message Source Event 与独立 Bot Inbox Admission,commit 后确认,再沿既有 group-mention harvest/steer 唤醒 Orchestrator。来源不需要本地 Channel placement,模型从 Inbox 了解接收身份、发送人及 group/thread/root/parent;`bridge_read` 读本地已保存来源,`bridge_reply` 仅接受来源 ID 与正文,Host 推导本 Bot 的有效身份与原回复路由,复用 Outbox 后调用公开 `replyChecked`,不镜像到 Human DM。撤销、归档、consumer 丢失、旧 Grant revision 都不能授权未来收件或尚未开始的回复;重启不重发未知 Outbox。外部来源 turn 不扩展 Memory acceptance 的来源授权,也不自动写入 Memory。详见 [ADR-0106](../adr/0106-exclusive-im-intake-commits-bot-inbox-before-acknowledgement.md)。 #612 的 `bridge_context` 通过同一 Inbox 来源和 Bot 身份调用公开 `historyChecked`,区分群列表、附近 ±5 分钟 Chat 时间窗与可信 Thread 列表;Host 按 JSON 预算仅将完整返回内容 reconcile 到 canonical Source Event,并记录有界读取 metadata。普通历史不建立 Admission、不触发 wake;已经存在且实际返回的 Admission 沿用当前 turn 的成功/失败处理。Provider 返回的显示名仅补充同一 canonical 身份;发送人和 @ 人员 ID 保持身份依据。Inbox 沿用 harvest 的 Message/Source Event 引用格式,上下文返回可用的姓名和 @ 关联,缺失姓名时回退 ID。Human 来源详情显示读取记录与最近一页,不自行读取远端。 + +### Lark 管理员配对(ADR-0136) + +[#1027](https://github.com/BotHarness/BotHarness/issues/1027) 的 application-defined 配对 owner 使用 Messaging 所属的 Operational Database,以当前 PersonaBot、外部身份 Binding、Provider 认证的真实用户及原始私聊路由固定授权范围。已绑定 Lark 身份在既有独占 Consumer fanout 上保留账号级控制入口:纯文本 `/pair` 先提交申请再确认,不创建普通 Source Event、Bot Inbox Admission、模型唤醒或 Memory 写入。普通消息仍需自己的接收授权;删除聊天路线不关闭配对入口,也不产生普通 DM 接收权限。Client 显示实际接收器是否就绪。 + +首次能力只能由认证 Web Human 显式勾选并按当前申请 revision 审核;定位码不能兑换权限,首位申请者不会自动成为管理员。能力仅覆盖当前 Bot 的批准、拒绝、回答及保存规则资格,不扩展 VPS、原生 DSH API、其他 Bot 或审批人管理。每次使用重查当前 Binding、Bot、真实 actor、能力和状态;暂停期间不可用,永久撤销后重新配对必须重新审核。该切片交付配对与 Web 审核,IM 审批/提问控件仍由后续切片接入原生权威。参数、恢复及审计边界见 [ADR-0136](../adr/0136-lark-pairing-is-reviewed-bot-scoped-operational-authority.md)。 + +### Lark 普通角色与自动申请(ADR-0165) + +Sender permission diagnostics validate the Source Event's original ingress authority before reporting that a platform has no Role policy (#1951). Grant-backed sources retain their Binding and Grant checks; Conversation Ingest sources record `ingressBindingId` in the canonical Source Event and validate that original Binding plus the current Ingest. A shared reader needs its own current Binding in the same Provider/account scope, and any resolved Role must belong to that checked Binding. Disabled, revoked, replaced, blocked, or unavailable authority remains a refusal. Legacy Ingest sources without an ingress Binding anchor return `no-current-scoped-identity`; a fresh message supplies the evidence rather than attributing old content to a replacement Binding. This adds no content copy, grant, listener, or schema migration. + +The #1415 candidate extends the same ordinary-chat authority to checked Slack DMs. The existing Bot-wide restriction covers Lark conversations and Slack DMs, remaining off by default; a Slack actor is identified as `slack-workspace-user-id` under the current Bot/App/Provider fingerprint and Binding. Unknown restricted DMs create only pairing metadata and a fixed notice; Web review assigns an existing Role, approval asks again, and only a fresh message enters the canonical Source Event/Admission/reply path. Current permission/directory reads reuse the same owner, and revoke/restart/rebind retain their fences. Slack group and native management parity remain separate. There is no new schema, listener, credential store or native RBAC. The isolated source candidate passed actual Web approval, fresh DM/current permission/directory reply, revoke refusal and restart with matched Source Event/Admission/Outbox facts; Human interaction feedback is accepted. Published product and Slack group/native management qualification remain separate. See [ADR-0174](../adr/0174-slack-dm-chat-pairing-reuses-current-binding-authority.md). + +#1415 候选将同一普通聊天权威扩展到 checked Slack 私聊。Bot 既有发送者限制覆盖 Lark 会话及 Slack 私聊,默认仍关闭;Slack 人员以当前 Bot/App/Provider fingerprint 和 Binding 下的 `slack-workspace-user-id` 标识。未知受限私聊只产生配对元数据和固定通知;Web 审核分配已有角色,批准后要求重问,新的消息才进入规范 Source Event/Admission/回复路径。当前权限与目录读取沿用同一 owner,撤销、重启和重新绑定保留各自 fence;Slack 群聊及原生管理能力仍由独立验收推进。没有新增 schema、监听器、凭据存储或原生 RBAC。隔离源码候选的真实 Web 批准、新私聊/当前权限/目录回复、撤销拦截与重启已通过,Source Event/Admission/Outbox 与原生结果一致,Human 已接受交互。已发布产品、Slack 群聊及原生管理能力仍独立验证。 + +[#1423](https://github.com/BotHarness/DeepSeekBot/issues/1423) adds application-defined Behavior permission items at the same Messaging owner. Groups reference Bot-scoped stable IDs with explicit Allow/Deny; current permission queries deduplicate overlaps, retain every Group revision/source and expose same-item conflicts with Deny priority. One primary behavior Role remains applicable. Settings provide a shared item table, separate edit/create dialogs, searchable Group checkboxes and affected-person previews; stale item/group revisions refuse writes and referenced-item deletion requires removing Group references first. The second slice adds bilateral authored exclusions: a shared pure resolver previews incompatible effective Allows, and the canonical transaction refuses item/group/member edits or Deny deletion that would introduce them. Reciprocal links and revisions remain in the existing item records, with no schema change. Generation 81 persists items in Operational Database; authenticated senderAccess commands and the existing bridge_sender_permissions Tool remain the sole write/read seams. Text guides model behavior, not arbitrary resource isolation or proof of semantic consistency. See [ADR-0173](../adr/0173-group-behavior-items-resolve-by-id-with-explicit-deny.md). + +#1423 的第二片沿用同一 Messaging owner:Human 按稳定权限项 ID 声明双向互斥。共享纯 resolver 预览最终同时有效的 Allow;Host 在既有事务中拒绝会引入不兼容的权限项、组、成员编辑以及 Deny 删除。明确拒绝仍优先;双方引用与版本保存在现有记录,不增加 schema。配置检查也覆盖暂时停用 Binding 的成员配置,避免重新启用暴露潜在冲突。任意文字或主 Role 的语义矛盾不由此自动识别。 + +[#1406](https://github.com/BotHarness/DeepSeekBot/issues/1406) adds application-defined External Authorization Groups and person exceptions at the same Messaging owner. One current Chat pairing can belong to multiple groups in its exact Bot/App Binding while keeping one primary behavior Role. Each current resolution unions Role, group, legacy management and person Allow, then removes any explicit Deny; private native delivery, decision and unused approval checks use this result. Group membership cannot approve chat or mint a management destination, and revoke/re-pair never inherits old member IDs or exceptions. Direct authenticated Web Human input retains default complete product authority and native execution approval. Generation 79 stores bounded revisioned group records in the Operational Database; existing authenticated senderAccess commands, snapshot and on-demand Tools carry the current sources without a second Consumer or cache. The Client adds Groups, scoped member selection and personal exceptions to pairing settings. See [ADR-0168](../adr/0168-external-groups-are-current-scoped-management-authority.md) for migration and boundaries. + +#1406 在同一 Messaging owner 增加应用定义的 External Authorization Group 与个人例外。当前 Chat pairing 可加入同一 Bot/App Binding 下的多个授权组,仍保留一个主行为 Role。每次解析先合并 Role、组、已有管理配对及个人 Allow,再扣除任意显式 Deny;私聊原生审批的投递、决定及尚未使用的批准都读取此结果。入组不批准聊天、不创建管理目的地,撤销后重新配对不继承旧成员 ID 或例外。直接认证的 Web Human 输入默认保留完整产品权限与原生执行审批。Generation 79 在 Operational Database 保存有界且按 revision 修改的组记录;沿用认证 senderAccess 命令、快照与按需 Tools 展示当前来源,不增加 Consumer 或缓存。Client 配对设置加入授权组、范围内成员选择及个人例外;迁移与边界见 ADR-0168。 + +人员、角色与申请表格的创建/修改时间来自 Messaging owner 持久化的 JSON 元数据,不由 Client 加载时间推断;读取和通知投递记账不推进修改时间。旧记录未记录的时间保持未知。时间字段是兼容的可选元数据,沿用 Operational Database authority,不增加 SQL schema generation。 + +[#1414](https://github.com/BotHarness/DeepSeekBot/issues/1414) extends the same owner with explicit `review-pairing` and a separately resolved Management Delegation Ceiling. A Lark group-only request can receive a public pairing card without a private-DM prerequisite; current reviewers with an observed trusted private route may also receive private copies. The qualified Provider retains its exclusive Consumer and a distinct `botharness/pairing-v1` callback namespace. Messaging checks the actual actor, current Binding and route, pending request and selected Role revision, and the reviewer's current use/delegation authority before a single canonical decision. Generation 80 stores bounded card receipts and actor-attributed selections; uncertain sends are not blindly repeated. Cards disclose review metadata rather than the blocked instruction. Approval only notifies the applicant to ask again; a new valid message must enter the normal Admission and model path. Group pairing cards do not change the private destination contract for native execution approvals or Questions. See [ADR-0171](../adr/0171-pairing-cards-check-current-review-and-delegation-authority.md). + +#1414 沿用该 owner,增加显式 `review-pairing` 与独立解析的 Management Delegation Ceiling。仅有群的 Lark 场景可直接收到公开配对卡片,不要求先建立私聊;已有可信私聊路由的当前审核人也可收到私聊副本。合格 Provider 保持独占 Consumer,使用独立的 `botharness/pairing-v1` 回调命名空间。Messaging 在唯一权威决定前检查真实操作者、当前 Binding/路线、待审核申请与选中 Role revision,以及审核人当前的使用/委派权限。Generation 80 持久保存有界卡片回执及具操作者归属的选择;未知发送结果不盲重试。卡片只显示审核元数据,不包含被拦截旧指令。批准只通知申请人重问;新的有效消息才进入既有 Admission 与模型路径。群配对卡片不改变原生执行审批或 Questions 的私聊目的地边界;详见 ADR-0171。 + +#1376 adds an explicit visitor exception on one existing allowed Bot/App group grant, independent of pairing and collection. Unpaired ordinary authors collected through authorized group/Bridge/thread/ingest paths remain passive (`silent` admissions, `context` scheduling evidence, no count/interval), never fill eligible wake thresholds, and may supply trusted author context to a later eligible request. The current requester alone supplies role-governed behavior; no background author union grants management powers. Closing, blocking and rebinding retain the independent authority fences. + +#1376 在已允许的具体 Bot/App 群授权上增加显式访客例外,与配对及采集独立。经授权的群、Bridge、线程或 ingest 采集到的未配对普通作者保持被动上下文(`silent` Admission、`context` 调度证据、无 count/interval),不填充有效请求的唤醒阈值,但可为后续有效请求提供带可信作者身份的上下文。角色行为以当前请求者为准,不合并背景作者的管理能力;关闭、封禁及重新绑定仍保留独立权限检查。 + +The #1375 application-defined Role mutation commands reuse this owner and authenticated sidebar seam. Revision-checked edits and single-role reassignment advance current policy revisions without rewriting historical Tool Results. The existing qualified private approval route reads current `approve`/`reject` Role capabilities for ordinary DM-origin pairings while preserving legacy explicit management grants. Group-origin pairing does not mint a management destination. Exact actor/Binding/pairing/receipt/native-lifetime checks remain Host owned; capability removal refuses delayed decisions and reassignment invalidates old routes. Natural-language behavior is not a resource ACL. + +The #1033 private Lark candidate adds independent `save-rules` management authority to that same resolver. Its distinct checked Provider callback previews the complete native operation and confirms a request/actor/private-receipt-bound nonce before the existing Channel Tool Approval owner can save and activate a rule. Generation 82 stores verified external creator provenance in the existing canonical Tool Approval Rule record. Each later match and final execution guard checks the original pairing and current `approve`/`save-rules` capabilities under the same Bot/App Binding. Authority loss permanently revokes the rule; re-enable and re-pair cannot restore it, while an independent still-effective grant preserves it. Web Human rules retain their prior authority. An IM-only approval creates a missing canonical Bot DM through the existing Channel Store and registry display-name lookup; it does not require opening Web or create a new Inbox Admission. Web and card status read the same canonical rule; fixed-text receipt-only cleanup may remove stale standing-rule claims after creator revocation without granting a destination or disclosing operation details. Native session-wide approval remains Web-only, and Workspace/Service grants are not enlarged. The Human accepted the actual exact-rule slice. The next candidate offers separately disclosed all-opaque scope only with Provider rule-card version 2 and its account capability; kind-bound nonces prevent an exact confirmation from granting that broader rule. External rules cannot auto-match Web-only native requests. The Human accepted the QA1 all-opaque first slice, and actual changed-input, Grant-scope, revoke/restart and final-card checks passed; an interrupted disabled-card update needed explicit idempotent receipt refresh. Published Provider qualification remains separate. See [ADR-0175](../adr/0175-external-tool-rules-retain-current-creator-authority.md). + +The #1041 source candidate observes Runtime-owned committed `sessionFailure` Channel messages and verifies Session ownership. Default-off event settings and bounded delivery metadata live in Messaging's Operational Database. Failure alerts resolve the same current private management route as approvals, qualify its genuine DM source through the checked Provider, then apply a final authorization/Registration fence. Only fixed failure/Session metadata is sent; error bodies and prompts remain canonical in Web. Delivery does not hold the failed turn, grants no decision capability, retries only known-unsent attempts within three tries and preserves Unknown Outcome without replay. Restart cancels pending intents and marks interrupted sends unknown. Bot DM sidebar controls use the existing authenticated Typert/API Gateway seam; group/additional Provider and Host-down monitoring remain separate. See [ADR-0176](../adr/0176-work-failure-alerts-use-committed-facts-and-management-route.md). + +#1041 源码候选观察 Runtime 已提交的 `sessionFailure` Channel 消息并校验 Session 归属。默认关闭的事件设置及有界投递元数据由 Messaging Operational Database 持有。失败通知沿用审批的当前私聊管理目的地,通过 checked Provider 核对真实 DM 来源,并在发送前校验授权及 Registration。仅发送固定失败/Session 元数据,错误正文和提示保留在 Web;通知不阻塞失败工作、不授予决策能力,仅对确定未发送尝试最多三次,未知结果不重放。重启取消等待意图并把中断发送记录为未知。Bot DM 侧栏经现有认证 Typert/API Gateway 配置;群、其他 Provider 和 Host-down 监控仍由独立功能推进。 + +#1375 的应用定义 Role 修改命令沿用同一 owner 和认证侧栏接口。按 revision 编辑角色或为人员重新分配单一角色会推进当前策略 revision,但不改写历史 Tool Result。现有合格私聊审批路线为从 DM 配对的普通人员读取当前角色的 `approve`/`reject` 能力,并保留既有显式管理授权;群内配对不创建管理目的地。真实 actor、Binding、配对、回执和原生请求生命周期仍由 Host 校验;移除能力拒绝延迟决定,重新分配使旧路线失效。自然语言行为不构成资源 ACL。 + +[#1373](https://github.com/BotHarness/DeepSeekBot/issues/1373) 沿用 Messaging owner 与 Operational Database,新增普通 External User Role、默认关闭的发送者限制和 `conversation` purpose 配对;既有 `management` purpose 的 `/pair` 显式能力保持独立。普通角色先支持自然语言行为权限与空管理能力,不新增 DSH 原生 RBAC,也不宣称隔离邮件、代码或任意 Shell 资源。配对按当前 PersonaBot、App Binding/fingerprint 与 Provider 认证的 Lark open ID 限定,可跨允许的 DM/群复用,但不跨 Bot/App/平台或重新绑定继承。Host 在默认接收、显式 Bridge、会话接入、共享来源及模型消费处逐 Bot 校验资格;不同作者的角色不合并。 + +未配对的受限 DM/群 @ 先持久提交有界审核元数据,不保存旧指令正文、不建立请求 Source Event/Inbox Admission、wake 或 Task。认证 Web Human 在 Bot DM Channel sidebar 经既有 API Gateway/Client seam 选择角色并按 revision 审核。批准先提交,再通过当前独占 Consumer 的受检查回复能力发送固定重问通知;只允许新的有效消息进入原有模型回复/Outbox 路径。通知尝试在外部调用前记录,未知结果不盲重试;接收回调先返回,避免与 Provider 账号队列死锁。撤销立即关闭后续资格及尚未执行的相关 Admission,既有历史保留。Generation 78、迁移和边界详见 [ADR-0165](../adr/0165-lark-chat-pairing-is-reviewed-current-binding-authority.md)。三个只读目录与完整首轮真人反馈仍由 #1374 交付,角色编辑/重新分配与显式开放群/被动上下文分别保留 #1375/#1376 的真人依赖。 + +The admitted Inbox context carries compact trusted sender/App/Source Event references rather than automatically injecting roles or permission snapshots. The application-defined read-only `bridge_sender_permissions` Orchestrator Tool calls the existing Messaging owner, resolves one accessible source and returns current pairing status, effective Role, policy revision and query time. It creates no authority or observation. Stable guidance calls for a fresh lookup of the actual requester before a new permission-sensitive operation, while ordinary chat need not query; unresolved lookup defers the governed operation. Historical Tool Results are dated evidence. Deterministic admission and supported management checks remain Host-owned. + +入箱消息只附可信发送者、App 和 Source Event 引用,不自动注入角色或权限快照。应用定义的只读 `bridge_sender_permissions` Orchestrator Tool 沿用 Messaging owner,解析可访问的规范来源,返回当前配对状态、有效角色、policy revision 与查询时间,不创建权限或观察记录。稳定行为指导要求对新的权限敏感请求重新查询真实申请人,普通聊天不强制查询;无法解析时暂缓受该策略约束的操作。历史 Tool Result 只是带日期的证据。确定性的接收资格与已支持管理动作仍由 Host 校验。 + +The #1374 `bridge_directory` Tool adds three bounded/pageable reads through the same Messaging owner and Operational Database: effective ordinary paired people/current Roles, known configured external conversations/states, and paired people observed in the conversation of an accessible trusted Source Event. Observations intersect current Bot/App/Binding pairing with authorized retained sources, including current joined shared placements, and exclude purged or previous-Binding evidence. At most 1000 recent matching sources are examined per query; query time, first/last observation times, truncation and incomplete coverage remain explicit. This is never a complete/current platform member list or a new destination/impersonation authority. Role-governed questions still resolve the actual requester with the current-permission Tool. Directory results are queried as needed, group replies remain visible to the whole group, and the complete first-flow Human feedback remains required before #1375/#1376. + +#1374 的 `bridge_directory` Tool 沿用同一 Messaging owner 与 Operational Database,提供三个有界可分页只读视图:有效普通配对人员与当前角色、已知已配置外部会话及状态,以及在可信可访问 Source Event 所属会话中观察过的配对人员。观察结果将当前 Bot/App/Binding 配对与授权保留来源取交集,包括当前加入的共享 placement,并排除已清除或旧 Binding 的证据;每次最多检查最近 1000 条匹配来源,明确给出查询时间、首末观察时间、截断与不完整覆盖。它不代表完整或当前平台成员名单,不授予新目的地或冒充权限。受角色策略约束的问题仍对真实申请人调用当前权限 Tool;目录按需查询,群回复仍全群可见,完整首轮真人反馈仍是 #1375/#1376 的门槛。 + +### Lark 私聊审批(ADR-0141) + +[#1029](https://github.com/BotHarness/BotHarness/issues/1029) 将已提交的工具审批发往 Web 显式选择的已配对管理私聊;通知路由及回执由 Messaging owner 在现有 Operational Database generation 60 保存,完整请求/决定仍归 canonical Channel 与 native Session。通知、决定接受、原生执行结果和卡片更新分别投影。现有独占 Consumer fanout 单独处理官方 SDK 卡片操作,不建立普通 Source Event、Inbox 或 Memory。公开的受检查 Provider capability 负责原始私聊/实际发送人、自己的卡片回执和平台写入前 fence;Host 校验实际点击者的当前 Bot/Binding/fingerprint/配对能力与 revision、目的地、request↔receipt、完整操作及原生执行者。允许一次/拒绝重入现有工具审批 broker,Web 与 IM 共用一位胜出者,执行前再次核验撤权与完整参数。回调确认不等于批准,批准不等于成功执行;重启使旧卡片失效,未知发送结果不重发。该切片不保存规则、不处理群审批/原生问题,也不释放 native wait。见 [ADR-0141](../adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md)。 + +### Bot 之间的 Channel 协作(ADR-0065) + +Orchestrator Session 中注册的 `group_attention_get` 与 `group_attention_set` 是 DSH model-facing Tool,消费 BotHarness Host 的 application-defined Channel capability;Host 从该 Session 所属 PersonaBot 确定 actor,并在读写时复核当前 Group membership。未保存的 Group 偏好只读为 digest 5 条/30 秒、revision 0;Human Bridge 写入和 Bot Tool 写入共用 Channel record 中的 `wakePolicies`,每次实质变更在同一 SQLite 事务里追加不可改写的 actor/时间/revision 审计事实。审计表只存历史,不作为第二套当前偏好;Admission 在消息提交时固定有效策略与 revision,不会随之后的编辑回写。 + +#366 把 Human DM、Bot DM、群内提及、普通群消息、入群邀请与申请流程、Assignment 报告及生命周期的内建来源规则写成每个 PersonaBot 本地的不可改写修订事实。Host 在建立新 Inbox Admission 的同一事务里读取对应规则并保存来源规则 revision/wake 快照;既有 Admission 不回填。普通群消息的来源默认值沿用 5 条/30 秒 digest,适用的 per-Channel `wakePolicies` 覆盖仍保留在 Group Channel record;Assignment 进度报告仍按报告状态与回复请求决定是否即时唤醒。资料页经 Host Bridge 查询所有有效规则、修订及最后修改者。来源类别修订与 Channel 覆盖分属不同作用域。 + +#370 的 `source_attention_get/set/reset` 是仅对所属 PersonaBot 可见的 Orchestrator Tools,Human Profile 通过 Typert/API Gateway Bridge 编辑同一 `bot_source_policy_revisions` 修订权威。v1.0 可编辑提醒强度的来源为 `assignment-report`(`conditional`/`immediate`)与 `group-ordinary`(`immediate`/`digest`/`mentions`/`silent`,汇总条数及间隔有界);两者始终 `admit`。`human-dm`、`bot-dm`、`group-mention` 始终接收并即时唤醒;Human Bridge 与所属 Orchestrator 的 `source_attention_set/reset` 可编辑/恢复 `delivery: steer | turn`,默认 `steer`。直接来源要求 `wake: immediate` 和显式 delivery,拒绝汇总参数;其他可编辑来源拒绝 delivery。Host 在投递唤醒时读取当前 delivery;Admission 保留入队时的 wake 与修订。`group-invite`、`group-join-request`、`group-join-decision`、`assignment-lifecycle` 保持即时工作流通知;其中与 completed Report 明确配对的成功执行通知只搭乘收割,不重复触发同一因果唤醒(ADR-0077);其余来源没有安全的延后/静默收割语义,因此规则只读,Bot Tool 与 Human Bridge 都拒绝其修改。删除可编辑覆盖以带 Human/Bot actor 的新修订恢复内建默认,不删除历史;后提交的 Admission 才读取新修订。Orchestrator 真正启动时为所纳入的来源类别追加不可改写的 wake-attempt 事实,近七天计数从这些事实投影,而非把 Admission 数或汇总阈值当作唤醒次数。Group Channel 的普通消息覆盖继续独立存于 Channel record,优先于 PersonaBot 来源默认值;冻结规则按 ADR-0076 延后。 + +Attention 已交付契约(ADR-0070/0074/0077、#364):四档偏好均为普通群消息保留该 Bot 的 Inbox Admission。`mentions` 不自行唤醒,但同群直接 @ 可带入有限的待处理上下文;`silent` 不自行唤醒,也不搭乘 @,只在 Bot 显式读取时进入本轮。群聊触发 harvest 时同时选择触发消息附近的上下文与最早待处理的一段;单群至多 100 条,并受整轮文本/token 预算约束。提示中明确省略数量与继续读取位置,未选中消息保持待处理,后续合格轮次继续从最早处推进。`channel_read` 只把实际返回并进入本轮的 Admission 纳入处理集合:进入本轮显示处理中,成功结束才已处理,失败显示需修复。内部 observed 保留审计用途,不新增常用“完成”工具,也不把 Human 打开 Channel 当作 Bot 处理。#362 的通用多来源 Inbox Trigger 与 Attention 聚合仍待后续切片。 + +当前 #47 首个高流量 Group 切片把每位成员的普通消息 Wake Policy 写在 Channel record;该 preference 归 Bot 所有,Bot 可用自己的工具读写(含 count/interval),Human 可在成员侧栏查看并覆盖;四档:`all`(每条普通消息即时成为 attention)、`digest`(N 条 / T 秒汇总;默认)、`mentions`(只有直接 @ 到达)、`silent`(记录但不唤醒、也不搭车,只能显式读取)(ADR-0074)。任一模式下,普通消息作为 Source Event 与该 Bot 的 `group-ordinary` Inbox Admission 在同一 SQLite 事务中提交,Admission 固定当时的 policy revision;即时模式逐条就绪,汇总记录阈值,mentions 与 silent 记录空阈值。未保存偏好时采用 digest 的默认阈值,保留已保存的模式与 revision。直接 @ 走独立的即时 Admission,不等待汇总。Host 在达到数量或时间上限后把该 digest 批次标记为就绪;忙碌期间新到的即时项不再各自排队,只标记就绪集合,当前回合结束或空闲时由一次 harvest turn 消费全部就绪项(ADR-0077)。直接 @ 与 DM 默认 steer:有活动回合时在下一个安全 step 注入,否则并入下一次 harvest。重启从 pending/retryable Admission 重建计时,收割只把实际纳入 Orchestrator context 的 Admission 标为处理中,回合成功结束才标为已处理;Bot 通过 `channel_read` 明确读取到的普通群消息也加入该回合,成功后标为已处理,失败则需修复,未返回的消息保持待处理;是否向 Channel 回复仍由 Bot 决定。静默收件为普通消息保留待处理 Admission,但不设置汇总阈值,不自动唤醒,也不进入 harvest;只有显式 `channel_read` 才会进入本轮处理,直接 @ 仍即时。归档中的 Bot 在消息提交时不产生新的 Inbox Admission;群消息与其他活跃 Bot 的收件仍独立提交。唤醒处理按 Source 类别而非平台分类:外部平台在 Bridge 边界归一化为 Source Event,运行时只认 Source 类别与 admission reason(ADR-0075)。Bot 自管自己的 attention policy(来源类别规则 + per-Channel 覆盖 + count/interval),Human 可覆盖;冻结规则按 ADR-0076 延后,安全闸门不属于 policy。通用多来源 Attention 聚合与 Inbox Trigger 仍待后续切片。 + +五个应用自定义 attention Tools 使用现有逐 Bot 的来源策略与 Channel preference Provider。`source_attention_set` 省略 `sourceClass` 时默认 `assignment-report`,仅允许 `conditional|immediate` 且不能传 digest 参数;`group-ordinary` 允许 `immediate|digest|mentions|silent`,只有 `digest` 接受 digest 参数。计数与间隔使用整数 schema,由 Host 强制执行 1–100 与 1–3600 秒边界,省略时保留有效设置。逐 Channel 的 `group_attention_set` 在所有模式下保留可选 digest 设置。Source reset 恢复内置规则,不清除 Channel override,也不改变历史 Admission。读取、编辑和重置返回有效值、修订、最后编辑者/时间及有界七日 source wake 计数;非法组合在策略写入前失败。 + +当前 Bot-scoped botAttention Bridge 查询直接从 Inbox Admission、Source Event、Channel placement 与 Assignment Directory 投影有界页,按 Source Event 时间与 ID 排序,返回状态、发送者、摘要以及可用的 Channel 消息或 Assignment Session 引用;它不另建收件内容。Assignment Report 与 Source Event 在同一事务中形成该 Bot 的 Inbox Admission,并带报告状态;报告进入 Orchestrator 回合时记录观察,回合完成后标记已处理,是否向 Channel 回复由 Bot 决定。重启后未观察的到期报告可唤醒一次,已进入回合却中断的报告显示需要修复。Human–PersonaBot DM 的 Channel sidebar 在有事实时显示 Bot Inbox entry,按来源 Channel 或 Assignment 分组,待处理项展开,已处理和明确忽略的历史折叠;Bot 在收到或读取某条 Channel 消息后可用 `inbox_ignore` 明确忽略,决定写在同一 Admission 上并保留消息历史;回合完成本身不再 resolve 任何发给 Bot 的条目:它们变为 open,直到 Orchestrator 用 `inbox_resolve` 标记 done 或 ignored(ADR-0188),阅读本身不自动写入长期 Memory。点击来源消息复用 Channel timeline 的 around 定位,点击 Assignment 报告打开事项详情。Group Channel 不显示该 entry;Human 查看侧栏不改变 Bot 的观察事实(ADR-0070、#47、#152)。 + +Human Inbox 的首个可运行切片在 Bot mode 左侧栏的 Messages 上方提供独立入口,默认显示待 Human 处理的群聊加入申请、仍存活的原生提问和工具审批;#546 已交付的未读视图将群聊及 Bot→Human DM 按 Channel 汇总,信息视图保留无 Channel 的事项完成报告。提问项只在 DSH 原生请求仍等待 Human 答复,工具审批项只在 BotHarness 审批请求仍存活、且 Channel 内没有答复、取消或审批决定时出现;打开后定位到对应私聊卡片。事项的最新报告为 `waiting-human` 且 open ask 仍存活,或报告为 `blocked` 且尚未解决时,Human Inbox 从 Assignment Directory 投影同一条按 Session ID 稳定标识的待办;状态升级为受阻时更新摘要与报告来源,不重复建项。Orchestrator 回复后事项运行期间暂隐藏该项;若事项再次空闲或出错但没有新的解除受阻报告,待办继续显示。打开后进入该 Bot 私聊并展开事项详情;完成报告或停止事项后待办消失。事项完成报告则按最新 Source Event 投影到“仅供了解”,Human 可打开来源或忽略该份报告;忽略决定单独保存 Source Event ID、决定与时间,新报告仍会出现,不复制 Inbox 内容。Host 从 Channel record 中的待处理申请、Source Event/Channel placement 以及 Human 的 Channel read position 投影列表,不另存 Inbox 内容;批准或拒绝沿用 Group 决策事务,已了解沿用 Channel 已读位置。查询按时间与稳定 ID 分页,并把游标绑定到分类、Bot/Channel 过滤条件与排序方向;Client 在切换范围时丢弃旧响应。Bot 的 Channel Admission 进入 needs-repair 时,同一权威按受影响 Bot 与 Source Event 投影一条待办;Human 可打开 Bot Inbox 或来源消息,来源 Channel 已删除时降级到 Bot Inbox,修复状态解除后待办消失。Workspace Grant 请求也从 Bot DM Source Event 投影为待办;新提交的回复只有带有效 Grant 引用才会清除,历史已存储的本地化授权文字回复仍按兼容规则识别;unknown-outcome、rebind 与 readiness 等原因仍待各自的 typed Attention facts(ADR-0071、#126)。 + +[#687](https://github.com/BotHarness/BotHarness/issues/687) 切片让事件展开、行动执行和准确来源跳转彼此独立。只有点击整行才打开 Inbox 详情;工作区行动把权威请求上下文载入既有选择器,回复、提问、审批和 Assignment 行动使用原生 Modal 回应表单。重新打开工作区选择器时重查权威状态;这些控件沿用既有 Host 命令,不把决定权复制到 Client。 + +#547 切片允许本地 Human 在 Human Inbox 查看捕获的群聊或 Bot DM 未读消息、展开相邻上下文并原位回复。Channel owner 按该 Human 的可见范围返回 timeline,并在发送时再次验证回复目标;Inbox 复用现有 `channelSend`,只提交一条带来源消息引用的 Human Source Event。草稿与重试身份仅是 Client 临时状态;Inbox 刷新或发送失败保留草稿,未修改内容的重试复用同一消息身份。看到具体来源内容推进其权威已读位置,展开未读摘要不推进。来源及已确认回复均保留准确的 Channel 消息导航。 + +#550 切片允许 Human 在 Inbox 同一来源上下文面板中查看并决定实时工具审批,复用 DM 审批卡及既有 `toolApprovalStatus`/`toolApprovalDecide` Bridge 命令。认证 Host 再次检查来源请求和实时授权范围,提交权威 Channel 审批决定,并只恢复对应原生调用。批准、拒绝或过期后该请求离开待行动投影,其他 Bot 的请求保持独立,默认最久等待优先。成功或过期失败后 Client 刷新 Inbox 与独立行动提示,并从保留的旧分页中移除已确认解决的项。有界附近消息与准确来源跳转沿用现有 Channel timeline 边界,不新增审批存储或生命周期;已处理历史由 #553 投影。 + +#553 从已提交的 typed Human 回应 Source Event,经 Channel placement 关联原始提问、审批、Grant 请求或 Assignment 报告,投影已处理历史。同一请求 Source Event 只保留第一份权威回应的历史行,携带请求、回答、回应时间、Bot 与来源 DM;不复制消息、不增加历史表。游标绑定类别、Bot、Channel 和排序;阅读消息或忽略完成报告不产生已处理行动。Assignment 原生 Inbox 接收答复后才移除 Human 待办;仅收到 Human DM 保留转发地址和未解除待办;旧 blocked ask 已有权威 Human 答复时,后续明确报告建立新请求。历史上下文独立查询准确回答,不依赖请求附近各两条消息或已过期的实时 broker 推断已处理状态。Client 丢弃旧筛选结果,每次刷新最多重查三页、每页 50 项;更多记录可沿刷新后的游标再次加载。Assignment 上下文高亮准确报告,「查看 Session」进入原始 DSH Session,「查看答复」定位所属 DM 的准确回答。 + +#551 在同一 Inbox 来源面板支持实时原生提问,复用来源 DM 提问卡的准确问题、选项与自定义输入,以及既有 `userQuestionStatus`/`userQuestionAnswer` Bridge 操作。拥有请求的 Channel question broker 校验实时 Orchestrator、目标与答案,提交一次权威回答并恢复原生请求。Client 与工具审批共享决定后的刷新,从保留分页移除已确认回答或过期请求,其他 Bot 保持独立。有界上下文与准确来源跳转沿用既有 Channel timeline,请求生命周期及已处理历史仍归各自既有边界。 + +### 本地 Human 名称目标设计(ADR-0103) + +本地 Human 在一个 DSH Profile 内有一个可选默认显示名,在 BotHarness 插件设置中编辑,未设置时使用 `Human`。Human 参与的每个 DM 或 Group Channel 都可用 **Human Channel nickname** 覆盖默认名,通过 Channel 头部菜单中的「我的昵称」编辑。清除昵称恢复继承;修改默认名只影响没有覆盖值的 Channel。这支持 Human 与不同角色聊天时使用不同称呼,本次只保存昵称,不增加角色背景、另一个 Human 账号或另一份 Human Inbox。 + +既有应用自定义 Messaging 权威拥有默认名与 Channel 覆盖值。Host 按 Channel 昵称 → Human 默认显示名 → `Human` 解析名称,浏览器 tab 不拥有独立副本。名字修改经既有 Typert/API Gateway seam 指向 Host-owned 本地 Human。既有成员记录中的默认 `Human` 不视为用户主动设置的 Channel 昵称。Channel 作者名、成员、提及选择器、收件明细及 Human Inbox 上下文使用同一有效名称;Bot 获得的 Channel 成员及新组装或明确查询的消息上下文也使用它。昵称不授予权限,也不提供角色扮演指令;既有 DSH SessionEvent 与已组装的模型输入仍保留执行历史。 + +Human 与 PersonaBot 的提及保存带类型的稳定目标,在显示时解析可见标签,包括历史消息。Human 名称取消息所属 Channel 的当前有效名称,Human Inbox 中也按来源 Channel 解析;PersonaBot 名称取其身份的当前名称。已知目标优先使用当前名字;目标不可用时可保留记录的标签作为展示回退,绝不按名字改指另一个目标。普通文本不会被重新解释为可信提及。Source Event 内容与原提及范围保持不变,显示标签长度变化不修改持久 offsets,也不产生 Source Revision、新通知、Bot Admission 或 wake。 + +名字允许重复,包括同一 Channel 中的 Human 与 PersonaBot 同名;成员与提及界面区分 Human/你和 PersonaBot,并保留目标 ID。Channel 昵称始终标记同一个 Human ID,阅读位置、行动与提及仍归同一份 Human Inbox。外部账号映射与多人登录留待后续 Bridge 工作。默认名路径使用 Messaging 的 `local_human_names` 记录及受信 `humanIdentity`/`humanNameSet` Bridge 操作;Channel 摘要投影当前 Human 成员,作者、回执和可信提及按其与 PersonaBot 当前身份解析名称。Bot 的 Channel 读取在原消息旁提供带类型的 `actorNames`,仍受既有输出预算约束。名称提交沿用 roster 实时通知,不产生 Channel placement 或注意力事实。各 Channel 覆盖使用 Messaging 的 `channel_human_nicknames`,以 Channel ID 与 Human ID 为键;`channelHumanNameSet` 检查当前 Human 参与资格,拒绝只读 Bot-to-Bot 观察。DM/群聊头部菜单经现有 Bridge 编辑或清除覆盖。明确昵称即使文字与默认名相同也独立保存;清除覆盖后恢复继承(#622)。按 ID 引用提及可参考 [Slack 官方提及语法](https://docs.slack.dev/messaging/formatting-message-text/);Channel 昵称覆盖来自本地 roleplay 使用场景。 + +### 活动中心目标设计(ADR-0098、ADR-0099) + +#2026 将活动中心收束到个人 Human Inbox,完整页面和 Dock 使用同一原位工具审批卡,移除总览切换及用量、活跃度等统计呈现。首个切片通过现有 Human Attention 查询定位所属 Channel 消息,再读取权威审批状态;每次提交仍由原有 Channel Tool Approval owner 判断是否待处理。卡片默认提供允许一次、精确规则与拒绝;全范围授权保留在来源聊天。上下文在卡片下展开,摘要不推进已读;批量操作仅在显式选择模式出现。成功后由权威列表移除驱动约 220ms 的不可交互退场,失败原位保留并可重试,不向下一项转移审批焦点。此切片不新增 Host API、持久化表或审批生命周期;三组折叠列表与其搜索排序留待首个切片的 Human QA 后继续。下文总览、Channel 活跃度及 #698/#703/#705 的 UI 描述记录旧交付;查询能力保留,但不再作为当前活动中心的入口。 + +#548 切片从已加入 Group 的 Source Event 与同 Channel placement 的 `replyTo` 关系投影「回复我」:只包含 Bot 对本地 Human 可见消息的直接回复,仍绑定该 Human 的成员可见范围。每条回复使用 Source Event ID 稳定标识,按最近活动排序并允许 Bot/Channel 过滤,已读后保留浏览并从权威读位置计算未读标识。个人回复不重复进入「其他未读」Channel 汇总,入口未读总数仍统计所有不同的未读 Source Event。两个 Client 窗口及 Host 重启均从同一查询重建。原位回复和准确来源导航复用 #547 路径;上下文按原始时间顺序展示作者、头像、时间及回复目标,可展开有界相邻消息,宽屏采用列表与上下文并列,窄屏堆叠。 + +#549 切片将个人视图扩展为「提及与回复」。Orchestrator 通过 `channel_list` 发现当前 Group Human 成员,再用 `channel_send.mention_human_ids` 指定其稳定身份。Channel owner 验证当前成员关系,在已有 Source Event payload 内提交 Human 目标与显示偏移;普通文本不提供身份。一条 Bot 消息同时提及并直接回复本地 Human 时,个人视图只列一项,未读总数只计一次。现有 `replies` RPC category 和 Source Event item 身份保持兼容,可信提及用 `channel-mention` 区分。两种原因共用最近优先排序、Bot/Channel 筛选、已读状态、有界时间序上下文、原位回复及准确导航。Human 提及元数据不改变 Bot Admission 或 Wake Policy;范围仍是单一本地 Human,不新增全体 Human 广播或账号配置。 + +#123 审批提示切片通过现有版本化 Activity 快照及实时流独立发布正数 `attention.approvalCount`,不改变执行状态。process-local 来源是拥有原生工具审批的 broker:仅在权威 Channel 卡片提交后计数,决定、取消、scope 失效或 Fiber 销毁时,先移除提示再恢复 Tool 答案。侧栏、输入框与总览头像共用这个有界数量;请求身份及原始载荷仍只留在所属审批界面。新 Host 没有 live 审批,重启不从历史请求卡片复活提示。原生问题 broker 同样只在卡片提交后发布 `questionCount`。限时问题卡按 Session 和调用定位,不依赖提问的 Agent:Agent 释放后仍计数;新 Host 会重新打开原 Orchestrator Session 仍持有原生问题的未决限时卡片,回答经 Bot 的轮次队列、以卡片自身的 Source Event 为锚点恢复该 Session([ADR-0187](../adr/0187-personabots-use-a-product-preset-with-timed-questions.md))。其他 attention 类别及明确 waiting-on-Assignment 留待后续切片。 + +首个总览切片(#541)通过 `botharness/activityOverview` 读取现有 Human attention 投影的完整明确行动数,并连接 PersonaBot Registry、根 Session 归属、当前 activity 和 DSH 原生 live Agent 状态。首个切片当时将原生提问/审批等待和空闲 Assignment 的未答请求显示为等待或受阻;当前总览则将 Human 待办与执行状态分开,如上文所述。只有 live 且 thinking/working 的根 Session 进入正在执行列表,重启后不把旧日志的执行状态当作当前运行。视图拥有每五秒的查询刷新及卸载清理,离开视图后的旧响应不会覆盖新状态;无新增数据库表或执行权威。 + +在 #2026 之前,Bot 模式用一个「活动中心」入口承载「总览」和个人「收件箱」两个视图;前述 Human Inbox 段落记录已交付的首批投影,以下是后续目标。总览给出未解决的明确 Human 行动数、各 PersonaBot 实时状态,以及正在执行模型或工具的 Orchestrator/Assignment Session;点击 Bot 进入其私聊,点击任一 Session 退出 Bot 模式并打开 DSH 原始模式中的对应 Session。等待、受阻与空闲不计为活跃 Session。今日 Channel 活跃度按已提交消息数统计,主图逐 Channel 区分 Human/Bot,展开后按发送者查看;全局及逐 Bot token 用量可看近七天趋势,不按 Channel 猜测归因;Memory 展示逐 Bot 近七天已提交变更次数和当前未提交提示。各卡片消费各自权威的读模型,不另建消息、用量或运行事实账本(#34、#39、#424)。 + +收件箱面向当前本地 Human;已读事实按 Human 身份寻址,为将来多 Human 留出同一语义,但本切片不交付多账号。明确待行动、提及与回复、其余未读、信息更新和已处理历史分开呈现。待行动只计仍需 Human 决定或解除阻塞的 typed 请求,按等待最久排序;提及及未读按最近活动排序。繁忙 Channel 的未读折叠为一个 Channel 摘要,提及可定位准确消息;原生 Channel 尚无 Thread 子对话,不按 Thread 分组。展开摘要不推进已读位置;实际看到具体消息,或明确标记已读时,才在权威 Channel 读位置推进到相应消息。活动中心入口数字只计去重后的未读 Source Event,另用独立提示表示未解决行动;同一消息即使关联多个 Bot、兼属报告或提及,也只在 Human Channel attention 里出现一次,多个 Bot 分别提出的真实请求则各有行动卡。 + +行动卡在收件箱内显示问题、选项或输入框与简短可展开来源上下文;Human 可沿用来源处相同的授权命令直接回答,也可跳转原消息。已失效或被他人解决的卡片不允许继续提交;处理后从待行动移入可筛选的已处理历史,仍链接权威请求与回答。群组可回应请求须显式指明合格 Human 范围,首份有效回答解决同一请求,不暗含全员提及。#126 下一条 tracer 先交付本地 Human 的 Group 未读汇总、准确来源定位和已读;Human 提及/回复与 Inbox 内回应分步扩展。当前 ChannelMention 只记录 Bot,Human 提及需新的可信目标身份。跨 Bot 总览由 #541 交付,Bot Inbox 仍只负责选中 PersonaBot 的事件时间线。 + +Human 在 Group 中选择「@所有 Bot」时,Host 在发送时把当前 Channel 的活跃 Bot 成员解析为逐个直接提及:只提交一条 Channel Source Event,每位目标 Bot 获得与手动单独 @ 相同的 Inbox Admission、attention 与 wake;发送前向 Human 显示实际目标人数。它既不访问其他 Channel 的 Bot,也不允许 Bot 使用该快捷广播,不引入新的 Wake Policy。此输入能力由 #542 单独交付,不改变上述 Human 未读去重。 + +Bot-to-Bot DM 是两个 PersonaBot 参与的真实 `dm` Channel。Bot A 通过可信 Session ownership 以自己的 Actor 身份向 B 发送消息;Messaging 在同一权威中提交 Source Event、Channel placement 与 B 的 Inbox Admission,Bot-hop guard 限制循环,A 不接收自己的输出。Human 可以只读打开此类默认不在 roster 显示的 Channel,但不会成为第三位成员。A 每次向非 Human DM Channel 发出已提交消息时,引起这次发送的 Channel(群、Human DM 或 Bot DM;不可用时为 Human–A DM)都会出现居中的 Channel Notice,指向这次发信与可查看的对话,而不复制正文;A 的 Bot Inbox 同时得到一条已处理、永不唤醒的记录(ADR-0154)。 + +Human–PersonaBot DM 是这个 Bot 的时间线(ADR-0182):Bot 在群或 Bot DM 中发言时,同一事务在它的 Human DM 写入一条只含 `{ channelId, messageId }` 的 `botChannelPost` Channel Notice,摘录在读取时从原 Source Event 投影,不复制正文;原消息不可用时显示不可用。起因在别处的 Memory commit 记录除了留在起因 Channel,也在 Human DM 增加一个指向同一 Source Event 的 placement,读取时以 `noticeOrigin` 标出来源。Human DM 内的发言、以及已落在 Human DM 的 `botDmAction` 不再重复写入。Client 在 DM 聊天顶部 ··· 菜单中按动作类型(发言、记忆更新)提供只影响本地显示的开关,默认全部显示;同一 Channel 的连续发言合并为一行。 + +应用定义的 `list_bot_contacts` Tool 在所属 Orchestrator 的 Agent Scope 中读取同一 PersonaBot Registry。`query` 对名称、完整简介及稳定 ID 做不区分大小写的子串搜索,最多 200 字;也可直接浏览,默认每页 20 位,`limit` 为 1–50。将 `nextCursor` 原样作为 `cursor`,保持 query 不变;游标绑定所属 Bot 和规范化筛选,结果按稳定 ID 的 ordinal 升序排列。分页读取当前资料:名单及匹配状态不变时续页不重复、不漏人,改名不改变 ID 排序,移除/暂停者不再返回;游标之前的新建或新匹配身份需要重新搜索。结果包含 `botId`、最多 128 字的名称和最多 160 字的简介预览,并显式标记截断;含续页信息的完整 JSON 页最多 12,000 个 UTF-16 字符,因此可少于请求条数。仅传 `bot_id` 可读取单个活跃同事最多 1,000 字的简介预览。资料始终是非可信数据,不提供 Soul、私有 Memory 或凭据;重名用 ID 区分。返回的 `botId` 可原样用于 `bot_dm_send`、群邀请与 mention,发送时仍由 Messaging 重查目标活跃状态及群成员权限;发现过程不引入第二份目录或权限权威([#568](https://github.com/BotHarness/BotHarness/issues/568))。 + +Human–A DM 里选中 `@B` 会在 Human 消息中持久保存 B 的稳定 ID 与文字范围;Host 在 A 的 Orchestrator 回合组装输入时重新读取 B 的当前名称与最多 400 字的简介,并把它们作为有界联系人资料提供给 A。重名靠 ID 区分,改名采用当前资料,已归档或失效的目标在发送时被拒绝;手打或粘贴的 `@名字` 只是普通文字。此操作不唤醒 B 或改变 DM 成员;只有 A 后续显式发送 Bot-to-Bot DM 消息才触发 B 的 Inbox。Group Channel 中 Human 或已加入的 Bot 可 `@` 已加入的 Bot;一条消息仍只有一个 Source Event,每位目标 Bot 独立获得 Inbox Admission。Bot 创建 Group 时,Host 从 Orchestrator ownership 确定创建者,初始成员只有该 Bot;创建者可邀请活跃非成员 Bot。邀请状态存于同一 SQLite Channel record,创建待处理邀请、无 Channel placement 的 system Source Event 和受邀 Bot 的 `group-invite` Inbox Admission 在一个事务内提交。邀请携带可信的 Bot 协作跳数和受邀 Bot 的创建时间,跨 Bot 唤醒不重置循环限制,删除后重建的同名 Bot 也不能继承旧邀请。受邀者收到自己的 DM 上下文中的邀请 prompt,但在接受前没有 Group read/send 权限;接受把 membership 与邀请状态同时更新,拒绝不增加成员。Bot 模式提供默认开启的 Group 邀请自动接受(ADR-0073):设置由原生 Profile Config `botharness-client.autoAcceptGroupInvites` 持久保存,Client Fiber 将其实时读取器绑定到拥有邀请事务的 Core;只影响新邀请。开启时同一事务写入成员关系、accepted 邀请、system Source Event 与 handled Admission,并记录 `respondedBy=profile-policy`,不启动唤醒、不伪造 observed;关闭时保留上述逐邀请决定,Bot 的接受或拒绝也在同一事务结束邀请 Admission。启动时依据已有 accepted/declined 邀请补齐旧 Admission 的终态,避免重投或重启重复唤醒。重复邀请与重复同向决定幂等,归档目标时取消待处理邀请,重启时恢复仍待处理的 Admission。创建者可改群名、移出其他 Bot 成员;任一已入群 Bot 可用 `group_leave` 自行退出,成员关系在同一 Channel record 中更新,读写权限立即撤销,待处理群消息不再唤醒它;若创建者退出,Bot 管理权消失、成员界面标明由 Human 管理,其余成员与历史仍保留。Bot 自主退出或任何现有移出成员操作,都会在同一事务中提交一条 Host 撰写的群内成员变动消息,分别记录“离开”和“被移出”的类型与正文,供留在群内的人查看,并给每位仍在群内且活跃的 Bot 建立独立的普通消息 Inbox Admission,记录各自当时的 Channel 注意力偏好;退出者没有 Admission。all 可即时唤醒,digest 到达数量或时间条件后唤醒,mentions 可随之后的直接提及进入回合,silent 仅在显式读取时处理。进入回合的成员变动提示标明 Channel system,Bot 可按需回复。提交后的界面实时发布与 Bot 唤醒彼此独立;唤醒通知失败时运行中的 Bot Runtime 按有界退避重试持久 Admission,重启后仍由启动扫描恢复。Human 的群聊侧栏将成员名单与群管理分区:成员区标题的加号打开可搜索的邀请弹窗,带待办数字的铃铛打开邀请与入群申请处理弹窗;成员行左键打开 Bot 私聊,右侧菜单或右键菜单提供私聊、消息提醒策略弹窗与移出群聊。群管理区只呈现群头像、群名,次级菜单提供解散命令;Human 裁切 1:1 头像后将有界 WebP 图像写入同一 Channel record,Host 仍校验图像。邀请仍通过同一 Channel 邀请事实及 Bot Inbox Admission;Human 独占整群逻辑删除命令。关闭自动接受时,Human 发起的邀请在 Bot 接受前不授予群访问权限;删除撤销成员访问,保留 Source Event 等运行证据而不作破坏性清除。首个 Group Human `@` 切片是 #254,后续协作切片由 #278 组织。 + +Human 在自己的 PersonaBot DM 输入框中从 `#` 候选选中 Group,消息才记录稳定 Channel ID 与选中文字范围;Human 已发消息中的引用可点击进入该 Group。Host 在该 Bot 的 Orchestrator 输入中重查当前名称,非成员只收到 ID、名称和是否已加入,不读取成员或历史,普通手打的 `#名称` 没有引用身份。Bot 可在该 Human DM 所触发的回合用 `group_join_request` 请求加入选中的 Group;请求本身不会改变 membership。待处理申请保存在同一 Channel record,Bot 创建者收到独立的 system Source Event 与 Bot Inbox Admission,Human 在成员区通知弹窗可批准或拒绝,创建者可用 `group_join_decide` 决定。首个决定与成员更新、申请者的结果通知在本地事务中提交;申请者收到结果后才能按现有成员规则使用 `channel_read` 和 `channel_send`。重试、重启、改名、同名群都使用稳定 ID;归档申请者或删除群会取消待处理申请(ADR-0069、#292)。 + +应用定义的 `group_rename` 与 `group_remove_member` 工具只返回已提交 Channel 的简短确认:`channelId`、当前 `name` 与 `outcome`(`renamed` 或 `member-removed`);移除成员还包含 `memberBotId`。确认中不含头像、邀请/入群申请历史、成员列表或提醒策略。所属 Host 命令仍检查当前 Bot 创建者及成员身份,通过 Channel authority 提交;Human bridge 继续返回完整呈现记录。改名持久化未返回记录时,工具明确失败。查看当前已加入的 Channel 与成员身份应使用 `channel_list`,不能把命令确认当作群快照。 + +`group_create` 确认新建的 `channelId`、当前 `name` 与 `outcome: created`。`group_invite_bot` 确认已检查的 `channelId`、`inviteId`、`inviteeBotId`,并以所属命令返回的实际邀请状态作为 `outcome`,不假定所有结果都为 pending。`group_invite_respond` 返回同样的引用与实际决定状态,并附当前群 `name`。这些确认不包含内部身份版本时间戳或无关的 Channel 呈现状态。拒绝邀请不授予成员、读取或发送权限;重复相同决定保持相同引用,冲突、旧身份、已取消或未获授权的决定保留所属命令的错误。 + +`group_join_request` 确认当前 Human DM 已选中的 `channelId`、`requestId`、`requesterBotId` 与所属命令实际返回的申请状态 `outcome`;`group_join_decide` 返回同样的引用和实际决定状态,并附当前群 `name`。申请确认不要求非成员群出现在已加入的 Channel 查询中,也不授予读取或发送权限。确认不包含完整群记录、成员列表、头像、提醒策略或内部身份版本时间戳;首个决定、成员更新和申请者通知仍由同一 Channel authority 提交。重复同向决定保留相同引用且不再次通知,冲突决定、已归档或旧身份申请者、失去管理权的创建者继续由所属命令拒绝。 + +Orchestrator 的应用定义 channel_list 工具从当前 PersonaBot 的 Session ownership 派生身份,只返回其已加入的 Group、Human DM 和 Bot DM;可按名称、Channel 类型、稳定 Bot 成员 ID 筛选并分页,结果带当前成员身份。它是 canonical Channel record 的授权查询 Consumer,不创建第二份成员目录。Bot 用返回的稳定 Channel ID 调用 channel_send;发送时仍重新检查当前成员资格,旧查询结果不会授予访问权。应用定义的 channel_read 查询在成员校验后对该 Channel 的完整有序消息历史应用正文、作者及日期过滤,再返回有界游标页;回复预览仍解析自原消息,不因过滤失去引用。`channel_read(scope=joined, text=...)` 对当前已加入的 Channel 进行跨频道正文搜索,结果按时间与稳定 ID 排序并分页;成员关系变化会使旧游标失效。原先仅扫描每个 Channel 最近 200 条的 `channel_search` 工具已移除,Bot 不再面对两个含义重叠的搜索入口。 + +应用定义的 Channel 查询工具枚举 `type: group|dm`、`scope: channel|joined` 和 `author_kind: human|bot|bridged|system`,所属 Host 仍防御非法值。`channel_list(channel_id=...)` 在全部过滤后没有可访问匹配时返回 `{channels: [], outcome: no-accessible-match}`;未知、不可访问或与其它过滤不符的目标使用同一确认,不泄露存在性,普通空搜索仍返回 `{channels: []}`。`channel_read(scope=joined)` 必须有非空白 `text`,不能同时指定 `channel_id`;`author_bot_id` 只能与省略或 `bot` 的作者类型组合。日期界限包含端点,日期形式 `YYYY-MM-DD` 的下界为 UTC 当日零时、上界包含整个 UTC 日;不可解析或倒序范围失败。游标仍绑定原过滤,跨已加入 Channel 查询还绑定当前成员集合;读取仅观察实际返回的消息。两个工具的 `limit` 保留 number 与既有兼容行为:默认 20,向下取整后夹取至 list 的 1–100 或 read 的 1–200。固定 DSH 0.2.0-rc.1 转换器支持 integer,但不支持 minimum/maximum;本票不收紧已有小数和越界输入。 + +模型侧 `channel_read` Consumer 使用 Host 所有的可行动消息投影,在精确 ID observation 前对完整序列化结果施加 12,000 个 UTF-16 code unit 的输出预算(ADR-0102)。投影去除 Human receipts、deliveries 和 Channel revision,保留完整正文、回复、可信附件及行动引用。因预算未返回的消息仍为 pending,并提供绑定原过滤的继续游标;首条超长消息提供 `message_id` 完整内容读取路径。有界 JSON 片段绑定当前投影的哈希及偏移,每次复查成员资格,只有同一活跃 turn 接收到完整连续内容后才加入消费集合。Human Channel / Inbox canonical records 和 bridge 呈现保持不变。 + +### 外部 CLI 的在线请求与回复 + +应用定义的在线 CLI 依照 ADR-0159,每次从当前 Host 启动令牌换取内存 cookie,经既有 DSH HTTP unary carrier 调用 Typert Bridge;不另设监听器、凭据缓存或数据库旁路。发送先由 Registry 查询确认 Bot,再由 Channel owner 建立 DM,使用 `human-UUID` 消息 ID 提交;重试去重仍归 Host。新增 `channelSendStatus` 只读查询复用 `source_events`、`inbox_admissions`、`channel_output_origins` 和 `session_ownership`,按原请求 Source Event、同一 DM 与 Bot 所有的 Session 返回已提交回复和当前处理状态,不以时间窗口或最新一条消息猜测对应关系。问题/审批卡片、通知及失败不算回复;超时保留回执供查询,不自动重发。工具审批、正式问题、工作区授权和发布状态沿用各自 Host owner;无新增耐久表或权限权威。见 [CLI 指南](../bot-cli.zh.md) 与 [#1317](https://github.com/BotHarness/DeepSeekBot/issues/1317)。 + +依照 [ADR-0162](../adr/0162-online-cli-management-keeps-explicit-host-authority.md),显式 Host 选择扩展到 Registry 创建/查询、Model Preset/Plan 与 Channel 发现。Zip/目录由 CLI 上传调用者本地字节,经已有 authenticated Bot Zip import owner 验证和创建;Git 获取仍在 Host 执行。创建和模型应用是分阶段 owner 操作,部分失败保留已知身份,未知结果不自动重试;连接失败不切换到离线 Profile。attention、Session 与 activity 查询只读既有投影,提供有界调试摘要;测试脚本经同一业务 CLI 驱动精确请求与持久回复,并可显式调用删除 owner 的 preview/confirm/retry 清理本轮身份,保留 Memory。writer lease 不构成 RPC 全局串行锁,各 owner 的版本/HEAD 与幂等契约保持独立。见 [#1363](https://github.com/BotHarness/DeepSeekBot/issues/1363)。 + +### CLI 的 IM 应用授权 + +应用定义的 CLI Consumer 通过既有 Messaging Service 的 `messagingApps` 发现已资格验证的 Provider setup descriptor,再经同一个已认证 DSH HTTP carrier 调用原生 Connection Fetch Registry 的 `dsh-im/app-setup` endpoint。该 Provider 拥有授权 attempt、过期和原生账号/凭据持久化;CLI 只保留一次调用的内存 cookie,不创建额外 store 或生命周期。飞书凭据和微信验证码仅从 stdin 进入目的明确的 `setup.credentials`/`setup.verify` payload;stdout 只投影经过校验的非敏感状态、二维码、账号引用与指纹。微信扫码由 Human 在手机确认,再用同一 attempt 查询;IM 应用授权不选择 PersonaBot 或建立 Messaging Grant。见 [ADR-0161](../adr/0161-cli-im-authorization-keeps-provider-attempt-authority.md) 与 [#1318](https://github.com/BotHarness/DeepSeekBot/issues/1318)。 + +### 配对设备的远程访问 + +[ADR-0199](../adr/0199-paired-devices-reach-the-host-through-a-same-listener-device-gateway.md) 已由 [#1652](https://github.com/BotHarness/DeepSeekBot/issues/1652) 实现:配对的手机不持有任何 DSH 凭据。应用定义的 Device Pairing 模块(`packages/core/src/devices/`)经 DSH 公开的 `webServer.register`/`registerUpgrade` 在同一 `dsh web` 监听器上提供设备网关:设备在 BotHarness 前缀下以 `Authorization: Device .` 调用与 Web Client 相同的 unary 信封和 Remote stream mux 帧;模块每次按运维数据库中的设备记录(只存哈希)校验并更新最近使用时间,再用 Host 内存中经 `connection.authenticatedUrl()` 自行换取的 loopback cookie 转发至原生 `/api` 与 `/api/remote.mux`。DSH 签名 cookie 的密钥本就跨 Host 重启保留,但 cookie 不标识设备,且 stream 升级不经过 `connection/request`,故不能把 cookie 交给设备。撤销立即拒绝后续请求(编码 401:`device-revoked`/`device-unknown`/`device-credential-missing`),并关闭该设备已打开的 stream socket。配对邀请、列表与撤销只在已认证的 `/api` carrier 上提供给 Human:Web Client「设置 → 设备」显示一次性、10 分钟有效的二维码(即 `deepseekbot://pair?p=…` 深链接,携带 Host 标识与按序路由),列出设备的配对与最近使用时间,并可撤销。见 [#1644](https://github.com/BotHarness/DeepSeekBot/issues/1644)。 + +托管 Connect(目标状态,尚未实现):[ADR-0241](../adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md)([#2387](https://github.com/BotHarness/DeepSeekBot/issues/2387))在路由列表里增加一条 Connect 路由,顺序为 局域网 → Tailscale HTTPS → Connect。账号复用 TOB 的 WorkOS 环境,只用于发现 Host 和经纪一次性配对凭据;本仓库新建的中继 Worker 为每台开启 Connect 的 Host 代管一条 Cloudflare Tunnel,ingress 只放行 `/botharness-device/*`,应用流量不经过 Worker。手机登录后无需在电脑上确认即可配对,新设备会在电脑和其他手机上提示并可一键吊销。先在中国大陆以外实现并验收,大陆可达性之后另找测试者验证,不阻塞实现。 + +### 手机推送 + +[ADR-0202](../adr/0202-phone-push-is-a-content-free-projection-of-the-human-inbox.md)([#1655](https://github.com/BotHarness/DeepSeekBot/issues/1655)):应用定义的 Push delivery 模块(`packages/core/src/push/`)是 Human Inbox 的投影,不另设通知权威。Human Inbox 表或 PersonaBot 的审批、提问、私密输入计数变化时(以及每分钟一次的兜底扫描),模块读取 `action` 项与未读的 `replies` 项,对每台已登记的配对设备只发送一次新项,并经 Expo Push Service 投递;推送标题为频道名(`#名称`,私信为 PersonaBot 名称),正文为所需操作加上工具与主要参数、问题或消息的简短预览(折叠空白,最多 140 字符);私密输入请求不含内容([ADR-0213](../adr/0213-phone-push-shows-the-channel-and-a-short-preview.md))。数据字段只含 Host 标识、Human Inbox 项标识、PersonaBot 标识与读取请求所需的 Channel/消息标识,并携带未处理 Human 操作数作为角标。设备经设备网关的 `/botharness-device/push` 登记 Expo push token、切换各类通知、发送测试通知;撤销设备或 Expo 回报 `DeviceNotRegistered` 时删除 token。发送失败有界重试,结果写入不含内容的 `push` 开发者日志。App 保存每台配对的 Host,点按通知先切换到对应 Host,再打开该审批、提问或私信。 + +## 5 · Orchestrator 与 Assignment control plane + +Human 不负责创建或选择执行 Conversation。Human–PersonaBot DM 是 Human 与该 Bot 直接对话的入口:消息先成为 Source Event,经 Bot Inbox 交给 Orchestrator;Orchestrator 再决定直接回复,或在授权与 capacity 内创建、复用和管理多个 Assignment Session。普通 Orchestrator assistant final 只留在 DSH SessionPersistence;只有显式 Channel messaging command 才产生 Human-facing Channel message。该 command 从可信 Session ownership 推导 PersonaBot Actor,并验证目标 Channel membership,不接受模型自报 bot id 或 author。右侧「会话」只投影明确归属该 PersonaBot 的独立 DSH 根 Session,包括 Orchestrator 与 Assignment,不展示 Subagent;归属与角色来自 Session Ownership,标题、工作区及实时运行状态来自 DSH 原生 Session 目录,不以 cwd 推断归属(ADR-0072)。 + +Workspace Grant 是 application-defined 的持久授权记录:Human 通过 DSH 的 Host 目录选择器或绝对路径输入添加一个现有文件夹,由 DSH Workspace registry 解析真实路径,再为 PersonaBot 创建 Grant。Orchestrator 的 cwd 始终是自己的 Memory Repository;它可读写 Memory,并可读取所有当前有效 Grant 的项目目录,只在 Human 为该 Grant 明确开启 Orchestrator 写入权限后可写项目目录,不能自行创建 Grant 或开启自己的写入权限。每个 Assignment 选择一个有效 Grant,固化 Grant ID、Workspace ID、单一 primary cwd 与权限快照,只能读写所选目录。撤销 Grant 会阻止两个角色后续的文件访问及该 Grant 的新事项创建、请求和恢复;历史 Session 不因重新授权而复活。授权列表中 Memory 是固定内部目录,项目目录可添加、撤销,撤销不会删除 DSH Workspace 或磁盘文件。DSH 原生 workspace-write 仅限制部分写入并不隔离读取,且可能允许临时目录写入。BotHarness 在最终 Tool guard 对 DSH 原生 read、read_image、write、edit、str_replace_editor、glob、grep 的路径按当前 Grant 校验:Orchestrator 可读 Memory 与所有有效 Grant、可写 Memory 与 Human 明确开启 Orchestrator 写入权限的 Grant;Assignment 只可读写固化的单一有效 Grant。Shell 与插件工具按 Session 自身的 DSH 沙箱模式与审批策略运行;DSH 发起的审批(例如 Shell 请求 `sandbox_permissions`)进入应用定义的 Channel Tool Approval owner。普通 Web 卡只有允许这次/始终允许/拒绝;新规则记住同一 Bot、根角色和当前授权范围内的当前工具,输入可变,旧精确与宽规则保持原义。每次原生询问仍获得独立的 `allowed-once` 审计;规则撤销或范围变化使其失效。工具批准不等于目录隔离,也不创建 Grant;Shell 可能触达目录之外,界面需说明。每个 PersonaBot 的 Access Preset 默认为 `workspace-write + ask`;Human 经既有风险确认可开启 `danger-full-access + ask`,按所选 scope 作用于当前 Orchestrator 和/或之后新建的 Assignment。已有 Assignment 保留其 mode/Grant/revision 快照;本次迁移只规范旧 Full Access 的 approval policy。Full Access 普通请求自动回答,识别出的权限/策略变更必须 Human 本次决定;撤销、当前身份和独立能力检查仍适用。详见下文三动作边界及 [ADR-0239](../adr/0239-three-action-tool-approval-and-full-access-use-native-ask.md)。 + +### Three-action tool approval and Full Access (#2080) + +Ordinary Web approval uses Allow once / Always allow / Reject. The application-defined Channel Tool Approval owner records `allowed-always-tool` with the Human decision, activates a revocable `tool` rule in the existing Operational Database, then returns DSH's native `allowed-once`. A tool rule matches the current Bot, root role, authority scope and tool name across changed inputs. Exact, all-opaque and Secret rules keep their old semantics; new ordinary cards do not mint all-tools authority. Current Grant/scope checks, execution guards and rule revocation remain authoritative. The Web Channel, Activity/companion reuse, online CLI and readable mobile history share that decision contract. + +Top-level Full Access composes its existing unconfined execution mode with native `ask`, replacing the earlier `never` combination. The same responder automatically answers ordinary requests only while current Full Access remains valid. Recognized permission/policy management always requires an individual Human decision and skips automatic rules and Reviewer; directory grants still use the authenticated Owner read/write command. Generation 104 preserves existing rules and updates only old Full Access Assignment approval snapshots, without changing mode, scope, Grant or preset revision. Delegated Subagent policy remains native `never`. This is application policy rather than an override of DSH's `never` semantics. See [ADR-0239](../adr/0239-three-action-tool-approval-and-full-access-use-native-ask.md). + +当所需项目尚无有效 Grant 时,Orchestrator 可通过专用工具在自己的 DM 提交一条持久授权请求卡;该工具只能请求,不能发放 Grant,也不会预建 Assignment。Human 在卡上明确确认文件夹读写后,认证 Owner 的共享命令核对当前身份与目录,在单个 Channel 事务中提交可写 Grant、关联 Human 决议和 Inbox Admission;提交并读回后才续接原任务。重复确认沿用已提交的决议,撤销后的旧请求不能重建权限。回复携带经 Host 核验的请求消息 ID 与有效 Grant ID;Human Inbox 从请求和回复 Source Event 投影一条待办,普通回复不解除,授权回复解除。包括历史请求在内,仅有关联有效 Grant 的已提交结构化回复解除授权待办;原始文本保留,普通“已授权”文案不作为授权证明。Inbox 与来源 DM 复用同一目录选择及授权操作,提交事务重新核验请求与 Bot Grant,两窗口只能提交一条有效回应。Orchestrator 重新列出有效 Grant,随后才用选中的 Grant 创建 Assignment;卡片和授权记录是不同的持久事实。普通 Assignment Ask 仍先回到 Orchestrator。Orchestrator 遇到 Human 未指明的 Memory 分支选择时,调用 DSH 原生 `ask_user_question`;BotHarness 在该 Agent 作用域的 `user-questions/request` waterfall 中提交持久 Channel 提问卡,Human 的选项或自定义回答经同一 DM 回到原生 Service,使同一 Session 继续。卡片保留来源 Session,回答和取消各有持久消息;停止、重启或旧请求不得再次回答。明确目标直接切换,不提问。Assignment 不直接向 Human 使用此原生工具,仍通过 Orchestrator 转问。 + +右侧是 Channel sidebar(ADR-0053):group Channel 显示成员与 Channel 管理 entries,Human–PersonaBot DM 显示该 PersonaBot 的 entries(会话、Memory、Bot Inbox、Computer 等);entries 由统一注册 seam 提供、可折叠、按声明顺序排列,注册描述符可提供 Lucide 图标名称与标题操作;会话、Bot 收件箱和记忆演化的显示设置放在各自标题右侧的齿轮里,会话范围/布局保持 per-PersonaBot 浏览器偏好、记忆术语保持全局浏览器偏好,未注册或不可用时直接不显示而不是占位。Chat 始终是中间的 Channel body。「会话」把 Orchestrator 固定在顶部,Assignment 按状态分为可折叠的「进行中」与「空闲」,按最近更新倒序并平滑过渡位置;「全部」保留已停止的历史 Session;Human 可切换按状态或按工作区折叠分组,每个 Bot 的范围、布局与折叠选择仅保存在本浏览器。点击行使用 DSH 原生 `UiWorkspace.openSession` 打开对应 Session,而不是维护一份只读事项详情。归属该 Bot 的根 Session 可通过原生标题栏及 Session 菜单返回 Bot 私聊;空闲时左侧标题前显示 Bot 头像,运行状态及日程标记仍优先显示。Assignment Directory 继续持有 Grant、续接、报告、停止、并发及审计事实。 + +Channel sidebar 的编辑模式只折叠当前呈现而不改写展开偏好,拖动/方向键只改变草稿;完成保存同类 scope 的顺序,取消丢弃草稿,恢复默认也属于草稿。浏览器本地分别保存所有 PersonaBot DM 共用的顺序与所有 group Channel 共用的顺序;未显示/未注册的稳定 ID 保留位置,新 entry 按注册顺序附加。切换选择或关闭侧栏丢弃编辑状态,授权撤销仍经原有 expandable seam 清除禁止展开的状态;排序不写 Host 或 Memory([#721](https://github.com/BotHarness/BotHarness/issues/721))。 + +桌面 Bot 模式(窗口宽 961 px 及以上)把 `main` 区域改为一个 dockview 布局([ADR-0203](../adr/0203-bot-mode-is-a-dockable-layout-that-plugins-extend-with-dock-panels.md)):Channel body 与每个 Channel sidebar entry 都是独立的 **Dock panel**,不叠成标签页、不浮动,只能拆分放到彼此旁边。BotHarness Client 用 `ctx.provide('dockPanels', …)` 提供 Dock panel Registry:其他插件 `inject` 后调用 `register({ id, title, icon, scope, defaultPosition, order, component, headerAction? })` 并获得释放函数,重复 id 抛错,排序按 `order` 再按 id。`scope: 'channel'` 的面板收到当前 Channel,没有 Channel 时显示空状态;`scope: 'global'` 的面板不随 Channel 变化。可选 `headerAction` 在同一面板标题栏渲染,并与正文接收相同的 Channel 范围;活动中心以此提供筛选与排序菜单,正文不再显示分类 tabs。`defaultPosition.zone` 只决定第一次打开的位置:`left` 为最左一列,`center` 在 Channel body 下方,`right`(默认)叠进右侧栏,`bottom` 在新工作区内放到 Channel body 下方,以保留全高控制列;旧布局仍为底部一行;`relativeTo` 指向的面板已打开时,紧接在它后面;Human 关闭过的面板回到它原来的位置。`channelSidebar.register` 现在同时把非设置类 entry 注册为 `channel` 面板,同一 tab 的 entry 默认上下相邻;窄屏遮罩侧栏仍直接读取 `channelSidebar`。同一 Service 还提供 `open`、`close`、`toggle`、`isOpen` 与 `openPanels`,由当前挂载的布局执行;Channel body 标题栏的 **Panels** 菜单与其他入口(例如侧栏快捷卡片)都走这组接口,菜单末尾是「重置布局」。布局只是浏览器本地呈现,保存在 `localStorage` 的 `botharness.bot-mode-layout`(版本 4),不经过 Host,因此没有 CLI 命令;插件自己的数据仍走它自己的 Service 与 Host Provider(#1789、#1791)。新工作区在控制列、旧侧栏回退在品牌行下方为每个已注册的 Dock panel(Channel body 除外)显示一张固定小卡片:卡片在面板打开时高亮,点击经 `toggle` 开关面板,Service 的 `available()` 告诉它当前是否挂载了布局。活动中心是内置的 `global` Dock panel(`id: 'activity-center'`),它的卡片取代原来的活动中心行并保留未读数与待处理红点;旧侧栏回退中没有挂载布局时,这张卡片打开完整的活动中心页面,其他卡片不可用(#1829)。 + +#2017 首片在符合 rc.2 AppFrame 结构的 Web/macOS 宽屏中启用工作区:一个不可关闭、可移到左/右/任意整列之间的 Workspace Control Rail(工作区控制列),以及独立的全局 Channel 列表面板(`bh:channels`)。列表沿用现有 Roster 与选中 Channel 的唯一状态。没有选中 Channel 时,控制列和全局面板仍可操作。`workspace-shell` 只对当前 AppFrame 标记后释放原生侧栏占位,临时将公开 `layout.toggleSidebar()` 命令接到 Channel 列表开关,退出、卸载或收窄时恢复原方法描述符并移除标记;不改原生 Store、宽度或折叠偏好。结构不匹配、命令无法接管或 Windows 标题栏布局使用旧侧栏回退。macOS 仅在红黄绿按钮覆盖的左上角避让,其他面板利用顶部空间,全屏取消避让。窗口拖拽与面板拖拽保持各自区域。 + +**两层布局(会话、记忆与定时任务已迁入,其余分阶段迁移)**:[ADR-0221](../adr/0221-global-and-channel-workspaces-own-panel-placement.md) 与 [#2048](https://github.com/BotHarness/DeepSeekBot/issues/2048) 在上述已交付的平铺布局之上定义 Global workspace → Messages panel → Channel workspace 的归属。#2049 首片把会话移入消息面板:沿用已有会话查询与快捷键命令,新增稳定 DOM 的四方向分割容器;版本 4 的原布局存储按稳定 Channel ID 保存开关、方向、比例和隐藏入口偏好,并保留升级前原始备份。旧 Work 迁移先执行;隐藏父面板保留子状态,会话快捷键恢复父子面板。最终 Control Rail 只管理全局面板和消息面板;频道子面板由消息 header 控制,右键该 header 以图标、名称和复选框管理开关显隐(隐藏开关同时关闭子面板,不设单独管理按钮),并在父面板内分割、移动、按频道保存比例与开关。隐藏父面板不清空子状态,也不改变执行或权限。群聊保留群资料和成员,不提供记忆文件、记忆演化、定时任务、Browser、Computer 或 PersonaBot Profile,即使会话显式查看成员 Bot 也不例外。共享的浏览器/电脑资源设置仍可属于全局;Bot 专属访问与视图不因物理资源共享而变成全局。 + +[#2049](https://github.com/BotHarness/DeepSeekBot/issues/2049) 实现首个可运行切片:仅把会话迁入消息面板,保留 ADR-0220 的 DM/群聊查询与访问校验,其他面板暂留外层。现有 Client 布局 owner 负责外层与每频道内层偏好、带备份的幂等旧布局迁移及首次使用种子;不新建 Host 数据权威。沿用原会话快捷键标识和原生绑定:父隐藏时恢复父子,父显示时仅切换子面板。正式目标只有两层,不引入递归工作区、标签页或浮窗。验收入口为真实 Web 升级旅程与 macOS Desktop,证据记录在该切片的 PR;其余子面板的迁移仍待后续反馈。 + +[#2067](https://github.com/BotHarness/DeepSeekBot/issues/2067) 将记忆文件、记忆演化和定时任务加入同一消息内布局:各分割容器保持稳定内容身份,分别保存方位与比例。方位由 Human 选择,消息面板变窄时不自动换轴或限制停靠方向;布局库的主题与拖放配置保持稳定,只在实际配置变化时更新,避免拖拽中的重排反馈。原布局 owner 的版本 5 在保留会话 v4 字段的同时,新增按 Bot DM Channel ID 保存的三个面板状态与首次使用种子;旧外层面板和 Rail 项移除前备份原记录。原快捷键标识与默认键(Primary+Alt+Shift+6 / E / 9)保持,父隐藏时恢复父子,群聊不响应 Bot 专属操作。右键消息 header 管理图标、名称、复选框形式的开关显隐;隐藏开关同时关闭子面板。所有变化仅涉及呈现,复用既有 Memory 和 Schedule 查询与权限 owner。[#2069](https://github.com/BotHarness/DeepSeekBot/issues/2069) 将浏览器/电脑加入同一 Bot 频道子面板集合:v6 单独备份旧记录并移除旧外层节点,保留 v5 记忆/定时任务状态;默认 Primary+Alt+Shift+B / 0 与自定义/解绑保持。未启用状态复用居中图标说明,启用仍使用原子面板 header 的显式电源操作;布局开闭不改变 Browser/Computer Access 或执行资源。 + +Control Rail switch visibility is a workspace preference (`hiddenControls`) in the existing versioned layout record. The rail context menu updates that preference and closes a hidden Panel through the existing Dock owner; checking it again restores the entry without opening content. A Panel header's Close action changes only open layout state. Explicit shortcuts can still toggle Panels independently of rail visibility. Tooltip keycaps project the effective native shortcut catalog, including customized bindings, and render outside the rail's clipping boundary. + +Application-defined pointer context menus share `ds/PointerMenu`: callers supply viewport coordinates, while the design-system component owns the body portal, measured coordinate conversion under CSS zoom, native frame clearance and viewport edge avoidance. Regular control-anchored dropdowns keep the native DSH Menu. This ownership prevents each Panel or Channel surface from inventing a separate offset. + +频道导航不会重建工作区和 Dock(ADR-0203)。`scoped-state.ts` 按选中频道及视图代次重置聊天临时状态和资源引用,并拒绝上一代次的异步回写;各频道草稿仍由现有草稿模块保存,Host 持久化不变。 + +布局版本 2 在同一浏览器记录中保存按容器尺寸恢复的比例几何、关闭面板的位置、开关顺序、群成员分割比例与开关,以及 0–16px 连续间距(默认 4px);版本 1 保留现有相对布局迁移。背景恢复原生 base 深色,面板采用 85% base/15% surface 混色和小圆角,仅略亮于背景;macOS 红黄绿区域同背景色,header/body 间隙为 `min(4, gap / 2)`。退回旧布局不会覆盖已有新工作区记录。消息正文在工作区中是可关闭的唯一「消息」面板;群成员从消息 header 展开为内部左右分割(20–60%),不再占全局开关。控制列按钮支持拖拽及 Alt+上下排序;选中时仅保留蓝色图标和底色,不显示边框。底部把手在悬停或键盘聚焦时出现,位于齿轮之上;工作区布局通过既有 Bot Settings section Slot 提供,Portal 保留当前 Dock 的设置动作,不创建第二份偏好存储。面板快捷键注册到 DSH Shortcuts Service,由原生键盘设置自定义,离开工作区或弹窗内不触发;不另建配置存储。Dockview 保持 Portal 内容常驻,控制列移动用 reuseExistingPanels 恢复布局,避免重新挂载;Bot 小人拖拽后的 click 被消费,不再误导航。完整平台矩阵及 Windows 原生 caption 留待后续验收。 + +项目显示偏好与排序共用浏览器本地 scope 和编辑事务:完成以一次偏好更新保存二者,取消一起丢弃,恢复默认布局按注册顺序显示全部可用 entry。隐藏项目仍列在编辑模式中,可通过显示按钮恢复;全部隐藏时设置入口仍可访问。正常模式不挂载隐藏项目的内容,但保留 header 的权限监听,让授权撤销继续清除禁止展开的状态;未注册 ID 保留显示偏好,新注册 entry 默认显示。隐藏不改变 Host 权限、执行或 Memory([#809](https://github.com/BotHarness/BotHarness/issues/809))。 + +显示设置二级菜单临时只展开对应 sidebar entry 供预览;连续选择保持菜单打开并立即更新原有显示偏好,关闭菜单恢复先前的展开呈现而不写入展开偏好,权限 gate 仍然生效;切换选择丢弃预览([#807](https://github.com/BotHarness/BotHarness/issues/807))。 + +Memory files 与 Memory evolution 的读取缓存仅属于 Client 的 Bridge action owner 和 Channel,最多保留 30 个 Channel;关闭/展开及后台刷新继续展示上次成功结果,首次成功之前使用 skeleton。成功空结果也是已加载状态;失败通过轻量提示与 Retry 恢复,重试中保留提示,成功后清除。切换 action owner/Channel 时重新挂载读取资源,旧请求不得更新新 scope;缓存不成为持久 Memory/Git 权威([#719](https://github.com/BotHarness/BotHarness/issues/719))。 + +Human–PersonaBot DM header 菜单或频道列表的「查看资料」直接打开 **PersonaBot Profile view**(ADR-0085,#2017 修订),临时替换消息面板内的 Chat 历史与 composer;返回或 Escape 恢复 Chat。从频道列表进入时先打开消息面板;资料导航在新 Dock 布局恢复后再次确保消息面板存在并激活,避免旧的关闭状态覆盖导航,沿用原布局位置。Profile popover 及其 client-local pinned-card 呈现状态已移除;所有可用的 **Profile Card** 直接在详情中展示,Display name 与 Avatar 仍在此编辑。Profile Card 继续通过有序、可增删、scope-aware 的 client 侧 Cordis registry 注册,Host 读模型不变。Group header 也直接进入 Group Profile view,保留有限数量的实时成员活动预览及 Group scope 卡片:Host 从该 Channel 已提交消息分页聚合近 26 周的每日消息数与按 author 分组的活跃度,经 Typert/API Gateway 交给 Client;Human/Bot 作者分别呈现,群管理仍在独立 Channel sidebar。DM Channel 只使用 PersonaBot Profile(#424)。自定义 Avatar 是有界 data URL 存在 owning Registry 的 SQLite 记录中(旧 `bot.json` 仅用于一次性迁入)(512×512 WebP、解码 ≤128 KiB、magic-byte 校验、拒绝远端 URL),经带缓存校验的认证读取 route 提供,缺失或损坏时确定性回退 identity-seeded Blobatar(ADR-0086)。创建 Bot 以及 Display name、岗位或 Avatar 变更后,Registry 把同一份呈现写进该 Bot Memory 的 `.botharness/bot.json`(像素配方、上传图片 `.botharness/avatar.*` 或 seeded 配方),只提交 `.botharness/`,其余键保留;Host 启动时为缺少该文件的 Bot 补齐。分享后 Bot 市场读取它,显示与侧栏一致(#966)。 + +DM composer 的固定 `/` 快捷动作与模型/工作区 chips 仅消费既有模型编辑器及 PersonaBot-scoped Workspace Grant 流程;目录预览是只读操作,授权确认显示 Bot、解析后的目录与访问范围,不暴露权限模式切换。快捷动作移除自己的输入片段,保留正文、引用与附件,不发送模型消息。图标加文字的模型和工作区数量居左,紧凑 token 用量居右。只读总量与 header Profile 使用同 Bot、同 26 周 activity snapshot 及缺失/部分数据语义,在已提交消息变化、打开 Profile 与有界轮询时刷新;从聊天 header 或频道列表「查看资料」直接进入消息面板内的 Profile view,不再经过资料浮层,也不再固定卡片到浮层;从频道列表进入时先开启消息面板。DM-scoped `profileSessionUsage` RPC 选择最新的所属 Orchestrator root 和最多五个最近更新的 Assignment roots,由既有 Usage owner 按需读取 native Session Query 日志,复用 token bucket 口径及继承前缀/所有权/删除校验,每个 root 最多纳入二十个 descendants;这些只读行不形成新的持久统计权威,部分和不可用日志明确标示。Window Companion 的显示/移除沿用原有 owner、生命周期与 Profile-local 偏好存储;入口保留在频道菜单,聊天 header 不再显示图钉,不新增权限权威(#1432、#2017)。 + +模型配置目标在 Profile 顶部提供紧凑的预设切换,详细的单 Bot 快照编辑置于活动图表下方的折叠区或弹窗;用量图表默认显示最近 7 天,可在现有 26 周查询范围内切换时间范围(#592);独立的长期累计与筛选查询由 #507 提供。Profile 的实际模型用量与配置的可用模型分开呈现,后者不冒充已经发生的调用(#488、#39)。 + +Computer 是 profile 级共享资源(ADR-0051、ADR-0082):Profile 的 **Computer Target** 在 Bot settings 中选择本机或容器,新 Bundle 默认本机,旧的无 target 配置保留 Docker 行为。Computer Provider 管理内部目标策略,官方 `ctx.computerUse` 的单一 Provider 注册不变(ADR-0079)。本机目标在 macOS、Windows 和 Linux 上都使用 Host 上按平台校验固定版本与 SHA-256 的 `cua-driver mcp --direct --embedded`,完全不探测 Docker;Human 显式点「检查权限」完成安装与就绪检查:macOS 读取辅助功能/屏幕录制权限(继承运行 DSH 的应用的 TCC 身份),Linux 需要可达的 X11/Wayland 显示(没有 DISPLAY 的服务器由本机目标启动私有的 Xvfb、D-Bus 会话和窗口管理器;DISPLAY 指向无桌面的裸 X 服务器(例如与 Bot Browser 共用的 Xvfb)时复用该显示,只补上窗口管理器和缺失的 D-Bus 会话;关闭驱动时一并停止),Windows 无需授权。Human 直接在自己的屏幕上完成登录。本机没有 Selkies viewer、归档迁移或空闲停机,手机通过 Host 的截图流查看并接管本机桌面(`/api/computer/frame` 长轮询驱动的 `get_desktop_state`,画面不变不重发;`/api/computer/input` 走桌面范围的点击、滚动、输入和按键;接管时先通过 `/api/computer/takeover` 暂停 Bot(拒绝所有 Bot 的 Computer 工具调用,包括截图;帧轮询和输入续租,手机离开 30 秒后自动失效),ADR-0210);容器仍使用 `docker exec` stdio MCP、Selkies viewer、持久卷与导出/导入。目标通过原生 configForms 持久化,volatile 更新关闭旧驱动并停止旧目标,清空工具目录与会话授权;等待 Human 的审批绑定目标版本,不能批准另一个目标的桌面操作。 + +手机远程控制容器 Computer 和容器 Browser 时可以走 WebRTC([ADR-0209](../adr/0209-selkies-webrtc-is-switched-on-per-phone-call-over-the-tailnet.md),[#1799](https://github.com/BotHarness/DeepSeekBot/issues/1799))。Host 有 tailnet 地址时,新建的容器默认仍用 WebSocket,另外配置好 WebRTC:ICE-lite、一个公布在 tailnet 地址上的 UDP 端口(Docker 只在 loopback 发布),以及空的 ICE 服务器列表。手机经设备网关打开信令路由后,Host 在 tailnet 地址上绑定该端口做转发,把容器切到 WebRTC;最后一个通话结束或设备被撤销时切回 WebSocket 并关闭端口。不使用 STUN 或 TURN;不可用时状态路由返回拒绝码,手机继续用 WebSocket 画面。 + +只有 Human 为某个 PersonaBot 打开 **Computer Access** 时,精选的观察/动作/验证工具与目标对应的指引才注入它的 Orchestrator 与 Assignment 会话作用域;每个会话的首次动作经 DSH 原生审批询问 Human 一次(profile 开关可自动允许),每次观察与动作都以脱敏的 **Computer Audit** 记入 `logs.db`(ADR-0080)。Computer 未准备好或权限被拒绝时工具返回可读错误,不威胁 Host 启动。 + +Browser 同样是 profile 级共享资源(ADR-0089):随 `deepseekbot` 默认安装的 `@botharness/browser` Bundle 运行受管的 **Bot Browser**(每个被分配的 browser profile 一个实例;默认共用一个 profile,命名 profile 按需启动、独立空闲停,ADR-0096)——优先复用机器上已装的 Chrome/Edge 加专属 profile(`$DSH_HOME/botharness/browser`),机器上没有可用浏览器时按需安装 version-pinned Chrome for Testing(`$DSH_HOME/botharness/browser-chromium`),CDP 端点收在 loopback,Human 可在窗口里登录一次。只有 Human 为某个 PersonaBot 打开 **Browser Access** 时,工具与指引才注入它的 Orchestrator 与 Assignment 会话作用域;只读的 `browser_open` 与 `browser_observe` 已交付,每个会话的首次动作走与 Computer 相同的原生审批(profile 开关可自动允许),每次观察与动作以脱敏 **Browser Audit** 记入 `logs.db`;每个 Bot 拥有自己的 **Bot Tab**(共享窗口里的后台标签;标签归属是可见性作用域,不是安全边界,ADR-0095)。Browser entry 已提供该 Bot 标签列表与焦点预览、`browser_screenshot`(只作为 model attachment,绝不进审计)与 Human **Browser Pause**(暂停该 Bot 的动作与模型截图,Human 始终可直接操作窗口);模型截图从入队到原生附件处理完成都检查该 Bot 的控制版本,Pause/Resume 或 profile 切换会拒绝未完成的旧截图;交互工具(click/type/press_key/scroll/wait)与多标签管理(`browser_tabs` list/open/select/close、后台标签、空闲收窗)已交付,ref 过期以"重新观察"错误收场;原生鼠标与键盘输入在 CDP Session 内准备焦点,不唤起前台窗口;导航与动作后的页面就绪检查要求相隔一个轮询间隔的两次完整状态,使用 15 秒截止时间,持续未就绪时返回错误而非成功,并要求先观察再决定是否重试可能已执行的动作。浏览器未运行时工具返回可读错误;profile 导出留待后续阶段。 + +Local Browser 的 `localDriver` 可显式选择默认 `current` 或试用 `agent-browser`([ADR-0124](../adr/0124-local-browser-drivers-share-host-authority.md)):两者共用 Browser Provider、专属持久 browser profile、Chrome 启停与 Human 窗口。候选仅在私有 IPC 内以锁定版本的原生进程连接这个受管 CDP 端点,按 profile 串行选择精确 target,ref 绑定当前 DOM 身份,绝不通过同名元素或相邻标签恢复。Browser Access、拥有者 Session、原生审批、Pause、Audit 和模型附件仍由原 Provider 管理;切换驱动撤销旧授权并等待旧实例清理,取消会停止候选与其 Chrome。Container 可独立选择 `containerDriver=current`(默认)或 `agent-browser`(#768):同一个 Host 原生候选进程通过现有专属 Container CDP 中继连接,转发原有已认证共享 Viewer;有界上传 staging、截图、Human 输入前 Pause 和全屏仍遵守原有权限。正常停止 Container 时先有界请求 Chrome 正常关闭,再清理已验证归属的桌面,以保留刚写入的 profile 数据;清理失败仍阻止替换。Stop 在清理前撤销共享该 profile 的全部 Bot 注册与授权;原生审批绑定具体 Session 注册,晚到的 Human 预览由 Bot 失效版本拦截。空闲标签关闭撤销对应 Bot,空闲 runtime 关闭撤销整个 profile;清理失败保留共享执行屏障,直到后续空闲清理重试或 Human Stop 成功。日常浏览器模式保留各自驱动。Local 与 Container 的真实 PersonaBot 对比结果分别记录在 #767、#768,默认值不变。 + +Managed Browser 观察在原有精确引用旁提供有界的非敏感字段值及适用控件状态。可选 Local 候选驱动保守压缩完整 AX 骨架并保留页面/弹窗语义;包含原始控件值时保持 AX 原文,避免误删多行值。两种表示均不改变 Browser 授权和引用身份 ([#787](https://github.com/BotHarness/BotHarness/issues/787)). + +Browser Target 通过原生 Profile 设置选择 Local、Container 或 Daily Browser(ADR-0114/0116),默认仍为 Local。Container 使用固定 digest 的官方 Chrome 镜像、资源上限、命名 profile 独立且带所有权标签的 volume 与内部 CDP,独立于 Computer。Browser 和 Computer 的独立 Client Bundle 编译复用 `packages/client/src/client/remote-viewer/` 的纯展示组件,无 Computer runtime 依赖:容器画面在侧栏默认只读,Human Open 展开同一连接的全屏 Viewer,复用接管、状态、缩放和收起交互。Browser 确认 Host Pause 后才允许输入;关闭操作或收起全屏保持 Pause,恢复 Bot 需要明确 Resume。共享 Viewer 全屏期间,侧栏 overlay 提升层级,避免侧栏开关遮挡全屏收起控件。切换 target、profile、viewer 地址或关闭 Access 均撤销当前 UI 输入权限。Target 切换撤销 Session 授权、待审批作用域、ref 与标签归属,等待 runtime 成功停止后才允许新动作;显式上传经有界文件复制,空闲停止保留数据,不挂载 Host 目录。日常浏览器的只读借用切片见下一段。 +本地 Browser 接管复用同一套 Viewer 家族([ADR-0163](../adr/0163-local-browser-takeover-reuses-computer-viewer-family.md),#1322):Host 以 `/botharness-browser/viewer/local/` 直供本地帧缓冲(截图轮询成流,CDP screencast 只留给 Bot 侧 observe/screenshot),Client 仍走共享 `remote-viewer`。点击、滚动与键盘输入经 Viewer 端点进入当前 Bot Tab(提交后的文本经 `Input.insertText`,IME 在 Human 设备上组字),输入要求 Pause;小视口默认触控板模式(滑动移动虚拟光标、轻点点击),可切直点。验证步骤走 ask-permission 接管:`browser_takeover` 先暂停并签发一次性链接(至多 10 分钟,带说明),Human 在 Viewer 横幅里 Done / Could not finish,Bot 在 `await` 里做服务端页面状态校验后 Resume(旧引用失效,必须重新观察)。键入的秘密只记字符数,审计(mint/accept/input/complete/expire 含完成原因)与会话录制只读、无删除口。 + +Daily Browser([#741](https://github.com/BotHarness/BotHarness/issues/741),[ADR-0116](../adr/0116-daily-browser-tabs-use-explicit-ephemeral-borrowing.md))通过 MV3 扩展显式借用一个 Human 标签页,仅注册 `browser_observe`,保留 Browser Access、可信 Session ownership、原生首次动作审批和既有审计。Browser Service 拥有进程内的一次性配对码、绑定扩展 Origin 的令牌、固定 tab/URL 的临时 lease 与 observation deadline;不建立持久授权库。认证 Client 创建 Bot 配对码;扩展经仅 loopback 的 WebServer prefix 兑换,先展示 Bot/当前页面,再经 Human 单独 Share。只有批准的观察才通过 activeTab/scripting 读取 main document 的有界可见文字和控件名称,不读输入值、Cookie 或其他标签页,URL/documentId 拒绝跨文档结果。侧栏显示当前只读借用与 Return;归还、任何导航/刷新、关闭标签、Access 撤销、Target 切换、Host 关闭或失联会拒绝 pending observation、撤销动作审批,重启不恢复借用;Human 标签保留。扩展只使用 loopback host permission、activeTab、scripting、session storage 和 alarms,不依赖 Computer 或启动 Human 默认 profile。[安装与操作指南](../daily-browser.zh.md)。 + +日常 Chrome 的明确控制是独立的 `daily-control` Target(ADR-0121,#766),不升级 `extension` 只读借用。现有 Browser Tool Provider 仍拥有 Access、可信 Session ownership、原生审批、Pause、Audit 和注册生命周期;独立 Subprocess 运行固定版本官方 Playwright 扩展连接器。Human 先在官方扩展选择一个已有页面,再在 Browser entry 单独允许控制该文档。Bot 只得到 observe、type 和 ref-based click;每次观察的 refs 绑定原始 DOM 节点,Resume 必须重新观察。连接捕获的 Page 不跟随标签组或其他页;主文档导航/刷新、关闭、断连、归还、Access 或 Target/profile 变化、Host 退出撤销进程内授权和在途结果,重启不恢复,Human 标签保留。Computer 授权独立;[安装与操作指南](../daily-browser.zh.md)。 + +显式 `profile-control` 通过第一方受限扩展持久配对一个 Human Chrome Profile;Browser Access 与原生 Session 审批分别生效。只能操作普通网页;配对哈希持久化,但引用、选择、待执行命令和 Session 授权不恢复。多个 Bot 操作串行;导航使引用失效,暂停等待已发出操作结束。见 [ADR-0123](../adr/0123-daily-chrome-profile-control-is-an-explicit-persistent-pairing.md)。 + +Browser entry 的 header Access 开关控制展开:关闭即折叠并锁定,开启在同一次交互中展开。平面标签列表置顶 Provider 当前 Bot Tab,并显示标题与 URL。跟随开启时预览 Bot 当前工作页;关闭时固定当前画面所对应的 target,直到 Human 选择另一个已归属标签。预览只读取 observation,不改变 Provider 的当前标签与 Agent 控制;Pause 仍是独立的 Host command。 + +Human 的「打开 Bot 浏览器」通过现有进程内 per-Bot 标签页 Provider:唤起仍存活且归属此 Bot 的预览页(或当前页),恢复最小化窗口;Human 预览其他页时不改变 Bot 当前页指针。已关闭的归属页被清理,优先复用仍存活的归属页,否则创建一个归属此 Bot 的空白 Human 标签页。重复打开与 Bot 操作共用 per-Bot 队列并复用该页,不唤起或登记共享浏览器 profile 中其他 Bot 的页。Human 前台聚焦与 Agent 后台操作保持独立。 + +临时关闭 Browser Access 会撤销 Agent Scope 工具注册并取消该注册的在途调用;正在等待的调用及时结束,已发出的 CDP 操作等待自身完成后返回撤销错误,队列中的旧调用即使权限重新开启也不会执行。调用者取消信号同样保留,不放弃底层尚未完成的操作;Provider 的进程内标签归属与当前页指针保留,重新开启后工具可继续使用原工作页。显式停止/重置会清空这些记录;此连续性限于同一个 browser profile,标签归属不跨 Host 重启持久化。 + +修改 browser profile 分配时,Core 通过应用定义的 Host Service 调用现有 Browser Provider reset command。Client Profile combobox 合并 PersonaBot record 中已分配的名称与 Browser observation 返回的已存 profile 目录名;目录读取不创建 runtime、不跟随符号链接,也不另建目录清单存储。输入已有名称可选择,新名称通过明确的创建项分配;失焦与 Escape 不保存。Profile 名称先在既有 Host 保存接口验证;保留路径段 `.` 和 `..` 在写入 PersonaBot record 与调用 reset 前被拒绝,当前标签与 Pause 不变。runtime 对已存无效名称继续回退默认 profile,正常名称中的点仍被允许。切换的 Bot 在使用新 runtime 前清空旧的进程内当前页/标签归属记录与 Pause 状态;Browser Access 与 Session 授权仍各自独立。其他 Bot 的标签归属与旧 profile 的浏览器数据保留。重置记录有界生命周期诊断,切回原 profile 时不持久化或重新登记旧 target。 + +同一 browser profile 的并发首次调用等待同一次 Browser 启动;启动失败后可重新尝试,不生成第二个实例。Human 关闭当前 Bot Tab 后,下一次工具调用清除失效的进程内归属与当前页,并提示使用 `browser_tabs list` 选择另一页或 `browser_open` 恢复(#463)。 + +`browser_upload` 使用 Host 文件向当前 Bot Tab 附件字段挂载文件:文件 input 的 ref 精确选择该字段,上传按钮的 ref 在 CDP Session 内拦截原生 chooser,并使用实际 `Page.fileChooserOpened.backendNodeId`,不会猜测其他附件字段;省略 ref 使用第一个 `input[type=file]`。chooser 监听按 CDP Session 隔离,超时或失败后解除监听与拦截;文件不存在、无输入或按钮未打开 chooser 均返回可读错误。Browser Audit 只记录 ref、文件 basename 与大小,失败摘要隐藏传入的完整 Host 路径;发布前仍须 Human 明确确认。 + +Browser Tool Provider 在同一调用完成路径中记录成功与失败,包含授权、Access、Pause 及 Resume 后重新观察检查的即时拒绝;队列入口拒绝与运行失败仍各只记录一次。Browser Audit 使用注册时的 Bot、Session 及可信 Session ownership 的角色归属,写入现有 `logs.db`;输入只记录字符数、上传只记录文件名,不另建日志存储或读取接口。 + +Pause/Resume 在现有进程内 Provider 状态中失效该 Bot 的可操作观察。页面点击(ref 或坐标)、输入、按键、滚动及上传,必须先完成一次在当前控制状态变更后开始、且 Pause 未开启时成功返回的观察;读取失败、Pause 期间的读取以及旧的在途读取均不能满足此要求,截图也不能替代观察。Open 与标签管理保留以恢复缺失页面;Access 开关保留重新观察要求,显式 Profile 重置则重新开始标签归属。其他 Bot 各自独立。 + +```mermaid +flowchart LR + Inbox["Bot Inbox / Attention"] --> O["One active Orchestrator Session"] + O -->|"list / inspect"| Dir["Durable Assignment Directory"] + O -->|"create_assignment"| Gate{"Global active Assignments < limit?
default 8"} + Gate -- "no" --> Error["Structured + LLM-readable failure
no queue, no intent"] + Gate -- "yes" --> Runtime["Assignment Runtime"] + O -->|"send_assignment_request / stop_assignment"| Runtime + Runtime <--> W1["Independent Assignment Session A"] + Runtime <--> W2["Independent Assignment Session B"] + W1 -->|"report_to_orchestrator"| Report["Assignment Report Source Event"] + W1 -->|"completed Turn paired with this Report"| Completion["Host Completion Notice
paired completion #194"] + W2 -->|"confirmed stop / native cancellation / native error / Host recovery"| Notice["Host Lifecycle Notice
stop / cancellation / error #194
Host recovery: unconfirmed outcome"] + Report --> Inbox + Completion --> Inbox + Notice --> Inbox + W1 -.-> Sub["DSH Subagents
aggregate-only"] +``` + +Assignment Session 是 DSH independent root,以 DSH `sessionId` 为 canonical identity;Continuity Key 只是 PersonaBot-local alias。Orchestrator 通过六个工具 `list_assignments`、`inspect_assignment`、`create_assignment`、`send_assignment_request`、`wait_for_assignment`、`stop_assignment` 管理它们。Assignment Agent 只能用 `report_to_orchestrator` 向 Orchestrator 回报;其 Agent Scope 没有 Channel send capability,普通 final 也不会写入 Channel。v1 没有 Assignment-to-Assignment 直连、广播或等待队列。 + +带地址的答复只在原生 Inbox 接收后清除所捕获的原问题;pending followup 不等于投递成功。可证的准备失败保留问题、权限/模型快照和空闲重试路径;原生投递结果不明或重启保持修复可见、不重放。仅收到 Human DM 不隐藏未解除待办或生成已处理历史;可证失败后可在原来源明确重试。旧答复不能清除新问题(#812)。 + +Assignment Runtime 的并发上限覆盖整个 Host 的所有 PersonaBot(默认 8),同时约束新建、按 Session 恢复空闲事项和按 Continuity Key 复用。恢复前先同步占用原有 Assignment Directory 的 working 名额,再交给 DSH;运行中的事项接收更新不增加名额。满额时返回 `assignment-capacity`、当前数量、上限和可重试标记,不启动执行、不清除待答问题、不改变模型或权限快照。事项停止确认前仍占名额;释放名额后,Orchestrator 可重试同一 Session,不引入等待队列(#811)。 + +Orchestrator 通过 `archive_assignment` / `unarchive_assignment` 显式归档和恢复 Assignment(ADR-0188)。归档调用 DSH Workspace Registry 原生的 `archiveSession`,不带 `stopActivity`:事项仍在执行、停止中或等待 Inbox 接收时直接拒绝(`assignment-active`),由 Orchestrator 先停止再归档。成功后 Assignment Directory 记录 `archived_at` 作为原生归档集合的镜像,并清除 Continuity Key;归档进行中和归档后的 Assignment Request 都会被拒绝。不会自动归档。`list_assignments` 支持文本搜索(用途、Continuity Key、最新报告)、活动筛选、`include_archived` 和 opaque cursor 分页,默认不含已归档事项。 + +哪些 event 进入 Bot Inbox、哪些唤醒 Orchestrator 仍由现有 wake 与 source policy 决定;被成功回合观察到的 Inbox Admission 记录 `opened_at` 并保持 open,不再按是否 @ Bot 或来源类型另设规则。`inbox_resolve` 在同一 Admission 上写入 resolution、note 与 Orchestrator Session,按 id 或过滤条件批量处理;过滤条件先返回数量、样例与基于命中集合的确认 token,只有带同一 token 的重复调用才落库。`inbox_list` 按 Channel 对话或 Assignment 分组列出 open 条目,每轮的 `[Open Inbox]` 块只给出数量与前几条一行摘要。attempt state 仍是执行事实,open 由这些列派生,不改写历史已处理记录。`inbox_snooze` 在同一 Admission 上记录 `snoozed_until`、`snoozed_at` 与 note;同一 Channel 对话或 Assignment 的更新 Source Event 被 Bot 实际观察后才提前返回;未唤醒 Bot 的普通群消息不能取消到期唤醒,再次 snooze 不会提前。Host 在启动和每次 snooze 后为每个 Bot 设一个定时器,到期时以最早返回的条目为锚运行一次 Orchestrator 回合,列出返回条目与 note;失败则延后五分钟重试。每次成功回合后若仍有未 snooze 的 open 条目,Host 排入一次 `[Triage]` 回合;某一轮没有让 open 数量下降就停止,直到出现不在这批停滞条目中的新 open 条目才再次触发(ADR-0188)。 + +#1037 有界切片只在无后代、无执行中工具且无已批准待执行调用的 Assignment 等待 Human 审批时释放 live 运行名额。持久 activity 仍为 working,process-local executionWait 区分 waiting-human 与 waiting-capacity;最多 32 个等待中的 Assignment Session 使用独立上限。Human 决定提交后,原生 outcome 或下一模型 Step 必须先取得当前运行名额,实际工具执行前再次检查归属、Grant、原 Source Event 与准确操作。新建和空闲恢复仍在满额时立即拒绝;中止、停止与冷恢复均不重放原调用。同一 Orchestrator 的权限 continuation 与群聊隐私仍单独保持 gate。见 [ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md#live-approval-waits-and-running-permits)。 + +Human 在 Bot 模式设置中将此 Profile 级上限调整为 1–32。DSH 原生 Settings schema 的 Volatile field 由 Profile Config Editor 持久化;UI Plugin 的 Host Fiber 将 live reader 绑定到 application-defined Assignment Runtime,并在 dispose 时释放绑定。Client 只展示 Host 确认的保存值;Runtime 在每次新建或恢复空闲事项的准入时读取当前值。保存后立即影响后续准入,重启后保留;降低上限不中止已有执行,直到使用量低于新上限才允许启动新工作(#825)。 + +待处理 Workspace Grant 请求通过同一持久 Human-action 查询进入独立 Activity attention。只有经过校验的 Grant 关联 Human 回复、Inbox 忽略或来源 DM 移除才清除计数;普通授权文字不会清除。重启从已提交 action 重建计数,不伪造执行状态。 + +报告超过 2 KiB 时,Assignment Runtime 在提交 Inbox Admission 前把完整文本交给 DSH Spill Service 保存,并只将前缀预览、字节数、不透明 locator 与 provider 的检索提示写入 Assignment Directory、Source Event 和 Bot Inbox;单份报告上限为 1 MiB。Orchestrator 的 `inspect_assignment` 可通过 DSH Session Query 读取该事项最近四条原生日志事件(最多 12,000 个字符),也可指定 `report_offset` 从已接收报告的原生 `tool/call` 事件每次读取至多 2,000 个字符,并通过 `nextOffset` 继续;两种读取均先核实事项归属,返回查询条数、实际读取量与估算 token 成本。Spill Service 仅负责保存且 locator 对消费者不透明;Orchestrator 即使不能直接读取本地 Spill 路径,也能通过 Session Query 找回该报告,不额外建立一份全文报告数据库。 + +Agent 可自行选择发送内联报告,或先写入其工作区文件并报告路径;超长内联报告由 Host 自动处理,不触发额外提问。 + +当前 `stop_assignment` 通过 DSH `Agent.cancel({ kind: "user" })` 取消活动回合并清空待执行输入;BotHarness 先持久记录「停止中」,待 Agent 静止后在同一事务里记录「已停止」和 Host 来源的 Lifecycle Notice。通知进入该 PersonaBot 的 Bot Inbox,Orchestrator 可在私聊报告停止结果;未观察的通知在 Host 重启后恢复,已交给 Orchestrator 但在运行中中断的通知标为 needs-repair,以免重复执行不确定的副作用。停止的事项不再接收请求或迟到报告,其 Continuity Key 可用于新 Session;取消不销毁 DSH Session 历史。成功完成路径读取可信 DSH `turn/start` 的编号并记录在独立 Assignment Report 的 payload 中;原生 `turn/end` 确认成功后,Host 仅为同一所属 Session、同一 Turn 的 completed Report 追加一条幂等完成通知,保存该报告 Source Event ID 与结束 seq。报告保持 Bot 来源,通知保持 Host/system 来源;Bot Inbox 只读查询返回 Turn 和关联报告 ID。配对通知搭乘报告触发的收割或下一次真实 Turn,不单独唤醒,也不因报告已处理而伪造通知已观察/已处理;重启仍保持这一规则,正文同时保留报告语义与 Host 完成事实及各自来源引用([ADR-0077](../adr/0077-turn-time-harvest-consumes-the-ready-attention-set.md))。progress-only Turn 不产生完成配对通知。Human 通过 DSH 原生 Session 控制取消活动 Assignment 时,adapter 仅从已提交 reason=aborted 的 turn/end 读取可信 Turn/end seq;Runtime 在同一事务内投影 error、释放 Continuity Key,并按所属 Session/Turn/native-turn-aborted cause 幂等写入 Host/system Lifecycle Notice 与 Inbox Admission。它沿用即时收割,保留原 Report,不伪造成功配对或自动重试;通知与原报告分别可导航,已处理通知重启不重放。Orchestrator stop 路径抑制原生取消回调,继续只发已确认 stop 的通知([ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md))。已提交的原生 error Turn 沿用同一失败结算事务,以 failed/native-turn-error 区分来源;adapter 只传可信 Turn/end 身份,不把原始错误写入通知。Runtime 释放 Continuity Key,保留进度 Report,独立即时收割一次,不生成成功配对、自动重试或替代 Assignment;已处理事实冷重启不重放。Host 启动时把仍为 working/running 的 Directory 行在同一事务内转为 error、释放 Continuity Key,并沿已保存来源策略纳入一条 System interrupted/host-recovery 通知。正文说明旧执行结果未确认,不推断原生 Turn/end 身份或报告配对;保留原报告,既有收割可唤醒 Orchestrator 一次,但不自动恢复 Assignment。状态转换和已处理事实再冷重启不重复通知或收割。准入前失败仍是独立边界,#194 尚待完成的视觉验收记录在 issue。 + +Assignment Request 的 `context-update`、`next-step`、`next-turn` 分别映射到经过验证的 DSH inject、steer、followup seam;普通请求不 cancel 当前 step。跨 SQLite/DSH 边界只保留最小 Assignment Delivery Intent,重启时有界 reconciliation;歧义进入 `needs-repair`,不扩张为通用 workflow engine。 + +统一 **Sessions panel** 保留 `sessions` 身份并退役 `group-work`([ADR-0220](../adr/0220-one-sessions-panel-with-explicit-bot-or-channel-scope.md),#2034)。Header 的范围选择是 Client 本地偏好:DM 固定当前 Bot,Group 默认当前频道,或由 Human 明确选择可访问的成员 Bot。Bot 范围复用 `sessions(slug)` 与原生 Session catalog;群内选择 Bot 使用面板本地查询结果,不覆盖当前 conversation 的共享 Sessions 状态。频道范围复用 `channelAssignments(channelId)` 的 Host 成员权限与原始 Source Event 归属检查,最多 200 条;达到上限显示截断提示。两种范围复用 Session 行状态投影并打开同一原生 Session ID;报告状态单独显示,空闲不等于完成。迟到响应在范围卸载后丢弃,失效 Bot 选择清除并回退频道范围。会话面板默认跟随当前频道时不重复显示范围标签;群聊范围选择收进视图选项菜单,仅显式指定 Bot 时在标题栏显示 `Bot · 名称`。 + +布局 v1/v2 升到 v3 前保留一次本地备份 `botharness.bot-mode-layout.before-sessions-v3`。已有 Sessions 位置优先;只打开 Work 时原位替换;两者关闭时不自动打开,并重写停放邻居和控制列偏好。快捷键通过 DSH 原生 revision-checked 配置接口迁移有效且无冲突的 Work 自定义绑定,保留 Sessions 显式覆盖(包括解绑)。冲突或失败指向快捷键设置;旧 Work 命令不再注册,原生接口不允许删除未注册命令的覆盖,因此保留其休眠配置以便回退。迁移完成标记防止用户后续重置 Sessions 时再次导入。旧版本回退需要恢复布局备份;Host 数据与 Session 生命周期无迁移。 + +### 5.1 · PersonaBot activity、事件与 renderer + +Web 的选中 Channel、Roster 与 Activity 消费在每个文档内共用一个受认证的 EventSource;既有 `/api/botharness/stream` 在选中 Channel 时携带 `channelId`/`after` 与 `roster=1`/`activity=1`,从 owning Channel live hub 组合原有 Channel revision 流、roster 通知、撤销基线与同一 Activity Projection 的有界快照。没有选中 Channel 时复用 roster/Activity 流。各逻辑消费者独立清理,最后一个离开时关闭物理流;Channel 选择变化调整查询,替换构造失败保留健康旧流,Host 合成 adapter 断开时取消两个原流读取者;重连错误清除可回放 Activity 基线,迟到消费者保持 stale 与 HTTP 恢复直到新快照到达。慢消费者只保留当前 Activity 更新,背压时跳过心跳;Channel revision/回放和 Owner 认证不改变。这是过程内传输复用,不创建新的 Activity 或内容权威([#1451](https://github.com/BotHarness/DeepSeekBot/issues/1451))。 + +DSH 失败的 `turn/end` 仍是执行事实权威;BotHarness 在所属 Orchestrator 或 Assignment 回合结束并判定失败后,由 Host 向对应 PersonaBot DM 提交一条带 role、Session ID、错误码、可用 HTTP status、简短错误详情和事项上下文的 application-defined failure notice。该消息是面向 Human 的持久通知,不把 SessionEvent 全文或工具日志复制成第二套执行历史;Client 把它渲染成可读的失败卡,刷新后从 Channel authority 恢复。Orchestrator 的 Human Source Event 仍依原有 retry/reconciliation 语义处理,失败通知不等于完成该 Source Event。 + +DSH SessionEvent 是 durable execution authority;BotHarness 不复制 tool call 或 assistant output 为第二套 Session fact。explicit Session Ownership 把 Session 归属到 PersonaBot 及 `orchestrator` / `assignment` root role,PersonaBot module 再把这些事实与 live liveness 折叠成一个可重建的 Activity Projection。Browser 首先经 Typert/API Gateway 查询 projection,随后消费带单调 revision 的 live update;revision 断档时重新查询,而不是由 Client 自己推导状态。 + +每个页面共用的 Channel/通讯录/Activity 实时流按 ADR-0181 由原生 Remote 流承载:Host 的 `botharness/liveFrames` 流式 Remote 方法打开既有 live hub(与 `/api/botharness/stream` 相同的查询、回放与拒绝语义),把它的事件帧逐条作为流项产出;Client 挂载对应 contribution,以 EventSource 形状的适配器经 Gateway 流监督器消费,断线后携带最后的事件 ID 续接,Host 拒绝(未知 Channel、修订超前、参数非法)时进入关闭态。过渡期的事件帧隧道 `botharness/liveFrames` 已在 #1502 移除;Remote 无法挂载流方法时仍回退到原 SSE 路由。通讯录与 Activity 进一步各用一条带类型的流(`botharness/followRoster`、`botharness/followActivity`):每代先发完整基线再发增量,Client 经官方 `RemoteSnapshotStream` 消费,帧流只剩所选 Channel(#1498)。所选 Channel 随后也有自己的带类型流 `botharness/followChannel(channelId, after)`:`opened` 给出本代起始修订号,`entry` 按修订号顺序给出已提交消息,`notification` 承载回执、已读位置与草稿预览;Host 的 Channel 订阅是唯一的帧源,流方法与 SSE 路由只是两种编码,Client 的 Channel 消费逻辑与传输无关,时间线分页仍按 ADR-0061(#1500)。按 ADR-0179,窗口贴着最新消息时由官方 `RemoteJournalStream` 负责:每代以游标加最新一页开场,条目必须紧接上一修订号,缺口由 `channelJournalPage` 读取限定修订号的一页修复,向上翻页经官方类的 prepend;Client 把整体替换合并进已显示的窗口。定位在已读位置或旧消息附近的窗口仍走 `channelTimeline`(#1503)。Window Companion 的订阅也改为带类型的流 `botharness/followCompanion`:Host 把既有 feed(租约、恢复与背压不变)投影为 baseline、selection、activity、message 帧,Client 经 `RemoteSnapshotStream` 消费,监督器重开时带上最后的 consumer 标识;订阅更新仍是独立的 POST。此后每个页面只剩原生 HMR 一条长连接(#1501)。 + +PersonaBot 活动通过公开 `activitySnapshot` 查询与认证 `scope=activity` SSE stream 交付同一份 Host 拥有的快照(`generation`、单调 `revision`、每个 Bot 的聚合状态)。每次连接先发送完整 baseline,实际聚合状态改变后推送完整快照;重连无需重放第二套活动历史。Client 原子地向置顶、普通行、rail 头像及 DM 输入框应用同一快照,拒绝同 generation 内的旧 revision,并防止过期 roster 响应覆盖当前活动。退出 Bot mode 或隐藏页面时关闭活动连接。 Activity Client 在进入时查询 baseline,修订跳号时重新查询。stream 失败或不可用时,每 10 秒最多一次重试并查询既有 `activitySnapshot` 权威,单次查询时限 5 秒;收到有效 live frame 后停止备用刷新。慢 Activity stream 只保留一个排队的完整快照,后续变化合并到最新快照,不重放原始执行事实。退出 Bot mode、隐藏页面或 Plugin disposal 会取消定时器、请求和订阅;旧 Host generation 的迟到响应不会覆盖新的 live baseline(#121)。执行状态由显式 Session Ownership 和现有 DSH SessionEvent Projection 提供,不来自 roster 轮询或本地发送标记(#120/#536)。 Channel stream 建立连接时还补发已见 cursor 前最多 100 条消息的当前回执,补足 HTTP 快照与实时连接之间的窗口;不引入轮询或第二套 Admission 权威。 + +application-defined `botharness/personabot/activity` Cordis Event 在 projection 改变后以 `emit` 发出,供 Host 内的 Live2D、3D 或其他 Plugin 同步。Orchestrator 活跃时负责呈现;当它明确 `waiting-on-assignment` 时,活动来源切换为 Assignment:同类 tool kind 使用对应 effect,多类并行回退到通用 `working`。waiting、blocked、approval 与 informational attention 单独投影,不进入可配置 priority。 + +PersonaBot Output Committed 已由 canonical Channel writer 的成功提交回调提供:可信 Runtime 传入本次调用的 Session/Source origin,Host 校验显式归属后发布应用定义的 `botharness/personabot/output-committed`。载荷是冻结的公开正文/格式与 PersonaBot、Session、Channel、消息、修订及回复/来源引用白名单,不复制工具/审批卡或私有上下文;origin 不持久化、不进入 SSE。重复/冲突、回滚、模型增量和重启重建不通知。当前 Cordis emit 不隔离 callback 错误,因此同一 EventsService 的 emit dispatch 逐消费者安全调用,Promise 不等待且失败仅记录固定诊断;Consumer Fiber 销毁自动清理,不另建 listener registry、日志权威或 browser API。订阅方法见 [Output guide](../dev/guides/personabot-output.md)。 + +Tool activity notification 只广播 `toolKind`、可选 `toolName`、SessionEvent reference 与 Tool 显式声明的 `publicDetail`;完整 arguments/result 由受控 Capability 按引用读取。Channel output commit 后另发 PersonaBot output notification,TTS 与说话动画消费该 public output,而不是任意 Tool 参数或尚未提交的草稿。 当前 `publicDetail` 是 Tool Provider 在其实际注册的 DSH Tool Definition 上通过应用定义 `withPublicToolDetail` 显式提供的同步声明;Host 不从原生 call-card title/rawInput 推断公开文本。文本限制为 160 字符的非空单行,控制字符与双向格式符拒绝,失效或抛错时省略;并发声明不一致时省略,已有 revisioned Activity 与认证 Gateway 传递同一字段。浏览器标签页 Provider 只返回固定操作文案,不公开 URL、标题或 tab identity。 + +工具 Activity 的首个切片(#122)由待执行 `tool/call` 与配对的 `tool/result` 投影事实,经 Agent Scope 内已注册工具的 presenter 解析类别。Activity 快照只保留有界类别与已注册名称;presenter 标题、原始输入、路径、diff、参数和结果均留在原生 Session。并发同类保留效果,不同类别回退通用工作;一个结果不会隐藏尚未结束的调用。每次投影变更先提交一个 revision,再发布应用定义的 process-local `botharness/personabot/activity` Cordis 通知与完整 SSE 快照,同状态的工具变化也会通知。侧栏 hover/focus 与输入框读取同一安全摘要,键盘展开在每个活跃 Session 的独立卡片内显示最新安全活动,展开区不重复 Bot 名称;既有 motion 偏好控制相同效果。最新记录由同一 Host 投影拥有,携带进程内 opaque 显示 key、可信角色、可选的原生 `session/title` 名称、Host 观察时间与 revision,经 snapshot/SSE 同步;名称最长 1,024 字符,拒绝控制或双向格式字符,创建、改名和重建都读取相同的已提交标题事实。输入框展开区是透明浮层,只有全宽 Session 卡片保留背景;桌面名称与状态同行,窄屏换行,Assignment 使用原生名称并省略过长文字。按实测展开高度给最新消息留出空间,避免遮住审批操作,较早历史仍可滚到透明区域后方。同角色 Session 仍分别显示,完成或销毁后移出,idle 时不留记录,Host 重启后原子重建当前基线。显示 key 不是原始 Session ID,也不是完整详情查询授权;不保留八条历史,不持久化或回放 Session transcript,不包含参数、结果或 reasoning。授权 opaque 完整详情通过 Host-only `botharnessActivityDetails.read(reference)` Capability 读取,角色优先级聚合仍归 #123。 当前执行来源由已记录的 Session Ownership 的 root role 与 Subagent provenance 推导,Activity 聚合字段公开主会话、任务会话、子代理三个类别及各类正在执行工具的会话数,展开区的 per-Session 最新记录另以 opaque key 区分各个活跃 Session;同一会话多个工具只计一个来源。角色分布通过现有 revisioned snapshot、Cordis 通知和相同安全摘要交付,原始 Session 标识、路径和工具数据不会进入该来源字段。该来源信息不改变当前状态或效果选择,角色优先级仍由 #123 完成。 + +活动中心总览读取与侧栏/消息框相同的 Host Tracker 执行选择及有界工具摘要。原生问题/审批卡片和其他 Human 待办保持独立的 `hasAction`/待办数投影,不改写 SessionEvents,也不以 waiting/blocked 覆盖执行。活跃归属根 Session 卡片沿用原生执行事实和运行中根过滤。原生问题的 live owner 在请求卡提交后向同一 Tracker 发布 `questionCount`,与 `approvalCount` 分别保留;Avatar 数字显示两者之和,悬浮说明区分来源。回答、取消、Agent dispose 和 Plugin close 从 owning pending map 清除,Host 重启不重放历史待办。Assignment waiting-human/blocked 待办复用 Human Inbox 的同一权威查询、回复/隐藏/停止条件,事务提交后派生到 Tracker 的 `waitingHumanCount`/`blockedCount`,不把报告状态写成 Session 执行状态;冷启动重建未解决的持久化报告。所有计数使用同一个 Avatar 数字和独立本地化说明。application-defined `wait_for_assignment` Tool 通过 Runtime 等待已提交报告或执行结束,使用原生 Tool AbortSignal,限时 1–120 秒。当 Orchestrator 的全部活跃 Tool 调用都属于明确等待操作时,共享执行选择呈现所属 Assignment;若 Orchestrator 仍有其他工作则保持优先。等待 lease 为 process-local,由报告、完成、取消、超时或 Runtime close 释放,不从历史 Tool 名字推断或重放。其他共享 attention 仍属于 #123 后续切片。 + +Client 通过一个 Avatar module 在侧栏行、响应式 Pin Grid、消息、顶部和 composer activity row 中呈现同一 projection。默认 Blobatar media 可在 thinking/working 时运动;自定义图片保持静止,由外层 Activity Frame 表达状态;group Channel 可用最多三个头像与 `+N` 的 facepile。Human Inbox 则统一投影 Channel Attention 与 PersonaBot Attention,并按 action-required / informational 分类;#546 从持久 Channel placement 与按 Human 身份保存的 read position 投影未读数;同一 Source Event 只计一次,入口另示待行动提示。 + +Tool detail Capability 默认拒绝所有 Consumer。部署 Human 在 `botharness-core.activityDetailConsumers` 显式列出受信任的 Host Plugin runtime name;Cordis Service caller Context 提供实际调用方 Fiber,只有处于 ACTIVE 的允许 Consumer 才可读取。它是受信任 Host Plugin 之间的部署授权边界,不是同进程恶意代码沙箱。服务不注册 Browser RPC 或 Fetch 详情端点;opaque reference 本身不授予权限,也不是 Session 显示 key。每次读取审计 Consumer、结果与字节数,不记录 reference、原生 Session ID、参数或结果。 + +共享状态与 Tool 摘要优先选择活跃 Orchestrator Session,安全的展开列表仍保留双方 Session;Orchestrator 结束后恢复展示剩余已归属 Session 的活动。#123 首片不推断等待标记或建立新的 attention 生命周期;明确等待现由 Runtime 拥有的有界操作提供;其他正交 attention 仍属后续契约。 + +引用表只保存指向原生 SessionEvent 的 process-local locator,最多 256 项,有效期五分钟;请求时校验当前归属及归属模块的进程内单调 repair revision,再从存活 Session 的 canonical snapshot 读取精确 Tool arguments 与配对 result(包括原生 meta),返回独立 JSON 副本,完整数据超过 64 KiB 时拒绝。归属改变、原生事实消失、Turn 边界、Session disposal、projection rebuild 或 Host disposal 均使旧引用失效;无持久 payload 副本或 replay。已完成 Tool 的 Consumer 可在当前 Turn 内使用此前收到的引用读取结果,Activity 则继续只显示当前待执行工具。 + +### 5.2 · 可编辑 Avatar(已接受设计,待交付) + +[ADR-0118](../adr/0118-editable-avatar-appearance-is-independent-of-activity.md) 记录 Human 已确认的设计目标:人物插画与抽象小角色两个 Avatar Family 共用真实 Bot-state 语义与过渡规则,各自适配部件和姿态。保存的有版本 Avatar Appearance 配方归 PersonaBot owning module;执行与 Human attention 消费现有 owning projection;逐帧姿态和过渡时间归 Client renderer,不写 SessionEvent、不改保存造型,也不另建状态聚合。首版编辑目标包含细分发件与有界五官几何参数;抽象角色可短暂变成有界点阵/符号,配件暂时收起、稳定姿态恢复时完整回来;小头像的动作更短、更克制,大形象更丰富。保存配方与派生静态快照;缺少兼容部件/协议版本时保留配方、显示原外形快照,并明确暂不可编辑/播放角色动画,独立活动提示继续消费真实事实。候选渲染路线是现有 React Client 内的受控 SVG、稳定节点与有界姿态采样,不预先引入新动效依赖。首个切片先验证编辑、保存、重读、真实工作动作与独立 attention(依赖 #123 owner 已交付的共享合同),第二家族复用语义合同;完成/错误动作后续另定结果作用域。shared native Human attention 继续由 #123 owner 提供,explicit waiting-on-Assignment 不由头像推断。 + +[#751](https://github.com/BotHarness/BotHarness/issues/751) 的首条人物路径使用 Registry 的 `setAppearance`:受控、严格版本化的部件与十六进制颜色配方和 Host 由同一 SVG 派生的 512×512 PNG 在一次原子写入内保存,SHA-256 revision 绑定配方与快照。DTO 只携带配方/revision 与既有快照 URL;图片覆盖或移除会清除 composed 配方。Client 的 Profile 草稿在 Save 前不写入,侧栏和大图消费同一保存配方;稳定 SVG 节点上的 Web Animations 只持有有界局部姿态,真实 activity 切换会重新定向,减少动画、隐藏、离屏和卸载取消资源。Host 光栅化依赖仅在保存时执行,不进入 Client Bundle;第二家族、细分发件/五官和缺版本兼容回退仍由后续切片验收。 + +### 5.3 · 窗口伙伴(已接受设计,待实现) + +[ADR-0144](../adr/0144-window-companions-consume-owned-activity-and-scoped-output.md) 与 [规格 #1135](https://github.com/BotHarness/BotHarness/issues/1135) 记录 Q1–Q23 共识。Human 主动从 Bot 行右键或 Channel header 的具体 Bot 入口选择“显示为窗口伙伴”,与 Channel 置顶独立;通过官方 RC 的 shell overlay Slot 跨 Harness 页面显示既有半身 Avatar。Client companion owner 持有当前 Client × DSH Profile 的选择、每 Bot 来源/范围/漫游偏好,以及所有伙伴共享的卡片层数/保留数量设置;不更改 Registry 的身份/外观、Memory 或 Channel pin。伙伴像素视图省略画布底色与方形头像框背景,呈现透明人物轮廓;既有侧栏头像与保存配方不变,透明区域仍保留可用 hit area 和独立 attention。图片/缺失 rig 快照不自动抠图。底部漫游、拖拽、受窗口边界约束的位置和锚定像素气泡属于呈现层,逐帧姿态不跨 RPC。 + +消费路径为 owning Registry/Activity Projection/canonical Channel → 认证 Typert/API Gateway 与有界 live/query adapter → 独立 companion Client owner → 既有 Avatar 与卡片。当前 Activity Client 退出 Bot mode 会关闭消费,当前 SSE 只有单 Channel 输出流;伙伴的独立生命周期和按 Bot 跨 Channel 输出合同是待实现工作,不由现有页面组件顺便提供。Host 内其他 Plugin 继续查询同一 Activity Service 并消费 application-defined Cordis 通知;不存在伙伴 UI 到 Activity authority 的反向写入。 + +每 Bot 独立开关 Activity 标签、DM、群聊消息,默认开、开、关。Companion Visibility 默认“Bot 和我都在的 Channel”,另有“仅我和 Bot 的 Channel”与“所有 Bot 加入的 Channel”;Bot–Bot DM 在第三档归 DM 开关。只展示当前 Bot 的已提交输出并标识来源 Channel/DM 双方。第三档需要面向受信 Human Consumer 的 Host-owned Bot 输出只读合同,范围限制在被选 Bot 的 authored output;不能删除通用 timeline 成员检查、授予模型新权限或读取其他作者的完整历史。点击原 Channel 仍检查 owning 读边界,不能访问时解释限制。 + +首次选择/Client 或 Host 重启建立 Host 一致的新消息基线,不重播旧输出,直接查询当前 Activity;短暂后台/断线仅有界恢复本轮基线后尚未播出的消息,canonical identity 去重并拒绝旧 generation 响应。关闭来源清卡片与待播,重新开启或新合格 Channel 从当前基线继续。进程内 output-committed 只是通知,不能充当持久 cursor;查询成本与队列都必须有界。气泡到期/移除不更新 Channel read position。独立 Human attention 即使所有播放关闭仍保留并导航到 owning 活动/Human Inbox,不从计数编造正文或审批控件。 + +每 Bot 一张当前活动卡加独立并行逐字消息卡,默认折叠 3 层、最多保留 20 张未到期卡;全局设置可调整。hover/focus 展开稳定列表,暂停漫游与到期,已显示文字继续播放,新增内容只计数、退出后有界合并;长文预览、列表滚动,基本气泡避让。无自动 +N 折叠或钉选数量上限,Human 通过拖动、暂停漫游、移除调整。归档保留静态伙伴与标记、删除清选择;reduced motion、freshness、上传图片静态 media 和版本快照降级沿既有 Avatar 合同。先完成真实 Host→Client 的一个像素伙伴 Activity/DM 与操作/恢复切片,Human 验证后扩展多个 Bot、群聊与三档范围;闭合/半开/张开文字节奏嘴型作为随后 BotPixel 兼容扩展。独立桌面窗口、fork 分发与全身动作不属于此目标。 + +[ADR-0150](../adr/0150-window-companion-requests-use-their-live-owners.md) 记录 Human 在 #1178 接受的 attention 扩展:伙伴显示真实 owning Tool Approval 的当前待处理请求并可直接决策,独立于普通输出的播放开关和范围。审批正文来自现有 live owner 的已提交请求,不由 attention 计数或旧 Channel 历史重建;原规格保留历史边界。 + +生产 feed 通过 Fiber 拥有的可释放 attachment 观察 `ChannelToolApproval`,在已确认选择快照中投影当前已提交请求;首次钉选、重连都不需要重播历史。`CompanionRequests` 将待处理请求放入有总数提示的常驻滚动区域,独立于装饰气泡容量;普通消息暂缓逐字与到期,显示等待数量。复用 `ToolApprovalCard`、原生控件和既有审批 RPC,只有明确匹配 Bot/DM/Session/call 且 live 的 companion target 可脱离当前 Channel;普通聊天选择检查保留。提交前重新读取状态,Host 仍验证规范请求和 native caller;竞争决定只能一次生效。断线禁用、状态读取失败可重试,拥有者结算或撤销后快照移除请求并恢复键盘焦点,Host 重启不从耐久请求历史重建 live 权限。 + +依赖切片 #1179 将既有 `ChannelUserQuestions` owner 接入同一 feed,并在 `CompanionRequests` 中增加正式提问变体。`UserQuestionCard` 与 Chat、Human Inbox 共用,保留全部问题、选项说明、单选/多选和自定义文字。当前回答草稿不受普通消息和悬浮影响,不另建持久化;明确匹配 Bot/DM/Session 且 live 的目标在提交前重新读状态,可在其他 Channel 仍被选择时直接回答。规范拥有者校验、提交且只接受一次,再恢复原 native request。普通问句不生成交互控件,新 Host 不从历史重建待答问题;审批 attention 与导航保持独立。 + +### 5.4 · 形象物种与自绘部件(部分已实现) + +[ADR-0148](../adr/0148-avatar-species-and-custom-parts-extend-one-pixel-rig.md) 在不新增 rig 的前提下扩展像素 Avatar Family。Avatar Species(人类、精灵、哥布林、矮人、兽人、猫、狗、狐狸、兔子、熊、花)是像素胸像骨架上的基底:轮廓、耳朵、鼻子或吻部、建议身体色与可接受部件;动作、锚点、说话嘴型与伙伴透明轮廓保持共享。头发拆为可分别上色的分片,新增穿过头发的头饰槽(前后两层)。当前物种无法显示的选择保存并隐藏,不被替换。 + +Custom Part 是有界像素网格,每格引用外形颜色或固定色并带明度档位,因此随外形换色。BotPixel 负责其类型、校验、渲染与纯函数绘制算法;PersonaBot 所属模块把副本嵌入 Avatar Appearance,DSH Profile 中由 Host 管理的 Part Library 以内容哈希身份和来源保存可复用部件。导出的 PersonaBot 携带其部件;单个部件以内嵌数据的 PNG 流转。眼睛、眉毛和嘴不可自绘,眨眼与说话帧仍由 rig 负责。 + +新 PersonaBot 记录全域名字种子版本;没有该记录的 PersonaBot 保持仅人类的种子。使用物种、新槽位或 Custom Part 的 recipe 提升 asset/schema 版本,旧 Client 显示保存的快照;既有 recipe 渲染不变。首个切片验证哥布林、左右侧发分片与一个自绘头饰,从编辑器到 Part Library、再到 Window Companion,并经过导出/导入。 + +目前已实现:哥布林、精灵、矮人、兽人和花物种,左右侧发分片,胡子,中世纪服装与头饰,全域种子(#1210、#1212–#1214),第一条 Custom Part 路径(#1211),自绘头发(#1238),自绘其他部件(#1240)、部件文件分享(#1215),完整绘制工具(#1216),每片头发单独配色与内置头饰(#1217),普通 PNG 导入和派生编辑(#1219),以及带毛色花纹的动物物种(#1218)。asset version 4 新增前发、后发和呆毛颜色、单根呆毛,以及与配饰并存、可放内置头饰或 Custom Part 的头饰位;只有 Human 编辑这些项时编辑器才把 recipe 升到 version 4,并把作为配饰戴着的头饰移入头饰位。自绘的前发、侧发或后发会替换对应的内置部件,原来的选择仍会保留;其中发色的像素会像内置头发一样上阴影,所以编辑时从当前样式压平成的格子开始。自绘头饰的 recipe 是 asset version 3。Part Library 是 `avatar-part-library` 模块的 `avatar_part_library` 表(schema generation 73),以部件内容哈希为键,并保存名称、来源和父部件。Client 编辑器通过 `partLibraryList`、`partLibraryAdd`、`partLibraryExport` 和 `partLibraryImport` 四个桥接方法使用它;`part-file` 把单个部件编码为 ×8 预览 PNG,部件数据放在 `botharness-part` tEXt 块中,整个部件库导出为这种 PNG 的 zip,导入时校验每个块的 CRC 并按内容去重;导入 Bot 时其外形穿戴的部件以 `imported-bot` 来源加入部件库,作者为该 Bot 的显示名;编辑器提供带变亮/变暗明暗笔的铅笔、橡皮、四连通填充、按 Shift 锁定角度和正方形的直线与矩形、限定在区域内并带 4×4 或 2×2 Bayer 抖动的明暗渐变、可重新随机的 ±1 明暗杂色、Alt 点击或长按吸管、所有工具的中线镜像、前后两层、每次操作一步撤销(触屏双指撤销、三指重做)、触屏偏移光标和 1× 预览;每个工具的格子都由 BotPixel 计算,结果仍是外形颜色加明暗档位。外形嵌入部件副本,因此部件会随 `.botharness/bot.json` 和 Bot Zip 一起带走。`part-image` 解码部件尺寸(或整数倍放大)的普通 PNG,经 `partLibraryImport` 报告颜色数,`partLibraryImportImage` 保留最常见的颜色(最近色、不抖动),以 `imported-image` 来源存为固定颜色的格子;带部件库父部件保存的部件来源为 `derived`,并以 `parentAuthor` 记录父部件作者。 + +### 5.5 · Bot 主色(已实现) + +[ADR-0177](../adr/0177-bot-primary-color-is-portable-identity-not-theme-state.md) 将 application-defined Bot Primary Color 定义为 PersonaBot 的视觉身份来源,而不是 DSH-native Service、SessionEvent、Client 偏好或全局主题品牌 token。现有 PersonaBot Host owner 管理自动/自定义模式与可移植兜底来源;Client 共享来源解析,再针对实际背景与 light/dark 派生名字颜色。自动模式采用当前 Avatar 的主 `hairColor`,没有可用主发色(上传图片、无头发、line family 或不支持的外形)时使用按 PersonaBot ID 确定的兜底色;自定义覆盖独立于 Avatar Appearance。主题切换不修改 Avatar 或身份来源。 + +已实现(#1471):PersonaBot Registry 记录的可选 `primaryColor` 字段保存来源策略(缺失即自动,无 schema generation 变化);`botPrimaryColorSet` bridge 方法沿用 DM 范围的既有授权,校验后保存,自动模式保留已携带的兜底来源。`primary-color` 模块提供 Avatar renderer 与主色解析共用的外形解析,二者不会各自实现默认头像算法。Bot Profile 的头像弹窗提供独立的 Bot 主色设置(自动/自定义、预设、取色器、HEX)与本地 light/dark 预览,Host 保存成功后才更新共享呈现;取消或失败不发布草稿。DM 的 Bot 作者名按实测背景派生名字色:保持色相与色度,只调整明度,直到对比度不低于 4.5:1,黑白灰保持无彩色。群聊(#1472)沿用同一来源解析:群聊与 DM 的 Bot 作者名、@ 候选名、输入框中选中的 Bot 提及和已发送的 Bot 提及各自按自身实测背景(含半透明提及底色与对方气泡)派生名字色;只给名字文字着色,提及底色、气泡、正文、Human 名字、Channel 引用和“所有 Bot”保持原样,主题切换时重新测量。分享与导入(#1473)沿用既有的 `.botharness/bot.json` descriptor:导出与每次保存主色时写入 `primaryColor`(模式、自定义来源,以及已携带或按来源 PersonaBot ID 得出的自动兜底色),不写任何主题派生色;Git 与 Zip 导入在创建新身份后读取它,新 ID 不改变携带的兜底色,自动模式仍是自动并在有可用主发色时跟随发色。没有该字段的旧 descriptor 按新身份的自动策略处理;字段不合法时沿用 descriptor 既有校验,整份 descriptor 被忽略,不写入 Registry。删除后的归属(#1474)沿用 Registry 保留的历史记录:PersonaBot Deletion 受理时把当时解析出的来源色写入该记录的 `primaryColor.retained`,不新增存储,也不做逐消息或逐统计时间点的颜色快照。`list` 对已删除身份返回带 `retained` 的策略,没有该值的旧记录在读取时按 PersonaBot ID 兜底且不回写;`overviewUsage` 的每个 Bot 行返回 `primaryColor` 来源色(现存 Bot 为当前解析值,已删除为保留值)。Client 用同一套名字色派生给已删除 Bot 的历史作者名、已发送提及和用量列表名字着色;现存 Bot 忽略记录上残留的 `retained`,继续跟随当前来源。按 Bot 的用量图表(#1476)读取 `overviewUsage` 行上的来源色:以图表实测背景派生对比度不低于 3:1 的图表色,再按固定层级(缓存读 100%、其他输入 62%、输出 36%,与背景混合)和固定顺序绘制 token 子类,分项不可用的用量保持中性色;色调图例和 tooltip 身份标记说明含义。概览的每日总用量、Profile 的按模型视图等以 token 类型或模型为主要比较对象的图表保留分类色;单个 Bot 自己的 token 图表(popover 的迷你图和 Profile 的每日总量视图)与按 Bot 的条形使用同一组色调和色调图例;消息框右下角 token 用量弹层里的 Bot 名字和各 Session 占比条也使用该 Bot 的名字色与图表色。颜色只取决于该 Bot 的来源色和背景,不随排序、分页、时间范围或参与的 Bot 改变。单 Bot 活跃度热力图(#1475)沿用同一来源:Profile 与 popover 中的事件活跃和 Memory 提交热力图把按实测背景派生的图表色写入该热力图的 `--bh-heat-color`,四档强度仍按原有比例与中性底色混合,计数、分档和 tooltip 不变,零活动保持主题中性色;主题切换时重新派生。Group Channel 的聚合热力图没有单一 Bot 身份,继续使用品牌强调色。 + +ADR-0177 的六个切片(#1471–#1476)至此全部实现;外部 IM 客户端的渲染不在范围内。这些仍是 ADR-0177 的已接受目标,不是运行时事实;完整范围与验收见[设计记录](../research/2026-10-12-bot-primary-color-design.md)。 + +## 6 · 持久化、导出与恢复边界 + +```mermaid +flowchart TB + subgraph Profile["One DSH profile"] + DB[("botharness.db
operational authority + attachment bindings")] + Files["Optional Memory repositories
Markdown · Git authority"] + Attachments["Attachment files + identity records"] + CAS["Pending legacy attachment / Soul CAS bytes"] + DSHS["DSH SessionPersistence
transcripts · execution"] + Creds["DSH credentials / settings"] + end + + Barrier["Manual Export Profile
backup barrier + consistent snapshots"] + Package["one compressed
.botharness-backup"] + Stage["Import Profile staging
validate · migrate · dependency check"] + Target["v1: new stopped environment only
cold + suspended authorities"] + + DB --> Barrier + Files --> Barrier + Attachments --> Barrier + CAS --> Barrier + DSHS -.->|"adapter-supported facets"| Barrier + Creds -.->|"declarations only; never secrets"| Barrier + Barrier --> Package + Package --> Stage + Stage --> Target +``` + +| Data | Authority | Portability | +| --------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| operational facts | `$DSH_HOME/botharness/botharness.db` | consistent SQLite snapshot inside manual profile backup | +| operational logs (debug timeline) | `$DSH_HOME/botharness/logs.db` (lightweight owner, rebuild-empty) | excluded from backup; emailable as-is | +| optional Memory repositories | Git-backed Memory Provider | selected SoulSnapshot / PersonaBot Export / profile backup | +| attachments | Host-managed files, identity receipts and Messaging bindings; pending legacy CAS | current referenced files and identity mappings | +| Soul bytes | content-addressed files | dependency-closed selected bytes | +| Session transcript / execution | DSH SessionPersistence | only through a verified DSH export adapter; otherwise declared omitted | +| credentials and DSH settings | DSH services | never copied; restore creates suspended rebind requests | + +v1 只有两个备份动作:Export Profile 生成一个 self-contained `.botharness-backup`,Import Profile 选择一个文件。没有自动备份、scheduler、catalog、retention 或 incremental chain。Restore 总是在隔离 staging 中验证;成功后 PersonaBot 仍为 cold,provider authority suspended,Workspace/model/plugin dependencies 必须在目标机重新解析并由 Human 明确激活。 + +#886 的 v1 实现使用 application-defined Portability coordinator;各 owning module 提供历史 Memory 定位、当前附件引用闭包与恢复时的挂起转换。Database owner 在现有 Profile Writer Lease 下阻止事务写入并执行 `VACUUM INTO`;`ContentPurge.withCheckpoint` 覆盖同步快照与受管文件捕获。文件在捕获前后核对身份、时间、大小和 SHA-256,集合变化、缺失引用、超出资源/屏障时限均拒绝发布;屏障释放后压缩并完整校验。包保留 deleted identity 的 retained Memory,只有 canonical erasure proof 才允许省略;源 Git 授权配置替换为安全默认值,恢复后 hooks 隔离。当前界限为 DSH `0.2.0-rc.1`、60,000 entries 与 512 MiB;底层 Session 内容无已验证 adapter,明确标为 unsupported。 + +Settings 只经现有 Connection Fetch seam 预览、下载、检查文件与操作目标就绪度,不接收目标路径或替换正在运行的数据。受信任本地 `botharness-profile restore` 命令仅接受不存在的新目标;在 sibling staging 中校验路径/hash/SQLite/Generation、取得 Writer Lease、应用真实 Purge checkpoint、重定位 Memory,再一次提交。可执行 Bundle/Profile 配置、credentials、Workspace 文件和 debug logs 不导入;目标由固定版本 DSH 与已安装 Bundle 启动。Replace Existing 和 Managed Transfer 不属于此版本的运行入口。 + +Disaster Restore 将全部原生 Session 引用设为内容不可用,保留历史归属;中断 Assignment/Inbox/Outbox 均不重放。Bot、触发器、外部绑定、Service Grant、Workspace 与持久工具授权挂起,Browser/Computer access 关闭。目标 Human 配置明确 Model Plan、本地凭证并授权该 Plan revision,再确认同身份双活风险后显式激活;新 Orchestrator ownership 与恢复收据同一事务建立,执行使用新原生 Session。后续 Plan 变化再次阻止执行,所有实际模型调用仍经当前 catalog 验证,不能静默回退。激活仅开放本地 Bot 执行,外部 authority 与 Workspace 仍须独立重新授权。[操作与限制](../settings.md#complete-environment-backup-and-restore)。 + +Diagnostic Bundle([ADR-0185](../adr/0185-diagnostic-bundles-are-local-redacted-read-only-exports.md),#1533)是另一条只读导出:用户在时间线多选消息(消息菜单“多选”,右键或触屏长按打开)并在“导出”菜单里选择诊断包后,经 Connection Fetch 预览并下载一个本地 zip,范围从第一条到最后一条所选消息(只选一条时到现在)、前后各多 30 分钟,所选消息在 manifest 与时间线中标记,带上参与 Bot 的 Human DM、所导出 Channel 内的 Inbox 收件、其拥有的 Orchestrator/Assignment/Subagent Session 原生事件(起点处正在运行的 turn 从 `turn/start` 算起)和合并时间线。`DiagnosticsExport` 只通过 Channel store、Inbox history、Session Ownership 与原生 `sessionQuery` 读取,标准脱敏后只在内存中保留预览结果,不写盘、不建表、不上传;Memory、Workspace、附件内容、凭据与其他 Channel 不进入导出。 + +图中的 Attachment files 表示新附件真实文件及记录,Messaging 绑定将已转换旧引用解析到当前真实文件,legacy CAS 仅服务未转换依赖。hash 相同不恢复共享,缺少归属或含糊的旧调用明确失败。后续 Backup/Export 要包含当前被引用的文件与身份映射,引用感知清理及显式 Purge 也必须涵盖这些真实文件;一次明确导出保存当前字节,不建立持续附件版本归档。 + +### 删除与清除(已接受设计,待实现) + +[ADR-0130](../adr/0130-deletion-preserves-history-and-makes-memory-erasure-explicit.md) 与 #138 将 PersonaBot/Channel 的结束参与和物理内容清除分开。删除 PersonaBot 的确认框展示实际 Memory Repository 与依赖,提供“打开记忆文件夹”及默认不勾选的“同时删除记忆文件”;打开文件夹不改变勾选或确认状态。未勾选时保留记忆及 Git,勾选后也只能清除经 Host 重查、专属且归属明确的仓库。共享、路径变化或无法证明归属时不可清除;Workspace、原生 DSH Session、远端 Git、外部导出与备份不随之删除。普通删除先关闭执行/收件/授权入口,等待所拥有的 AgentHandle 执行树静止,再保留 deleted identity、历史 Session Ownership 和仍保留记忆的持久位置;它不同于可恢复归档。Channel 删除保留消息及因果归属,只结束其成员和路由,不删除外部会话或其他路由;Hidden Channel 仍可恢复。另行确认的 Content Purge 才清除选中的 Source Event 正文,附件需检查共享引用,并覆盖 legacy CAS 与当前真实文件绑定。 + +现有 roster stream 在提交后和重连时投影不含正文的清除位置选择器。Client 清理当前和缓存会话、侧栏和 attention 摘要,拦截延迟响应回填,并显示清除标记。这些内存选择器来自 Host,不构成第二份持久权威。 + +Purge Ledger 是应用定义的 Host 深模块权威,必须单调持久并位于可恢复数据库快照之外。清除先接受 ledger,再幂等应用 Messaging 清除与受管文件清理;中断时已接受的范围不可重新显示、投递或用于新出站效果,文件失败需明确报告尚未清完。#886 必须依赖真实 ledger/checkpoint 实现,不能用空占位代替;恢复在 Messaging 可读前合并并应用 package/destination union,独立离线旧备份只保证其自身 checkpoint。备份包括保留的 deleted-identity 记忆仓库。设计验收不等于运行功能已交付。 + +### Channel 内容清除运行路径(#897) + +Generation 70 的应用定义 `ContentPurge` Host owner 通过已有 Typert/API Gateway 提供已结束 Group 的只读历史、所选 Source Event 预览和单独确认;不注册模型 Tool。已结束 Group 的身份保持占用,同名新群聊不覆盖旧历史。本地和外部来源可以共享到仍活跃的会话,但全部位置在对应 revision 上均须仍在 Human 当前可读范围内。普通删除只结束所选 Channel 的路由/摄入,保留其他 Channel/Inbox 目标及外部会话。[ADR-0146](../adr/0146-content-purge-retains-file-selectors-and-settles-issued-effects.md) 记录文件、效果与恢复边界。 Memory 通过自己的查询和 retained/deleted 仓库定位提供已记录的来源衍生提交;Workspace Grant 提供可能的副本位置。这些引用参与确认前重验证,不写入清除账本,也不声称扫描过未追踪的文件或导出。 + +独立 `botharness/purge/ledger.db` 由同一 Host 生命周期拥有,SQLite FULL 同步事务先接受无正文的事件身份、作者/时间/因果、Human 审计及托管文件选择器,再移除运行正文;该文件不属于 `botharness.db` 快照。最小墓碑及 placement 保留,secure-delete 后截断 WAL;应用失败关闭运行数据库进入 recovery,启动先应用事实再创建 Messaging Consumers。Attachment owner 重查全部保留来源、迁移/获取绑定和 Outbox 引用后,仅移除已审阅的独占当前文件和 legacy CAS;共享文件保留,受阻清理可见且重启继续。待投递意图脱敏并取消,已发出请求在活跃 Host 中按真实证据结算,冷中断保持结果未知。持久栅栏和原生 Tool Guard 拒绝重复回填、旧命令与新的内容依赖效果。 + +`checkpoint()` 导出已验证事实;`withCheckpoint` 在同步或异步快照回调完整结束前持有清除屏障,Profile Backup 在其更广的 Backup Barrier 内调用。冷 `restoring` 验证 v1/v2 包检查点,单调 union package/destination 事实并保留目标审计,再应用、开放 Messaging。新目标只可从已验证包初始化;已建立账本缺失、损坏/不支持的数据或 union 中缺少运行快照引用事实均关闭入口。v2 包含托管文件身份及外部/系统作者。备份 Settings 与本地恢复入口由 Portability module 提供。 + +预览列出所选来源、全部 placement/Admission、文件移除/保留及依赖效果;info 控件披露仍保留的 Assignment/其他因果来源正文、未改写的 Human Memory/Workspace/导出、原生 DSH Session 提示/结果、外部副本、Git 远程及离线备份。旧独立文件只执行其包含的检查点。[验收指南](../agents/qa-channel-purge.md) 覆盖真实 SQLite/文件恢复及 DSH 共享文件/中断清理。 + +### PersonaBot 删除运行路径(#896) + +Registry 在 Generation 66 持久化不可恢复的身份关闭状态和确认时的记忆位置。活动查询排除已接受删除的身份;历史查询保留身份、Session Ownership、Report、消息和用量归属。Human Profile 确认框复用 Typert/API Gateway 与原生 Host 文件打开能力。“同时删除记忆文件”默认不勾选,清除需要专属 Git 仓库、已记录的目录身份,以及与其他保留仓库和所有注册 Workspace 均无重叠。新建自定义仓库可以记录同样的归属证明;已有自定义路径本身不足以证明独占归属。Host 共享存储也受到保护。 + +删除 owner 先接受持久身份关闭状态,再停止该 Bot 所拥有的原生 AgentHandle 执行树、撤销 Workspace/工具规则,并停用其 Messaging 绑定。已发出的原生/provider 效果保留实际结果。可选文件清理具有明确接受的范围和未完成状态;重试只使用原目录身份,不能选择新路径。DSH Session Persistence 与共享 provider 账号保留。冷启动仍排除已删除身份,即使清理需要修复。后续 Profile Backup 必须枚举历史 Registry 记录及删除位置,区分主动清除的记忆。Channel Content Purge 与支持恢复的 ledger 仍属于 #897。 + +### Bot Marketplace(已接受设计,待实现) + +目录查询将 GitHub topics 与 `.botharness/bot.json` 的 tags(兼容旧 `roles`,存于 D1 的 `roles` 列)一起用于文字搜索、`topic` 筛选和 `/v1/topics` 统计;同一仓库的同名标签只计数一次,统计排除收录标记 `botharness-bot` 及未上架仓库。FTS5 同时索引两类标签,迁移会从已存数据重建索引。每小时刷新不依赖 push 即更新 GitHub topics;检测到 push 后重新读取 README 和 bot.json,并替换旧标签。 + +[ADR-0131](../adr/0131-bot-marketplace-starts-as-a-github-indexed-catalog.md) 与 #18 让 Bot Marketplace 先作为 GitHub 索引目录上线:作者给公开仓库加 `botharness-bot` topic 即同意收录,也可在 Marketplace 贴链接立即抓取。独立的 Cloudflare Worker 与 D1 每日按 topic 切片发现、每小时用 GraphQL 批量刷新,README 进入 FTS5 索引;浏览用 keyset cursor,搜索只取前 200 条。harness 的 Marketplace modal 展示 README 详情;安装复用 #298 的 Git URL 创建路径,确认框显示最新提交并提示第三方仓库风险。贴链接与一键举报共用 ALTCHA 和限流。不建账号、不计下载。[ADR-0135](../adr/0135-a-bot-moves-between-people-as-a-zip-of-its-memory-files.md) 撤销了基于账号的第二阶段:把单个 Bot 交给别人或换设备,改为在 harness 里导出 Bot Zip(默认只含 Memory 文件和 `.botharness/bot.json`,整包导出时可选带 Git 历史),对方从创建菜单的「从 zip 导入」得到新的 PersonaBot。 + +[ADR-0240](../adr/0240-marketplace-listings-localize-author-first-with-generated-summaries.md) 让列表按读者语言(中文/英文)显示:作者在仓库根目录放 `README.zh-CN.md`/`README.en.md`,在 `.botharness/bot.json` 的 `i18n` 里写翻译后的名称和简介;Worker 推送后一并抓取,`lang=zh|en` 时优先返回。某语言没有作者简介时,Worker 在每小时刷新后经 OpenCode Go 的免费模型按 README 生成一句简介(按输入哈希缓存、每轮限量,标记 `bioGenerated`),作者文本总是优先;README 正文不做机器翻译。Host 的 Marketplace modal 与 CLI 按界面语言传 `lang`。 + +## 7 · 关键边界 + +- 正常运行只认 explicit Session ownership;`cwd` 只可作为迁移/修复提示,不能决定 PersonaBot 身份。 +- DSH Session 状态是执行权威;BotHarness 只投影 activity/last-run,并将 semantic Assignment Report 与 Host Lifecycle Notice 分开。 +- Provider capability 不等于授权;发现一个飞书频道也不自动授予向它发消息的权限。 +- UI 不直接读文件或数据库,不自己推导业务状态;它消费 Host read models,并把 command 交回 owning module。 +- PersonaBot archive 先关闭 admissions、wakes 和外部 actions,再停止 Orchestrator、Assignment 与 owned Subagents;purge 是单独的破坏性动作。 +- Browser 与 Host 是两个 Cordis 应用;Host service 不跨进程 inject,统一走 `/api` client bridge。 +- Memory 跨设备同步是 Orchestrator 的原生 Git 行为,不是 Host 服务;BotHarness 不持有远端仓库或凭据,也不提供一键同步,非 Git 所有者经 Portability 迁移记忆(ADR-0084)。 +- Roadmap Project #1 保持 private;文档同步只用 `read:project` 读取显式 `In Progress` 和 Artifact,经过 fail-closed 白名单投影后才提交公开 JSON。Project notes、private items、assignee、backlog 与 ETA 不跨越这条发布边界。 + +### 产品发布身份(已接受设计,执行门禁待交付) + +[ADR-0216](../adr/0216-dsb-product-versions-identify-immutable-package-sets.md) 将电脑端 DSB 软件身份与 npm 产品包身份统一;Core/UI/Browser/Computer 同号,DSH/独立 Provider/手机保持各自版本。既有准备流程的冻结 Release Plan 绑定源码与完整产物,而不是新增版本服务。交付 QA 后版本与字节不可替换;Beta 转正式须验证新的正式包集合。Host 的现有 Release Service 投影实际运行身份和独立的待生效版本,经既有认证 bridge 同时提供 CLI/Web;缺失发布元数据不能伪装源码构建。发布按依赖核验后推进产品入口,失败恢复同一计划/字节,不宣称多包原子性。规则详见[版本规范](../agents/version-policy.zh.md),实施由 [#1979](https://github.com/BotHarness/DeepSeekBot/issues/1979) 的三个切片负责;本节不表示全部发布门禁已实现,也不授予标签迁移、发布或共享设备使用权限。 + +#1980 的运行身份沿用 Release Service:四个第一方 Plugin 在模块加载时将不可变包名、版本与真实目录快照附在各自 callback 上。Core 捕获产品依赖组合,随后从原生 Registry 的 ACTIVE Fiber 读取实际 callback 快照并比对;等待组件启动时返回 `composition-unverified`,关于页面允许重新读取身份,不能只凭已安装清单宣布发布身份。运行版本和待生效状态在当前 Service 内保留;后续请求继续校验实际 callback,组件被替换后身份可失效,但不提前宣布新版本。配置重新应用复用同一 Core 模块捕获的组合;磁盘安装不能提前改变运行版本。该快照是应用定义的进程诊断,不是新持久化版本库,也不是对任意可执行 Plugin 的安全认证。源码构建使用编译时 Git/dirty 身份;具体安装、重启与 Human QA 证据仍由 issue/PR 记录。 + +#2173 的应用定义升级发现仍由同一个 Release Service 负责。Bot 模式进入时通过认证 bridge 查询运行身份、真实 DSH 版本及 registry 版本清单;兼容性复用当前 Host 的原生检查器,覆盖产品与精确依赖组件。绝对最新版本不兼容时继续寻找最新兼容版本;元数据缺失、搜索超时或网络失败保持未知并短期重试,不宣称没有更新。客户端按本地 Profile、发布通道和精确目标保存提醒关闭记录,与已安装版本更新说明分开。确认安装前再次核验兼容性,再进入既有原生安装和权限边界;已暂存版本仍独立提示重启,不提前改变运行身份。CLI `release-check --force` 与 UI 共用该 Service 并绕过查询缓存。 + +## 8 · 实现顺序与可并发范围 + +1. #77 验证 pinned DSH 的 Agent/SessionPersistence/Subagent seams;#79 建立 operational database owner。这两项可并行。 +2. #80 在 #77 与 #79 后实现 explicit Session ownership 和 activity projection。 +3. #81 在 #77、#79、#80 后先交付最小 DM → Orchestrator → Assignment → report → DM reply tracer bullet,并同时提供可验收的事项列表/详情;#47 的后续 Assignment coordination 在该切片通过后扩展。 +4. #78 可与上述工作并行研究 Feishu provider contract,但 #48 的 adapter 实现受它约束。 +5. #74 的 Memory 工作在上述主链通过后,以独立 optional Provider tracer bullet 推进;#75、#76 保持 focused design/grill,避免阻塞首个可体验闭环。 + +## 9 · 如何维护 + +- 模块、数据流、事务边界或 authority 发生结构变化时,同步本文件、英文镜像和 `docs/architecture/diagrams/*.mmd`。 +- 运行 `pnpm diagrams` 提交 light/dark SVG;`scripts/sync-docs.mjs` 将本文和图同步到 `apps/docs`。 +- 配套:BotHarness 产品术语 `CONTEXT.zh.md`(英文为 `CONTEXT.md`);取舍与理由 `docs/adr/`。Platform Spec 与 App PRD 已归档为历史工作草稿,不再作为并列设计权威。 + +## 附件文件操作切片 + +[ADR-0105](../adr/0105-attachments-use-native-file-operations-under-source-authority.md) 沿用现有 Attachment owner 管理原件当前内容。`channel_attachment_save` 校验当前 Channel 成员权限与准确的消息/fileId 归属,再将原始字节流另存到明确允许 Orchestrator 写入的 Grant;已有目的文件不覆盖。原生文件工具处理另存文件,Shell 使用该 Grant 的 `workdir` 并保留 Human 审批。Agent Scope 中复用原生 Tool 注册,通过隔离的 application-defined Policy Provider 在每次 `tools/execute` 选择单一获准根目录,不改 Memory cwd 或全局 Provider。写入权限变更使旧审批规则范围失效;已开始的 Shell 可能完成。 + +`channel_attachment_import` 明确选择当前 Memory/Grant 读取权限内的 canonical 普通文件,交给既有 owner 创建独立可下载 Attachment,再经 `channel_send` 当前发送权限回发。另存与导入不解析文件,不授予 Shell 权限。#632 交付本地 ZIP/CSV 链路。#633 的 `channel_attachment_open` 从精确来源消息与文件生成仅当前回合有效的原生访问选择:`read` 只允许指定原件路径;`edit-original` 另需既有 Human 工具审批或匹配的已保存规则。原生 guard 每次复查当前来源权限与文件可用性,拒绝其他路径,包括同目录兄弟文件。隔离 Policy Provider 为原生修改选用该附件已有的 data 目录,不授权整个附件仓库或元数据目录;不透明 Shell 保留审批。该选择在回合结束时清除,不是持久授权。原生写回修改既有文件,共享身份在刷新/重启后读取当前字节,独立上传仍然独立。不增加 Source Revision、文件变化 Inbox Admission、唤醒、应用锁或版本档案。仅 Inbox 的 Lark 文件链路由 #657/ADR-0107 集成;Slack 仍是后续 provider 切片。 + +[#657 的外部文件切片](../adr/0107-external-files-use-trusted-source-capabilities-and-existing-owner.md) 为既有 dsh-im Service 增加可选公开文件能力。真实文本 @ 仅解析其准确父文件元信息;canonical Source Event/Inbox 保留来源,不产生 Channel placement。首次访问重新校验原账号、群/话题及资源关联,将有界字节流交给既有 Attachment owner;普通 receipt 让稳定文件身份在重启后复用,缺失原件不重建。来源详情下载与 `bridge_attachment_save` 共用该权限。原生文件工具和经审批的 Shell 处理可写 Grant 中的独立副本,当前回合通过 `channel_attachment_import` 明确选择结果后才能 `bridge_reply_file`。既有 Outbox 在外部效果前记录文件引用,再复查当前 Grant/来源,经 provider 原生上传与准确话题回复。不确定结果不重试,不增加文件目录、解析器、SDK 连接、转录存储或文件自动唤醒。 + +### Human Inbox 中回应 Assignment + +等待/受阻卡片以 Assignment Session 聚合,读取准确报告 Source Event 和至多前后各两条报告。Human 在 Inbox 回答时,消息通过现有 Bot DM authority 提交,并携带 `assignmentReply: {sessionId, sourceEventId}`;事务核验所属 Bot、运行状态、当前请求或空闲受阻报告,以及尚无已提交 Human 回应;若之后的权威接收前失败记录指向同一未解除 ask 且事项空闲,可明确重试。新的 Human 回应消费这次机会,并发提交仍只接收一份。此消息由 Orchestrator 接收并转交 Assignment,不直接恢复事项或清除 ask;完成、失败或停止后的报告拒绝新回应。同 ID 重试返回原提交。普通进展保留未解决 ask,较弱等待报告不覆盖较强的受阻 ask。点击 Bot 打开私聊,点击 Assignment Session 切换到 DSH 原始 Session(ADR-0071)。 + +Human 回应目标及时间使用 Messaging 拥有的 SQLite 索引。列表超过 150 项时明确暂停自动轮询,保留正在浏览的旧记录;可见的「刷新回到当前列表」按钮保留筛选条件,重查最多三页并获取新游标。「加载更多」不截断当前记录,成功行动命令仍刷新权威状态。 + +## 共享 Channel Bridge — #634 + +[ADR-0108](../adr/0108-shared-channel-bridge-places-canonical-external-sources.md) 为授权 Lark Grant 增加明确选择已有 Group Channel 的收件位置。Messaging 在 ACK 前将同一个外部 Source Event、canonical Channel placement 与绑定收件 Bot 的 Inbox Admission 原子提交(#634 验证 @;普通文字见 #613)。原生时间线和授权成员读取投影有界的外部发送人/时间/正文/来源;其他成员获得可见性,不复制消息、身份、Admission 或唤醒。既有 harvest/steer 路径以该本地 Channel 作为入站上下文,只有明确使用自身身份的 checked reply 才回到 Lark。当前成员、绑定与 Grant revision 控制收件、唤醒、读取和未开始的回复;退出/撤销保留已有共享事实。默认仍仅入 Inbox;该切片保留每 Grant 一个目标、每 Source 一个 placement,重投不移动历史。多目标、话题跟进与协作继续由 #629 的后续 tracer 交付;普通文字收件沿用下述 #613 策略。 + +```mermaid +flowchart LR + IM["Authorized Lark group
verified Human @"] --> Provider["dsh-im public Service
exclusive Consumer"] + Provider --> Commit["Messaging transaction
canonical Source Event"] + Commit --> Placement["One existing Group Channel placement"] + Commit --> Admission["Addressed bound Bot Inbox Admission"] + Placement --> Members["Current Human and Bot members
native timeline and bounded reads"] + Admission --> Runtime["Existing harvest / steer
one Orchestrator"] + Runtime --> Reply["Explicit own-identity reply
current grant and source checks"] + Reply --> Outbox["Existing durable Outbox"] + Outbox --> Provider + Provider --> IM +``` + +## 外部群收件与唤醒 — #613 + +[ADR-0109](../adr/0109-external-group-collection-is-separate-from-wake.md) 将每个授权 PersonaBot/账号指纹/具体 Chat 的普通文字收件与唤醒分开。Messaging 保存不可改写的策略版本与编辑者;Human Profile 和所属 Orchestrator 的 scoped Tools 可选择仅收 @ 或普通文字全量,以及数量/时间 digest、下一轮 immediate、随本群 @ 阅读或 silent。开启全量要求当前 exclusive Consumer 确实收到一条该群非 @ 消息;仅验证时不保留该消息正文或 Source Event。接收租约与 Host 重启会重置能力验证,已保存策略保持不变,平台缺失的事件不回填。 + +新收件在 ACK 前原子提交 Source、已有目标 Channel placement(如配置)和绑定 Bot 自己的 Admission,固定当时的 Messaging revision 与阈值。Bot Runtime 复用现有有界 harvest 和计时;普通消息在回合边界入队,不 steer 正在执行的模型/工具步骤。@ 保留既有 steer/turn 规则,可同批阅读本群的待处理 digest/mentions 上下文;silent 只在显式读取时进入本轮。重启从 pending Admission 的历史阈值恢复,编辑只影响后续消息,重投不重新分类。是否回复仍由 Bot 独立决定;其他 Channel 成员的普通消息 attention 由 #638 交付,话题跟进由 #614 交付。 + +## 外部话题参与 — #614 + +应用定义的 Messaging Service 为已授权 Lark 群提供明确的话题跟进。PersonaBot 从自己 Inbox 的可信 Source Event 选择 Thread;Host 检查当前 Grant、Consumer、账号 fingerprint、Chat/Thread/root,以及该 Consumer 是否实际交付过本话题的无 @ 回复。发过一次回复不自动跟进。按 Grant/Thread 追加不可变策略版本,Bot 选择跟进或沿用群规则;Human 可跟进、排除普通回复或交回群默认,明确的 Human 覆盖在恢复继承前优先于 Bot 修改。 + +普通消息按 Thread 覆盖再按群 collection 判断,唤醒默认继承群 ordinary wake,亦可提供有界覆盖。直接 @ 继续走原 addressed 路径。退出恢复群规则,不等于在全量收件群中禁收;不回填、不重写历史。canonical Inbox Admission 在 ACK 前冻结 group/Thread revision 和实际 wake/count/interval,后续 harvest 沿用现有安全 turn/steer 边界。持久策略重启后保留;实际投递资格证据属于当前 Consumer 生命周期。撤销、归档、关闭 Consumer 与过期来源/Grant 始终优先。 + +PersonaBot Profile 展示最多 50 个 Inbox 锚定话题的收件方式、修改者和管理 Modal;无话题能力的平台不生成控件。DSH Tool Registry 与 Typert/API Gateway 分别承载 Bot 和 Human Consumer,应用权限仍由 Messaging Host 持有。参见 [ADR-0110](../adr/0110-external-thread-following-is-scoped-and-explicit.md)。 + +### Human Inbox 详情与移除(#687 QA) + +手机 Activity 通过认证 Bridge 读取同一应用定义 Human Attention owner。查询版本 1 在分组分页前执行安全摘要搜索、未读消息筛选,个人消息默认提及优先或显式按时间排序,游标绑定当前筛选。消费者分别加载可见分组;已处理历史需显式筛选。没有 Activity 专用持久化或第二套请求生命周期。悬浮顶部栏仅持有临时筛选、搜索和排序状态,折叠偏好仍按设备与 Host 保存。旧 Host 明确拒绝,避免静默返回未筛选结果。 + +消息窗口上下沿的等宽箭头分别增量读取历史/较新上下文;下沿到达此前末尾后仍能查询后来消息。Channel 沿用原时间线游标,Assignment 报告通过同一个有界查询继续读取边沿。每条消息在悬停或键盘聚焦时显示精确来源按钮,触屏保持可用;Assignment 原始报告则打开所属 DSH Session。常规手动刷新与底部来源按钮移除,失败可重试且保留回复草稿。 + +Human Attention 在原 operational database 拥有 `human_inbox_dismissals`,只保存 Human、item、Source Event key、可选 unread placement revision 和时间,不保存另一份消息。Host 验证可见原来源后提交 Inbox-only Dismiss;它不答复、审批、授权或推进已读,不创建已处理记录。查询分页和待行动计数排除已移除项;入口未读总数仍由原已读位置决定。Channel 未读汇总只隐藏当时 revision 及之前的批次,之后的消息重新出现;新的 Assignment 报告也不被旧决定隐藏。多窗口与重启共享同一状态,原 Channel 卡片仍能处理,随后真正回应的已处理历史仍引用原权威。再次点击同一行只收起详情,不移除。 + +### 总览行动与 Session 行卡(#698) + +总览默认显示非 idle 状态或有权威 Human 待行动的 PersonaBot;「显示空闲 Bot」可查看其余 Bot。等待/受阻工作显示为状态或行动,不计为正在执行。每张 Bot 卡复用 Human Inbox 的原生行动表单与选择器,以独立的 Bot 过滤 Client 查询缓存读取同一 Human Attention Bridge,最久等待优先、有界刷新并沿原游标继续分页。决定与移除后刷新该列表及总行动数;沿用 Inbox 的移除排除规则,不新增持久表或请求生命周期。正在执行的根 Session 用紧凑行卡显示原生 DSH Session 列表的当前 displayTitle;总览打开/刷新时加载该公共列表,订阅名称更新,并用图标区分 Orchestrator 与 Assignment。只有原生名称不可用时才回退用途/角色;点击仍退出 Bot mode 并打开准确原始 Session。 + +## 今日 Channel 活跃度(#703) + +application-defined `botharness/channelActivityToday` 查询由 Channel owner 在既有 SQLite 中聚合 Host 本地日内可见 placement 的不同 Source Event,尊重 Human 成员与可见修订边界,排除已删 Channel、Bot 私聊及其生成的注意力通知。响应给出准确的 Human/Bot/其他计数、当前 Channel Human 昵称、Registry Bot 名字、桥接发送者标签,以及日期/时区/半开时间边界。总览使用同一尺度的紧凑堆叠条,可展开发送者明细并跳转 Channel;默认渲染 20 行且提供继续查看,完整总量不截断。视图拥有每 30 秒、午夜和手动刷新及卸载清理;查询失败显式呈现,不推进已读、不生成 wake,不增加 schema 或第二套计数存储。 + +总览优先排列需 Human 行动的 Bot,并先展示 canonical 行动表单,再显示执行中的 Session。显式全部已读命令先固定每个 Human 可见 Channel 的消息位置,再推进既有已读游标;之后到达的消息仍未读,不改变请求解决、Inbox 移除或 Bot attention。统计复用锁定版本的 TanStack 图表与主题,以前端偏好记忆折叠状态,并保留可访问的发送者明细。 ([#705](https://github.com/BotHarness/BotHarness/issues/705)). + +总览 token 统计(#709)通过应用定义的 `overviewUsage(period, after?)` Typert 查询消费既有保留 Usage 权威。今日或含今日的七个 Host 本地日期返回完整整体总量/每日分项,与有界 Bot 明细分页独立;缺失报告不再让整段合计变成未知:每个桶返回已上报部分的合计,只有完全没有上报值的桶才是空;总量缺失的行用其已上报分项作下限,并以 `partial` 标记该合计不完整,Web 与手机显示为“N+”;核对和历史基线状态明确。当前 Registry 名称标记行;不在当前 Bot 列表中的保留统计仍可见,但不虚构身份或 Profile 入口。Client 复用 Profile UsageChart/主题,以可清理的轮询读取已结算事实,并通过既有 DM 导航打开当前 Bot Profile。不增加账本、Channel token 归因或模型/wake 策略。 + +总览 Memory 统计(#716)通过应用定义的 `overviewMemory(after?)` Typert 查询读取现有 Memory Service 与 Registry。每页至多十个当前 Bot;每个仓库的所有非恢复/stash 引用可达的普通 Git 提交按 committer 时间归入含今日的七个 Host 本地日期,共享提交仅计一次。当前 staged/unstaged/untracked 状态单独显示,不推断文件作者或审批需求;缺失、无效、超时或读取失败的仓库显示不可用,不伪装为零提交或 clean。仓库 Git 查询有超时并异步执行,避免阻塞共享 Host;查询关闭可选 Git 锁与 fsmonitor,不 stage、commit、reconcile 或建立 checkpoint;沿用 ADR-0068 的仓库权威,不增持久统计账本。Client 复用 Profile 紧凑卡片及 TanStack 主题;可展开每日值,保留已加载分页刷新,折叠 Statistics/退出视图时清理查询资源。 + +### 外部身份独立生命周期(#699) + +[ADR-0111](../adr/0111-external-identity-lifecycle-is-independent-of-grants.md) 将应用定义的 PersonaBot 外部身份与会话 Grant 分开。Messaging 既有 bindings 表持久保存启用偏好、本地名称及 revision;仅绑定通过可信 dsh-im 的认证账号元信息,不创建 Grant 或 listener。Profile 以独立身份表/Modal 管理;Group 没有身份表。暂停停止该身份的 Consumer lease,并由同一个 Host 权限门禁拒绝未开始的 Client/Bot 外部效果;原有路线、Source Event 和政策快照保留。恢复校验同一账号和原目标 digest,不扩大范围。撤销单个 Grant 不解绑身份;明确解绑使该身份所有 Grant 失效,保留可检查配置和消息,不删除 Provider 凭据。#700 继续交付 Channel Bridge 表。 + +Messaging 的 bindings/grants 事务提交,以及进程内 Consumer lease 开始、停止、连接成功或失败,均使既有认证 roster 状态流失效。已打开的 Profile 身份与 Channel Bridge 视图收到通知后重查同一 Host 权威;通知不携带身份、凭据或授权内容,也不保存接收连接状态或重放外部事件(#855)。 + +### Channel Bridge 管理(#700) + +[ADR-0112](../adr/0112-channel-bridge-intake-is-managed-at-the-existing-grant.md) 将有 revision 的收件偏好嵌入既有 Messaging Grant(Generation 50),由 Group Profile 的 Bridge 表/Lark Modal 管理。Human 命令校验当前 Human/收件 Bot 成员资格、Grant/配置版本以及原授权账号和目标。暂停保留独占 Provider lease,在 canonical 持久化之前丢弃后续收件;已收来源仍按当前权限读取和回复。删除移除收件范围/目标、递增 Grant revision 并关闭 lease,拒绝旧来源未开始的效果,同时保留历史、身份和独立发送范围;不会自动回退 Inbox。管理界面的添加/恢复持久记录 Provider 发送时间边界;早于边界的迟到消息仅确认、不投递,重启后仍有效。这依赖已验证 Provider 的发送时间与对齐时钟;迁移路线在管理界面激活前保留既有语义。恢复不请求回填或重复 listener。收件条件不替代每个 Bot 的 attention/harvest/wake,收件身份不授权其他成员借用发言身份。旧收件接口更新同一权威;多来源/DM 投递和成员自身身份回复仍由后续切片交付。 + +### 共享普通消息的成员 Attention(#638) + +[ADR-0113](../adr/0113-shared-external-traffic-uses-member-channel-harvest.md) 将明确收件并首次放入 Group Channel 的普通外部来源,在同一 canonical 事务中按当前活跃成员各自的频道覆盖/Bot 默认策略建立 Admission。重投不会补发给后来加入的成员或重写策略快照;直接外部 @ 仍只走接收身份的既有提及路径。每成员使用既有 Channel count/time digest、有界最旧优先 harvest、安全 turn 排队和恢复;已放入 Group 的普通来源不再走身份专属外部 digest。接收 Bot 明确设置的话题 wake 覆盖保持独立分区,不影响其他成员。来源文本保留发送者、平台、外部消息 ID 和 Source Event;共享收件不授权借用身份。群频道侧栏的唤醒策略项按成员卡片读取 Host 的实际有效策略与继承来源,编辑沿用既有审计 owner;频道连接器仍只控制收件。无远端离线回填、新队列或共享 Inbox 存储。 + +## Composer footer preferences and generation speed(#1958) + +The application-defined Client footer owns a browser-local display preference (`botharness.composer-footer.v1`): visible item order and left/right placement. Defaults show model, Workspace and token usage; speed is opt-in. Preferences never grant access or cross devices. A visibility panel opened by right-click, long-press or the `/footer` composer shortcut and a temporary pointer/keyboard arrangement mode share this preference. The panel stays open across toggles. With no rendered items, the footer collapses completely; `/footer` remains available for recovery. Desktop labels stay on one line with ellipsis; phone-width Web keeps its compact layout. + +The Host observes committed DSH `assistant/message` SessionEvents and keeps one process-local measurement per Session until the next step, reply consumption or Session disposal. A completed reply may carry optional `perf` metadata through the existing Channel/Source Event persistence and timeline projection; old messages require no backfill. Provider output counts are used when usable, otherwise reply text yields an estimate. Both reply and footer labels use the compact `36 tps` format; estimation metadata remains available in the message data. The footer uses its own inline size for responsive presentation: at 480px or less, model/workspace labels collapse to named, titled icons while numeric usage/speed remains visible, without resize subscriptions or preference writes. Phone-width Web uses the same applicable controls and visibility/order preferences, with compact 28px-high buttons on all pointer types. This is diagnostic metadata, not billing authority. The Client only loads averages while enabled: at most four pages/200 messages for up to 20 measured replies, refreshed on a completed measured reply, without polling or per-token scans. Hidden or purged content is excluded from displayed measurements. + +## Client-local composer recovery(#1459) + +DM 与 Group 的未发送撰写上下文由 application-defined Client recovery owner 保存于浏览器 localStorage(ADR-0184;原设计编号 0160 已被其他决策占用),不进入 Source Event、Session Persistence、Bot Memory 或 telemetry。Host/Profile 通过 Host bridge 返回持久 opaque identity,组合可信 Human 与 canonical Channel ID 隔离草稿;身份未就绪或切换时不展示旧范围输入。正文、明确 mentions/Channel references、reply ID 和已上传附件引用逐次保存,不依赖卸载事件,也不保存 File 字节。 + +恢复先经现有 Typert/API Gateway seam 校验当前 Human 可见回复及 Attachment owner 引用,再结合当前 roster 校验目标;失效引用保留正文并提示,未完成上传不能伪装 ready,`@all` 需重新选择。不可变版本及因果时钟保留并发标签页的双方输入,显式选择后重新校验;空闲视图同步,正在编辑的内容不被静默覆盖。存储损坏/配额/不可用明确提示,保留有效旧记录与内存输入。此切片不提供跨设备同步。 + +提交快照与输入召回(#1460)在同一恢复范围下增加一份有界的提交尝试列表(`composer-submission-store`,localStorage `botharness.submissions.v1:`)。通过本地校验的提交先以原 client message ID 保存正文、明确 tokens、reply ID 与已上传引用,再清空输入框;提交前被拒绝的输入撤回记录、仍是草稿。状态为 pending/sent/failed/unknown/restored:Host 明确拒绝为 failed,请求没有得到回答为 unknown(界面显示「结果未确认」,不称未发送),页面关闭时遗留的 pending 按 unknown 处理。unknown 只通过向 owning Host 按原 message ID 查询来核对:已提交则记为 sent 并由规范消息取代本地气泡,明确不存在则转为 failed 并可放回输入框,查询不到结果则保持 unknown 且不允许放回;不自动重发,也没有重试队列。Host 的 `channelSend` 对相同 ID 加相同内容返回已有消息,因此 ACK 丢失不会产生重复消息。空输入框按 ↑ 召回本 Channel 的提交尝试(含失败与未确认),↑↓ 浏览,越过最新一条回到召回前的草稿;召回只填入正文与仍有效的明确 tokens,不发送,不带回附件与回复对象并给出提示;编辑后方向键恢复光标移动,IME 与候选菜单优先。普通历史保留最近 100 条且不超过 256 KiB,按最旧裁剪;failed 与 unknown 不受该预算淘汰。这份列表是恢复数据而非消息日志,规范消息仍由 Host 拥有。 + +## Human 群聊全部 Bot 提及(#542) + +群聊组合器的 `@所有 Bot` 是临时的 Human 输入意图,预览显示当前群内已加入且未暂停的接收 Bot 人数;DM 不提供该选项,粘贴文本不携带选择权威。现有 Channel Store 使用 Registry 与成员事实生成包含名单及显示名的预览 revision。提交前及实际提交边界校验同一预览,名单改变(即使人数相同)或人数为零时返回更新后的可信预览,经既有 Typert 错误 details 显示新人数、保留草稿,并等待 Human 再次发送。 + +Host 将选中的单一 token 展开为普通逐个 @Bot 的正文及稳定 ID/范围,提交一个 Source Event 和 placement,再复用每个 Bot 的普通 group-mention Admission、attention 与 wake policy;普通消息 silent 不屏蔽明确提及。重试相同 messageId 在当前成员校验前复用已提交内容,重启保留普通消息及各接收者状态。该预览不写入消息或引入 broadcast Source Class;Bot 的 Tool 不暴露此快捷方式。见 [ADR-0099](../adr/0099-human-all-bot-mention-expands-to-direct-mentions.md)。 + +### 明确分享自己的 Inbox 来源(#636) + +[ADR-0115](../adr/0115-explicit-inbox-sharing-adds-canonical-placement.md) 向当前 Orchestrator 暴露 `bridge_share`。Messaging 在执行时检查自己的 Inbox 来源、当前接收身份/Grant 与已加入的 Group,再原子提交一个 canonical placement 和各成员普通收件记录。接收 Bot 保留原 admission,其他成员沿用自己的 Channel Attention 与数量/时间 harvest。Channel 渲染直接读取 canonical 来源内容,保留发送人、平台和外部 ID。分享不改变后续收件,不镜像到 Human DM,不对外发送,也不授权成员借用接收身份。同目标重试返回已提交结果,不补收给后来成员;首个切片拒绝另一个目标、DM 和已经投递到 Channel 的来源。撤销授权会阻止新的副作用,但保留已共享历史。多 placement 与独立接收路径由下述 #635 切片交付。 + +[ADR-0117](../adr/0117-external-only-reports-use-owned-outbox-correspondence.md) 增加明确请求的纯外部报告。`bridge_targets` 返回自己已有的授权群 Grant,`bridge_post` 沿用 Outbox,不产生 Channel placement 或 admission。可选 Provider 回执保留原生消息/会话 ID;`bridge_outbox` 提供有界预览或一条 canonical 报告,撤销后仍可读。真实收件的 parent/root 只关联同 Bot、账号 fingerprint 和会话的原报告。Profile 与来源 Modal 读取 Outbox 投影。可选且认证的自身回传仅补充已有对应关系,不产生收件提醒;不假定真实 Lark 必定回传。未知结果不自动重试,沿用 @/跟进和 dispatch 前撤销检查。 + +### External platform behavior defaults (#701) + +[ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) assigns qualified Lark collection, ordinary-message harvest and identity enabled preferences to the existing Messaging database owner. Immutable global revisions cross the authenticated Typert/API Gateway; native Bot settings and Profile tables display effective values and inheritance origin. Legacy configurations remain custom; new qualified configurations inherit, and restoring inheritance uses current defaults. Explicit Thread/source collection overrides platform collection; a member's Channel wake override precedes its explicit PersonaBot source rule and then external-platform defaults. Local messages retain local defaults. Sources and per-member admissions save revisions and thresholds; digest buckets partition those snapshots. Identity pause reuses the existing dispatch gate and Consumer lease lifecycle; resume revalidates the same authorization and fences old events without backfill. No new identity, Grant, listener, automatic reply or Inbox store results from a defaults change. + +[ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) 将已资格验证的 Lark 收件、普通消息汇总与身份启停偏好归于既有 Messaging 数据库权威。全局不可变版本经认证 Typert/API Gateway 供 Bot 设置、Profile 与 Bot 工具读取;旧配置保持自定义,新配置明确继承,恢复继承使用当前默认值。话题/来源收件覆盖平台默认;成员 Channel 唤醒覆盖 PersonaBot 显式来源规则,再回退外部平台默认。本地消息保持既有默认。Source 与各成员 Admission 保存版本及阈值,汇总按快照分桶。身份暂停复用既有发送门禁和 Consumer lease,恢复校验原授权并拒收边界前的迟到事件,不回填;修改默认值不产生新身份、Grant、listener、自动回复或 Inbox 存储。 + +### Multiple source and target routes (#635) + +[ADR-0120](../adr/0120-multiple-bridge-routes-retain-canonical-sources.md) keeps multiple explicit Channel/Inbox routes under the existing authorized Messaging Grant. One qualified Provider Consumer lease per verified account fans out to conversation-scoped Grants. A canonical external Source Event has one content authority and multiple per-Channel placements; Inbox-only has no transcript. Immutable per-Bot/per-route snapshots retain authorization, collection and wake evidence beside one admission per Bot/source. Any ready live path can select that source once; handling one Bot cannot consume another member's admission. Current membership and identity gates protect intake and reads; context and attachments retain their own receiving-identity boundaries; explicit shared-source replies use the responder-owned authorization described below. Route pause/delete/reenable do not affect surviving routes or backfill accepted sources. Profile tables expose explicit DM versus Inbox-only destinations, independent switches and native add/edit/delete Modals. Generation 52 requires a pre-upgrade Profile backup or forward recovery, rather than code-only rollback. + +[ADR-0120](../adr/0120-multiple-bridge-routes-retain-canonical-sources.md) 将多条明确的 Channel/Inbox 路由保留在既有 Messaging Grant 下;同一已验证账号的一个 Provider Consumer lease 向各授权会话分发。外部 Source Event 保留一份内容权威和各 Channel placement,Inbox-only 不生成聊天历史。每 Bot/路径的不可变快照独立记录授权、收件与唤醒证据,每 Bot/来源仍只有一条 admission;任一有效路径就绪时只选择来源一次,处理一个成员不会消耗其他成员的工作。收件及读取检查当前成员和身份权限,上下文与附件仍遵守接收身份边界;明确的共享来源回复使用下述回复者自身授权。单条路由暂停、删除或恢复不影响其他路径,也不回填旧来源。Profile 通过独立 Switch 和原生 Modal 管理路由,明确区分 DM 投递与 Inbox-only。Generation 52 需升级前 Profile 备份或向前修复,不能仅回退代码。 + +### Own-identity replies to shared external sources (#637) + +[ADR-0122](../adr/0122-shared-source-replies-use-responder-owned-authorization.md) keeps source content and receiving identity canonical while allowing a current Group member to inspect its shared placement without creating an Inbox Admission. An explicit reply requires that member's own enabled external identity and one verified Grant for the original external group. The optional dsh-im checked reply-context contract resolves the exact source under the responder's application, retaining conversation/thread/root/parent while translating only the app-scoped sender identifier. The existing Outbox records a per-responder/source intent, owned identity, qualified route, receipt and honest outcome; Profile projects these details. Membership, Binding/Grant revisions and Provider Registration are fenced again after remote validation and immediately before SDK dispatch. Missing authority, mismatched routes and unknown outcomes never borrow the receiver, fall back to the group mainline or retry blindly. Text replies add no source copy, automatic ownership lock, wake or schema migration; shared remote context/files remain separately qualified. + +[ADR-0122](../adr/0122-shared-source-replies-use-responder-owned-authorization.md) 保留一份来源内容和原接收身份,让当前 Group 成员读取已共享的 placement,而不新增 Inbox 收件。明确回复必须使用该成员自己启用的外部身份和原外部群的唯一已验证 Grant。dsh-im 可选的回复核验契约以回复者应用读取原消息,保持会话/话题/root/parent,只解析该应用范围内的发送人 ID。既有 Outbox 按回复者/来源持久记录独立请求、身份、路线、回执与真实结果,Profile 展示这些事实。远端核验完成后、SDK 发送前再次检查成员、身份/Grant 版本与 Provider Registration。缺少授权、路线不符或未知结果时,不借用接收身份、不回退群主线、不盲目重试。文本回复不新增来源副本、自动抢单锁、唤醒或数据库迁移;共享远端上下文和附件仍需各自的资格验证。 回复核验前,既有 Consumer fanout 可在收件关闭时取得仅供回复的账号 lease;外部来信确认后丢弃,不新增 Source Event 或 Inbox,自己的准确回显仍可关联 Outbox。Profile 仍显示收件关闭,身份/Provider 生命周期会取消该进程内连接。 + +[ADR-0125](../adr/0125-nearby-context-combines-time-coverage-and-count-minima.md) 更新外部 `bridge_context` 的 nearby 选择:先通过有界分页覆盖来源前后各五分钟内的可见 Human 文字消息,再为不足的一侧补齐默认前 10/后 5 条;Bot 可指定每侧 0–20 条,条数是保底而不是密集窗口上限。Lark Provider 依次遍历时间窗口、最近更早消息、最近更晚消息,按精确时间过滤秒级边界;不等待未来消息。Messaging 的进程内 opaque continuation 绑定 Bot、来源、Grant revision、范围与条数参数,每页续期 30 分钟,每次仍重新校验当前权限与 Consumer/Provider 生命周期。输出预算、略过记录和续页均明确呈现;普通历史仅形成既有 Source Event 观察,不创建 Inbox Admission 或新的消息权威。 + +[ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md) extends the existing checked DSH Service Provider/Consumer seam to Slack Human text mentions. The native Socket Plugin verifies the workspace, Bot and App pairing, persists exclusive Consumer mode, and acknowledges only accepted canonical intake; loss of a lease does not create a standalone Session. Slack channel IDs, message timestamps and event delivery IDs remain distinct. Replies use the original native thread timestamp (or root message timestamp to start a thread), current own-Bot channel/source qualification, the existing pre-dispatch fence and an explicit native receipt. Client identity/connector/source views reuse their existing projections with platform-correct labels. Explicit Slack source context reads now use the same checked Provider: every page requalifies own account/member/source/lease, separately adapts newest-first channel history and chronological native reply chunks (including root reservation/deduplication and time-bounded cursors towards older chunks), and persists only returned canonical Source Events without historical Inbox Admission or wake (#819). Nearby reads paginate the inclusive five-minute window and supplement nearest visible Human text to default 10 before / 5 after, each configurable 0–20. Signed runtime-local continuations retain bounded native IDs/counts, not message bodies; channel-history coverage is distinct from complete native-thread coverage. The #831 file tracer separately opts into one hosted attachment on a Human mention: exact native source-file association is rechecked at read time, safe ID/name/type/size metadata reaches canonical Source Events, and bounded private streams reuse the Attachment owner. Explicit result files use the existing Outbox with a fence before native upload completion in the original thread. Private URLs remain Provider-local; redirects, revoked leases and changed sources refuse. The #837 ordinary-text Consumer opt-in uses native message.channels events with current account/App/public membership/lease checks; own mentions remain on app_mention only. Explicitly authorized groups retain default mention-only collection, real-delivery qualification and existing count/time or immediate wake. Profile reuses the same compact collection editor; no new store, queue, Session or mandatory reply is introduced. #854 extends the existing explicit thread policy to Slack native `thread_ts`: root and thread match, while no parent ID is fabricated. A real unmentioned child (`ts != thread_ts`) on the current lease qualifies follow; a mainline root does not. Source/account/Grant checks, Human overrides, per-member Admission snapshots and count/time wake remain unchanged. Migrated connector Grants retain the Profile thread table and policy Modal. Ordinary files remain separately qualified; #843 extends the existing platform defaults after intake qualification. + +Slack external-only reports (#863) use the same canonical Outbox as Lark: an explicit own-identity `bridge_post` persists text and checked native `channel`/`ts` receipt without a Channel placement or self admission. An eligible Human thread reply associates through native root identity under the same account/fingerprint/conversation; existing connector and wake policy determine intake. Publication does not imply topic follow. Provider account/member/Consumer/Registration checks precede a single non-retrying send; ambiguous outcomes remain unknown. See [ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md). + +[ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md) 将既有 DSH Service Provider/Consumer 契约扩展至 Slack 的 Human 文字 @。原生 Socket Plugin 核验工作区、Bot 与 App 对应关系,持久保存独占 Consumer 模式,仅在 canonical 收件接受后确认;lease 丢失不创建独立 Session。Slack 频道 ID、消息时间戳和事件投递 ID 分别保留。回复沿原生话题时间戳(主线消息则用其时间戳新建话题),重新核验自身身份的群成员资格及原消息,经过既有发送前门禁并保存原生回执。身份表、频道连接器表和来源详情复用既有投影,按实际平台显示名称。Slack 来源上下文通过同一 checked Provider 主动读取:每页复查自身账号/成员/来源/lease,分别适配频道历史倒序和原生话题页内正序/向旧消息块续页,为附加 root 预留空间并去重,只持久化返回的 canonical Source Events,不生成历史 Inbox Admission 或唤醒(#819)。附近查询完整翻页读取前后各五分钟窗口,稀疏时补齐最近的前 10/后 5 条 Human 文本,两侧可各设 0–20。运行实例签名的续页只含有界原生 ID/计数,不含消息正文;频道历史的覆盖范围区别于完整原生话题。#831 单独为 Human @ 消息启用一个 hosted 附件:读取时复查原生消息与文件关联,安全 ID/名称/类型/大小进入 canonical Source Event,有界私有下载流复用 Attachment owner。明确导入的结果文件通过既有 Outbox 回传原生话题,在上传完成可见前执行门禁。私有 URL 留在 Provider 内部,拒绝跳转、已撤销 lease 和已变更来源。#837 通过 Consumer 明确 opt-in 接收原生 message.channels 普通文字,复查当前账号/App/公开群成员/lease;自己的 @ 只走 app_mention。已授权群保留默认只收 @、真实普通投递验证及现有数量/时间汇总或逐条排队唤醒,Profile 复用紧凑收件策略编辑器,不增加存储、队列、Session 或强制回复。#854 将既有显式话题策略适配到 Slack 原生 `thread_ts`:root 与 thread 相同,不制造 parent ID。当前 lease 上真实无 @ 子回复(`ts != thread_ts`)才证明可跟进,主线 root 不提供该证明。来源/账号/Grant 校验、Human 覆盖、每成员 Admission 快照和数量/时间唤醒保持既有契约;迁移后的频道连接器 Grant 保留 Profile 话题表与策略 Modal。普通文件仍需独立资格验证;#843 在收件资格验证后扩展既有全局默认设置。 + +### 已资格验证平台的默认行为 + +[#1156](https://github.com/BotHarness/BotHarness/issues/1156) qualifies two QQ applications through the existing independent binding and Channel Bridge model. Each app-scoped conversation is explicitly mapped; neither matching text nor a shared local Channel equates external locators or lends passive reply proof. The canonical Source Event projects its receiving app reference/name into QQ Channel source labels and details, and Group Profile activity retains app identity. These optional presentation fields add no store or sending authority; earlier projections remain readable. Native acceptance receipts and observed Provider Echo remain distinct capabilities. + +Native own Echo remains unqualified: accepted send receipts and visible original-group replies do not establish a platform callback of the responding App's own message. The current dual-App slice is partial #1156 delivery. + +The optional developer Provider's maintained SDK 1.0.4 build patch observes the WebSocket message boundary before decode/JSON, links bounded transport and Gateway metadata by process-local connection/frame counters, and exposes start/end totals through existing connection health. Diagnostics remain process evidence; they create no Source, Admission, Outbox or Echo authority and are separate from the managed product Provider pin. + +自身原生 Echo 尚未通过验收:发送接受回执与原群可见回复不能证明平台回推了响应应用自己的消息;当前双 App 切片属于 #1156 的阶段性交付。 + +可选开发 Provider 的维护版 SDK 1.0.4 构建补丁在 WebSocket message 进入 decode/JSON 前观测,按进程内 connection/frame 计数关联有界传输与 Gateway 元数据,并通过既有连接健康状态提供首尾累计数。诊断仅为进程证据,不产生 Source、Admission、Outbox 或 Echo 权威,与托管产品 Provider pin 保持独立。 + +[#1156](https://github.com/BotHarness/BotHarness/issues/1156) 沿用独立应用绑定与 Channel Bridge 模型验证两个 QQ 应用。各自应用范围的会话分别显式映射;相同文字或共同本地 Channel 不会合并外部定位,也不会借出被动回复证明。Canonical Source Event 把接收应用 reference/名称投影到 QQ Channel 来源标签和详情,群 Profile 活动保留应用身份。这些可选展示字段不增加存储或发送权限,旧投影仍可阅读;原生接受回执与 Provider Echo 观察保持独立资格。 + +[#1153](https://github.com/BotHarness/BotHarness/issues/1153) exposes Group Channel sync from the received QQ conversation through the existing Channel Bridge command. Inbox Admission and optional Channel placement share the canonical Source Event; disabling placement retains history. Messaging stores bounded reception metadata by Bot, Provider and authenticated app fingerprint in its own database. Local controls and public account inspections record distinguishable boundaries, while restart continuity remains explicitly unverified; no second transcript or remote backfill is introduced. Same-app rebind preserves blocks and mute preferences without restoring revoked Channel authority. See [ADR-0152](../adr/0152-qq-reception-intervals-record-local-observations.md). + +[#1153](https://github.com/BotHarness/BotHarness/issues/1153) 通过既有 Channel Bridge 操作,在已接收的 QQ 会话里提供群 Channel 同步。Inbox Admission 与可选 Channel 落点共享 canonical Source Event,停止落点保留历史。Messaging 在自己的数据库里按 Bot、Provider 和已认证应用 fingerprint 保存有界接收元数据,区分本地控制与公开账号检查的边界;重启连续性明确标为未验证,不引入第二份消息记录或远端补收。重绑同一应用保留屏蔽和静音,但不恢复已撤销的 Channel 权限。见 [ADR-0152](../adr/0152-qq-reception-intervals-record-local-observations.md)。 +The [#1159](https://github.com/BotHarness/DeepSeekBot/issues/1159) incoming-voice extension extends the same checked Service with independent transcript/audio opt-ins and the existing strict native quote proof. Native platform ASR or explicit unavailable provenance reaches canonical Source/Inbox and the existing Orchestrator text reply. Original audio remains source-bound canonical media; explicit playback acquires a separate WAV through the shared bounded SILK Worker. WeChat encoding checks remain platform-specific. There is no second voice store, automatic ASR, ordinary reception or native voice output. One maintained-runtime application qualified native quoted ASR → model → own-app original-group text reply (Human confirmed), original download and actual Client keyboard playback; other application/codec/modern-Host combinations remain independent qualifications. + +[#1159](https://github.com/BotHarness/DeepSeekBot/issues/1159) 语音入站扩展沿同一受检 Service 增加独立转写/原音协商,并复用严格原生引用证明。原生平台转写或明确不可用来源进入 canonical Source/Inbox,沿现有 Orchestrator 在原群回文字。原音保持来源约束,显式播放通过共享有界 SILK Worker 获取独立 WAV;微信编码校验仍由自己的平台边界负责。不新增第二份语音记录、自动 ASR、普通消息接收或原生语音输出,一个维护版运行应用已通过真实引用语音转写 → 模型 → 本应用原群文字答复(Human 确认)、原音下载及实际 Client 键盘播放;其他应用/编码/现代 Host 的组合仍需独立验收。 + +The [#1158](https://github.com/BotHarness/DeepSeekBot/issues/1158) file candidate adds explicit generic-file negotiation to the same QQ Service. Direct native files and qualifying embedded type-103 file quotes belong to the current mention Source Event; a single raw file block, matching message/group/author and consistent native reference indices are required. No adjacent-message matching, history cache or historical Source is introduced; private download tickets stay in Provider memory. Shared Channel projection adds a file card whose download uses the existing source/identity/Grant checks, canonical AttachmentStore and forced attachment response. Processing reuses an independent Workspace working copy and imported result. Upload type 4 uses the same final account/source/lease/Core fence and native receipt as image output; actual application permission remains unqualified. + +[#1158](https://github.com/BotHarness/DeepSeekBot/issues/1158) 文件候选在同一 QQ Service 增加显式普通文件协商。直接原生文件及合格的内嵌 type-103 文件引用均归属当前 @ 消息 Source Event:要求唯一原始文件块、消息/群/作者身份吻合、原生引用索引一致,不关联相邻消息、不增加历史缓存或历史 Source,私有下载票据保留在 Provider 内存。共享 Channel 投影增加文件卡片,下载复用现有来源/身份/Grant 检查与 canonical AttachmentStore,并强制附件响应。处理复用独立 Workspace 工作副本及导入结果。类型 4 上传复用图片输出的最终账号/来源/租约/Core 检查及原生回执;实际应用权限尚未验收。 + +The [#1157](https://github.com/BotHarness/BotHarness/issues/1157) QQ image candidate extends the same checked Service with opt-in image intake, bounded private source tickets and acquisition, and native image output. Canonical Source Event/Inbox and AttachmentStore remain the authorities. Human preview uses the original implicit account Grant plus its current Channel route and canonical placement when no independent reception-path record exists; membership, identity, scope and revocation checks still apply. Preview creates no Observation or wake. Actual model access reuses working-copy save and native `read_image` on an image-capable calling route. Image upload never sends a message itself; account/lease/source and Core authority are checked again immediately before the single final native POST. Its public native message receipt settles the existing Outbox; unknown is not retried. Caption-first presentation preserves attachment association without inventing native interleaving. Real QQ/model/Client acceptance remains separate evidence. + +[#1157](https://github.com/BotHarness/BotHarness/issues/1157) QQ 图片候选沿同一 checked Service 扩展显式图片收件、有界私有来源票据与获取,以及原生图片输出。Canonical Source Event/Inbox 与 AttachmentStore 继续拥有权威。没有独立接收路径记录时,Human 预览使用原 implicit 账号 Grant、其当前 Channel 路由及 canonical placement,仍检查成员、身份、范围和撤销。预览不产生 Observation 或唤醒。实际模型访问复用工作副本保存及支持图片的当前调用路由上的原生 `read_image`。图片上传自身不发消息;最终单次原生 POST 前再次检查账号/租约/来源和 Core 授权。公开原生消息回执结算既有 Outbox,unknown 不重试。说明在前的展示保留附件关联,不虚构原生穿插顺序。真实 QQ/模型/Client 验收证据独立记录。 + +The [#1152](https://github.com/BotHarness/BotHarness/issues/1152) QQ group-text candidate registers through the existing dsh-im Service Provider/Consumer seam. Official application authentication supplies the account fingerprint; trusted group mention text commits into the existing Source Event and Bot Inbox, wakes the existing Orchestrator and replies through its own application in the original group. Reply-only qualification does not require proactive capability. Exact source proof is bounded and process-local: five minutes, five attempted replies, no group-history reread or durable resume cursor. A pre-dispatch authority fence and native acceptance receipt preserve the existing Outbox; unknown results remain unknown without automatic retry. See [ADR-0151](../adr/0151-qq-group-replies-use-authenticated-apps-and-process-local-source-proof.md) and the [QQ guide](../qq-connection.md). Real group/model qualification and public release are separate delivery evidence. + +The [#1154](https://github.com/BotHarness/DeepSeekBot/issues/1154) QQ delayed-result slice reuses [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md)'s shared posting policy through an explicitly authorized saved group target when the Provider lacks QQ discovery. Known reply expiry retains the original failed Outbox intent; an explicit proactive report uses a distinct request identity, own-app/target correspondence, final fence, shared hourly quota and native receipt. Unknown replies do not become proactive reports, and repeated report requests after restart read the existing outcome. This adds no QQ-specific policy, automatic retry, local Human DM placement or additional durable result store. Real current-app delayed-task qualification remains separate from source regression coverage. + +[#1154](https://github.com/BotHarness/DeepSeekBot/issues/1154) 的 QQ 延迟结果切片在 Provider 缺少 QQ 群发现能力时,通过已显式授权的保存群目标复用 [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md) 的共享发送策略。明确的回复过期保留原失败 Outbox 意图;显式主动报告使用独立请求身份、自己的应用与目的地一致性、最终门禁、共享每小时限额和原生回执。不确定回复不会转为主动报告,重启后的重复报告请求读取既有结果。没有 QQ 专属策略、自动重试、Human DM 落点或第二份持久结果存储。当前应用的真实延迟任务验收与源码回归分别记录。 + +The #1155 ordinary-text extension uses the same exclusive checked QQ inbound consumer. Its software capability enables forwarding, while actual ordinary-delivery qualification belongs to the current Core receiver lease and follows the existing account/conversation intake boundary. A mentions-only probe supplies bounded process evidence without canonical ordinary collection. Explicit collection uses the existing Source, Observation, harvest and Channel policies; intentional external speech still requires independent posting authority. Neither a stored collection preference nor a blocked-interval event establishes current eligibility. No QQ history store, permission flag or standalone receiver is added. + +#1155 普通文字扩展沿用同一 QQ 独占受检消费者。软件能力允许转交事件,真实普通投递资格则归属于当前 Core 接收器 lease,并遵循已有账户/会话接收时间边界。只收 @ 模式下的探测提供有界进程证据,不进行普通内容的 canonical 收集。显式收集复用已有 Source、Observation、harvest 与 Channel 策略,主动外部发言仍需独立发送权限。保存偏好及屏蔽期间的旧事件均不证明当前资格,不新增 QQ 历史存储、权限标志或独立接收器。 + +[#1152](https://github.com/BotHarness/BotHarness/issues/1152) 的 QQ 群文字候选沿用现有 dsh-im Service Provider/Consumer 边界。官方应用认证提供账号 fingerprint;可信群提及文字提交到既有 Source Event 和 Bot Inbox,唤醒既有 Orchestrator,并使用自己的应用身份在原群答复。仅回复资格不要求主动发送能力。准确来源证明有界且仅存在于进程内:五分钟、五次回复尝试,没有群历史重读或持久恢复游标。发送前授权门禁和原生接收回执沿用既有 Outbox,unknown 不自动重试。见 [ADR-0151](../adr/0151-qq-group-replies-use-authenticated-apps-and-process-local-source-proof.md) 与 [QQ 指南](../qq-connection.zh.md)。真实群/模型资格与公开发布分别记录交付证据。 + +[#843](https://github.com/BotHarness/BotHarness/issues/843) 按 [ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) 将既有全局偏好扩展至 Slack 群文字。Bot 设置可选择 Lark 或 Slack,各自保留草稿与独立不可变版本;认证读取 RPC 可指定已资格验证的平台,旧调用仍读取 Lark。Generation 53 保留全部旧默认值与显式覆盖,仅扩展允许的平台。新绑定的 Slack 身份默认继承,旧身份保留原选择,由 Human 明确恢复继承。修改只作用于后续事件及仍继承的收件/汇总/身份行为;群授权、真实投递验证、Admission 快照与恢复接收时间边界仍由原模块管理,不新增账号、Grant、消息、队列或回复权威。 + +[#855](https://github.com/BotHarness/BotHarness/issues/855) adds a **development-source qualified mention/reply** Discord checked adapter through the same Provider/Consumer and canonical Inbox seams. App/Bot identity is authenticated and checked against Gateway READY. An explicitly external-only Profile avoids standalone bootstrap; consumer loss preserves external ownership. A public thread maps to its verified parent conversation plus exact child-channel `threadId`, without manufactured message ancestry. Replies check current native permissions/source, run the authority fence and validate native author/destination/receipt without fallback or retry. See [ADR-0128](../adr/0128-discord-checked-replies-preserve-native-child-channel-routing.md). The real channel/thread model, native identity/guild boundary and refusal/recovery checks passed under agent-operated E2E acceptance; [verification](../dev/verification/discord-855-mention-reply.md) bounds the qualification to that development runtime. Product Provider promotion and other Discord capabilities remain separate. + +[#855](https://github.com/BotHarness/BotHarness/issues/855) 通过相同 Provider/Consumer 和 canonical Inbox 边界增加**开发来源提及/回复已取得资格**的 Discord adapter。App/Bot 身份由认证 API 与 Gateway READY 校验,显式 external-only Profile 避免先进入独立 Session,consumer 消失仍保留外部所有权。公开 thread 使用已验证的父会话与准确子频道 `threadId`,不虚构消息祖先。回复复查原生权限/原消息,执行授权门禁并验证原生作者/目的地/回执,不转发、不重试。见 [ADR-0128](../adr/0128-discord-checked-replies-preserve-native-child-channel-routing.md)。真实频道/thread 模型、原生身份/服务器边界与拒绝/恢复检查已按 agent-operated E2E 指令通过;[验证记录](../dev/verification/discord-855-mention-reply.md) 将资格限定于该开发运行时。产品 Provider 晋升与其他 Discord 能力另行处理。 + +[#937](https://github.com/BotHarness/BotHarness/issues/937) extends that candidate Provider through the existing `bridge_context` / `historyChecked` seam: bounded group and existing-public-thread Human-text pages, native App content visibility plus view/history checks before and after the HTTP page, and runtime-local signed cursors bound to account, original source route, scope and limit. Canonical context retention creates no Admission or placement; a conflicting retained source rolls back the page and reports `source-conflict`. Native edits never replace the earlier source evidence. [The exact candidate checkpoint](../dev/verification/discord-937-context-reads.md) separates real single-page model success, direct native cursor checks and restored-permission/restart refusal from still-pending model continuation/conflict acceptance. Nearby, ordinary subscription, files and product dependency promotion remain outside this slice. + +[#937](https://github.com/BotHarness/BotHarness/issues/937) 沿既有 `bridge_context`/`historyChecked` 边界扩展该候选 Provider:有界 group/已有公开 thread Human 文本页,HTTP 页读取前后核对原生 App 正文可见性及查看/历史权限,运行时内签名 cursor 绑定账号、原来源路由、范围和页限。Canonical 上下文留存不创建 Admission 或 placement;留存来源冲突使整页回滚并返回 `source-conflict`,远端编辑不替换早期来源证据。[准确候选检查点](../dev/verification/discord-937-context-reads.zh.md) 区分真实模型单页成功、直接原生 cursor 检查及恢复权限/重启后的拒绝,与仍待完成的模型续页/冲突实测。Nearby、普通订阅、文件和产品依赖晋升不属于本片。 + +[#845](https://github.com/BotHarness/BotHarness/issues/845) qualifies Slack sources through the existing shared Channel routes and member Attention, without a second store. Group Profile exposes separate effective Lark/Slack wake previews; the legacy Lark RPC field remains compatible. The bilingual [IM Provider integration guide](../dev/guides/im-provider-integration.md) records the checked identity, intake, native routing, bounded context/file and acceptance boundaries for future providers. + +[#845](https://github.com/BotHarness/BotHarness/issues/845) 使用既有共享 Channel 路由与成员 Attention 验证 Slack 来源,不增加存储。Group Profile 分别展示 Lark/Slack 的有效唤醒策略,保留旧 Lark RPC 字段兼容。[IM Provider 接入指南](../dev/guides/im-provider-integration.zh.md) 汇总后续平台需要复用与验证的身份、收件、原生路由、有界读取/文件及验收边界。 + +[#878](https://github.com/BotHarness/BotHarness/issues/878) adds a **WeChat paired-owner DM slice with source and installed-product E2E verified and Human QA approved**, through the existing checked Provider/Consumer, canonical Source/Inbox and own-identity Outbox. The QR-paired owner is an explicit native DM Grant, with no fabricated mention/group/thread or local DM mirror. The Provider keeps the original private continuation outside public snapshots and advances polling only after canonical acceptance; absent leases remain external-only. Checked text replies use the source-bound continuation and a pre-send authority fence. Their receipt is explicitly a client acknowledgement, not a native message ID or read/delivery proof. The #903 file slice reuses the same Source Event/Inbox path: one native file is retained as safe metadata, privately downloaded into canonical attachments on demand, and an explicitly selected result replies to the original DM. CDN tickets/AES keys never enter public DTOs; downloads stop at 25 MiB, and a required current-authorization fence runs after upload before the final send. Declared oversized files remain inspectable but not downloadable. The #904 image candidate separately negotiates native image intake, derives format from bounded decrypted bytes, and reuses canonical attachments plus own-identity image replies with a post-upload fence. The Client preview is authenticated and disposable; model understanding additionally requires the actual DSH model route to support images. Installed-product image intake, preview and actual DeepSeek Flash image understanding are verified; the Human confirmed the native image result in the original WeChat DM. Receiver-side byte equality and a public image release are not claimed. The #905 candidate preserves platform voice-transcript availability, native item ID and duration in the existing Source Event and exposes provenance in Inbox/Source UI and the Orchestrator input. It does not implement ASR or audio playback; a real native voice transcript reached the model and the Human confirmed its own-identity text reply in the original DM. The #906 raw-audio extension additionally negotiates a private source-bound audio attachment and optional native codec metadata, retaining the unchanged original in canonical attachments. Explicit playback or working-copy selection runs bounded SILK decoding in a Worker and acquires a separate mono 24 kHz WAV through the same AttachmentStore; authenticated preview resources are disposed on close, and current source/account/Grant fences remain in force even for cached output. Unsupported codecs remain downloadable originals. This adds no ASR, automatic model audio input or native outgoing voice. Fresh raw-audio E2E qualification is tracked separately from the #905 transcript evidence. Remote context, groups and canonical proactive reports remain unavailable in that earlier slice. See [ADR-0129](../adr/0129-wechat-owner-dms-use-private-source-continuations.md). + +[#996](https://github.com/BotHarness/BotHarness/issues/996) extends the existing canonical DM admission/reply path to explicitly authorized Lark private chats. A checked user Target retains its outbound `openId` and an optional exact private `chatId`; only a qualified Provider accepting that locator exposes a DM receive scope. The same PersonaBot can retain Discord and Lark identities, with private messages admitted as `human-dm` into its existing Inbox and source-bound replies through the Lark identity. Missing private-chat locators remain outbound-only; private DM reception is excluded from Group Channel Bridge controls. This adds no local DM mirror, Session store, permission broadening or second Bot. + +### Human Bridge media presentation + +[ADR-0217](../adr/0217-im-conversation-mirroring-is-an-opt-in-projection.md) adds independently opted-in IM conversation presentation and local-DM forwarding. Both default off and share one effective preference per Binding/private conversation, across implicit and explicit Grants. Incoming presentation places the canonical Source Event without another Admission; accepted outgoing presentation carries its Outbox and original-source lineage and participates in Content Purge. Only the new mirror placement reduces media to safe metadata; ordinary connector placements keep their original media checks. Enabling starts a prospective epoch; current epoch and Binding checks fence projection and bounded recovery. Forwarding uses the exact opted-in Binding, existing Outbox and final Provider send fence, including every long-message part. A closed switch or off/on cycle stops unsent work. WeChat qualifies this first slice; the other IM adapters remain separate issues under #1990. + +[ADR-0137](../adr/0137-human-bridge-media-uses-channel-source-authority.md) places Human external-image access in the existing Messaging and Attachment owners. The Source Event retains trusted native descriptors and order; realtime and historical Channel placement projections expose media references without resource keys. The existing attachment route under the authenticated API Gateway rechecks Human Channel access and independent recorded reception paths using Channel, Source Event and attachment IDs. The Provider verifies the original message/resource association before acquisition. Visible images acquire automatically, reuse the deterministic Attachment identity and enlarge in the native Modal. Reception stop refuses new acquisition; unbind/revocation refuses acquisition and cached serving through that path. Missing or purged originals are not recreated. Human viewing creates no Observation, Inbox Admission, model image input or send permission. #1021 is the Lark image tracer; files, audio/video and other providers extend the contract in subsequent slices, with real platform qualification tracked in each issue/PR. + +#1022 沿同一权威接通 Lark 经核验的单个引用文件:投影与取得共享媒体资格判断,未知 MIME/大小不作推断。Human 明确下载才取得有界原件;支持原生打开的 Host 复用既有应用菜单,`channelMediaTarget` 每次按当前 Channel/来源/文件授权解析 canonical Attachment 目标。CLI 提供同一授权路径的目标和下载命令;没有新增 Provider、消息存储或 Bot 注意力。直接文件、多文件/混排与真实平台验收保持独立资格边界。 +[#908](https://github.com/BotHarness/BotHarness/issues/908) extends the same WeChat Source Event authority with opt-in one-level native quote evidence: server message ID, item ID, embedded text, display summary and partial-reference metadata remain distinct. Missing bodies resolve only from readable canonical sources in the same currently authorized account/private conversation, or accepted Outbox records with genuine native server receipts; WeChat client acknowledgements never qualify. No Provider transcript store or standalone Session lookup is introduced. `bridge_context(retained|retained-nearby)` returns bounded canonical local records with explicit `retained-local-sources` coverage, current own-identity/Grant/lease inspection, initial row-boundary snapshot and authorization-bound opaque keyset cursors. These reads audit the anchor Source Event without creating Admissions or wake; unavailable references do not imply remote deletion or a Thread. Client source details and Orchestrator input distinguish native quoted text, local resolution and unavailable content. Remote WeChat history/search remains unavailable. + +[#909](https://github.com/BotHarness/BotHarness/issues/909) routes the explicitly authorized WeChat paired-owner DM through existing Channel connector records: local DM/Group placements and Inbox-only paths reference one canonical Source Event. Group members retain their independent Attention/harvest policy; private Inbox and explicitly connected local DM paths retain the `human-dm` policy. The native origin remains `dm`, never a fabricated WeChat group or Thread. Shared source readability does not transfer the receiving identity or its private continuation: each external reply still requires the acting Bot's own enabled identity and Grant. Existing intake boundaries, placement deduplication, membership checks and dispatch fences apply to switches, deletion, revocation and restart. No schema, Provider transcript store or new Session lifecycle is added. Fresh installed-product qualification is recorded on #909. + +[#910](https://github.com/BotHarness/BotHarness/issues/910) extends the canonical external-only report path to qualified paired-owner WeChat DMs ([ADR-0139](../adr/0139-wechat-external-reports-use-private-owner-context.md)). The Profile form and `bridge_post` share owned Outbox authority. The Provider explicitly negotiates receipt and final-fence support, keeps current owner context private with fingerprint/order/retention checks, and invokes the application's current Binding/Grant/Registration fence immediately before one native send. Genuine server IDs stay separate from client acknowledgements. Missing context and native rejection offer explicit fresh-message recovery; unknown outcomes never trigger automatic retry. No local DM mirror, new Inbox delivery, scheduler or standalone Provider Session is introduced. + +[#911](https://github.com/BotHarness/BotHarness/issues/911) qualifies a process-local checked typing lease for the authorized WeChat owner DM ([ADR-0145](../adr/0145-wechat-typing-follows-owned-processing-leases.md)). Canonical Messaging resolves own source, Binding, Grant and Registration authority; actual Orchestrator/accepted steering and related Assignment work share one bounded native lifecycle. The Provider keeps tickets and continuations private, renews only under a current fence and cancels on the last processing owner or invalidation. Identity persistence stores only the preference; live activity resets on restart and never becomes a SessionEvent, Outbox delivery or read receipt. Windows native visibility, stop and recovery passed Human QA on #911. + +[#912](https://github.com/BotHarness/BotHarness/issues/912) extends the existing immutable defaults owner to WeChat identity enablement and typing with independent inheritance markers ([ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md)). Generation 69 preserves old choices as custom; new bindings inherit. Global typing changes invalidate only inherited live leases, and identity resume retains the existing receive-after fence. The owner-DM editor omits unsupported group/Thread controls; Channel connectors keep separate routing and member policies. No new authority or message store is introduced. Native #912 fresh-message QA remains pending. + +### 窗口伙伴(#1138–#1142) + +EventSource 在暂时 HTTP 失败后进入终态 CLOSED 时,集合 owner 用单一重试计时器重新建立连接,间隔从 1 秒退避到最多 8 秒。最后确认的过程消费 ID 通过 `?resume=` 携带;新连接随后原生自动重连时,`Last-Event-ID` 优先于查询参数。两条路径共享同一认证、有界 Host 租约与规范资格重查。成功基线重置退避;移除最后一个选择或卸载 Plugin 清理计时器和恢复标识。Client 重启不保留恢复标识。 + +Client 独立的 `CompanionMotion` 拥有有界拖拽姿态、速度采样、连续重力、横向阻尼、轻微地面回弹与落地收敛。View 继续拥有既有帧循环;带阻尼的二阶角弹簧在拖拽反向、停留与松手时保留角度和角速度,气泡跟随同一位置。有界积分步长和倾角上限避免延迟帧使姿态失稳。调整窗口保留当前运动并重新收敛边界、协调拖拽原点,指针取消只匹配活动捕获。最终落地只保存归一化横向位置;减少动效时清除弹簧能量、直接回到底部并关闭姿态效果。这些呈现动力学独立实现,参考 Coopanion 的拖拽/空中/落地交互概念,不引入其源码或美术。 + +集合共用一个 Client-owned `CompanionSound`,消费既有文字播放循环新显示的已提交字素,以及 `CompanionMotion` 的抓起、快速拖动、快速释放与首次触地反馈。拖动音效冷却 500ms,地面小反弹不重复播放;取消捕获与减少动效不补播。沿用既有 `speechSound` 存储字段保存统一伙伴音效偏好,避免重置已选设置。全局伙伴声音设置按 Client/Profile 持久化,默认关闭;Web Audio 只在可信 Human 操作时按需创建,受限或不可用不影响消息。短合成音节随 Bot 身份和文字变化,标点与空白静音,并发与频率有界,不排队补播。静音、隐藏/离屏、断线、归档、移除和销毁停止相应声音。该呈现不新增模型工具或 TTS,不持久化音频资源或播放进度([#1173](https://github.com/BotHarness/DeepSeekBot/issues/1173))。 + +Avatar 为伙伴消费者提供可释放的锚点读取器:每次形象替换只采集一次受支持头部的局部顶部中心,既有可见帧循环只读取该局部组当前显示的屏幕矩阵,包含外层旋转、缩放及过渡中的变换原点。像素 Activity 覆盖使用相同头部区域点与 SVG 根矩阵;图片、未知 rig 或不可用的 SVG 几何使用可见 Avatar 盒子顶部中心。不逐帧扫描整个 SVG 边界,也不向 Host 发送姿态 RPC。`CompanionBubbles` 保留原始投射点,让直立卡片受视口、工具栏与多伙伴避让约束,偏移后用像素式连线保持来源关系。阅读延续既有位置与离开宽限;隐藏/离屏停止采样,形象替换/销毁清除读取器([#1177](https://github.com/BotHarness/DeepSeekBot/issues/1177))。 + +应用定义的 Window Companion owner 跟随 Client Plugin 生命周期,位于 Bot 模式页面之外,通过官方 `shell.overlay` Slot 渲染。本地偏好按 Client origin 与原生 `profileContext.dir` 的不透明哈希隔离,消息卡片不落盘。受认证的 Connection Fetch `/api/botharness/companion` 提供 Profile 上下文及只处理未来消息的 SSE 基线;消费者在同一 Host turn 同步注册并读取快照,再投影 Registry 形象、现有全 Bot Activity 和拥有者已提交的 Human–Bot DM 输出。Channel store 与 Session ownership 保持权威;草稿和历史查询不进入首个 feed。 + +应用定义的 `WindowCompanions` 集合拥有 Profile 的钉选集合和共用容量,组合每个 Bot 独立的播放 owner 与运动 View。侧栏菜单与各 Bot 的 header 按钮均操作此集合,与 Channel 置顶无关。偏好从单选 v1 迁移到 v2;只持久化形象选择、来源/范围、位置、走动与容量。多个伙伴复用一条受认证 SSE,通过原生随机的临时 consumer ID 和同路径 POST 原子替换消费集合;控制句柄随流关闭释放,既不是身份也不是耐久权威。递增选择版本阻止迟到的基线初始化被替换的消费;增加或移除其他 Bot 不重置正在阅读的卡片。 + +选中伙伴时,共享 feed 同时供给现有 Client Activity store,替代其独立 Activity SSE。慢消费者分别合并 Activity 和选择快照,按 Bot 有界保留消息并轮流排出。容量默认层叠 3/保留 20,层数 1–10、保留 1–100 且层数不超过保留;每个 Bot 的 Host 待发队列、Client 保留卡片与阅读到达队列各受保留数量约束。阅读中最多两倍保留数量的卡片,离开时合并最新消息;已有文字按完整字素继续播放。View 做基本气泡避让与视口夹取,不设钉选数量上限或自动折叠人物。移除一个 Bot 只释放自身播放,最后一个移除后关闭共享流。透明半身使用 PixelFigure 公共 body/head 结构,仅伙伴 surface 去掉底板,普通头像配方与背景保持原样。每个 Bot 的 DM、群聊与 Activity 标签独立开关;消息开关与 own-dm/shared/all-bot 范围相交。Channel owner 的 `observeOutput` 单条规范查询返回安全正文、作者、当前成员关系与独立的 Human 读取判定,Feed 验证所选 Bot 后推送,队列在范围变化及排出时重新资格判断。来源或范围变化只移除不再符合条件的显示及排队卡片,同一 Host 的确认基线保留仍可见内容与阅读进度,新启用来源不回放禁用期;点击来源先按需刷新 owner 会话列表,再检查锚点 timeline 读取权限,失败在卡片解释。卡片标明 Channel、Bot DM 双方和原会话可读性,不自动加入或改变已读位置。宽范围仅新增所选 Bot 的输出观察,既有通用 timeline 和模型能力不扩权。同一 Host 的 EventSource 断线重连通过 `Last-Event-ID` 识别过程租约;最多保留 60 秒、128 个已断开的租约,每 Bot 的规范消息引用受保留数量约束。Channel owner 的 `outputCheckpoint` 读取最新 placement 位置,`observeOutput` 返回单条位置;首次选择及新开启来源建立 Host 基线,延迟的旧通知不能成为新消息。四类来源各有 Client 过程 epoch,离线开关或范围变更后只恢复连续启用且仍合格的引用;补播重新查询规范正文和成员关系。恢复 GET 原子接管持续收件,等待 POST 确认或握手再次中断时仍有界留存;重复规范引用不占用额外队列容量。订阅确认先于补播;Client 保留同 Host 的阅读进度,按 Channel/message 标识去重。租约或 generation 失效、Client 重启均开始未来基线;无全历史扫描、耐久消息副本或共享已读写入。页面隐藏取消帧采样并暂停文字和到期时间。生命周期扩展仍按规格 [#1135](https://github.com/BotHarness/DeepSeekBot/issues/1135) 交付。 + +Companion Feed 通过一条可释放的订阅直接观察已提交的 `bot-registry` 通知,独立于 Bot 模式。归档保留带标签的静止形象;规范的暂停/重新启用变化重置有界消息引用和来源检查点。过程生命周期标识随流恢复保留,让合并过的归档/重新启用变化仍可被 Client 识别,不新增耐久身份状态。确认删除通过已确认选择快照发送移除 ID;collection 只释放对应子项、偏好和卡片,旧 revision 不能恢复已删除项。当前 owner 的 attention 事实仅用于导航。上传图片保留原媒体与整体交互;不支持的 rig/schema 保留配方和带版本的保存快照,并明确说明动作限制。图片 owner 拒绝过期版本 URL,不以另一形象替换该版本。键盘焦点与 hover 同样展开阅读;菜单键/Shift+F10 打开原生菜单,Escape 返回焦点,关闭气泡选取存活控件。减少动态效果立即归位,离屏/隐藏停止采样,销毁释放 observer、帧及 morph/fade 动画;旧动画完成回调不能再排入动作。性能验收仍须真实多 Bot 帧、内存、资源测量及 Human 同意的预算。 + +支持的像素伙伴通过 BotPixel 的可选 `mouthLayers` 公共合约预绘制 saved/closed/half-open/open 四层,普通头像与 Host 保存快照保持默认输出。`WindowCompanion` 从既有安全字素播放进度派生一份嘴型,不引入说话 Activity、第二个消息队列、计时器或耐久表情状态。生产 Avatar 仅在 companion surface 请求这些层,独立 `AvatarSpeech` 资源在既有 SVG 内做短透明度过渡,不重建人物或重启眨眼/转头;说话时暂时显露脸部,外层 Activity 和 attention 仍反映真实事实。标点闭嘴,播放结束、取消、归档、断线及销毁恢复保存表情;减少动态效果、隐藏与离屏由现有共享策略立即归静。多卡片选择第一条仍在逐字呈现的卡片驱动一套有界嘴型。上传图片、不支持的 family/版本保留原有降级,不拼接替代部件。该呈现不需要 TTS、音素、模型动作标记或 Tool 私有内容([#1143](https://github.com/BotHarness/DeepSeekBot/issues/1143))。 + +### Own-Bot Inbox history (#1278) + +The application-defined `inbox_history` Orchestrator Tool reads only the calling PersonaBot's canonical Inbox Admissions, including handled messages and self-records. Generation 77 adds a rebuildable FTS5 trigram projection over Source Event bodies, notice labels, commit subjects and changed paths. Source Event insert/update/delete and Purge Ledger application maintain the index in the same transaction; both the projection and every returned page exclude purged source/cause content. FTS5 secure-delete is enabled alongside the Purge owner's SQLite secure_delete setting, so purge removes old index entries rather than retaining recoverable delete keys. This query neither observes work nor changes Attention Decisions. The native Tool Consumer obtains Bot identity from trusted Orchestrator ownership; it does not accept a target Bot argument. Channels, Inbox history and Memory remain separate authorities for conversations, the Bot's diary, and distilled memory. + +Pages contain at most 50 items and 300 Unicode characters per snippet. Literal searches require at least three Unicode characters. Chronological pages use a timestamp/id keyset and an initial Source Event rowid boundary. Relevance pages preserve their first FTS order in a bounded process-local snapshot containing only Source Event ids, then rejoin current canonical data on each read so purged, removed or no-longer-matching rows disappear. Ranked cursors expire after ten minutes or Host restart; the cache holds at most 64 searches with at most 10,000 matches each, requiring narrower filters for larger result sets. Neither the cache nor the index creates a second transcript or resumable work queue. New self-record payloads persist their actual cause id for cause filtering; older records without that id remain searchable without inventing provenance. See [ADR-0154](../adr/0154-bot-self-records-keep-memory-commits-and-bot-actions-in-the-timeline-and-inbox.md). + +应用定义的 `inbox_history` Orchestrator Tool 只读取调用者 PersonaBot 的规范 Inbox Admission,包括已处理消息和自我记录。Generation 77 新增可从 Source Event 重建的 FTS5 trigram 投影;事件写入、更新、删除与 Purge Ledger 应用在同一事务维护索引,查询排除被清除的来源及起因内容,同时启用 FTS5 secure-delete 与 Purge owner 既有的 SQLite secure_delete,避免旧索引段残留被清除词条;不观察待办、不改变 Attention Decision。Bot 身份来自可信 Orchestrator ownership,工具不接受目标 Bot 参数。频道、收件箱历史和记忆分别保有对话、Bot 日记与提炼后记忆的权威。 + +每页最多 50 条、摘要最多 300 个 Unicode 字符,字面文本搜索至少需要三个 Unicode 字符。时间排序按时间/id 翻页,并固定首次读取的 Source Event rowid 上界;相关性排序以进程内、有界的 id 快照保留首次 FTS 次序,每次重新联接当前权威内容,移除已清除、已撤回或不再匹配的项。相关性游标十分钟或 Host 重启后失效,最多缓存 64 次搜索,每次最多 10,000 个匹配,超过时要求缩小筛选范围。快照不保存正文、不成为第二份记录或工作队列。新自我记录保存实际起因 id;旧记录缺少 id 时不推测起因。 + +### AX Client 启动证据 + +应用定义的 Client 诊断 collector 扩展既有 ADR-0063/0064 开发者日志边界。在明确启用的 Host 中(隔离 AX launcher 只为自己的子进程启用),Host Cordis Fiber 通过原生 `webServer.tapIndex` 在 Client 导入前安装 observer,并在 `/api/botharness/client-diagnostics` 注册精确、经过认证的 Connection Fetch route。浏览器报告白名单错误/生命周期 code、顺序化文档 attempt、固定保留的首次失败与 shell 挂载记录及有界遗漏;Host 保留有界实时 read model,并将同一过程证据写入既有 `logs.db`。它不创建 SessionEvent、产品状态、额外 SSE 或操作权威。API 健康、已提交且可见的 shell 观测、新鲜度、真实浏览器 console/DOM 核验分别成立;未知、后台延后及失败证据不能隐式变成成功。见 [agent 核验流程](../dev/guides/client-startup-diagnostics.zh.md) 与 [#1184](https://github.com/BotHarness/DeepSeekBot/issues/1184)。 + +### Provider-owned inline app setup (#1111) + +The optional application-defined `setupVersion: 1` / `describeSetup(channel)` capability on the public same-Host Service supplies only a versioned settings endpoint descriptor. The browser calls the Provider-owned `dsh-im/app-setup` management transport directly; BotHarness Host has no credential or QR-session command. Provider-owned bounded attempts retain setup state, create the account in `external-consumer` mode before its first connection, and return only its opaque account reference and authenticated `describeBot` projection. The Client hands the existing Binding command only provider/account reference and fingerprint; the existing identity owner revalidates it and acquires the exclusive intake Registration. Closing the dialog leaves the browser handle resumable for the Provider attempt's lifetime; cancellation prevents a not-yet-committed account, while an account already created remains available to bind. Older Providers keep the existing Settings-and-return path. This adds no operational schema, credential store or second message receiver. The candidate covers Lark credentials and WeChat QR/code setup. QR attempts use the same Provider-owned transport and bounded lifetime, refuse replacement of existing configured accounts, and are revoked with their originating Registration. Lark native DM/group replies are qualified on the earlier packaged candidate; native WeChat pairing and final visual/recording qualification remain required before #1111 is complete. + +### Mobile contextual creation (#2195) + +The mobile app uses its existing Expo Router tab and root Stack owners for an adjacent contextual creation action ([ADR-0235](../adr/0235-mobile-creation-shares-the-existing-tab-navigator.md)). iOS bottom controls use SwiftUI Button and native iOS 26 glass styles; the expanded creation menu uses a native rounded glass backdrop with an opaque accessibility/older-OS fallback. Android retains OCAT controls; navigation state still belongs to Expo Router. A presentation-only menu resolves stable Computer or cloud account/Organization keys; All asks for a target. Computer creation remains in the existing Host full-client flow. Cloud creation rechecks account scope and owner access through CloudClient, then uses the existing idempotent public mutation and opens its returned Channel. No mobile permission store or second messaging authority is added; native evidence is tracked separately from source checks. + +### Mobile presentation (#2028) + +Android chat Markdown has a separate, disposable geometry module. `MarkdownViewportContext` supplies the existing chat viewport and scroll values; the UI thread reports coarse visible-window changes, while the renderer retains measured block heights and minimum bubble width. Offscreen text becomes equal-height spacing and restores through the same Markdown path. Width/font changes discard geometry, native media remains mounted, and screen readers use the full document. This module owns no message data, persistence, playback cursor or Host command. + +The block parser also retains its disposable lexer result with exact source provenance. Android rendering and window-support checks reuse that result; repaired tails and source-only blocks reparse, while document-wide references retain their complete parse context. Append-only boundary lookback preserves unchanged block identities. The [single-paragraph Arena](../research/2183-single-paragraph-arena.md) measures the CPU benefit separately from native drawing limits; this cache owns no conversation state. + +The local Android Expo `StreamReveal` module decorates that existing React Native paragraph with bounded appearance-only spans and a native `Choreographer` clock. Parsing, text, line layout, selection and accessibility retain their existing owners; no React update or text setter runs on animation frames. The shared reveal budget currently limits animation to 48 graphemes, with overflow visible immediately. Corrections, reduced motion and detach settle spans and remove callbacks. Older binaries without the module keep the JavaScript fade, and installing the native module requires a rebuild. The [matched Release Arena](../research/2028-stream-arena.md) records the selected implementation and its performance limits. While an active Android paragraph exceeds two screen heights, the wrapper can draw the same untransformed TextView into a screen-bounded software bitmap; this reduces repeated hardware text recording without changing the child's layout or text. The bitmap is a disposable rendering cache, released when normal drawing resumes or the wrapper detaches. Returning to normal composition invalidates the child's hardware display list. Transformed, translucent or otherwise unsupported child composition retains the normal view path. + +The Expo client keeps profile read caching and reply presentation separate from Host authority. One native drawer stack owns the handle and nested settings/history/edit routes; editor components reuse their Host command ports without creating another modal. Profile usage exposes the same reported lower-bound total and partial marker as global usage, while the underlying strict usage query keeps unknown totals distinct. A bounded TanStack Query resource is scoped to each authenticated Host client and Bot/DM identity; profile navigation opens the native shell before data arrives, reuses successful sections during refresh and retains them on partial failure. Conversation publishes stable timeline snapshots and separately subscribed live draft bodies. A disposable conversation-level playback owner retains a separate grapheme cursor and input reserve, controls semantic release cadence, and carries the same row through draft-to-commit draining without changing canonical messages. Its ticks notify only the active bubble; historical messages render directly. Reading history holds the visual cursor until a completed return gesture or explicit jump. Covered Chats views detach roster/Inbox view subscriptions and catch up on focus while the shared transports keep receiving events. A device-local Streaming / Complete bubbles preference controls whether draft playback is shown. Android renders supported Markdown as stable, memoized source blocks using synchronous React Native text/layout, with enriched-renderer fallback for images and native-only extensions; iOS retains its existing renderer. Neither cache nor preference changes Channel persistence, draft ingestion or model execution. Native text visibility and frame timing require their own device evidence; see [ADR-0219](../adr/0219-mobile-presentation-keeps-caches-and-stream-buffers-local.md). + +### Mobile chat backgrounds (#2118) + +The Expo app owns a device-local default and optional saved-Host-ID/Channel-ID overrides, separate from Host appearance and PersonaBot Profile Banner. Its transactional editor commits versioned local metadata before publishing a new effective choice. Normalized owned JPEG basenames rebase under the current document container; image reclamation protects committed references, live drafts and in-flight imports. Unreadable metadata falls back safely without collecting images. One stable canvas/image/theme-veil layer sits outside scrolling and keyboard-responsive content. This introduces no Host API, model action, Memory or message authority; see [ADR-0228](../adr/0228-mobile-chat-backgrounds-are-transactional-device-local-presentation.md). + +### Optional mobile statistics (#1935) + +The application-defined Mobile Telemetry owner observes the existing Expo saved-Host, Host client and Conversation boundaries. Pairing success follows redemption and saved-Host commit; connection readiness follows authenticated live Roster loading; first-chat acceptance follows Host acceptance or reconciliation of the same outgoing message. Bot-message observation requires a subsequent new committed entry in the foreground conversation. PushSession retains notification authority and gains no telemetry in this slice. + +A local default-on preference preserves saved opt-outs. The switch lives at the bottom of Settings with collapsed details. A once-per-phone informational notice mirrors the Web client, with privacy/source/settings links and a persisted dismissal; collection does not wait for acknowledgment. The owner persists the preference, notice-seen marker, a random Mobile Install ID, three activation markers and a sanitized bounded queue; it never receives Host/Channel/message identifiers or content. One lazy, memory-only official React Native SDK adapter transports the approved events through the existing first-party proxy, with automatic collection and remote configuration disabled. No new Host API, persistence domain or RPC interceptor is introduced. A 30-second connection observation deadline never terminates Host reconnection. See [ADR-0214](../adr/0214-mobile-statistics-use-explicit-consent-and-an-independent-install-id.md) and [privacy/qualification runbook](../mobile-statistics.md). + +手机统计新安装默认开启,保留已有关闭选择,开关独立于 Host 并位于设置最底部,说明默认折叠。首次说明沿用 Web 客户端:可查看隐私、源码和设置,点击知道了后记住已读状态;这是信息告知,不是采集门禁或条款同意。生产资格仍需独立验收。已有配对、Host client、Conversation 和 PushSession 保留产品权威。统计只接收粗粒度结果;关闭后清除本地标识与待发队列,不能撤回服务端已收到的事件。生产配置需真机、PostHog 收数、公开隐私说明和 Human QA 分别验收,不能以自动化测试或 CI 替代。 + +### DSH 原生会话列表的 Bot 文件夹(#1997) + +首个 tracer 增加统一的 DeepSeekBot 产品分组,下面按 Bot/角色建立子组,以显式 PersonaBot ownership 收纳 Orchestrator 与 Assignment 根 Session,与 cwd 无关。两级标题复用 Channel sidebar 的 PanelHeader,并各自保存 Client 本地折叠偏好。现有 botSessionIds 查询在旧 ID 列表之外返回分组元数据;标题、执行状态和导航继续使用原生 Session 事实及 UiWorkspace。分组不修改 Workspace 成员、Grant 或 Session header。RC2 没有附加分组 Slot,因此有界的应用适配层在原生树中增加自有 portal 挂载点,并仅在挂载期间遮蔽重复行;找不到结构时保留原生列表。原生搜索仍独立工作;新增分组行中的原生菜单留待首个 tracer 的 Human 反馈后扩展。 + +### Searchable Bot Settings + +The application-defined Bot Settings modal keeps a single active settings Slot mounted. Its localized search index resolves only currently registered pages, then selects the page and focuses the matching settings row or section; searching never mounts hidden providers or starts their requests. Appearance contains a nested layout Slot so the live workspace retains ownership of gap/reset state. DeepSeekBot appearance is an application-defined Client-local authority: independent light/dark/system selection, separate per-mode palettes and Primary overrides persist per browser/site or Desktop application, without a Host key. Its disposable palette scope covers only owned roots and modal/menu surfaces. Per-mode global Header/Body material overrides resolve against one of four presets, painting Dock header/content surfaces with background alpha and backdrop blur while keeping message/control surfaces independent. Stable functional Panel overrides are stored per mode and edited only from Header context menus, with per-property global inheritance and target-scoped reset. An authenticated, ephemeral Host delivery lease lets the CLI address one online Client and use the same local owner; it stores no preference mirror. Language remains a DSH-native preference through `locale.setLocale`. See [ADR-0236](../adr/0236-deepseekbot-appearance-is-client-local-and-scoped.md). Notifications and Keyboard shortcuts are first-level pages. The shortcuts page reuses existing Bot bindings and the DSH-native Panel command catalog/edit protocol, including revision checks, conflicts and native recording interception. The General tutorial describes the current Panel workspace and retains the narrow-layout fallback. + +### Channel workspace split topology (#2076) + +The Client owns a per-Channel binary split tree inside Messages. A leaf is message content or one registered Channel subpanel; a branch stores its axis and first-child fraction. Hidden/unavailable leaves collapse only in the rendered projection, leaving the saved tree intact. Bot-only availability and shortcut dispatch still use the existing ChannelDock owner. No Host capability, data authority, or execution permission changes. + +Version 7 retains the old per-panel open/hidden-control records and migrates legacy side/fraction geometry lazily into an equivalent tree. It backs up the prior raw record before upgrade. Explicit dragging splits the destination leaf; resizing only updates the selected branch. Stable keyed portals render into leaf-owned DOM containers, which move between layout slots without remounting content. Width changes never rewrite topology. See ADR-0222. + +## DSH compatibility admission + +For the 1.4.0 candidate after the 0.2.1 API migration, application-defined product packaging declares `>=0.2.0-rc.2 <0.3.0-0` in first-party DSH engines. Host-provided peers follow that policy and additionally name the qualified 0.2.1 prerelease tuple for npm default SemVer matching. The DSH-native Bundle/Profile preflight owns admission and includes prereleases; no application exemption bypass is introduced. Exact development and native-patch pins remain reproducible, while `productDshVersions` enumerates only real versions selected for artifact qualification. The application-defined compatibility adapter chooses native legacy or activation-based Subagent APIs before execution, keeps native delivery/cancellation ownership, and validates Owner identity using that Host’s trusted invocation. Native shell Plugins come from the running Host; the bundled scoped file Plugin retains its read_image fix and adapts the older Session cwd locally when the workingDirectory service is absent. The preset uses the common native baseline and the native ComputerUseRegistry child Plugin owned by the product Computer Plugin; its pinned implementation is identical across both qualified releases, without a second incompatible native-package installation. Forward admission does not establish future runtime compatibility. See [ADR-0087's current correction](../adr/0087-dsh-compatibility-is-a-semver-range-with-a-verified-floor.md) and the [1.4.0 qualification plan](../agents/dsh-1.4-release-qualification.md). + +### Mobile cloud service sessions (T1 UAT) + +The phone can enter **Cloud services** without a paired Host. Its application-defined `CloudClient` owns trusted public configuration, the live PKCE transaction and separately stored cloud credentials; `HostSession` continues to own local Host pairing. Strictly validated public JSON from a QR or explicit paste is reviewed by the Human, compared with credential-free discovery, then pinned. System-browser WorkOS login and the service's verified `/api/me` establish instance admission. SecureStore rotation precedes readback; local-only logout is visibly distinct from remote-confirmed revocation. Cold OAuth callbacks cannot resume a transaction. See [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md) and [native UAT gates](../agents/tob-mobile-uat.md). Cloud workspaces use the same client owner: create sends a stable request ID saved before the network call, selection rotates the independent WorkOS session for the target organization, and the service verifies current membership. The phone displays owner/member from the verified response; only owner can submit versioned description updates. Workspace settings live in TOB Org DO storage, not the local Host. With TOB authenticated-user policy enabled, the client accepts explicit admin or user roles and retries server-owned default-workspace onboarding without granting instance administration. An ordinary user can create and select additional workspaces; instance admission and current organization membership remain separate checks. UAT Simulator ordinary-user creation, CLI agreement, restart/refresh and logout isolation are recorded in the multi-user evidence; the next HTTP channel slice adds owner creation, organization-wide channel discovery and durable text through the same CloudClient. Its pending operation is bound to identity, organization and channel before I/O, then retried with the same key after uncertain failure. Channel SQLite remains TOB-owned; The next invitation slice uses this same owner: owner send/list/revoke and verified-recipient inbox/accept, with a user/org/email-bound pending operation persisted before issuance. Accepted members explicitly select the workspace through the existing token refresh; invitation IDs grant no authority. WorkOS owns membership activation and TOB Org DO journals issuance and first active observation for new-member history. Direct channel guests use a separate inbox/channel entry in the same CloudClient; acceptance does not select or join the host organization. Channel DO owns the user-bound grant and exact message-sequence floor. Guest sends persist a distinct channel-bound retry scope. The initial path targets existing verified users without email-delivery claims. Its native/UAT qualification, Agents, WebSocket and physical-device qualification remain separate gates. + +Mobile cloud Agent management uses the TOB channel authority independently of Host pairing. The existing SecureStore vault retains one explicit credential handoff; public snapshots omit its secret. Copy revalidates owner rights, and revoke removes remote access. This does not grant local Bot work authority; see ADR-0229. Members can request a fixed health check through the same CloudClient. SecureStore retains the exact target, deadline and operation before transmission; restart retry cannot extend authority. TOB Channel DO stores the canonical request/result, while the Agent operator separately grants local work. Local retry deletion does not cancel an accepted request. The current phone path reloads history explicitly; Host forwarding and WebSocket remain separate increments. + +### Cloud Agent Host connections (#2155) + +A channel-scoped TOB Agent can be explicitly imported into a Host and bound through the existing Messaging account/Bot surface. `CloudConnections` keeps public identity, policy and cursor in the Operational Database; the DSH native Credentials Provider alone stores the secret. Discovery precedes bearer transmission, and public identity qualification precedes activation. `botharness/tob` consumes bounded HTTP history into the existing Source Event/Admission/Inbox; it preserves remote Agent authorship and original-Human work provenance. Current cloud work policy is rechecked at Messaging admission and source consumption, above ordinary-message and bridge wake settings; context sharing does not transfer execution authority. Canonical commit precedes the transport checkpoint, so interrupted reads replay rather than skip. Existing Outbox operation IDs supply cloud send idempotency. This application-defined connector is separate from the protected local steward Tob. The application-defined `CloudWork` owner and generation-101 intent/result ledger expose the same bounded health operation through an Agent-scoped native Tool and authenticated direct CLI. Canonical source/current Human policy supply authority; durable execution claims prevent silent reruns and a fixed one-hop forwarding intent prevents execute fallback after uncertain delegation. Transport, checkpoint replay and fixed model-mediated A-to-B health work are qualified on two UAT Hosts, including both Host restarts with no repeat execution. A fresh mobile-to-two-Host run and WebSocket remain separate gates; see [ADR-0230](../adr/0230-cloud-agent-connections-use-native-credentials-and-messaging.md). + +### Mobile workspace directory (#2161) + +The root shell renders the existing drawer once across computer and cloud screens. HostSession retains paired-computer authority; CloudClient retains cloud configuration and sessions. An in-memory, paginated directory combines verified organization memberships with organization identity obtained from authorized channel-guest listings. Membership wins per instance/organization. The directory is presentation only: guest selection calls filtered guest routes and cannot acquire organization membership or sibling channels. Opening refreshes current grants; closing invalidates old responses. See the unified-navigation extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). Source tests do not replace native switch/revoke/large-text acceptance. + +### Mobile mixed conversation entry (#2179) + +The application-defined mobile conversation directory combines the selected Host roster and the authenticated cloud account's paginated organization/guest Channel listings. It is a client projection: Computer messages remain Host-owned; cloud messages remain TOB-owned. Cloud directory reads borrow and restore organization token scope under the existing CloudClient lock without changing the selected workspace. Account/config changes discard late directory results. Host routes bind a Host ID and cloud routes bind an account/config scope, refusing an old destination after a source change. Host caches and unsent text are source-namespaced; offline Computer sends are disabled and text drafts have no automatic retry transport. This first slice retains one active Host; parallel Hosts and Web/Desktop follow under #2180. Cloud server read/pin/recent-message summaries follow under #2181, and ordinary cloud wake policy under #2182. Source checks are not native/UAT qualification. + +### Mobile Chats environment paging (#2194) + +Chats shares its persisted device-local conversation filter with Activity. The native source pager derives tabs from verified Organization listings, including empty memberships and authorized guest labels, in enrolled-source order. Source/account-qualified keys preserve identity while swiping; tab selection does not switch Host management authority. Unsupported Cloud Activity has an explicit notice and no borrowed Computer badge; All discloses partial Computer-only coverage. This first slice retains one active Computer. See the Chats environment extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). + +### Browser Remote Hosts and Host Feed (#2500) + +Remote Bot Browser and Computer viewers reuse `apps/mobile/src/host/browser-viewer.ts` and `computer-viewer.ts`. The Web adapter owns one mounted viewer against the selected Host client, projects its state through `useSyncExternalStore`, and uses the existing viewer title bar and design-system controls. Frame polling and input use device-authenticated HTTP; the Host Feed mux retains the one-time stream-ticket handshake. Closing or disconnecting aborts polls and input, waits for pending takeover requests, then releases control on the original Host. Replacement viewers wait for that cleanup. Inputs belong to one explicit control generation: resuming the Bot, losing control or losing frames invalidates that generation, even while the Host Feed remains connected. Recovered frames remain read-only until another explicit takeover. Shared Browser/Computer frame loops discard the cursor after a failed frame request, or a 204 received while not live, and fetch the current frame through the existing route; unchanged screens can therefore recover without a new sequence. The Web panel localizes recovery guidance while retaining the raw notice in the viewer snapshot for diagnostics. Removing a Host first closes its viewers and forgets the local entry, then waits up to five seconds for pending takeover/release cleanup before revoking the pairing; an unresponsive Host does not block removal indefinitely, but cleanup after that deadline may require the Host operator to resume Browser. The Computer remains the profile's shared desktop, while Browser scope includes the Bot slug. No Remote Host full-client fallback or new Host route is introduced. + +The application-defined browser `RemoteHosts` owner keeps its pairings and selected Host tab in device-local storage. Each Remote Host reuses the phone's Host client and conversation module through the device gateway; This Host never relays the Remote Host's credential. The shared `createInbox(client, { activity: true })` supplies each Remote Host's Human Inbox and unread snapshot. The first browser subscriber starts the client and Inbox, and the last stops them while retaining the last Inbox snapshot. Authenticated avatar fetches publish an immutable URL-to-object-URL map; removal, roster replacement and final unsubscribe release obsolete objects and discard late fetch results. The storage-key rename imports existing browser pairings and the selected tab. See [ADR-0242](../adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md) and [ADR-0243](../adr/0243-every-client-reads-every-host-through-one-host-feed.md). + +Remote conversation presentation retains the source Channel Journal entry through the shared conversation and message-display modules, then uses the existing Web message body and request cards. A conversation-scoped adapter supplies the source Host client and validates the active Host/Channel before decisions. Attachment reads resolve through that client's `resource(path)` and device credential, with no local-store or local-URL fallback; changing conversations aborts reads and releases object URLs. Activity and conversation cards share `createHostRequestActions`, with request references from the Inbox and Journal respectively. Only a Host-accepted decision completes a submission; stale decisions trigger the existing cards' status reload. Decisions reuse the shared attention actions and remain subject to the source Host's ownership and permission checks. + +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 has no closed-page notification path. + +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. + +### Version-bound Memory image previews (#2214, first Web slice) + +The application-defined Memory Service exposes bounded PNG representations alongside its existing current-file and Git-diff queries. Metadata identifies worktree/index/exact reachable commit plus a SHA-256 byte revision; authenticated image transfer rechecks that owner, repository selector, path and revision before returning `image/png` with no-store semantics. It does not accept arbitrary blob IDs, revision expressions or Host paths. Current reads retain path containment, control-directory exclusion and safe-open checks; Git reads accept regular-file blobs only. A changed mutable source returns an explicit stale refusal rather than current bytes under an old version label. + +The Web reader and shared diff renderer display PNGs, with before/after panels that stack on narrow screens and reuse the existing image gallery for enlargement. Staged compares HEAD/index, unstaged index/worktree, current HEAD/worktree, and historical commits their first parent. Image bytes stay outside text/JSON diffs and are requested lazily by the browser. `memory-image` exports the same representation through the authenticated Host to a new file, with a JSON receipt. The preview budget is 25 MiB, 16,384 pixels per axis and 40 million pixels; existing text and download budgets remain separate. The second Web slice below adds Markdown rendering; other local image formats, editor/file-card previews and mobile remain in #2214. + +### Web Memory Markdown previews (#2214, second tracer) + +The application-defined Memory Service returns bounded full Markdown documents alongside existing current-file and working/commit diff results. Git remains the durable authority. A document descriptor carries its selected `source`, SHA-256 `revision`, status and original UTF-8 body. The shared safe-open / regular-Git-blob reader preserves containment, reachable-commit, mutable-read and byte limits. Each document is bounded at 4 MiB; commit queries share a 4 MiB budget across document bodies. Existing line-diff and image limits remain independent. + +Web readers render `.md` and `.markdown` by default with a source toggle. Working and historical diffs default to lines and offer labeled rendered before/after documents, stacking on narrow Web viewports. The document renderer uses bundled `react-markdown` / `remark-gfm` because the pinned native Markdown component cannot override HTTP(S) image loading before its request; it keeps DSH tokens and the existing image gallery. Raw HTML remains literal text. Local file links stay inert; ordinary external HTTP(S) links use browser navigation. + +Visible inline local PNGs use the existing authenticated Connection Fetch image route with the document path/source/revision and authored `reference`. The owning Memory query resolves a directory-relative or repository-root path, validates the document revision before and after image reading, and applies the same bounded PNG checks as standalone previews. The Client receives a temporary Blob URL and aborts/releases it on selection changes or source mode. Index and commit references read their actual Git blobs; missing historical images never substitute current files. Mutable images are observations of the selected source at load time; refresh reloads their dependencies. + +External HTTPS images require a per-image click and load directly in the browser with anonymous cross-origin access and no referrer; CORS/network restrictions may refuse them. These are live external references, including in historical documents. No Host proxy, new durable store, SessionEvent or preview-triggered Bot wake is introduced. The existing `memory-image` CLI offers the identical local-image query through `--image-reference`. Other local formats, editor and message-file previews, and native mobile remain later slices. + +## 原生 Panel Plugin 开发目标(ADR-0234) + +首片实现入口见 [Panel Plugin 开发指南](../panel-plugin.md):独立 VRM Bundle 消费 UI 提供的公开 `dockPanels` / `botPresentation` Service;Activity 仍由现有 ClientStore 投影维护,已提交 DM 输出由按 Bot 共享的文字时钟呈现,固定实例元数据扩展现有布局文档。 + +[ADR-0234](../adr/0234-panels-are-native-plugins-with-independent-binding.md) 记录已确认目标;以下为整体目标,已实现的 T1 范围以首片指南与验收记录为准。第一方与独立作者的面板复用原生 Plugin/Fiber 和 Bundle/Profile 组合。领域 owner 的 Host Service 与具名实时 Cordis Event 独立于面板显隐;浏览器 Consumer 沿用认证后的 Typert/API Gateway 与支持的 stream。状态型展示需恢复当前状态,首版不建设通用离线事件补发。后续 Memory 观测要覆盖无对话时的外部工作树变化,保留既有 Observation/Admission owner,并与 Git commit 区分。 + +实现、实例、绑定与工作区位置分开。普通实现允许多个实例,重复安装不重复创建。消息面板内的 Bot-bound Panel 跟随 PersonaBot 私聊;群聊不显示内容和打开入口,也不增加成员选择器。显式绑定的全局工作区实例保留并清楚标明所选 Bot/Channel;Group-channel-bound 复用 Channel 绑定并限制为群聊。现有频道子面板不因此获得跨工作区拖放,绑定不授予数据访问或代码隔离。 + +必需系统角色保留其唯一性、已注册的第一方兜底和独立恢复入口。替换优先复用原生 Slots 的优先级与有界渲染错误退让;这不等于 HMR 回滚,也不是现有应用 Registry 的重复 id 拒绝已经具备的能力。 + +首个纵向切片 [T1 #2222](https://github.com/BotHarness/DeepSeekBot/issues/2222) 为使用 Three.js/three-vrm 的独立 **VRM+Activity+说话表现面板**。动作消费已有 Activity 事实;说话表现独立消费真实、已提交的 Bot 输出,允许边工作边说话。首版使用文字节奏驱动表情、嘴型与轻手势,不要求音素级口型或新增 TTS。验收原生安装、两个 Bot 绑定、群聊无入口、当前状态与实时更新、重连不重播旧话或重复发声、减少动态效果、资产/渲染失败降级及 GPU 资源释放。此方向替代仅复用旧角色 renderer 的提议,不要求整体迁移浮窗交互或构建生活模拟世界。 + +[T2 #2223](https://github.com/BotHarness/DeepSeekBot/issues/2223) 将办公室切片改为无 Bot/Channel 绑定的 **全局 Task Kanban**,管理当前 Profile 内共享的 Task。Human 卡片操作与 Bot 查询/创建/更新工具使用同一授权 Service、持久记录和提交后通知;关闭面板不影响工具工作。演进 #1792 已有 to-do owner 并兼容旧记录,不增加第二份存储。Task 状态与进度独立于 Session 内计划和 Assignment 执行,创建、移动、完成或重开卡片不自动启动执行。验收重启/重开一致性、显式并发冲突及直接 CLI/工具 parity。 + +[Spec #2219](https://github.com/BotHarness/DeepSeekBot/issues/2219)、T1–T7、[T8–T15 迁移父工单 #2234](https://github.com/BotHarness/DeepSeekBot/issues/2234) 及其子工单统一规划在 [Panel Plugins Project #21](https://github.com/orgs/BotHarness/projects/21) 的 v2.0。T1 已进入首片实现与隔离 Host 验证;Human 验收尚待反馈,仍是扩展门槛。交付状态与依赖以工单为准:T2–T6 依赖 T1,T7 依赖 T6,迁移父票与 T8–T15 各自依赖 T1。迁移与 T2–T7 同属 T1 后的扩展分支,子票不等待协调父票完成,也不隐式等待 T7;并与 #1791/#1792 作者能力、#2096 布局 CLI 协调。 + +T4 保留 ADR-0220 的 Sessions 范围:DM 中为 Bot 所有的 Sessions,Group 默认按原始 Source Event 查看 Assignments,并保留已有显式成员 Bot 筛选。群聊不显示 Bot-bound 面板不等于移除该筛选,也不改变 Session ownership。T5 的 Members 在 DM 消息内隐藏,但全局固定 Group 实例不受当前聊天切换影响。Panel Consumer 释放与卸载领域 Provider/整个 Bundle 分开;后者遵循原生生命周期,能力缺失须明确显示不可用。 + +Nested appearance surfaces use the same Client-local owner and functional `dock:` identities as top-level panels. Memory detail views share one identity, while file lists and evolution remain separate. Direct surface paint preserves global inheritance independently of parent customization; real registered Plugin contributions receive the generic wrapper without new domain or layout authority (ADR-0236, #2261). + +DeepSeekBot's image wallpaper has a separate Client-local media owner and one atomic IndexedDB Blob/crop record. One disposable layer spans the enabled workspace behind the sidebar and panels; original bytes stay on the current browser/site or Desktop application. The CLI transfers bounded chunks to one explicitly selected online Client and commits through that same owner, while the Host remains a transient transport (ADR-0236, #2262). + +Video wallpaper extends the same local media record with an original video and generated poster. A disposable Client playback controller observes focus/visibility, reports actual playing/paused/poster/inactive state to the same owner, and unloads sources on exit or replacement. Failure displays the persisted poster without changing media authority or DSH preferences (ADR-0236, #2263). + +Appearance recovery composes the existing local preference and media owners without a third store. Current-mode clear affects only its functional Panel map. Explicit full reset preflights both revisions, saves defaults, then deletes the atomic media record; partial media failure is reported and retryable rather than presented as a cross-store transaction. A transient resource generation invalidates previews on accepted media changes, including removal from an already-empty store (ADR-0236, #2264). + +### Desktop whole-window zoom + +[ADR-0237](../adr/0237-desktop-window-zoom-is-client-owned.md) defines an application-owned Client +zoom Service on the stable desktop origin. Settings, native Shortcut Registry commands and an +explicitly addressed CLI share its versioned local default/current-primary-window document; +the authenticated Host route is an ephemeral delivery relay with no preference copy. A pinned, +MIT-preserving downstream theme composition leases only the original font-shortcut Fiber, +then returns it on disposal. The product build carries that licensed theme and a portable ESM +schema module. Bundle patches retain the original `ui-theme` row and canonical Settings +namespace. An explicit `deepseekbot-zoom-setup enable` command installs one Profile-local +package link through the selected Desktop's bundled package manager and requires a restart. +The macOS path and Windows candidate require Desktop 0.2.1-rc.2, refusing other overrides. +Windows reads executable FileVersion, checks live processes with CIM, and validates owned +junctions under the same Profile writer lock. The Client checks a matching platform lease +before registering the same native Shortcut commands; Ctrl+0 restores the saved default. +`disable` removes only this dependency and its exact owned link, preserving font preferences; +run it before product removal. Installing the Bundle alone does not activate compatibility. +Windows native qualification remains in #2270; Linux remains disabled. +It does not edit the installed desktop shell. Ordinary Web zoom is unchanged. Native-input, +packaged-install and Human QA evidence remain separate from source coverage; numberpad, +broader reliability and other desktop platforms are tracked in #2269/#2270. + +Bot-mode Dockview content overlays and splitter movement convert viewport coordinates to local CSS units through the repository-pinned Dockview compatibility patch. The patch is bundled in the Client; it changes no DSH executable and preserves persistent panel instances and the existing layout store. The macOS workspace corner separately retains the native 88×40 viewport-pixel traffic-light footprint and converts measured group intersections into local header padding; zoom, layout and fullscreen updates do not change that physical footprint. + +Bot-owned choice controls use the shared `ds/Select` → `PointerMenu` geometry owner under +window zoom. It keeps the native Menu renderer and keyboard owner inside the owning Modal, +converts viewport anchor coordinates through effective CSS zoom, clamps to the safe viewport +and returns focus after dismissal. No second shortcut router or Panel state is introduced. diff --git a/docs/bot-cli.md b/docs/bot-cli.md index 80fa872b71..941cc7283c 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -6,6 +6,10 @@ Bot summaries from `list` also include `lastActivityAt` (epoch milliseconds) whe ## Usage +`deepseekbot browser-notification-clients` lists online Client targets. `deepseekbot browser-notifications --client-id --remote-host-id ` reads that browser's local choice and revision. To write, add `--expected-revision --patch-stdin` and pipe `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`. These commands use the serving Host's usual online authentication and existing Client control relay; the Host persists no browser choice or Remote Host credential. Writes refuse stale revisions and unknown Hosts. Enabling requires permission already granted in the browser (`notification-permission-required` otherwise); CLI never opens the OS permission prompt. Closing the page ends both its control target and alerts. + +`deepseekbot device-notifications --device-file ` reads the admitted device's send-preference copy from a live Host. Add `--preferences-stdin` and pipe `{"approval":true,"question":true,"private-input":true,"reply":false}` to replace its four kinds, even before a push token is registered. The private file has the saved pairing shape (`hostId`, `hostName`, `routes`, `deviceId`, `credential`); never put its credential in arguments or publish the file. Output is one JSON document without credentials; revoked pairings return `device-revoked`. This edits that Host's delivery copy only; the phone's persisted preference is authoritative and overwrites it on the next sync. Browser notification choices remain local to that browser and do not change Host attention. + ```bash deepseekbot create --name [--persona | --persona-stdin] [--core-memory | --core-memory-stdin] diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 68c0e816d6..4f96b13810 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -6,6 +6,10 @@ ## 用法 +`deepseekbot browser-notification-clients` 列出在线 Client。`deepseekbot browser-notifications --client-id --remote-host-id ` 读取该浏览器的本地选择和 revision。写入时加 `--expected-revision --patch-stdin`,从标准输入传入 `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`。命令使用提供页面的 Host 的现有认证及 Client 定向控制通道;Host 不保存浏览器选择或远程凭据。版本过期或远程不存在时明确拒绝。开启前须已在浏览器授予通知权限,否则返回 `notification-permission-required`;CLI 不弹出系统授权。页面关闭后,控制目标和提醒都会结束。 + +`deepseekbot device-notifications --device-file <私有配对.json>` 从运行中的 Host 读取当前配对设备的通知发送副本。加上 `--preferences-stdin`,从标准输入传入 `{"approval":true,"question":true,"private-input":true,"reply":false}`,即可替换四种通知选择,无需先注册推送 token。私有文件使用已保存配对的结构(`hostId`、`hostName`、`routes`、`deviceId`、`credential`);凭据不能放入参数或公开文件。标准输出仅有一个不含凭据的 JSON,配对撤销后返回 `device-revoked`。命令只修改该 Host 的发送副本;手机持久化选择仍是权威,下次同步会覆盖副本。浏览器通知选择只保存在该浏览器,不影响 Host attention。 + ```bash deepseekbot create --name [--persona | --persona-stdin] [--core-memory | --core-memory-stdin] diff --git a/packages/browser/lib/client.js b/packages/browser/lib/client.js index 07f4046738..4f75ca5880 100644 --- a/packages/browser/lib/client.js +++ b/packages/browser/lib/client.js @@ -823,18 +823,18 @@ window.__ModuleLoader__.load({ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { role: "status", "data-panel-empty": "", - className: "bhx78zum5 bhxdt5ytf bhx6s0dn4 bhxl56j7k bhx1iyjqo2 bhx1mckxr8 bhx1tsjn1a bhx9f619 bhx2b8uid bhxr3raxm", + className: "bhx78zum5 bhxdt5ytf bhx6s0dn4 bhxl56j7k bhx1iyjqo2 bhx16qymgn bhx14opcb4 bhx9f619 bhx2b8uid bhx1hablki", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "aria-hidden": "true", - className: "bhx1i1gie9 bhxdkzj7d bhx2lah0s bhxwq55zj", + className: "bhx1iur23p bhx1hfu2dq bhx2lah0s bhx14dlum0", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChannelSidebarIcon, { name: icon, size: "100%" }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { - className: "bhx1ghz6dp bhxxc7z9f bhxr3raxm bhxgowt09 bhx12cu2lo", + className: "bhx1ghz6dp bhxxc7z9f bhx1hablki bhx17cqwc0 bhx15n174q", children }), action === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { @@ -2038,34 +2038,34 @@ window.__ModuleLoader__.load({ const styles$1 = { note: { kogj98: "bhx1ghz6dp", - kGuDYH: "bhxgowt09", - kLWn49: "bhx12cu2lo", + kGuDYH: "bhx17cqwc0", + kLWn49: "bhx15n174q", kHjlTd: "bhxj0a0fe", $$css: true }, inset: { - k8WAf4: "bhx89kiln", - kg3NbH: "bhx1w8vjyx", + k8WAf4: "bhx1leccfj", + kg3NbH: "bhx792b8t", $$css: true }, muted: { - kMwMTN: "bhxr3raxm", + kMwMTN: "bhx1hablki", $$css: true }, subtle: { - kMwMTN: "bhxwq55zj", + kMwMTN: "bhx14dlum0", $$css: true }, danger: { - kMwMTN: "bhx8ay0r6", + kMwMTN: "bhx1sspbqh", $$css: true }, success: { - kMwMTN: "bhx1pideu2", + kMwMTN: "bhx108ve1s", $$css: true }, warning: { - kMwMTN: "bhxz9v26x", + kMwMTN: "bhx1c0x75y", $$css: true } }; @@ -2081,9 +2081,9 @@ window.__ModuleLoader__.load({ function ProgressLine({ percent, label }) { return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { role: "status", - className: "bhxrvj5dj bhx5v8n67 bhxh8yej3 bhxr3raxm bhxgowt09 bhx12cu2lo", + className: "bhxrvj5dj bhx1fykdyo bhxh8yej3 bhx1hablki bhx17cqwc0 bhx15n174q", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("progress", { - className: "bhxh8yej3 bhxols6we bhx1g31g6d", + className: "bhxh8yej3 bhxols6we bhx1rpn2j5", max: 100, value: Math.max(0, Math.min(100, percent)) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })] @@ -2095,7 +2095,7 @@ window.__ModuleLoader__.load({ navigator.clipboard?.writeText(command).then(() => setCopied(true)); }; return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhxrvj5dj bhx2zm2ee bhx619ttb bhxh8yej3", + className: "bhxrvj5dj bhxcksle6 bhx619ttb bhxh8yej3", children: [ installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { tone: "warning", @@ -2105,11 +2105,11 @@ window.__ModuleLoader__.load({ label: installing.label }), access === "none" && installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { children: labels.manual }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", { - className: "bhx1ghz6dp bhxh8yej3 bhx9f619 bhx89kiln bhx1w8vjyx bhx4g09nt bhx1xojs80 bhx24dycr bhxmz2490 bhxgowt09 bhx12cu2lo bhx126k92a bhxj0a0fe", + className: "bhx1ghz6dp bhxh8yej3 bhx9f619 bhx1leccfj bhx792b8t bhxjpou06 bhxblcitj bhx1gs2zk7 bhxmz2490 bhx17cqwc0 bhx15n174q bhx126k92a bhxj0a0fe", children: command })] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhx12e6dxb bhx1a02dak", + className: "bhx78zum5 bhx1nry0o3 bhx1a02dak", children: [ access !== "none" && installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { size: "sm", @@ -2592,7 +2592,7 @@ window.__ModuleLoader__.load({ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { ref: mount, "data-pointer-menu": "", - className: "bhxixxii4 bhxu96u03 bhx13vifvy bhx51uzqw bhx1kkor1i bhxp6lfbs bhx47corl", + className: "bhxixxii4 bhxu96u03 bhx13vifvy bhx1uif995 bhxjgjsw3 bhxp6lfbs bhx47corl", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Menu, { ...props, open: true, @@ -2606,21 +2606,21 @@ window.__ModuleLoader__.load({ const pillStyles = { pill: { k1xSpc: "bhx3nfvp2", kGNEyG: "bhx6s0dn4", - kOIVth: "bhx12e6dxb", + kOIVth: "bhx1nry0o3", ks0D6T: "bhx193iq5w", - kZKoxP: "bhxxevliy", - kg3NbH: "bhx1vid4s1", + kZKoxP: "bhx1n56ao0", + kg3NbH: "bhxa30tib", kMzoRj: "bhxc342km", - kaIpWk: "bhx4g09nt", - kWkggS: "bhxw43b1w bhx1lc0nsx", - kMwMTN: "bhx24dycr", + kaIpWk: "bhxjpou06", + kWkggS: "bhxfo3ty bhxrplx1s", + kMwMTN: "bhx1gs2zk7", kMv6JI: "bhxjb2p0i", - kGuDYH: "bhx5g6txa", - kLWn49: "bhx3350y7", + kGuDYH: "bhxer0amy", + kLWn49: "bhx1jatdxk", khDVqt: "bhxuxw1ft", kkrTdU: "bhx1ypdohk bhxtgyt42", kSiTet: "bhx1hc1fzr bhxijokvz", - kI3sdo: "bhx1a2a7pz bhx1m95a6t", + kI3sdo: "bhx1a2a7pz bhx5dwnpn", kInvED: "bhx1y3gkto", $$css: true } }; @@ -2695,7 +2695,7 @@ window.__ModuleLoader__.load({ className: "bhxeuugli bhxb3r6kr bhxlyipyv bhxuxw1ft", children: current?.label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { - className: "bhx3nfvp2 bhx2lah0s bhxr3raxm", + className: "bhx3nfvp2 bhx2lah0s bhx1hablki", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 12 }) })] }), active && point ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointerMenu, { @@ -2736,24 +2736,24 @@ window.__ModuleLoader__.load({ "aria-labelledby": titleId, "aria-describedby": description === void 0 ? void 0 : descriptionId, ...data, - className: "bhx78zum5 bhx6s0dn4 bhx4bgz6 bhx15bqsqq bhx1q0g3np bhxcxmu7o bhx1uptv2i bhx1mckxr8 bhx1kwkzks bhxv0lzym bhxeuugli bhxe74ayl bhxrvphhj bhx1q0q8m5 bhxnoyelg bhx1bta2m4", + className: "bhx78zum5 bhx6s0dn4 bhx4bgz6 bhx15bqsqq bhx1q0g3np bhxcxmu7o bhx1uptv2i bhx16qymgn bhx19fcs9c bhx1cg4dk7 bhxeuugli bhxz617pa bhx1gtm3y5 bhx1q0q8m5 bhxqlejh0 bhx1bta2m4", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhxdt5ytf bhx5v8n67 bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli", + className: "bhx78zum5 bhxdt5ytf bhx1fykdyo bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { id: titleId, - className: "bhx24dycr bhxskcadq bhx1gy5ux2 bhx1nj67n bhxj0a0fe", + className: "bhx1gs2zk7 bhx2dqotr bhx11nzxlp bhxs1wz3g bhxj0a0fe", children: title }), description === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { id: descriptionId, - className: "bhxr3raxm bhxgowt09 bhx12cu2lo bhxj0a0fe", + className: "bhx1hablki bhx17cqwc0 bhx15n174q bhxj0a0fe", children: description }), children ] }), control === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { - className: "bhx78zum5 bhx1a02dak bhx6s0dn4 bhx13a6bvl bhx403fy9 bhx3xz8fp bhx2zm2ee bhx2lah0s bhxeuugli bhxh9uqaf bhx1r6q2vj bhx1cnm762", + className: "bhx78zum5 bhx1a02dak bhx6s0dn4 bhx13a6bvl bhx403fy9 bhx3xz8fp bhxcksle6 bhx2lah0s bhxeuugli bhxh9uqaf bhx1r6q2vj bhx1cnm762", children: control })] }); @@ -3228,42 +3228,42 @@ window.__ModuleLoader__.load({ .bhx-level-0{} .bhx-level-1:not(#\\#){} .bhx1ghz6dp:not(#\\#){margin:0} -.bhx1tsjn1a:not(#\\#){padding:16px} +.bhx14opcb4:not(#\\#){padding:16px} .bhx-level-2:not(#\\#):not(#\\#){} -.bhx7vmdhn:not(#\\#):not(#\\#){border-color:var(--dsw-alias-border-l2)} -.bhx10e1no3:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-md, 12px)} -.bhx4g09nt:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-sm, 8px)} +.bhx118alp2:not(#\\#):not(#\\#){border-color:var(--dsw-alias-border-l2)} +.bhx1w7u4cj:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-md, 12px)} +.bhxjpou06:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-sm, 8px)} .bhxbsl7fq:not(#\\#):not(#\\#){border-style:dashed} .bhxc342km:not(#\\#):not(#\\#){border-width:0} -.bhx1sfpsak:not(#\\#):not(#\\#){border-width:1px} -.bhx1mckxr8:not(#\\#):not(#\\#){gap:12px} -.bhx5v8n67:not(#\\#):not(#\\#){gap:2px} -.bhx12e6dxb:not(#\\#):not(#\\#){gap:4px} -.bhx2zm2ee:not(#\\#):not(#\\#){gap:8px} -.bhx1d3k6p2:not(#\\#):not(#\\#){margin-block:12px} +.bhxc2pr59:not(#\\#):not(#\\#){border-width:1px} +.bhx16qymgn:not(#\\#):not(#\\#){gap:12px} +.bhx1fykdyo:not(#\\#):not(#\\#){gap:2px} +.bhx1nry0o3:not(#\\#):not(#\\#){gap:4px} +.bhxcksle6:not(#\\#):not(#\\#){gap:8px} +.bhxx7iei1:not(#\\#):not(#\\#){margin-block:12px} .bhx1a2a7pz:not(#\\#):not(#\\#){outline:none} .bhxb3r6kr:not(#\\#):not(#\\#){overflow:hidden} -.bhxe74ayl:not(#\\#):not(#\\#){padding-block:12px} -.bhx1tv32aw:not(#\\#):not(#\\#){padding-block:24px} -.bhx89kiln:not(#\\#):not(#\\#){padding-block:4px} -.bhx1vid4s1:not(#\\#):not(#\\#){padding-inline:12px 8px} -.bhx1xa34d1:not(#\\#):not(#\\#){padding-inline:16px} -.bhx1w8vjyx:not(#\\#):not(#\\#){padding-inline:4px} -.bhx1m95a6t:focus-visible:not(#\\#):not(#\\#){outline:var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary)} -@media (max-width: 648px){.bhxv0lzym.bhxv0lzym:not(#\\#):not(#\\#){gap:8px}} -@container bh-settings (max-width: 480px){.bhx1kwkzks.bhx1kwkzks:not(#\\#):not(#\\#){gap:8px}} +.bhxz617pa:not(#\\#):not(#\\#){padding-block:12px} +.bhxxx7nam:not(#\\#):not(#\\#){padding-block:24px} +.bhx1leccfj:not(#\\#):not(#\\#){padding-block:4px} +.bhxa30tib:not(#\\#):not(#\\#){padding-inline:12px 8px} +.bhxwajc3y:not(#\\#):not(#\\#){padding-inline:16px} +.bhx792b8t:not(#\\#):not(#\\#){padding-inline:4px} +.bhx5dwnpn:focus-visible:not(#\\#):not(#\\#){outline:var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary)} +@media (max-width: 648px){.bhx1cg4dk7.bhx1cg4dk7:not(#\\#):not(#\\#){gap:8px}} +@container bh-settings (max-width: 480px){.bhx19fcs9c.bhx19fcs9c:not(#\\#):not(#\\#){gap:8px}} .bhx-level-3:not(#\\#):not(#\\#):not(#\\#){} -.bhx1g31g6d:not(#\\#):not(#\\#):not(#\\#){accent-color:var(--dsw-alias-state-business-primary)} +.bhx1rpn2j5:not(#\\#):not(#\\#):not(#\\#){accent-color:var(--dsw-alias-state-business-primary)} .bhx6s0dn4:not(#\\#):not(#\\#):not(#\\#){align-items:center} -.bhxw43b1w:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-bg-module-platform)} -.bhx1xojs80:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)} +.bhxfo3ty:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-bg-module-platform)} +.bhxblcitj:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)} .bhx9f619:not(#\\#):not(#\\#):not(#\\#){box-sizing:border-box} -.bhx24dycr:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-primary)} -.bhxr3raxm:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-secondary)} -.bhxwq55zj:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-tertiary)} -.bhx8ay0r6:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-error-primary)} -.bhx1pideu2:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-success-primary)} -.bhxz9v26x:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-warn-primary)} +.bhx1gs2zk7:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-primary)} +.bhx1hablki:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-secondary)} +.bhx14dlum0:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-tertiary)} +.bhx1sspbqh:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-error-primary)} +.bhx108ve1s:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-success-primary)} +.bhx1c0x75y:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-warn-primary)} .bhx1ypdohk:not(#\\#):not(#\\#):not(#\\#){cursor:pointer} .bhx78zum5:not(#\\#):not(#\\#):not(#\\#){display:flex} .bhxrvj5dj:not(#\\#):not(#\\#):not(#\\#){display:grid} @@ -3277,16 +3277,16 @@ window.__ModuleLoader__.load({ .bhx1a02dak:not(#\\#):not(#\\#):not(#\\#){flex-wrap:wrap} .bhxjb2p0i:not(#\\#):not(#\\#):not(#\\#){font-family:inherit} .bhxmz2490:not(#\\#):not(#\\#):not(#\\#){font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} -.bhxgowt09:not(#\\#):not(#\\#):not(#\\#){font-size:12px} -.bhx5g6txa:not(#\\#):not(#\\#):not(#\\#){font-size:13px} -.bhxskcadq:not(#\\#):not(#\\#):not(#\\#){font-size:14px} -.bhx1nj67n:not(#\\#):not(#\\#):not(#\\#){font-weight:500} +.bhx17cqwc0:not(#\\#):not(#\\#):not(#\\#){font-size:12px} +.bhxer0amy:not(#\\#):not(#\\#):not(#\\#){font-size:13px} +.bhx2dqotr:not(#\\#):not(#\\#):not(#\\#){font-size:14px} +.bhxs1wz3g:not(#\\#):not(#\\#):not(#\\#){font-weight:500} .bhxl56j7k:not(#\\#):not(#\\#):not(#\\#){justify-content:center} .bhx13a6bvl:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-end} .bhx619ttb:not(#\\#):not(#\\#):not(#\\#){justify-items:start} -.bhx12cu2lo:not(#\\#):not(#\\#):not(#\\#){line-height:16px} -.bhx1gy5ux2:not(#\\#):not(#\\#):not(#\\#){line-height:20px} -.bhx3350y7:not(#\\#):not(#\\#):not(#\\#){line-height:20px} +.bhx15n174q:not(#\\#):not(#\\#):not(#\\#){line-height:16px} +.bhx11nzxlp:not(#\\#):not(#\\#):not(#\\#){line-height:20px} +.bhx1jatdxk:not(#\\#):not(#\\#):not(#\\#){line-height:20px} .bhx1hc1fzr:not(#\\#):not(#\\#):not(#\\#){opacity:1} .bhx1y3gkto:not(#\\#):not(#\\#):not(#\\#){outline-offset:1px} .bhxj0a0fe:not(#\\#):not(#\\#):not(#\\#){overflow-wrap:anywhere} @@ -3301,7 +3301,7 @@ window.__ModuleLoader__.load({ .bhxp6lfbs:not(#\\#):not(#\\#):not(#\\#){z-index:1100} .bhxtgyt42:disabled:not(#\\#):not(#\\#):not(#\\#){cursor:default} .bhxijokvz:disabled:not(#\\#):not(#\\#):not(#\\#){opacity:.5} -.bhx1lc0nsx:hover:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)} +.bhxrplx1s:hover:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)} @media (max-width: 648px){.bhx15bqsqq.bhx15bqsqq:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}} @media (max-width: 648px){.bhx1uptv2i.bhx1uptv2i:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}} @media (max-width: 648px){.bhx3xz8fp.bhx3xz8fp:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}} @@ -3309,24 +3309,24 @@ window.__ModuleLoader__.load({ @container bh-settings (max-width: 480px){.bhxcxmu7o.bhxcxmu7o:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}} @container bh-settings (max-width: 480px){.bhx403fy9.bhx403fy9:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}} .bhx-level-4:not(#\\#):not(#\\#):not(#\\#):not(#\\#){} -.bhxnoyelg:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:var(--dsw-alias-border-l1)} +.bhxqlejh0:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:var(--dsw-alias-border-l1)} .bhx1q0q8m5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-style:solid} -.bhxrvphhj:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-width:1px} -.bhx1kkor1i:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:1px} -.bhxxevliy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:28px} -.bhxdkzj7d:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:40px} +.bhx1gtm3y5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-width:1px} +.bhxjgjsw3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:1px} +.bhx1n56ao0:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:28px} +.bhx1hfu2dq:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:40px} .bhxols6we:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:6px} .bhxu96u03:not(#\\#):not(#\\#):not(#\\#):not(#\\#){left:0} .bhx193iq5w:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%} .bhxxc7z9f:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:360px} .bhxh9uqaf:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:60%} .bhxeuugli:not(#\\#):not(#\\#):not(#\\#):not(#\\#){min-width:0} -.bhx1e1oj2y:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-bottom:4px} -.bhxb590mu:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:16px} +.bhxugyd1d:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-bottom:4px} +.bhx2o8g9q:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:16px} .bhx13vifvy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){top:0} .bhxh8yej3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:100%} -.bhx51uzqw:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:1px} -.bhx1i1gie9:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:40px} +.bhx1uif995:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:1px} +.bhx1iur23p:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:40px} .bhx10zg81t:first-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:0} .bhx1bta2m4:last-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:transparent} @media (max-width: 648px){.bhx1cnm762.bhx1cnm762:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}} diff --git a/packages/client/src/client/appearance-control.ts b/packages/client/src/client/appearance-control.ts index 985154dcb1..4bcf82f22d 100644 --- a/packages/client/src/client/appearance-control.ts +++ b/packages/client/src/client/appearance-control.ts @@ -3,6 +3,7 @@ import { WallpaperError } from './wallpaper-store.js'; import type { Wallpaper } from './wallpaper.js'; import { createWallpaperControl } from './wallpaper-control.js'; import { Appearance, AppearanceError, object } from './appearance.js'; +import { remoteHosts } from './remote-hosts.js'; export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper): () => void { const abort = new AbortController(); @@ -38,7 +39,22 @@ export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper) try { if (typeof command.expiresAt !== 'number' || Date.now() > command.expiresAt) throw new AppearanceError('appearance-request-stale'); - if (command.recovery !== undefined) { + if (command.notifications !== undefined) { + const sources = remoteHosts(); + const input = object(command.notifications); + if ( + typeof input.hostId !== 'string' || + !sources.getSnapshot().hosts.some((entry) => entry.host.hostId === input.hostId) + ) + throw new AppearanceError('notification-host-unknown'); + try { + result = await sources.notifications.control(input, command.expectedRevision); + } catch (error) { + throw new AppearanceError( + error instanceof Error ? error.message : 'notification-client-failed', + ); + } + } else if (command.recovery !== undefined) { if (!wallpaper) throw new WallpaperError('wallpaper-unavailable'); result = await resetAppearance( owner, diff --git a/packages/client/src/client/host-tabs.tsx b/packages/client/src/client/host-tabs.tsx index 4c4170e1b0..9b7afdd594 100644 --- a/packages/client/src/client/host-tabs.tsx +++ b/packages/client/src/client/host-tabs.tsx @@ -13,6 +13,7 @@ import { TextField } from './ds/field.js'; import { Tooltip } from './ds/tooltip.js'; import { border, color, radius, size, space, text } from './ds/tokens.stylex.js'; import type { BotHarnessKey, BotHarnessTranslate } from './locale.js'; +import { RemoteNotificationSettings } from './remote-notification-settings.js'; import { HOST_COLOR_IDS, HOST_COLORS, @@ -262,6 +263,7 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null ); const [menu, setMenu] = useState<{ hostId: string; point: MenuPoint } | undefined>(undefined); const [removing, setRemoving] = useState(undefined); + const [notificationHost, setNotificationHost] = useState(); const [styling, setStyling] = useState<{ hostId: string; name: string } | undefined>(undefined); const [settings, setSettings] = useState<{ hostId: string; name: string }>(); const proxy = useRef(null); @@ -339,6 +341,7 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null ? [] : [ { id: 'settings', label: t('remoteHosts.settings') }, + { id: 'notifications', label: t('remoteHosts.notifications') }, { id: 'remove', label: t('remoteHosts.remove.confirm'), danger: true }, ]), ]} @@ -348,6 +351,7 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null if (id === 'settings' && menuRemote !== undefined) setSettings({ hostId: menuRemote.host.hostId, name: menuRemote.host.hostName }); if (id === 'remove' && menuRemote !== undefined) setRemoving(menuRemote); + if (id === 'notifications' && menuRemote !== undefined) setNotificationHost(menuRemote); }} onClose={() => setMenu(undefined)} /> @@ -370,6 +374,15 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null t={t} /> )} + {notificationHost === undefined ? null : ( + setNotificationHost(undefined)} + /> + )} {removing === undefined ? null : ( void): () => void; pair(link: string, deviceName: string, localHostId?: string): Promise; @@ -144,6 +147,19 @@ export function createRemoteHosts(options: { let tab = readTab(storage); let open: RemoteConversationTarget | undefined; let snapshot: RemoteHostsSnapshot = { hosts: [], avatars, tab }; + const notifications = createRemoteNotifications({ + storage, + api: browserNotificationApi(), + privateBody: () => + document.documentElement.lang.startsWith('zh') + ? '需要你提供私密输入' + : 'Needs private input from you', + open(target) { + globalThis.window?.focus(); + open = target; + publish(); + }, + }); const fetchAvatars = (hosts: readonly RemoteHost[]): void => { const wanted = new Map( @@ -193,6 +209,7 @@ export function createRemoteHosts(options: { activity, })); fetchAvatars(hosts); + if (listeners.size > 0) notifications.update(hosts); const known = tab === ALL_HOSTS || tab === THIS_HOST || clients.has(tab); snapshot = { hosts, @@ -265,6 +282,7 @@ export function createRemoteHosts(options: { }; return { + notifications, getSnapshot: () => snapshot, client: (hostId) => clients.get(hostId)?.client, activity(hostId, query) { @@ -293,6 +311,7 @@ export function createRemoteHosts(options: { listeners.delete(listener); if (listeners.size > 0) return; for (const entry of clients.values()) stop(entry); + notifications.stop(); }; }, async pair(link, deviceName, localHostId) { diff --git a/packages/client/src/client/remote-notification-settings.tsx b/packages/client/src/client/remote-notification-settings.tsx new file mode 100644 index 0000000000..befc294aa3 --- /dev/null +++ b/packages/client/src/client/remote-notification-settings.tsx @@ -0,0 +1,93 @@ +import { useState, useSyncExternalStore } from 'react'; +import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'; +import { PUSH_KINDS, type PushKind } from '../../../../apps/mobile/src/host/push.js'; +import type { RemoteNotifications } from './remote-notifications.js'; +import type { BotHarnessTranslate, BotHarnessKey } from './locale.js'; +import { FormDialog } from './ds/form-dialog.js'; +import { SettingsRow, SettingsSection } from './ds/settings-row.js'; + +const labels: Record = { + approval: 'remoteHosts.notifications.approval', + question: 'remoteHosts.notifications.question', + 'private-input': 'remoteHosts.notifications.private', + reply: 'remoteHosts.notifications.reply', +}; + +export function RemoteNotificationSettings({ + hostId, + name, + notifications, + t, + onClose, +}: { + hostId: string; + name: string; + notifications: RemoteNotifications; + t: BotHarnessTranslate; + onClose(): void; +}) { + const snapshot = useSyncExternalStore(notifications.subscribe, notifications.getSnapshot); + const choice = notifications.choice(hostId); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const set = (patch: Parameters[1]) => { + setBusy(true); + setError(false); + void notifications + .set(hostId, patch) + .catch(() => setError(true)) + .finally(() => setBusy(false)); + }; + return ( + + + set({ enabled: value })} + disabled={ + busy || + (!choice.enabled && + (snapshot.permission === 'unsupported' || snapshot.permission === 'denied')) + } + /> + } + /> + {PUSH_KINDS.map((kind) => ( + set({ preferences: { [kind]: value } })} + /> + } + /> + ))} + + + ); +} diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts new file mode 100644 index 0000000000..abdca143d3 --- /dev/null +++ b/packages/client/src/client/remote-notifications.ts @@ -0,0 +1,243 @@ +import { + DEFAULT_NOTIFICATION_PREFERENCES, + parseNotificationPreferences, +} from '../../../../apps/mobile/src/host/device-notification-preferences.js'; +import type { PushKind, PushPreferences } from '../../../../apps/mobile/src/host/push.js'; +import type { HumanAttentionItem } from '../../../../apps/mobile/src/host/wire.js'; +import type { RemoteHost, HostStorage, RemoteConversationTarget } from './remote-hosts.js'; +import { + noticeBody, + type DesktopNotificationApi, + type DesktopNotificationHandle, + type DesktopNotificationPermission, +} from './desktop-notifications.js'; + +export const REMOTE_NOTIFICATION_KEY = 'botharness.remoteNotifications.v1'; +type Choice = { enabled: boolean; preferences: PushPreferences }; +type Snapshot = { + hosts: Readonly>; + permission: DesktopNotificationPermission; +}; +const off: Choice = { enabled: false, preferences: DEFAULT_NOTIFICATION_PREFERENCES }; +const kinds: Record = { + 'tool-approval': 'approval', + 'workspace-grant-request': 'approval', + 'auto-rejection': 'approval', + 'group-join-request': 'approval', + 'assignment-waiting-human': 'question', + 'assignment-blocked': 'question', + 'user-question': 'question', + 'private-input': 'private-input', + 'channel-reply': 'reply', + 'channel-mention': 'reply', +}; + +export function createRemoteNotifications(options: { + storage?: HostStorage | undefined; + api?: DesktopNotificationApi | undefined; + open(target: RemoteConversationTarget): void; + privateBody(): string; +}) { + const { api, storage } = options; + let hosts: Record = {}; + try { + const saved: unknown = JSON.parse(storage?.getItem(REMOTE_NOTIFICATION_KEY) ?? '{}'); + if (typeof saved === 'object' && saved !== null) + for (const [id, value] of Object.entries(saved)) { + const entry = value as Partial | null; + const preferences = parseNotificationPreferences(entry?.preferences); + if (preferences && typeof entry?.enabled === 'boolean') + hosts[id] = { enabled: entry.enabled, preferences }; + } + } catch {} + let snapshot: Snapshot = { hosts, permission: api?.permission() ?? 'unsupported' }; + const listeners = new Set<() => void>(); + const state = new Map< + string, + { + credential: string; + inbox: RemoteHost['inbox']; + baseline: boolean; + high: string; + seen: Set; + } + >(); + const shown = new Map(); + const revisions = new Map(); + const publish = () => { + snapshot = { hosts, permission: api?.permission() ?? 'unsupported' }; + for (const listener of listeners) listener(); + }; + const close = (hostId?: string) => { + for (const [key, notice] of shown) { + if (hostId !== undefined && notice.hostId !== hostId) continue; + notice.handle.onclick = null; + notice.handle.close(); + shown.delete(key); + } + }; + const choice = (hostId: string) => hosts[hostId] ?? off; + const owner = { + getSnapshot: () => snapshot, + choice, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async set( + hostId: string, + patch: { enabled?: boolean; preferences?: Partial }, + ) { + const revision = (revisions.get(hostId) ?? 0) + 1; + revisions.set(hostId, revision); + if (patch.enabled === true && api?.permission() === 'default') { + try { + await api.requestPermission(); + } catch {} + } + if (revisions.get(hostId) !== revision) return; + const current = choice(hostId); + const next = { + enabled: + patch.enabled === undefined + ? current.enabled + : patch.enabled && api?.permission() === 'granted', + preferences: { ...current.preferences, ...patch.preferences }, + }; + const updated = { ...hosts, [hostId]: next }; + storage?.setItem(REMOTE_NOTIFICATION_KEY, JSON.stringify(updated)); + hosts = updated; + close(hostId); + publish(); + }, + async control(input: Record, expectedRevision: unknown) { + const hostId = input.hostId as string; + const current = choice(hostId); + if (input.patch !== undefined) { + if (expectedRevision !== JSON.stringify(current)) + throw new Error('notification-revision-stale'); + const patch = input.patch as Partial | null; + const preferences = parseNotificationPreferences(patch?.preferences); + if (!preferences || typeof patch?.enabled !== 'boolean') + throw new Error('notification-invalid-input'); + if (patch.enabled && api?.permission() !== 'granted') + throw new Error('notification-permission-required'); + await owner.set(hostId, { enabled: patch.enabled, preferences }); + } + return { + hostId, + ...choice(hostId), + permission: api?.permission() ?? 'unsupported', + revision: JSON.stringify(choice(hostId)), + }; + }, + update(remotes: readonly RemoteHost[]) { + const present = new Set(remotes.map((remote) => remote.host.hostId)); + for (const id of state.keys()) + if (!present.has(id)) { + state.delete(id); + close(id); + } + const permission = api?.permission() ?? 'unsupported'; + if (permission !== snapshot.permission) { + publish(); + if (permission !== 'granted') close(); + } + for (const remote of remotes) { + const id = remote.host.hostId; + let entry = state.get(id); + if (entry === undefined || entry.credential !== remote.host.credential) { + close(id); + entry = { + credential: remote.host.credential, + inbox: { status: 'idle' }, + baseline: false, + high: '', + seen: new Set(), + }; + state.set(id, entry); + } + if ( + remote.snapshot.connection.status !== 'connected' || + remote.inbox.status !== 'ready' || + remote.inbox.stale + ) { + entry.inbox = remote.inbox; + entry.baseline = false; + close(id); + continue; + } + if (entry.inbox === remote.inbox) continue; + entry.inbox = remote.inbox; + const items = [...remote.inbox.sections.action, ...remote.inbox.sections.replies]; + const active = new Set( + items.filter((item) => item.isUnread !== false).map((item) => `${id}:${item.id}`), + ); + for (const [key, notice] of shown) + if (notice.hostId === id && !active.has(key)) { + notice.handle.onclick = null; + notice.handle.close(); + shown.delete(key); + } + const pref = choice(id); + for (const item of items) { + const key = `${id}:${item.id}`; + const known = entry.seen.has(item.id); + entry.seen.add(item.id); + const kind = kinds[item.kind]; + if ( + !entry.baseline || + known || + item.createdAt < entry.high || + kind === undefined || + item.isUnread === false || + !pref.enabled || + !pref.preferences[kind] || + permission !== 'granted' || + api === undefined + ) + continue; + const bot = remote.snapshot.roster?.bots.find((bot) => bot.slug === item.botSlug); + const title = `${remote.host.hostName} · ${item.channelName ?? bot?.displayName ?? item.botSlug}`; + try { + const handle = api.show(title, { + body: kind === 'private-input' ? options.privateBody() : noticeBody(item.summary), + tag: `botharness-remote:${key}`, + }); + shown.set(key, { hostId: id, handle }); + handle.onclick = (event) => { + event.preventDefault(); + handle.close(); + shown.delete(key); + options.open(target(remote, item)); + }; + } catch {} + } + entry.high = items.reduce( + (high, item) => (item.createdAt > high ? item.createdAt : high), + entry.high, + ); + entry.baseline = true; + while (entry.seen.size > 2000) entry.seen.delete(entry.seen.values().next().value!); + } + }, + stop() { + state.clear(); + close(); + }, + }; + return owner; +} + +function target(remote: RemoteHost, item: HumanAttentionItem): RemoteConversationTarget { + const bot = remote.snapshot.roster?.bots.find((bot) => bot.slug === item.botSlug); + return { + hostId: remote.host.hostId, + title: item.channelName ?? bot?.displayName ?? item.botSlug, + ...(item.channelId === undefined ? { botSlug: item.botSlug } : { channelId: item.channelId }), + }; +} + +export type RemoteNotifications = ReturnType; diff --git a/packages/client/test/remote-notifications.test.ts b/packages/client/test/remote-notifications.test.ts new file mode 100644 index 0000000000..084b1503a9 --- /dev/null +++ b/packages/client/test/remote-notifications.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createRemoteNotifications } from '../src/client/remote-notifications.js'; +import type { RemoteHost } from '../src/client/remote-hosts.js'; +import type { DesktopNotificationHandle } from '../src/client/desktop-notifications.js'; +import type { HumanAttentionItem } from '../../../apps/mobile/src/host/wire.js'; + +const item = (id: string, kind = 'user-question', at = id): HumanAttentionItem => ({ + id, + kind, + category: 'action', + botSlug: 'ada', + createdAt: at, + summary: 'Request preview', +}); +function remote(id: string, items: HumanAttentionItem[]): RemoteHost { + return { + host: { hostId: id, hostName: id, deviceId: id, credential: id, routes: [] }, + snapshot: { connection: { status: 'connected', route: '' } }, + inbox: { status: 'ready', sections: { action: items, replies: [], unread: [], handled: [] } }, + }; +} +function fixture() { + let permission: 'default' | 'granted' | 'denied' = 'default'; + const handles: DesktopNotificationHandle[] = []; + const show = vi.fn(() => { + const handle = { onclick: null, close: vi.fn() }; + handles.push(handle); + return handle; + }); + const request = vi.fn(async (): Promise<'granted'> => (permission = 'granted')); + const open = vi.fn(); + const storage = new Map(); + const options = { + storage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + api: { permission: () => permission, requestPermission: request, show }, + open, + privateBody: () => 'Private input needed', + }; + return { + notices: createRemoteNotifications(options), + options, + show, + request, + handles, + open, + permission: (next: typeof permission) => { + permission = next; + }, + }; +} + +describe('Remote Host browser alerts', () => { + it('CLI uses the same choice with revision checks and never prompts for permission', async () => { + const f = fixture(); + const before = await f.notices.control({ hostId: 'a' }, undefined); + const patch = { enabled: true, preferences: before.preferences }; + await expect(f.notices.control({ hostId: 'a', patch }, before.revision)).rejects.toThrow( + 'notification-permission-required', + ); + expect(f.request).not.toHaveBeenCalled(); + f.permission('granted'); + const after = await f.notices.control({ hostId: 'a', patch }, before.revision); + expect(after.enabled).toBe(true); + await expect(f.notices.control({ hostId: 'a', patch }, before.revision)).rejects.toThrow( + 'notification-revision-stale', + ); + }); + it('requests permission only on enable, skips initial snapshots, dedupes and scopes identical IDs', async () => { + const f = fixture(); + f.notices.update([remote('a', [item('1')]), remote('b', [item('1')])]); + expect(f.request).not.toHaveBeenCalled(); + await f.notices.set('a', { enabled: true }); + await f.notices.set('b', { enabled: true }); + f.notices.update([remote('a', [item('1'), item('2')]), remote('b', [item('1'), item('2')])]); + expect(f.show).toHaveBeenCalledTimes(2); + f.notices.update([remote('a', [item('1'), item('2')]), remote('b', [item('1'), item('2')])]); + expect(f.show).toHaveBeenCalledTimes(2); + f.handles[1]!.onclick!(new Event('click')); + expect(f.open).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'b' })); + expect(createRemoteNotifications(f.options).choice('a').enabled).toBe(true); + }); + it('honors kinds, never leaks private input and never replays suppressed items', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true, preferences: { question: false } }); + f.notices.update([remote('a', [])]); + f.notices.update([remote('a', [item('1'), item('2', 'private-input')])]); + expect(f.show).toHaveBeenCalledExactlyOnceWith('a · ada', { + body: 'Private input needed', + tag: 'botharness-remote:a:2', + }); + await f.notices.set('a', { preferences: { question: true } }); + f.notices.update([remote('a', [item('1'), item('2', 'private-input')])]); + expect(f.show).toHaveBeenCalledTimes(1); + }); + it('suppresses reconnect backlogs and old pagination, closes on refusal and disposal', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + const first = remote('a', [item('2')]); + f.notices.update([first]); + f.notices.update([{ ...first, snapshot: { connection: { status: 'stopped', route: '' } } }]); + f.notices.update([first]); + f.notices.update([remote('a', [item('2'), item('3')])]); + expect(f.show).not.toHaveBeenCalled(); + f.notices.update([remote('a', [item('1'), item('2'), item('3'), item('4')])]); + expect(f.show).toHaveBeenCalledTimes(1); + f.notices.stop(); + expect(f.handles[0]!.close).toHaveBeenCalled(); + expect(f.handles[0]!.onclick).toBeNull(); + }); + it('keeps denied permission off and handles a late permission result after disabling', async () => { + const f = fixture(); + f.permission('denied'); + await f.notices.set('a', { enabled: true }); + expect(f.notices.choice('a').enabled).toBe(false); + f.permission('default'); + let resolve!: () => void; + f.request.mockImplementation( + () => + new Promise((done) => { + resolve = () => done('granted'); + }), + ); + const enable = f.notices.set('a', { enabled: true }); + await f.notices.set('a', { enabled: false }); + resolve(); + await enable; + expect(f.notices.choice('a').enabled).toBe(false); + }); +}); diff --git a/packages/computer/lib/client.js b/packages/computer/lib/client.js index 7bfcabe5c4..9a23d0c6a5 100644 --- a/packages/computer/lib/client.js +++ b/packages/computer/lib/client.js @@ -823,18 +823,18 @@ window.__ModuleLoader__.load({ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { role: "status", "data-panel-empty": "", - className: "bhx78zum5 bhxdt5ytf bhx6s0dn4 bhxl56j7k bhx1iyjqo2 bhx1mckxr8 bhx1tsjn1a bhx9f619 bhx2b8uid bhxr3raxm", + className: "bhx78zum5 bhxdt5ytf bhx6s0dn4 bhxl56j7k bhx1iyjqo2 bhx16qymgn bhx14opcb4 bhx9f619 bhx2b8uid bhx1hablki", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "aria-hidden": "true", - className: "bhx1i1gie9 bhxdkzj7d bhx2lah0s bhxwq55zj", + className: "bhx1iur23p bhx1hfu2dq bhx2lah0s bhx14dlum0", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChannelSidebarIcon, { name: icon, size: "100%" }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { - className: "bhx1ghz6dp bhxxc7z9f bhxr3raxm bhxgowt09 bhx12cu2lo", + className: "bhx1ghz6dp bhxxc7z9f bhx1hablki bhx17cqwc0 bhx15n174q", children }), action === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { @@ -2040,34 +2040,34 @@ window.__ModuleLoader__.load({ const styles = { note: { kogj98: "bhx1ghz6dp", - kGuDYH: "bhxgowt09", - kLWn49: "bhx12cu2lo", + kGuDYH: "bhx17cqwc0", + kLWn49: "bhx15n174q", kHjlTd: "bhxj0a0fe", $$css: true }, inset: { - k8WAf4: "bhx89kiln", - kg3NbH: "bhx1w8vjyx", + k8WAf4: "bhx1leccfj", + kg3NbH: "bhx792b8t", $$css: true }, muted: { - kMwMTN: "bhxr3raxm", + kMwMTN: "bhx1hablki", $$css: true }, subtle: { - kMwMTN: "bhxwq55zj", + kMwMTN: "bhx14dlum0", $$css: true }, danger: { - kMwMTN: "bhx8ay0r6", + kMwMTN: "bhx1sspbqh", $$css: true }, success: { - kMwMTN: "bhx1pideu2", + kMwMTN: "bhx108ve1s", $$css: true }, warning: { - kMwMTN: "bhxz9v26x", + kMwMTN: "bhx1c0x75y", $$css: true } }; @@ -2083,21 +2083,21 @@ window.__ModuleLoader__.load({ const pillStyles = { pill: { k1xSpc: "bhx3nfvp2", kGNEyG: "bhx6s0dn4", - kOIVth: "bhx12e6dxb", + kOIVth: "bhx1nry0o3", ks0D6T: "bhx193iq5w", - kZKoxP: "bhxxevliy", - kg3NbH: "bhx1vid4s1", + kZKoxP: "bhx1n56ao0", + kg3NbH: "bhxa30tib", kMzoRj: "bhxc342km", - kaIpWk: "bhx4g09nt", - kWkggS: "bhxw43b1w bhx1lc0nsx", - kMwMTN: "bhx24dycr", + kaIpWk: "bhxjpou06", + kWkggS: "bhxfo3ty bhxrplx1s", + kMwMTN: "bhx1gs2zk7", kMv6JI: "bhxjb2p0i", - kGuDYH: "bhx5g6txa", - kLWn49: "bhx3350y7", + kGuDYH: "bhxer0amy", + kLWn49: "bhx1jatdxk", khDVqt: "bhxuxw1ft", kkrTdU: "bhx1ypdohk bhxtgyt42", kSiTet: "bhx1hc1fzr bhxijokvz", - kI3sdo: "bhx1a2a7pz bhx1m95a6t", + kI3sdo: "bhx1a2a7pz bhx5dwnpn", kInvED: "bhx1y3gkto", $$css: true } }; @@ -2105,13 +2105,13 @@ window.__ModuleLoader__.load({ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", ...rest, - className: "bhx3nfvp2 bhx6s0dn4 bhx12e6dxb bhx193iq5w bhxxevliy bhx1vid4s1 bhxc342km bhx4g09nt bhxw43b1w bhx1lc0nsx bhx24dycr bhxjb2p0i bhx5g6txa bhx3350y7 bhxuxw1ft bhx1ypdohk bhxtgyt42 bhx1hc1fzr bhxijokvz bhx1a2a7pz bhx1m95a6t bhx1y3gkto", + className: "bhx3nfvp2 bhx6s0dn4 bhx1nry0o3 bhx193iq5w bhx1n56ao0 bhxa30tib bhxc342km bhxjpou06 bhxfo3ty bhxrplx1s bhx1gs2zk7 bhxjb2p0i bhxer0amy bhx1jatdxk bhxuxw1ft bhx1ypdohk bhxtgyt42 bhx1hc1fzr bhxijokvz bhx1a2a7pz bhx5dwnpn bhx1y3gkto", children }); } function PillFileButton({ children, ...input }) { return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { - className: "bhx3nfvp2 bhx6s0dn4 bhx12e6dxb bhx193iq5w bhxxevliy bhx1vid4s1 bhxc342km bhx4g09nt bhxw43b1w bhx1lc0nsx bhx24dycr bhxjb2p0i bhx5g6txa bhx3350y7 bhxuxw1ft bhx1ypdohk bhxtgyt42 bhx1hc1fzr bhxijokvz bhx1a2a7pz bhx1m95a6t bhx1y3gkto", + className: "bhx3nfvp2 bhx6s0dn4 bhx1nry0o3 bhx193iq5w bhx1n56ao0 bhxa30tib bhxc342km bhxjpou06 bhxfo3ty bhxrplx1s bhx1gs2zk7 bhxjb2p0i bhxer0amy bhx1jatdxk bhxuxw1ft bhx1ypdohk bhxtgyt42 bhx1hc1fzr bhxijokvz bhx1a2a7pz bhx5dwnpn bhx1y3gkto", children: [children, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", { ...input, type: "file", @@ -2191,7 +2191,7 @@ window.__ModuleLoader__.load({ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { ref: mount, "data-pointer-menu": "", - className: "bhxixxii4 bhxu96u03 bhx13vifvy bhx51uzqw bhx1kkor1i bhxp6lfbs bhx47corl", + className: "bhxixxii4 bhxu96u03 bhx13vifvy bhx1uif995 bhxjgjsw3 bhxp6lfbs bhx47corl", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Menu$1, { ...props, open: true, @@ -2271,7 +2271,7 @@ window.__ModuleLoader__.load({ className: "bhxeuugli bhxb3r6kr bhxlyipyv bhxuxw1ft", children: current?.label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { - className: "bhx3nfvp2 bhx2lah0s bhxr3raxm", + className: "bhx3nfvp2 bhx2lah0s bhx1hablki", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 12 }) })] }), active && point ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointerMenu, { @@ -2312,24 +2312,24 @@ window.__ModuleLoader__.load({ "aria-labelledby": titleId, "aria-describedby": description === void 0 ? void 0 : descriptionId, ...data, - className: "bhx78zum5 bhx6s0dn4 bhx4bgz6 bhx15bqsqq bhx1q0g3np bhxcxmu7o bhx1uptv2i bhx1mckxr8 bhx1kwkzks bhxv0lzym bhxeuugli bhxe74ayl bhxrvphhj bhx1q0q8m5 bhxnoyelg bhx1bta2m4", + className: "bhx78zum5 bhx6s0dn4 bhx4bgz6 bhx15bqsqq bhx1q0g3np bhxcxmu7o bhx1uptv2i bhx16qymgn bhx19fcs9c bhx1cg4dk7 bhxeuugli bhxz617pa bhx1gtm3y5 bhx1q0q8m5 bhxqlejh0 bhx1bta2m4", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhxdt5ytf bhx5v8n67 bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli", + className: "bhx78zum5 bhxdt5ytf bhx1fykdyo bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { id: titleId, - className: "bhx24dycr bhxskcadq bhx1gy5ux2 bhx1nj67n bhxj0a0fe", + className: "bhx1gs2zk7 bhx2dqotr bhx11nzxlp bhxs1wz3g bhxj0a0fe", children: title }), description === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { id: descriptionId, - className: "bhxr3raxm bhxgowt09 bhx12cu2lo bhxj0a0fe", + className: "bhx1hablki bhx17cqwc0 bhx15n174q bhxj0a0fe", children: description }), children ] }), control === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { - className: "bhx78zum5 bhx1a02dak bhx6s0dn4 bhx13a6bvl bhx403fy9 bhx3xz8fp bhx2zm2ee bhx2lah0s bhxeuugli bhxh9uqaf bhx1r6q2vj bhx1cnm762", + className: "bhx78zum5 bhx1a02dak bhx6s0dn4 bhx13a6bvl bhx403fy9 bhx3xz8fp bhxcksle6 bhx2lah0s bhxeuugli bhxh9uqaf bhx1r6q2vj bhx1cnm762", children: control })] }); @@ -2342,9 +2342,9 @@ window.__ModuleLoader__.load({ function ProgressLine({ percent, label }) { return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { role: "status", - className: "bhxrvj5dj bhx5v8n67 bhxh8yej3 bhxr3raxm bhxgowt09 bhx12cu2lo", + className: "bhxrvj5dj bhx1fykdyo bhxh8yej3 bhx1hablki bhx17cqwc0 bhx15n174q", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("progress", { - className: "bhxh8yej3 bhxols6we bhx1g31g6d", + className: "bhxh8yej3 bhxols6we bhx1rpn2j5", max: 100, value: Math.max(0, Math.min(100, percent)) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })] @@ -2356,7 +2356,7 @@ window.__ModuleLoader__.load({ navigator.clipboard?.writeText(command).then(() => setCopied(true)); }; return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhxrvj5dj bhx2zm2ee bhx619ttb bhxh8yej3", + className: "bhxrvj5dj bhxcksle6 bhx619ttb bhxh8yej3", children: [ installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { tone: "warning", @@ -2366,11 +2366,11 @@ window.__ModuleLoader__.load({ label: installing.label }), access === "none" && installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { children: labels.manual }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", { - className: "bhx1ghz6dp bhxh8yej3 bhx9f619 bhx89kiln bhx1w8vjyx bhx4g09nt bhx1xojs80 bhx24dycr bhxmz2490 bhxgowt09 bhx12cu2lo bhx126k92a bhxj0a0fe", + className: "bhx1ghz6dp bhxh8yej3 bhx9f619 bhx1leccfj bhx792b8t bhxjpou06 bhxblcitj bhx1gs2zk7 bhxmz2490 bhx17cqwc0 bhx15n174q bhx126k92a bhxj0a0fe", children: command })] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhx12e6dxb bhx1a02dak", + className: "bhx78zum5 bhx1nry0o3 bhx1a02dak", children: [ access !== "none" && installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { size: "sm", @@ -2516,7 +2516,7 @@ window.__ModuleLoader__.load({ }), meta: t(granted ? "local.granted" : "local.description"), detail: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhxrvj5dj bhx2zm2ee bhx619ttb", + className: "bhxrvj5dj bhxcksle6 bhx619ttb", children: [failure === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { tone: "danger", children: failure @@ -2729,7 +2729,7 @@ window.__ModuleLoader__.load({ disabled: disabled === true, onClick: onToggle, children: [label, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { - className: "bhx3nfvp2 bhx2lah0s bhxr3raxm", + className: "bhx3nfvp2 bhx2lah0s bhx1hablki", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 12 }) })] }); @@ -2948,7 +2948,7 @@ window.__ModuleLoader__.load({ ref: busyResource }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { - className: "bhx1ghz6dp bhxb590mu bhx1e1oj2y bhxr3raxm bhxgowt09 bhx12cu2lo", + className: "bhx1ghz6dp bhx2o8g9q bhxugyd1d bhx1hablki bhx17cqwc0 bhx15n174q", children: t("section.description") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingsRow, { @@ -3011,7 +3011,7 @@ window.__ModuleLoader__.load({ ] }) }), manualOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhx6s0dn4 bhx1mckxr8 bhxeuugli bhxe74ayl bhxrvphhj bhx1q0q8m5 bhxnoyelg", + className: "bhx78zum5 bhx6s0dn4 bhx16qymgn bhxeuugli bhxz617pa bhx1gtm3y5 bhx1q0q8m5 bhxqlejh0", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, { className: { className: "bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli" }.className ?? "", value: manualPath, @@ -3144,7 +3144,7 @@ window.__ModuleLoader__.load({ ] }) }), selectedFile === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { - className: "bhx1ghz6dp bhxb3r6kr bhxlyipyv bhxuxw1ft bhxr3raxm bhxgowt09 bhx12cu2lo", + className: "bhx1ghz6dp bhxb3r6kr bhxlyipyv bhxuxw1ft bhx1hablki bhx17cqwc0 bhx15n174q", children: selectedFile }), busy !== void 0 && phaseKey !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { children: `${t(phaseKey)} · ${t("entry.elapsed", { seconds: liveElapsed })}` }) : null @@ -3171,7 +3171,7 @@ window.__ModuleLoader__.load({ } //#endregion //#region packages/client/src/client/ds/stylex-css.ts - const STYLEX_CSS = "\n.bhx-level-0{}\n.bhx-level-1:not(#\\#){}\n.bhx1ghz6dp:not(#\\#){margin:0}\n.bhx1tsjn1a:not(#\\#){padding:16px}\n.bhx-level-2:not(#\\#):not(#\\#){}\n.bhx7vmdhn:not(#\\#):not(#\\#){border-color:var(--dsw-alias-border-l2)}\n.bhx10e1no3:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-md, 12px)}\n.bhx4g09nt:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-sm, 8px)}\n.bhxbsl7fq:not(#\\#):not(#\\#){border-style:dashed}\n.bhxc342km:not(#\\#):not(#\\#){border-width:0}\n.bhx1sfpsak:not(#\\#):not(#\\#){border-width:1px}\n.bhx1mckxr8:not(#\\#):not(#\\#){gap:12px}\n.bhx5v8n67:not(#\\#):not(#\\#){gap:2px}\n.bhx12e6dxb:not(#\\#):not(#\\#){gap:4px}\n.bhx2zm2ee:not(#\\#):not(#\\#){gap:8px}\n.bhx1d3k6p2:not(#\\#):not(#\\#){margin-block:12px}\n.bhx1a2a7pz:not(#\\#):not(#\\#){outline:none}\n.bhxb3r6kr:not(#\\#):not(#\\#){overflow:hidden}\n.bhxe74ayl:not(#\\#):not(#\\#){padding-block:12px}\n.bhx1tv32aw:not(#\\#):not(#\\#){padding-block:24px}\n.bhx89kiln:not(#\\#):not(#\\#){padding-block:4px}\n.bhx1vid4s1:not(#\\#):not(#\\#){padding-inline:12px 8px}\n.bhx1xa34d1:not(#\\#):not(#\\#){padding-inline:16px}\n.bhx1w8vjyx:not(#\\#):not(#\\#){padding-inline:4px}\n.bhx1m95a6t:focus-visible:not(#\\#):not(#\\#){outline:var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary)}\n@media (max-width: 648px){.bhxv0lzym.bhxv0lzym:not(#\\#):not(#\\#){gap:8px}}\n@container bh-settings (max-width: 480px){.bhx1kwkzks.bhx1kwkzks:not(#\\#):not(#\\#){gap:8px}}\n.bhx-level-3:not(#\\#):not(#\\#):not(#\\#){}\n.bhx1g31g6d:not(#\\#):not(#\\#):not(#\\#){accent-color:var(--dsw-alias-state-business-primary)}\n.bhx6s0dn4:not(#\\#):not(#\\#):not(#\\#){align-items:center}\n.bhxw43b1w:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-bg-module-platform)}\n.bhx1xojs80:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)}\n.bhx9f619:not(#\\#):not(#\\#):not(#\\#){box-sizing:border-box}\n.bhx24dycr:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-primary)}\n.bhxr3raxm:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-secondary)}\n.bhxwq55zj:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-tertiary)}\n.bhx8ay0r6:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-error-primary)}\n.bhx1pideu2:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-success-primary)}\n.bhxz9v26x:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-warn-primary)}\n.bhx1ypdohk:not(#\\#):not(#\\#):not(#\\#){cursor:pointer}\n.bhx78zum5:not(#\\#):not(#\\#):not(#\\#){display:flex}\n.bhxrvj5dj:not(#\\#):not(#\\#):not(#\\#){display:grid}\n.bhx3nfvp2:not(#\\#):not(#\\#):not(#\\#){display:inline-flex}\n.bhx1r8uery:not(#\\#):not(#\\#):not(#\\#){flex-basis:0}\n.bhxdt5ytf:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}\n.bhx1q0g3np:not(#\\#):not(#\\#):not(#\\#){flex-direction:row}\n.bhx1iyjqo2:not(#\\#):not(#\\#):not(#\\#){flex-grow:1}\n.bhx2lah0s:not(#\\#):not(#\\#):not(#\\#){flex-shrink:0}\n.bhxs83m0k:not(#\\#):not(#\\#):not(#\\#){flex-shrink:1}\n.bhx1a02dak:not(#\\#):not(#\\#):not(#\\#){flex-wrap:wrap}\n.bhxjb2p0i:not(#\\#):not(#\\#):not(#\\#){font-family:inherit}\n.bhxmz2490:not(#\\#):not(#\\#):not(#\\#){font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}\n.bhxgowt09:not(#\\#):not(#\\#):not(#\\#){font-size:12px}\n.bhx5g6txa:not(#\\#):not(#\\#):not(#\\#){font-size:13px}\n.bhxskcadq:not(#\\#):not(#\\#):not(#\\#){font-size:14px}\n.bhx1nj67n:not(#\\#):not(#\\#):not(#\\#){font-weight:500}\n.bhxl56j7k:not(#\\#):not(#\\#):not(#\\#){justify-content:center}\n.bhx13a6bvl:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-end}\n.bhx619ttb:not(#\\#):not(#\\#):not(#\\#){justify-items:start}\n.bhx12cu2lo:not(#\\#):not(#\\#):not(#\\#){line-height:16px}\n.bhx1gy5ux2:not(#\\#):not(#\\#):not(#\\#){line-height:20px}\n.bhx3350y7:not(#\\#):not(#\\#):not(#\\#){line-height:20px}\n.bhx1hc1fzr:not(#\\#):not(#\\#):not(#\\#){opacity:1}\n.bhx1y3gkto:not(#\\#):not(#\\#):not(#\\#){outline-offset:1px}\n.bhxj0a0fe:not(#\\#):not(#\\#):not(#\\#){overflow-wrap:anywhere}\n.bhx67bb7w:not(#\\#):not(#\\#):not(#\\#){pointer-events:auto}\n.bhx47corl:not(#\\#):not(#\\#):not(#\\#){pointer-events:none}\n.bhxixxii4:not(#\\#):not(#\\#):not(#\\#){position:fixed}\n.bhx2b8uid:not(#\\#):not(#\\#):not(#\\#){text-align:center}\n.bhxp4054r:not(#\\#):not(#\\#):not(#\\#){text-align:end}\n.bhxlyipyv:not(#\\#):not(#\\#):not(#\\#){text-overflow:ellipsis}\n.bhxuxw1ft:not(#\\#):not(#\\#):not(#\\#){white-space:nowrap}\n.bhx126k92a:not(#\\#):not(#\\#):not(#\\#){white-space:pre-wrap}\n.bhxp6lfbs:not(#\\#):not(#\\#):not(#\\#){z-index:1100}\n.bhxtgyt42:disabled:not(#\\#):not(#\\#):not(#\\#){cursor:default}\n.bhxijokvz:disabled:not(#\\#):not(#\\#):not(#\\#){opacity:.5}\n.bhx1lc0nsx:hover:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)}\n@media (max-width: 648px){.bhx15bqsqq.bhx15bqsqq:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}}\n@media (max-width: 648px){.bhx1uptv2i.bhx1uptv2i:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}}\n@media (max-width: 648px){.bhx3xz8fp.bhx3xz8fp:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}}\n@container bh-settings (max-width: 480px){.bhx4bgz6.bhx4bgz6:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}}\n@container bh-settings (max-width: 480px){.bhxcxmu7o.bhxcxmu7o:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}}\n@container bh-settings (max-width: 480px){.bhx403fy9.bhx403fy9:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}}\n.bhx-level-4:not(#\\#):not(#\\#):not(#\\#):not(#\\#){}\n.bhxnoyelg:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:var(--dsw-alias-border-l1)}\n.bhx1q0q8m5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-style:solid}\n.bhxrvphhj:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-width:1px}\n.bhx1kkor1i:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:1px}\n.bhxxevliy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:28px}\n.bhxdkzj7d:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:40px}\n.bhxols6we:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:6px}\n.bhxu96u03:not(#\\#):not(#\\#):not(#\\#):not(#\\#){left:0}\n.bhx193iq5w:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}\n.bhxxc7z9f:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:360px}\n.bhxh9uqaf:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:60%}\n.bhxeuugli:not(#\\#):not(#\\#):not(#\\#):not(#\\#){min-width:0}\n.bhx1e1oj2y:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-bottom:4px}\n.bhxb590mu:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:16px}\n.bhx13vifvy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){top:0}\n.bhxh8yej3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:100%}\n.bhx51uzqw:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:1px}\n.bhx1i1gie9:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:40px}\n.bhx10zg81t:first-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:0}\n.bhx1bta2m4:last-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:transparent}\n@media (max-width: 648px){.bhx1cnm762.bhx1cnm762:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}}\n@container bh-settings (max-width: 480px){.bhx1r6q2vj.bhx1r6q2vj:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}}\n"; + const STYLEX_CSS = "\n.bhx-level-0{}\n.bhx-level-1:not(#\\#){}\n.bhx1ghz6dp:not(#\\#){margin:0}\n.bhx14opcb4:not(#\\#){padding:16px}\n.bhx-level-2:not(#\\#):not(#\\#){}\n.bhx118alp2:not(#\\#):not(#\\#){border-color:var(--dsw-alias-border-l2)}\n.bhx1w7u4cj:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-md, 12px)}\n.bhxjpou06:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-sm, 8px)}\n.bhxbsl7fq:not(#\\#):not(#\\#){border-style:dashed}\n.bhxc342km:not(#\\#):not(#\\#){border-width:0}\n.bhxc2pr59:not(#\\#):not(#\\#){border-width:1px}\n.bhx16qymgn:not(#\\#):not(#\\#){gap:12px}\n.bhx1fykdyo:not(#\\#):not(#\\#){gap:2px}\n.bhx1nry0o3:not(#\\#):not(#\\#){gap:4px}\n.bhxcksle6:not(#\\#):not(#\\#){gap:8px}\n.bhxx7iei1:not(#\\#):not(#\\#){margin-block:12px}\n.bhx1a2a7pz:not(#\\#):not(#\\#){outline:none}\n.bhxb3r6kr:not(#\\#):not(#\\#){overflow:hidden}\n.bhxz617pa:not(#\\#):not(#\\#){padding-block:12px}\n.bhxxx7nam:not(#\\#):not(#\\#){padding-block:24px}\n.bhx1leccfj:not(#\\#):not(#\\#){padding-block:4px}\n.bhxa30tib:not(#\\#):not(#\\#){padding-inline:12px 8px}\n.bhxwajc3y:not(#\\#):not(#\\#){padding-inline:16px}\n.bhx792b8t:not(#\\#):not(#\\#){padding-inline:4px}\n.bhx5dwnpn:focus-visible:not(#\\#):not(#\\#){outline:var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary)}\n@media (max-width: 648px){.bhx1cg4dk7.bhx1cg4dk7:not(#\\#):not(#\\#){gap:8px}}\n@container bh-settings (max-width: 480px){.bhx19fcs9c.bhx19fcs9c:not(#\\#):not(#\\#){gap:8px}}\n.bhx-level-3:not(#\\#):not(#\\#):not(#\\#){}\n.bhx1rpn2j5:not(#\\#):not(#\\#):not(#\\#){accent-color:var(--dsw-alias-state-business-primary)}\n.bhx6s0dn4:not(#\\#):not(#\\#):not(#\\#){align-items:center}\n.bhxfo3ty:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-bg-module-platform)}\n.bhxblcitj:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)}\n.bhx9f619:not(#\\#):not(#\\#):not(#\\#){box-sizing:border-box}\n.bhx1gs2zk7:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-primary)}\n.bhx1hablki:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-secondary)}\n.bhx14dlum0:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-tertiary)}\n.bhx1sspbqh:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-error-primary)}\n.bhx108ve1s:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-success-primary)}\n.bhx1c0x75y:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-warn-primary)}\n.bhx1ypdohk:not(#\\#):not(#\\#):not(#\\#){cursor:pointer}\n.bhx78zum5:not(#\\#):not(#\\#):not(#\\#){display:flex}\n.bhxrvj5dj:not(#\\#):not(#\\#):not(#\\#){display:grid}\n.bhx3nfvp2:not(#\\#):not(#\\#):not(#\\#){display:inline-flex}\n.bhx1r8uery:not(#\\#):not(#\\#):not(#\\#){flex-basis:0}\n.bhxdt5ytf:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}\n.bhx1q0g3np:not(#\\#):not(#\\#):not(#\\#){flex-direction:row}\n.bhx1iyjqo2:not(#\\#):not(#\\#):not(#\\#){flex-grow:1}\n.bhx2lah0s:not(#\\#):not(#\\#):not(#\\#){flex-shrink:0}\n.bhxs83m0k:not(#\\#):not(#\\#):not(#\\#){flex-shrink:1}\n.bhx1a02dak:not(#\\#):not(#\\#):not(#\\#){flex-wrap:wrap}\n.bhxjb2p0i:not(#\\#):not(#\\#):not(#\\#){font-family:inherit}\n.bhxmz2490:not(#\\#):not(#\\#):not(#\\#){font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}\n.bhx17cqwc0:not(#\\#):not(#\\#):not(#\\#){font-size:12px}\n.bhxer0amy:not(#\\#):not(#\\#):not(#\\#){font-size:13px}\n.bhx2dqotr:not(#\\#):not(#\\#):not(#\\#){font-size:14px}\n.bhxs1wz3g:not(#\\#):not(#\\#):not(#\\#){font-weight:500}\n.bhxl56j7k:not(#\\#):not(#\\#):not(#\\#){justify-content:center}\n.bhx13a6bvl:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-end}\n.bhx619ttb:not(#\\#):not(#\\#):not(#\\#){justify-items:start}\n.bhx15n174q:not(#\\#):not(#\\#):not(#\\#){line-height:16px}\n.bhx11nzxlp:not(#\\#):not(#\\#):not(#\\#){line-height:20px}\n.bhx1jatdxk:not(#\\#):not(#\\#):not(#\\#){line-height:20px}\n.bhx1hc1fzr:not(#\\#):not(#\\#):not(#\\#){opacity:1}\n.bhx1y3gkto:not(#\\#):not(#\\#):not(#\\#){outline-offset:1px}\n.bhxj0a0fe:not(#\\#):not(#\\#):not(#\\#){overflow-wrap:anywhere}\n.bhx67bb7w:not(#\\#):not(#\\#):not(#\\#){pointer-events:auto}\n.bhx47corl:not(#\\#):not(#\\#):not(#\\#){pointer-events:none}\n.bhxixxii4:not(#\\#):not(#\\#):not(#\\#){position:fixed}\n.bhx2b8uid:not(#\\#):not(#\\#):not(#\\#){text-align:center}\n.bhxp4054r:not(#\\#):not(#\\#):not(#\\#){text-align:end}\n.bhxlyipyv:not(#\\#):not(#\\#):not(#\\#){text-overflow:ellipsis}\n.bhxuxw1ft:not(#\\#):not(#\\#):not(#\\#){white-space:nowrap}\n.bhx126k92a:not(#\\#):not(#\\#):not(#\\#){white-space:pre-wrap}\n.bhxp6lfbs:not(#\\#):not(#\\#):not(#\\#){z-index:1100}\n.bhxtgyt42:disabled:not(#\\#):not(#\\#):not(#\\#){cursor:default}\n.bhxijokvz:disabled:not(#\\#):not(#\\#):not(#\\#){opacity:.5}\n.bhxrplx1s:hover:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)}\n@media (max-width: 648px){.bhx15bqsqq.bhx15bqsqq:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}}\n@media (max-width: 648px){.bhx1uptv2i.bhx1uptv2i:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}}\n@media (max-width: 648px){.bhx3xz8fp.bhx3xz8fp:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}}\n@container bh-settings (max-width: 480px){.bhx4bgz6.bhx4bgz6:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}}\n@container bh-settings (max-width: 480px){.bhxcxmu7o.bhxcxmu7o:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}}\n@container bh-settings (max-width: 480px){.bhx403fy9.bhx403fy9:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}}\n.bhx-level-4:not(#\\#):not(#\\#):not(#\\#):not(#\\#){}\n.bhxqlejh0:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:var(--dsw-alias-border-l1)}\n.bhx1q0q8m5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-style:solid}\n.bhx1gtm3y5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-width:1px}\n.bhxjgjsw3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:1px}\n.bhx1n56ao0:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:28px}\n.bhx1hfu2dq:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:40px}\n.bhxols6we:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:6px}\n.bhxu96u03:not(#\\#):not(#\\#):not(#\\#):not(#\\#){left:0}\n.bhx193iq5w:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}\n.bhxxc7z9f:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:360px}\n.bhxh9uqaf:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:60%}\n.bhxeuugli:not(#\\#):not(#\\#):not(#\\#):not(#\\#){min-width:0}\n.bhxugyd1d:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-bottom:4px}\n.bhx2o8g9q:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:16px}\n.bhx13vifvy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){top:0}\n.bhxh8yej3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:100%}\n.bhx1uif995:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:1px}\n.bhx1iur23p:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:40px}\n.bhx10zg81t:first-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:0}\n.bhx1bta2m4:last-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:transparent}\n@media (max-width: 648px){.bhx1cnm762.bhx1cnm762:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}}\n@container bh-settings (max-width: 480px){.bhx1r6q2vj.bhx1r6q2vj:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}}\n"; //#endregion //#region packages/computer/src/client/index.tsx const ACCESS_STYLE = ` diff --git a/packages/core/src/appearance/control.ts b/packages/core/src/appearance/control.ts index e923b94f52..8d1b795c15 100644 --- a/packages/core/src/appearance/control.ts +++ b/packages/core/src/appearance/control.ts @@ -7,6 +7,7 @@ interface Command { patch?: unknown; wallpaper?: unknown; recovery?: unknown; + notifications?: unknown; expectedRevision?: string; } interface Client { @@ -69,7 +70,9 @@ export function createAppearanceControl(now = Date.now) { if (!client) return refused('appearance-client-stale', 404); if (input.action === 'request') { if ( - [input.patch, input.wallpaper, input.recovery].filter((v) => v !== undefined).length > 1 + [input.patch, input.wallpaper, input.recovery, input.notifications].filter( + (v) => v !== undefined, + ).length > 1 ) return refused('appearance-invalid-input', 400); if (input.patch !== undefined && typeof input.expectedRevision !== 'string') @@ -79,6 +82,12 @@ export function createAppearanceControl(now = Date.now) { const command: Command = { id: randomUUID(), expiresAt: now() + 8000, + ...(input.notifications === undefined + ? {} + : { + notifications: input.notifications, + expectedRevision: input.expectedRevision as string, + }), ...(input.recovery === undefined ? {} : { recovery: input.recovery, expectedRevision: input.expectedRevision as string }), diff --git a/packages/core/src/bots/cli-browser-notifications.ts b/packages/core/src/bots/cli-browser-notifications.ts new file mode 100644 index 0000000000..dd906a5cc1 --- /dev/null +++ b/packages/core/src/bots/cli-browser-notifications.ts @@ -0,0 +1,69 @@ +import { CliLiveError } from './cli-live-error.js'; + +export const BROWSER_NOTIFICATION_COMMANDS = [ + { + command: 'browser-notification-clients', + description: 'list open Clients for browser notification preferences', + }, + { + command: 'browser-notifications', + description: + 'read or set one Remote Host notification choice on --client-id (--remote-host-id, --patch-stdin, --expected-revision)', + }, +] as const; + +export async function runBrowserNotifications( + command: string, + rest: string[], + values: { + 'client-id'?: string; + 'remote-host-id'?: string; + 'patch-stdin'?: boolean; + 'expected-revision'?: string; + }, + io: { readStdin?: () => Promise }, + connect: () => Promise<{ + http(path: string, body?: Record): Promise<{ bytes: Uint8Array }>; + }>, +) { + const list = command === 'browser-notification-clients'; + if ( + rest.length || + (list + ? values['client-id'] || + values['remote-host-id'] || + values['patch-stdin'] || + values['expected-revision'] + : !values['client-id'] || !values['remote-host-id']) + ) + throw new CliLiveError( + 'usage', + 'Select --client-id and --remote-host-id for browser-notifications.', + ); + if (!!values['patch-stdin'] !== (values['expected-revision'] !== undefined)) + throw new CliLiveError('usage', 'Writes require --patch-stdin and --expected-revision.'); + let patch: unknown; + if (values['patch-stdin']) { + try { + patch = JSON.parse((await io.readStdin?.()) ?? ''); + } catch { + throw new CliLiveError('invalid-input', 'Invalid notification JSON on stdin.'); + } + } + const host = await connect(); + const result = await host.http( + '/api/botharness/appearance-control', + list + ? undefined + : { + action: 'request', + clientId: values['client-id'], + notifications: { + hostId: values['remote-host-id'], + ...(patch === undefined ? {} : { patch }), + }, + ...(patch === undefined ? {} : { expectedRevision: values['expected-revision'] }), + }, + ); + return JSON.parse(new TextDecoder().decode(result.bytes)) as unknown; +} diff --git a/packages/core/src/bots/cli-device-notifications.ts b/packages/core/src/bots/cli-device-notifications.ts new file mode 100644 index 0000000000..eab341441b --- /dev/null +++ b/packages/core/src/bots/cli-device-notifications.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { parseSavedHost } from '../../../../apps/mobile/src/host/hosts.js'; +import { parseNotificationPreferences } from '../../../../apps/mobile/src/host/device-notification-preferences.js'; +import { createHostPush, PushRequestFailed } from '../../../../apps/mobile/src/host/push.js'; +import { CliLiveError } from './cli-live-error.js'; + +export const DEVICE_NOTIFICATION_COMMANDS = [ + { + command: 'device-notifications', + description: + 'read or copy a paired device notification preference (--device-file, --preferences-stdin)', + }, +] as const; +export const DEVICE_NOTIFICATION_OPTIONS = { + 'device-file': { type: 'string' }, + 'preferences-stdin': { type: 'boolean' }, +} as const; + +export async function runDeviceNotifications( + rest: string[], + values: { 'device-file'?: string; 'preferences-stdin'?: boolean }, + io: { readStdin?: () => Promise }, +): Promise { + if (rest.length || !values['device-file']) + throw new CliLiveError('usage', 'Provide --device-file with the saved pairing JSON.'); + let saved: unknown; + try { + saved = JSON.parse(readFileSync(values['device-file'], 'utf8')); + } catch { + throw new CliLiveError('invalid-input', 'Cannot read the saved pairing JSON.'); + } + const host = parseSavedHost(saved); + if (!host) throw new CliLiveError('invalid-input', 'Invalid saved pairing JSON.'); + let preferences; + if (values['preferences-stdin']) { + try { + preferences = parseNotificationPreferences(JSON.parse((await io.readStdin?.()) ?? '')); + } catch {} + if (!preferences) + throw new CliLiveError( + 'invalid-input', + 'Provide all four boolean notification kinds on stdin.', + ); + } + const push = createHostPush({ + async request(url, init) { + const response = await fetch(url, { ...init, redirect: 'error' }); + return { status: response.status, text: () => response.text() }; + }, + openSocket() { + throw new Error('unary-only'); + }, + }); + try { + return preferences === undefined + ? await push.settings(host) + : await push.setPreferences(host, preferences); + } catch (error) { + throw new CliLiveError( + error instanceof PushRequestFailed ? error.code : 'unreachable', + 'Device notification request refused.', + ); + } +} diff --git a/packages/core/src/bots/cli-live.ts b/packages/core/src/bots/cli-live.ts index 723ce232ee..980c04d9d8 100644 --- a/packages/core/src/bots/cli-live.ts +++ b/packages/core/src/bots/cli-live.ts @@ -1,1807 +1,1828 @@ -import { - WINDOW_ZOOM_COMMANDS, - WINDOW_ZOOM_OPTIONS, - runWindowZoomCommand, -} from './cli-window-zoom.js'; -import { PHONE_ROUTE_COMMANDS, runPhoneRouteCommand } from './cli-phone-route.js'; -import { DEVICES_COMMANDS, isDevicesCommand, runDevicesCommand } from './cli-devices.js'; -import { APPEARANCE_COMMANDS, APPEARANCE_OPTIONS, runAppearanceCommand } from './cli-appearance.js'; -import { prepareCloud, prepareCloudWork } from './cli-cloud.js'; -import { randomUUID } from 'node:crypto'; -import { isDeepStrictEqual } from 'node:util'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { isAbsolute } from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; -import { dmChannelId } from '../channels/channel.js'; -import type { BotCreateCliIo } from './bot-create-cli.js'; -import { CliLiveError } from './cli-live-error.js'; -import { BOT_TOOL_COMMANDS, runBotToolCommand } from './cli-bot-tool.js'; -import { - channelOperationKey, - CHANNEL_COMMANDS, - CHANNEL_OPTIONS, - isChannelCommand, - runChannelCommand, -} from './cli-channels.js'; -import { - BROWSER_COMPUTER_COMMANDS, - BROWSER_COMPUTER_OPTIONS, - isBrowserComputerCommand, - runBrowserComputerCommand, - type BrowserComputerValues, -} from './cli-browser-computer.js'; -import { - IM_COMMANDS, - IM_OPTIONS, - prepareImAuthorization, - runImAuthorization, - type ImValues, -} from './cli-im-authorization.js'; -import { - MESSAGING_COMMANDS, - MESSAGING_OPTIONS, - prepareMessaging, - runMessaging, - type MessagingCommand, - type MessagingValues, -} from './cli-messaging.js'; -import { - isModelsUsageCommand, - MODELS_USAGE_COMMANDS, - MODELS_USAGE_OPTIONS, - runModelsUsage, - type ModelsUsageValues, -} from './cli-models-usage.js'; -import { - HOST_ADMIN_COMMANDS, - HOST_ADMIN_OPTIONS, - isHostAdminCommand, - runHostAdminCommand, - type HostAdminValues, -} from './cli-host-admin.js'; -import { - INBOX_COMMANDS, - INBOX_OPTIONS, - isInboxCommand, - runInboxCommand, - type InboxValues, -} from './cli-inbox.js'; -import { - SCHEDULE_OPTIONS, - isScheduleCommand, - prepareScheduleCommand, - runScheduleCommand, - type ScheduleValues, -} from './cli-schedules.js'; -import { - PROFILE_COMMANDS, - PROFILE_OPTIONS, - isProfileCommand, - runProfileCommand, - type ProfileValues, -} from './cli-profile.js'; -import { - TODO_COMMANDS, - TODO_OPTIONS, - isTodoCommand, - runTodoCommand, - type TodoValues, -} from './cli-todos.js'; -import { - APPROVAL_TEMPLATE_COMMANDS, - APPROVAL_TEMPLATE_OPTIONS, - isApprovalTemplateCommand, - runApprovalTemplateCommand, - type ApprovalTemplateValues, -} from './cli-approval-templates.js'; -import { - APPROVAL_REVIEWER_COMMANDS, - APPROVAL_REVIEWER_OPTIONS, - isApprovalReviewerCommand, - runApprovalReviewerCommand, - type ApprovalReviewerValues, -} from './cli-approval-reviewer.js'; -import { - isStorageCommand, - runStorageCommand, - STORAGE_COMMANDS, - STORAGE_OPTIONS, - type StorageValues, -} from './cli-storage.js'; -import { - IDLE_COMPACTION_COMMANDS, - isIdleCompactionCommand, - runIdleCompactionCommand, -} from './cli-idle-compaction.js'; -import { - CONNECT_COMMANDS, - CONNECT_OPTIONS, - isConnectCommand, - runConnectCommand, -} from './cli-connect.js'; -export { CliLiveError } from './cli-live-error.js'; - -export const LIVE_OPTIONS = { - ...WINDOW_ZOOM_OPTIONS, - ...CONNECT_OPTIONS, - ...APPEARANCE_OPTIONS, - ...IM_OPTIONS, - ...MESSAGING_OPTIONS, - ...MODELS_USAGE_OPTIONS, - ...CHANNEL_OPTIONS, - ...HOST_ADMIN_OPTIONS, - ...INBOX_OPTIONS, - ...SCHEDULE_OPTIONS, - ...BROWSER_COMPUTER_OPTIONS, - ...PROFILE_OPTIONS, - ...TODO_OPTIONS, - ...APPROVAL_TEMPLATE_OPTIONS, - ...APPROVAL_REVIEWER_OPTIONS, - ...STORAGE_OPTIONS, - host: { type: 'string' }, - 'token-file': { type: 'string' }, - 'image-source': { type: 'string' }, - 'image-revision': { type: 'string' }, - 'image-reference': { type: 'string' }, - timeout: { type: 'string' }, - 'message-id': { type: 'string' }, - outcome: { type: 'string' }, - 'answer-stdin': { type: 'boolean' }, - 'values-stdin': { type: 'boolean' }, - since: { type: 'string' }, - workspace: { type: 'string' }, - mode: { type: 'string' }, - scope: { type: 'string' }, - 'acknowledge-risk': { type: 'boolean' }, - 'patch-stdin': { type: 'boolean' }, - 'previous-value': { type: 'string' }, - member: { type: 'string', multiple: true }, - app: { type: 'string' }, - bot: { type: 'string' }, - conversation: { type: 'string' }, - out: { type: 'string' }, - 'args-stdin': { type: 'boolean' }, - session: { type: 'string' }, - turns: { type: 'string' }, - offset: { type: 'string' }, - kind: { type: 'string' }, - checkpoint: { type: 'string' }, - 'expected-current': { type: 'string' }, - 'restore-id': { type: 'string' }, - 'repair-id': { type: 'string' }, - 'default-location': { type: 'boolean' }, - tag: { type: 'string' }, - target: { type: 'string' }, -} as const; - -const DEVICE_COMMANDS = [ - 'browser-open', - 'browser-frame', - 'browser-takeover', - 'browser-input', - 'browser-stop', - 'computer-start', - 'computer-status', - 'computer-stop', - 'browser-tools', - 'browser-tool', - 'computer-tools', - 'computer-tool', - 'computer-frame', - 'computer-takeover', - 'computer-input', -] as const; - -const IM_CONTROL_COMMANDS = [ - 'im-app-add', - 'im-app-status', - 'im-app-remove', - 'im-bind', - 'im-unbind', - 'im-status', - 'channel-connect', - 'channel-ingests', -] as const; - -const TOKEN_IM_PLATFORMS = new Set(['discord', 'slack', 'qq', 'dingtalk', 'wecom', 'matrix']); - -const CONTROL_COMMANDS = [ - 'browser-access-set', - 'computer-access-set', - 'access', - 'access-set', - 'settings', - 'settings-set', - 'workspace-create', - 'group-create', -] as const; - -export const LIVE_COMMANDS = [ - ...WINDOW_ZOOM_COMMANDS, - ...PHONE_ROUTE_COMMANDS, - ...DEVICES_COMMANDS, - ...CONNECT_COMMANDS, - ...IM_COMMANDS, - ...MESSAGING_COMMANDS, - { - command: 'cloud', - description: 'manage an explicit cloud Agent connection (private JSON through stdin)', - }, - { - command: 'cloud-work', - description: 'run the cloud_work Tool directly for a Bot using JSON from stdin', - }, - ...MODELS_USAGE_COMMANDS, - ...CHANNEL_COMMANDS, - ...HOST_ADMIN_COMMANDS, - ...APPEARANCE_COMMANDS, - ...INBOX_COMMANDS, - ...BROWSER_COMPUTER_COMMANDS, - ...PROFILE_COMMANDS, - ...BOT_TOOL_COMMANDS, - ...TODO_COMMANDS, - ...APPROVAL_TEMPLATE_COMMANDS, - ...APPROVAL_REVIEWER_COMMANDS, - ...IDLE_COMPACTION_COMMANDS, - ...STORAGE_COMMANDS, - { - command: 'send', - description: 'send a Human DM and collect its committed Bot reply from a live Host', - }, - { - command: 'send-status', - description: 'inspect a Human DM receipt and its exact Bot replies without resending', - }, - { - command: 'tool-approval-status', - description: 'inspect a pending tool approval on a live Host', - }, - { - command: 'tool-approval-decide', - description: 'allow once, always allow a secret_cli kind, or reject an owned tool approval', - }, - { command: 'user-question-status', description: 'inspect a formal Human question' }, - { command: 'ask-secret-status', description: 'inspect a private input request without values' }, - { - command: 'ask-secret-submit', - description: 'submit confidential field JSON from stdin through the authenticated Owner', - }, - { command: 'ask-secret-cancel', description: 'cancel a private input request as Owner' }, - { command: 'ask-secret-account-status', description: 'inspect current private account access' }, - { command: 'ask-secret-replace', description: 'open a blank replacement private input card' }, - { command: 'ask-secret-revoke', description: 'revoke local private account access as Owner' }, - { - command: 'ask-secret-accounts', - description: "list a Bot's private accounts as Owner, without values", - }, - { - command: 'user-question-answer', - description: 'answer a formal Human question with JSON from stdin', - }, - { command: 'release-info', description: 'read the running Host release lifecycle' }, - { command: 'workspace-options', description: 'list live Host workspaces for grants' }, - { - command: 'folder-browse', - description: 'list folder names on the Host computer, as the phone folder picker does', - }, - { command: 'grant-create', description: 'authorize a Bot workspace through the live Host' }, - { - command: 'browser-access-set', - description: "turn a Bot's Browser Access on or off through the live Host", - }, - { - command: 'computer-access-set', - description: "turn a Bot's Computer Access on or off through the live Host", - }, - { command: 'access', description: "read a Bot's access preset (workspace write or full access)" }, - { - command: 'access-set', - description: "set a Bot's access preset; full access needs --acknowledge-risk", - }, - { command: 'settings', description: 'read one live Host settings namespace' }, - { - command: 'settings-set', - description: 'patch one live Host settings namespace with JSON from stdin', - }, - { command: 'workspace-create', description: 'register an absolute folder as a Host workspace' }, - { command: 'group-create', description: 'create a group Channel with optional initial Bots' }, - { - command: 'im-app-add', - description: 'add a token-based IM app (Discord, Slack, …) with credentials JSON from stdin', - }, - { command: 'im-app-status', description: "read a token IM platform's apps and connection state" }, - { command: 'im-app-remove', description: 'remove a token IM app from the Provider' }, - { command: 'im-bind', description: 'bind a connected IM app to a Bot' }, - { command: 'im-unbind', description: 'unbind an IM app from a Bot' }, - { command: 'im-status', description: "read a Bot's IM apps and bindings with reception state" }, - { - command: 'channel-connect', - description: "connect an external group conversation to a Channel through a Bot's binding", - }, - { command: 'channel-ingests', description: "list a Channel's connected external conversations" }, - { command: 'browser-open', description: "start a Bot's Browser and open its tab for viewing" }, - { - command: 'browser-frame', - description: "save the current picture of a Bot's Browser to a file", - }, - { - command: 'browser-takeover', - description: "pause a Bot's Browser for Human input (--enabled) or hand it back (--disabled)", - }, - { - command: 'browser-input', - description: - "click, scroll, type or press a key in a paused Bot's Browser (input JSON from stdin)", - }, - { command: 'browser-stop', description: "stop a Bot's Browser" }, - { - command: 'computer-start', - description: 'start the profile Computer with explicit authorization', - }, - { command: 'computer-status', description: 'read the profile Computer target and state' }, - { - command: 'computer-frame', - description: 'save the current picture of the profile Computer desktop to a file', - }, - { - command: 'computer-takeover', - description: - 'pause Bot use of the profile Computer for Human input (--enabled) or hand it back (--disabled)', - }, - { - command: 'computer-input', - description: - 'click, scroll, type or press a key on a paused profile Computer desktop (input JSON from stdin)', - }, - { command: 'computer-stop', description: 'stop the profile Computer' }, - { - command: 'browser-tools', - description: 'list the Browser tools a Bot can call, with input schemas', - }, - { - command: 'browser-tool', - description: - 'run one Browser tool directly for a Bot, without the model (args JSON from stdin)', - }, - { - command: 'memory-image', - description: - 'export version-bound Memory image bytes (--path --image-source --image-revision [--image-reference] --out)', - }, - { command: 'memory-file-target', description: 'resolve a memory file path for a Bot' }, - { command: 'memory-graph', description: "read a Bot's memory commit graph" }, - { command: 'memory-commit-diff', description: 'read the full diff of one memory commit' }, - { command: 'memory-changes', description: 'list uncommitted memory changes' }, - { command: 'memory-working-diff', description: 'read the diff of one uncommitted memory file' }, - { command: 'memory-checkpoints', description: 'list memory recovery checkpoints' }, - { command: 'memory-restore', description: 'restore memory to a recovery checkpoint' }, - { command: 'memory-restore-commit', description: 'restore memory to an earlier commit' }, - { command: 'memory-versions', description: 'list named memory versions' }, - { command: 'memory-version-name', description: 'name a memory commit as a version' }, - { command: 'memory-version-unname', description: 'remove a memory version name' }, - { command: 'memory-repair', description: 'repair a memory repository against its expected head' }, - { - command: 'memory-move', - description: "move a Bot's Memory repository to another folder, or back to the default", - }, - { - command: 'bot-trace', - description: 'show which tools a Bot called in its recent turns, with outcomes', - }, - { - command: 'computer-tools', - description: 'list the Computer tools a Bot can call, with input schemas', - }, - { - command: 'computer-tool', - description: - 'run one Computer tool directly for a Bot, without the model (args JSON from stdin)', - }, -] as const; - -interface LiveValues - extends - ImValues, - MessagingValues, - ModelsUsageValues, - HostAdminValues, - InboxValues, - ScheduleValues, - BrowserComputerValues, - ProfileValues, - TodoValues, - ApprovalTemplateValues, - ApprovalReviewerValues { - 'client-id'?: string; - host?: string; - 'token-file'?: string; - 'image-source'?: string; - 'image-revision'?: string; - 'image-reference'?: string; - timeout?: string; - 'message-id'?: string; - body?: string; - 'body-stdin'?: boolean; - 'answer-stdin'?: boolean; - 'values-stdin'?: boolean; - outcome?: string; - since?: string; - workspace?: string; - limit?: string; - before?: string; - enabled?: boolean; - disabled?: boolean; - mode?: string; - scope?: string; - 'acknowledge-risk'?: boolean; - 'patch-stdin'?: boolean; - 'previous-value'?: string; - member?: string[]; - 'operation-key'?: string; - app?: string; - bot?: string; - conversation?: string; - out?: string; - 'args-stdin'?: boolean; - session?: string; - turns?: string; - offset?: string; - kind?: string; - checkpoint?: string; - 'expected-current'?: string; - 'restore-id'?: string; - 'repair-id'?: string; - 'memory-location'?: string; - 'default-location'?: boolean; - tag?: string; - path?: string; - sha?: string; - name?: string; - 'expected-head'?: string; - 'edit-id'?: string; - target?: string; -} - -function usage(message: string): never { - throw new CliLiveError('usage', message); -} - -function isLoopbackName(name: string): boolean { - return name === 'localhost' || name === '[::1]' || /^127\.\d+\.\d+\.\d+$/u.test(name); -} - -function hostUrl(value: string | undefined): URL { - if (!value) usage('Live commands require --host or DEEPSEEKBOT_HOST.'); - let url: URL; - try { - url = new URL(value); - } catch { - usage('--host must be an HTTP(S) origin.'); - } - const name = url.hostname; - const loopback = isLoopbackName(name); - const tailnet = - name.endsWith('.ts.net') || /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/u.test(name); - if ( - url.username || - url.password || - url.search || - url.hash || - url.pathname !== '/' || - !( - (loopback && (url.protocol === 'http:' || url.protocol === 'https:')) || - (tailnet && url.protocol === 'https:') - ) - ) { - usage( - '--host must be a loopback origin or a tailnet HTTPS origin, without credentials, query or path.', - ); - } - return url; -} - -function timeoutMs(value: string | undefined): number { - const seconds = value === undefined ? 120 : Number(value); - if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 600) - usage('--timeout must be between 0 and 600 seconds.'); - return Math.ceil(seconds * 1000); -} - -function record(value: unknown): Record { - return typeof value === 'object' && value !== null ? (value as Record) : {}; -} - -export async function connectLiveHost(values: LiveValues, io: BotCreateCliIo, signal: AbortSignal) { - const base = hostUrl(values.host ?? io.env['DEEPSEEKBOT_HOST']); - let token = io.env['DEEPSEEKBOT_HOST_TOKEN']; - if (values['token-file'] !== undefined) { - try { - token = readFileSync(values['token-file'], 'utf8').trim(); - } catch { - throw new CliLiveError('host-unauthorized', 'Cannot read the Host token file.'); - } - } - if (!token?.trim()) - throw new CliLiveError( - 'host-unauthorized', - 'Set DEEPSEEKBOT_HOST_TOKEN or provide --token-file.', - ); - const secret = token.trim(); - const login = new URL(base); - login.searchParams.set('token', secret); - let cookie = ''; - const refused = (status: number): CliLiveError => - status === 403 && !isLoopbackName(base.hostname) - ? new CliLiveError( - 'host-untrusted', - `The Host does not accept requests addressed to ${base.hostname}; restart it with --trusted-host ${base.hostname}.`, - ) - : new CliLiveError( - 'host-unauthorized', - 'The Host refused this authority cookie; refresh its launch token.', - ); - const request = async (url: URL, init: RequestInit = {}): Promise => { - try { - return await fetch(url, { ...init, signal, redirect: 'manual' }); - } catch { - throw new CliLiveError( - 'host-unreachable', - 'The Host could not be reached before the command deadline.', - ); - } - }; - for (let attempt = 0; attempt < 2; attempt += 1) { - const response = await request(login, { headers: cookie ? { cookie } : {} }); - const cookies = response.headers.getSetCookie(); - if (cookies.length > 0) cookie = cookies.map((entry) => entry.split(';')[0]).join('; '); - await response.body?.cancel(); - if (response.status === 401 || response.status === 403) - throw new CliLiveError( - 'host-unauthorized', - 'Host login was refused; refresh its launch token.', - ); - if (cookie) break; - } - if (!cookie) - throw new CliLiveError('host-unauthorized', 'Host login did not issue an authority cookie.'); - const readResponse = async (response: Response): Promise> => { - if (response.status === 401 || response.status === 403) { - await response.body?.cancel(); - throw refused(response.status); - } - try { - const body = await response.text(); - try { - return record(JSON.parse(body)); - } catch { - throw new CliLiveError('host-protocol-error', 'The Host did not return a JSON response.'); - } - } catch (error) { - if (error instanceof CliLiveError) throw error; - throw new CliLiveError( - 'host-unreachable', - 'The Host response was interrupted before the command deadline.', - ); - } - }; - const scrub = (value: string) => - value.replaceAll(secret, '[redacted]').replaceAll(encodeURIComponent(secret), '[redacted]'); - const rpc = async (method: string, args: Record): Promise => { - const name = method.includes('/') ? method : `botharness/${method}`; - const response = await request(new URL(`/api/${name}`, base), { - method: 'POST', - headers: { 'content-type': 'application/json', cookie }, - body: JSON.stringify({ - type: 'client-request', - rpcId: randomUUID(), - method: name, - payload: method.startsWith('dsh-im/') ? args : { args }, - }), - }); - const payload = await readResponse(response); - if (!response.ok || payload['type'] !== 'server-response') - throw new CliLiveError('host-protocol-error', 'The Host did not return a DSH RPC response.'); - const result = record(payload['result']); - if (result['ok'] !== true) { - const error = record(result['error']); - const code = typeof error['code'] === 'string' ? error['code'] : 'host-protocol-error'; - const message = - typeof error['message'] === 'string' ? error['message'] : 'The Host refused the command.'; - throw new CliLiveError(code, scrub(message)); - } - return result['value'] as T; - }; - const importBotZip = async ( - archive: Uint8Array, - metadata: { name?: string; displayName?: string; roles?: string[]; description?: string }, - ): Promise => { - const url = new URL('/api/botharness/bot-zip/import', base); - for (const key of ['name', 'displayName', 'description'] as const) { - if (metadata[key] !== undefined) url.searchParams.set(key, metadata[key]); - } - if (metadata.roles !== undefined) url.searchParams.set('roles', JSON.stringify(metadata.roles)); - const response = await request(url, { - method: 'POST', - headers: { cookie, 'content-type': 'application/zip' }, - body: new Uint8Array(archive), - }); - const payload = await readResponse(response); - if (!response.ok) { - const error = record(payload['error']); - const bot = record(payload['bot']); - throw new CliLiveError( - typeof error['code'] === 'string' ? scrub(error['code']) : 'host-protocol-error', - scrub( - typeof error['message'] === 'string' - ? error['message'] - : 'The Host refused this Bot Zip import.', - ), - undefined, - undefined, - typeof bot['id'] === 'string' && typeof bot['name'] === 'string' - ? { - bot: { id: bot['id'], name: bot['name'] }, - steps: [{ name: 'create-bot', status: 'ok' }], - outcome: 'created', - } - : undefined, - ); - } - return payload as T; - }; - const refuse = (path: string, status: number, bytes: Uint8Array): never => { - let detail: Record = {}; - try { - detail = record(JSON.parse(new TextDecoder().decode(bytes))); - } catch { - detail = { error: new TextDecoder().decode(bytes).slice(0, 200) }; - } - const nested = record(detail['error']); - const code = - typeof detail['code'] === 'string' - ? detail['code'] - : typeof nested['code'] === 'string' - ? nested['code'] - : undefined; - if ((status === 401 || status === 403) && code === undefined) throw refused(status); - const packages = Array.isArray(detail['packages']) - ? detail['packages'].filter((name): name is string => typeof name === 'string') - : []; - throw new CliLiveError( - code === undefined ? 'host-refused' : scrub(code), - scrub( - (typeof detail['error'] === 'string' && detail['error'] !== '' - ? detail['error'] - : typeof nested['message'] === 'string' - ? nested['message'] - : `The Host refused ${path} with HTTP ${String(status)}.`) + - (packages.length === 0 ? '' : ` Packages: ${packages.join(' ')}.`), - ), - ); - }; - const http = async ( - path: string, - body?: Record, - ): Promise<{ response: Response; bytes: Uint8Array }> => { - const response = await request(new URL(path, base), { - method: body === undefined ? 'GET' : 'POST', - headers: { - cookie, - ...(body === undefined ? {} : { 'content-type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - let bytes: Uint8Array; - try { - bytes = new Uint8Array(await response.arrayBuffer()); - } catch { - throw new CliLiveError( - 'host-unreachable', - 'The Host response was interrupted before the command deadline.', - ); - } - if (!response.ok) refuse(path, response.status, bytes); - return { response, bytes }; - }; - const stream = async (path: string, init: RequestInit = {}): Promise => { - const response = await request(new URL(path, base), { - ...init, - headers: { ...(init.headers as Record | undefined), cookie }, - }); - if (!response.ok) refuse(path, response.status, new Uint8Array(await response.arrayBuffer())); - return response; - }; - const socket = (path: string): WebSocket => { - const url = new URL(path, base); - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - const Socket = WebSocket as unknown as new ( - url: string, - init: { headers: Record }, - ) => WebSocket; - return new Socket(url.href, { headers: { cookie } }); - }; - return { rpc, importBotZip, http, stream, socket }; -} - -export async function runLiveCli( - command: string, - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - if (IM_COMMANDS.some((entry) => entry.command === command)) { - const operation = await prepareImAuthorization(command, rest, values, io); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - try { - return await runImAuthorization( - operation, - (await connectLiveHost(values, io, signal)).rpc, - signal, - ); - } catch (error) { - if ( - error instanceof CliLiveError && - !error.authorization && - command !== 'im-apps' && - command !== 'im-authorize' - ) - throw new CliLiveError(error.code, error.message, undefined, { - attemptId: operation.target, - }); - throw error; - } - } - if (MEMORY_COMMANDS.has(command)) return runMemoryCommand(command, rest, values, io); - if (isModelsUsageCommand(command)) - return runModelsUsage(command, rest, values, io, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isConnectCommand(command)) - return runConnectCommand(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (WINDOW_ZOOM_COMMANDS.some((entry) => entry.command === command)) - return runWindowZoomCommand(command, rest, values, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (PHONE_ROUTE_COMMANDS.some((entry) => entry.command === command)) - return runPhoneRouteCommand(command, rest, values, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (isDevicesCommand(command)) - return runDevicesCommand(command, rest, values, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (command === 'cloud-work') { - const input = await prepareCloudWork(rest, values, io); - const host = await connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))); - return host.rpc('cloudWork', input); - } - if (command === 'cloud') { - const input = await prepareCloud(rest, values, io); - const host = await connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))); - return host.rpc('cloudConnections', { input }); - } - if (MESSAGING_COMMANDS.some((entry) => entry.command === command)) { - const operation = await prepareMessaging(command as MessagingCommand, rest, values, io); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return runMessaging(operation, (await connectLiveHost(values, io, signal)).rpc); - } - if (isInboxCommand(command)) - return runInboxCommand(command, rest, values, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (isScheduleCommand(command)) { - const prepared = prepareScheduleCommand(command, rest, values); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return runScheduleCommand(prepared, (await connectLiveHost(values, io, signal)).rpc); - } - if (isBrowserComputerCommand(command)) - return runBrowserComputerCommand(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (APPEARANCE_COMMANDS.some((entry) => entry.command === command)) - return runAppearanceCommand(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (isProfileCommand(command)) - return runProfileCommand(command, rest, values, io, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isApprovalReviewerCommand(command)) - return runApprovalReviewerCommand(command, rest, values, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isStorageCommand(command)) - return runStorageCommand(command, rest, values as StorageValues, io, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isIdleCompactionCommand(command)) - return runIdleCompactionCommand(command, rest, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isApprovalTemplateCommand(command)) - return runApprovalTemplateCommand(command, rest, values, io, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isTodoCommand(command)) - return runTodoCommand(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (BOT_TOOL_COMMANDS.some((entry) => entry.command === command)) - return runBotToolCommand( - command as (typeof BOT_TOOL_COMMANDS)[number]['command'], - rest, - values, - io, - () => connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (command === 'bot-trace') { - if (rest.length !== 1 || !rest[0]?.trim()) usage('bot-trace needs exactly one bot id.'); - const turns = values.turns === undefined ? undefined : Number(values.turns); - if (turns !== undefined && (!Number.isInteger(turns) || turns < 1 || turns > 20)) - usage('--turns must be an integer from 1 to 20.'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc('botToolTrace', { - slug: rest[0].trim(), - ...(values.session === undefined ? {} : { sessionId: values.session }), - ...(turns === undefined ? {} : { turns }), - }); - } - if (isChannelCommand(command)) - return runChannelCommand(command, rest, values, io, connectLiveHost); - if (isHostAdminCommand(command)) { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return runHostAdminCommand(command, rest, values, io, { - connect: async () => (await connectLiveHost(values, io, signal)).rpc, - signal, - }); - } - if (DEVICE_COMMANDS.some((entry) => entry === command)) - return runDeviceCommand(command as (typeof DEVICE_COMMANDS)[number], rest, values, io); - if (command === 'folder-browse') { - if (rest.length > 1) usage('folder-browse takes at most one folder path.'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - const path = rest[0]?.trim(); - return (await connectLiveHost(values, io, signal)).rpc('folderBrowse', path ? { path } : {}); - } - if (command === 'grants') { - if (rest.length !== 1 || !rest[0]?.trim()) usage('grants needs exactly one bot id.'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc('grants', { slug: rest[0].trim() }); - } - if (IM_CONTROL_COMMANDS.some((entry) => entry === command)) - return runImControlCommand(command as (typeof IM_CONTROL_COMMANDS)[number], rest, values, io); - if (CONTROL_COMMANDS.some((entry) => entry === command)) - return runControlCommand(command as (typeof CONTROL_COMMANDS)[number], rest, values, io); - const noTarget = command === 'release-info' || command === 'workspace-options'; - if (rest.length !== (noTarget ? 0 : 1)) - usage( - `${command} ${noTarget ? 'takes no positional arguments' : 'needs exactly one target id'}.`, - ); - const target = rest[0]?.trim() ?? ''; - if (!noTarget && !target) usage(`${command} requires a nonempty target id.`); - const messageId = values['message-id']; - if ( - [ - 'send-status', - 'tool-approval-status', - 'tool-approval-decide', - 'user-question-status', - 'user-question-answer', - 'ask-secret-status', - 'ask-secret-submit', - 'ask-secret-cancel', - 'ask-secret-account-status', - 'ask-secret-replace', - 'ask-secret-revoke', - ].includes(command) && - !messageId?.trim() - ) - usage('--message-id is required.'); - let body: string | undefined; - let answer: unknown; - let confidential: Record | undefined; - if (command === 'ask-secret-submit') { - if (!values['values-stdin']) usage('ask-secret-submit requires --values-stdin.'); - try { - const input = await (io.readStdin?.() ?? Promise.resolve('')); - if (input.length > 140_000) throw new Error('Private input exceeds the limit'); - const parsed: unknown = JSON.parse(input); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error('Invalid private input'); - const entries = Object.entries(parsed); - if ( - entries.length < 1 || - entries.length > 8 || - entries.some( - ([key, value]) => - !/^[a-z][a-z0-9_]{0,47}$/.test(key) || - typeof value !== 'string' || - value.length > 16_384, - ) - ) - throw new Error('Invalid private fields'); - confidential = Object.fromEntries(entries); - } catch { - usage('Private input stdin must be a JSON object with 1–8 declared string fields.'); - } - } - if (command === 'send') { - if ((values.body !== undefined) === (values['body-stdin'] === true)) - usage('send requires exactly one of --body or --body-stdin.'); - body = values['body-stdin'] ? await (io.readStdin?.() ?? Promise.resolve('')) : values.body; - if (!body?.trim()) usage('send requires a nonempty message.'); - if ( - messageId !== undefined && - !/^human-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(messageId) - ) - usage('--message-id must be human- followed by a UUID.'); - } - if ( - command === 'tool-approval-decide' && - values.outcome !== 'allowed-once' && - values.outcome !== 'allowed-always-tool' && - values.outcome !== 'allowed-always-kind' && - values.outcome !== 'rejected' - ) - usage('--outcome must be allowed-once, allowed-always-tool, allowed-always-kind or rejected.'); - if (command === 'user-question-answer') { - if (!values['answer-stdin']) usage('user-question-answer requires --answer-stdin.'); - try { - answer = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); - } catch { - usage('Question answers must be valid JSON on stdin.'); - } - const answers = record(answer)['answers']; - if ( - !Array.isArray(answers) || - !answers.every((entry) => { - const item = record(entry); - return ( - typeof item['id'] === 'string' && - Array.isArray(item['selected']) && - item['selected'].every((choice) => typeof choice === 'string') && - (item['custom'] === undefined || typeof item['custom'] === 'string') - ); - }) - ) - usage( - 'Answer JSON requires answers with id, selected string array and optional custom text.', - ); - } - if (command === 'grant-create' && !values.workspace?.trim()) usage('--workspace is required.'); - const duration = timeoutMs(values.timeout); - const deadline = Date.now() + duration; - const signal = AbortSignal.timeout(duration); - const { rpc } = await connectLiveHost(values, io, signal); - if (command.startsWith('ask-secret-')) { - try { - if (command === 'ask-secret-accounts') { - const result = await rpc<{ - status?: unknown; - accounts?: unknown; - replaceable?: unknown; - }>('privateAccounts', { - slug: target, - }); - if ( - (result.status !== 'ready' && result.status !== 'unavailable') || - !Array.isArray(result.accounts) - ) - throw new Error('Invalid private accounts'); - const optional = (value: unknown) => (typeof value === 'string' ? value : undefined); - const accounts = result.accounts.map((entry: unknown) => { - const row = record(entry); - const status = row['status']; - if ( - typeof row['account'] !== 'string' || - (status !== 'configured' && status !== 'replacing' && status !== 'unavailable') || - !Array.isArray(row['fields']) - ) - throw new Error('Invalid private account'); - return { - account: row['account'], - purpose: optional(row['purpose']), - fields: row['fields'].map((value: unknown) => { - const field = record(value); - if ( - typeof field['id'] !== 'string' || - typeof field['label'] !== 'string' || - typeof field['required'] !== 'boolean' - ) - throw new Error('Invalid private field'); - return { id: field['id'], label: field['label'], required: field['required'] }; - }), - status, - configuredAt: optional(row['configuredAt']), - channelId: optional(row['channelId']), - messageId: optional(row['messageId']), - replacementMessageId: optional(row['replacementMessageId']), - }; - }); - return { - status: result.status, - accounts, - ...(typeof result.replaceable === 'boolean' ? { replaceable: result.replaceable } : {}), - }; - } - if (command === 'ask-secret-status' || command === 'ask-secret-account-status') { - const result = await rpc<{ status?: unknown }>( - command === 'ask-secret-status' ? 'privateInputStatus' : 'privateAccountStatus', - { - channelId: target, - messageId, - }, - ); - const statuses = - command === 'ask-secret-status' - ? ['pending', 'configured', 'cancelled', 'expired'] - : ['configured', 'revoked', 'superseded', 'unavailable']; - if (typeof result.status !== 'string' || !statuses.includes(result.status)) - throw new Error('Invalid private status'); - return { status: result.status }; - } - if (command === 'ask-secret-replace') { - const result = await rpc<{ messageId?: unknown }>('privateInputReplace', { - channelId: target, - messageId, - }); - if ( - typeof result.messageId !== 'string' || - !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(result.messageId) - ) - throw new Error('Invalid replacement card'); - return { messageId: result.messageId }; - } - if (command === 'ask-secret-revoke') { - const result = await rpc<{ - accepted?: unknown; - stopped?: unknown; - completed?: unknown; - uncertain?: unknown; - }>('privateAccountRevoke', { channelId: target, messageId }); - if ( - result.accepted !== true || - [result.stopped, result.completed, result.uncertain].some( - (count) => typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0, - ) - ) - throw new Error('Invalid revocation result'); - return { - accepted: true, - stopped: result.stopped, - completed: result.completed, - uncertain: result.uncertain, - }; - } - const method = command === 'ask-secret-submit' ? 'privateInputSubmit' : 'privateInputCancel'; - const result = await rpc<{ accepted?: unknown }>(method, { - channelId: target, - messageId, - ...(confidential === undefined ? {} : { values: confidential }), - }); - if (result.accepted !== true) throw new Error('Private request was not accepted'); - return { accepted: true }; - } catch { - throw new CliLiveError( - 'private-input-not-accepted', - 'Private input operation was not accepted. Check Owner authentication and request status before retrying.', - ); - } finally { - if (confidential !== undefined) - for (const key of Object.keys(confidential)) delete confidential[key]; - } - } - if (command === 'send') { - const channelId = dmChannelId(target); - const receipt = { channelId, messageId: messageId ?? `human-${randomUUID()}` }; - let sent: unknown; - try { - const bot = await rpc<{ bot: { displayName?: string } }>('get', { slug: target }); - await rpc('channelDm', { slug: target, displayName: bot.bot.displayName }); - sent = await rpc('channelSend', { channelId, body, messageId: receipt.messageId }); - while (Date.now() < deadline) { - const status = await rpc<{ state: string; replies: unknown[] }>( - 'channelSendStatus', - receipt, - ); - if (!status || !Array.isArray(status.replies) || typeof status.state !== 'string') - throw new CliLiveError( - 'host-protocol-error', - 'The Host returned an invalid send status.', - ); - if (status.state === 'handled' && status.replies.length > 0) - return { receipt, ...record(sent), ...status }; - if (status.state === 'handled' || status.state === 'needs-repair') - throw new CliLiveError( - status.state === 'handled' ? 'reply-not-produced' : 'send-needs-repair', - 'The Host has no committed Bot reply for this request.', - receipt, - ); - await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal }); - } - } catch (error) { - if (error instanceof CliLiveError) throw new CliLiveError(error.code, error.message, receipt); - throw new CliLiveError( - 'reply-timeout', - 'The command deadline elapsed; inspect send-status with this receipt before retrying.', - receipt, - ); - } - throw new CliLiveError( - 'reply-timeout', - 'No reply before the deadline; inspect send-status with this receipt before retrying.', - receipt, - ); - } - if (command === 'send-status') - return rpc('channelSendStatus', { channelId: dmChannelId(target), messageId }); - if (command === 'channel-messages') { - const limit = values.limit === undefined ? undefined : Number(values.limit); - if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 200)) - usage('--limit must be 1–200.'); - return rpc('channelMessages', { - channelId: target, - ...(limit === undefined ? {} : { limit }), - ...(values.before === undefined ? {} : { before: values.before }), - }); - } - if (command === 'tool-approval-status') - return rpc('toolApprovalStatus', { channelId: target, messageId }); - if (command === 'tool-approval-decide') - return rpc('toolApprovalDecide', { channelId: target, messageId, outcome: values.outcome }); - if (command === 'user-question-status') - return rpc('userQuestionStatus', { channelId: target, messageId }); - if (command === 'user-question-answer') - return rpc('userQuestionAnswer', { channelId: target, messageId, answer }); - if (command === 'workspace-options') return rpc('workspaceOptions', {}); - if (command === 'grant-create') - return rpc('grantCreate', { slug: target, workspaceId: values.workspace }); - return rpc('releaseInfo', values.since === undefined ? {} : { since: values.since }); -} - -async function runControlCommand( - command: (typeof CONTROL_COMMANDS)[number], - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one target.`); - const target = rest[0].trim(); - let patch: Record | undefined; - let reviewed: ReviewedSettings | undefined; - if (command === 'browser-access-set' || command === 'computer-access-set') { - if (values.enabled === values.disabled) - usage(`${command} needs exactly one of --enabled or --disabled.`); - } - if (command === 'access-set') { - if (values.mode !== 'workspace-write' && values.mode !== 'danger-full-access') - usage('--mode must be workspace-write or danger-full-access.'); - if (values.scope !== undefined && values.scope !== 'chat' && values.scope !== 'assignments') - usage('--scope must be chat or assignments.'); - if (values.mode === 'danger-full-access' && values['acknowledge-risk'] !== true) - usage( - 'Full access lets the Bot run any command and use the Browser and Computer without asking. Pass --acknowledge-risk to confirm.', - ); - } - if (command === 'settings-set') { - if (values['patch-stdin'] !== true) usage('settings-set requires --patch-stdin.'); - try { - const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error('not an object'); - patch = parsed as Record; - } catch { - usage('--patch-stdin must be a JSON object of settings fields.'); - } - reviewed = parseReviewedSettings(values, patch); - } - if (command === 'workspace-create' && !target.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(target)) - usage('workspace-create needs an absolute folder path.'); - const members = (values.member ?? []).map((member) => member.trim()).filter(Boolean); - const operationKey = - command === 'group-create' && values['operation-key'] !== undefined - ? channelOperationKey(values['operation-key']) - : undefined; - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - const { rpc } = await connectLiveHost(values, io, signal); - if (command === 'browser-access-set' || command === 'computer-access-set') - return rpc(command === 'browser-access-set' ? 'browserAccessSet' : 'computerAccessSet', { - slug: target, - enabled: values.enabled === true, - }); - if (command === 'access') return rpc('assignmentAccessGet', { slug: target }); - if (command === 'access-set') - return rpc('assignmentAccessSet', { - slug: target, - mode: values.mode, - ...(values.scope === undefined ? {} : { scope: values.scope }), - ...(values.mode === 'danger-full-access' ? { acknowledgeRisk: true } : {}), - }); - if (command === 'workspace-create') return rpc('workspace/create', { request: { path: target } }); - if (command === 'group-create') - return rpc('channelCreate', { - name: target, - members, - ...(operationKey === undefined ? {} : { operationKey }), - }); - const described = record(await rpc('settings/describe', {})); - const namespaces = Array.isArray(described['namespaces']) ? described['namespaces'] : []; - const found = namespaces.map(record).find((entry) => entry['ns'] === target); - if (found === undefined) - throw new CliLiveError('unknown-settings', `The Host has no settings namespace ${target}.`); - if (command === 'settings') - return { ns: target, revision: found['revision'], value: found['value'] }; - if (reviewed !== undefined) { - const current = record(found['value']); - if ( - found['revision'] !== reviewed.expectedRevision || - !Object.entries(reviewed.previousValue).every(([key, value]) => - isDeepStrictEqual(current[key], value), - ) - ) - throw new CliLiveError( - 'stale-setting', - 'The reviewed settings changed; read and review the current value before retrying.', - ); - } - const updated = record( - await rpc('settings/update', { - ns: target, - patch, - expectedRevision: reviewed?.expectedRevision ?? found['revision'], - }), - ); - return { ns: target, revision: updated['revision'], value: updated['value'] }; -} - -function appSummary(value: unknown) { - const app = record(value); - const bot = record(app['bot']); - const error = app['error'] === null || app['error'] === undefined ? null : record(app['error']); - return { - id: app['botId'], - name: bot['name'] ?? app['name'], - state: app['state'], - connected: app['connected'] === true, - error: error === null ? null : { code: error['code'], message: error['message'] }, - ...(app['stats'] === undefined ? {} : { stats: app['stats'] }), - }; -} - -function connectionSummary(platform: string, value: unknown) { - const status = record(value); - return { - platform, - state: status['state'], - apps: (Array.isArray(status['bots']) ? status['bots'] : []).map(appSummary), - }; -} - -async function runImControlCommand( - command: (typeof IM_CONTROL_COMMANDS)[number], - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one target.`); - const target = rest[0].trim(); - const app = values.app?.trim() ?? ''; - const platformCommand = - command === 'im-app-add' || command === 'im-app-status' || command === 'im-app-remove'; - if (platformCommand && !TOKEN_IM_PLATFORMS.has(target)) - usage( - `${command} supports ${[...TOKEN_IM_PLATFORMS].join(', ')}; use im-authorize for feishu or weixin.`, - ); - let credentials: Record | undefined; - if (command === 'im-app-add') { - if (values['credentials-stdin'] !== true) usage('im-app-add requires --credentials-stdin.'); - try { - const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error('not an object'); - credentials = parsed as Record; - } catch { - usage('--credentials-stdin must be a JSON object, for example {"token":"…"}.'); - } - } - if ((command === 'im-app-remove' || command === 'im-bind' || command === 'im-unbind') && !app) - usage(`${command} requires --app .`); - if (command === 'channel-connect' && (!values.bot?.trim() || !values.conversation?.trim())) - usage('channel-connect requires --bot and --conversation .'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - const { rpc } = await connectLiveHost(values, io, signal); - const provider = (method: string, payload: Record) => - rpc(`dsh-im/${target}`, { method, payload }); - if (command === 'im-app-add') - return connectionSummary(target, await provider('bot.bind-credentials', credentials!)); - if (command === 'im-app-status') - return connectionSummary(target, await provider('connection.status', {})); - if (command === 'im-app-remove') - return connectionSummary(target, await provider('bot.delete', { botId: app, confirm: true })); - if (command === 'channel-ingests') return rpc('channelIngests', { channelId: target }); - if (command === 'im-bind') { - const apps = record(await rpc('messagingApps', {})); - const found = (Array.isArray(apps['apps']) ? apps['apps'] : []) - .map(record) - .find((entry) => entry['ref'] === app); - if (found === undefined) - throw new CliLiveError('unknown-app', `The Host has no connected IM app ${app}.`); - return rpc('messagingIdentity', { - slug: target, - input: { - kind: 'bind', - providerId: found['providerId'], - accountRef: found['ref'], - fingerprint: found['fingerprint'], - }, - }); - } - const slug = command === 'channel-connect' ? values.bot!.trim() : target; - const snapshot = record(await rpc('messagingSnapshot', { slug })); - const identities = (Array.isArray(snapshot['identities']) ? snapshot['identities'] : []).map( - record, - ); - if (command === 'im-status') - return { - apps: (Array.isArray(snapshot['accounts']) ? snapshot['accounts'] : []) - .map(record) - .map((entry) => ({ - ref: entry['ref'], - platform: entry['platform'], - name: entry['name'], - connected: entry['connected'] === true, - })), - bindings: identities.map((entry) => ({ - id: entry['id'], - app: entry['accountRef'], - platform: entry['platform'], - name: entry['name'], - enabled: entry['enabled'], - availability: entry['availability'], - reception: entry['reception'], - conversations: entry['grantCount'], - })), - }; - const binding = identities.find((entry) => - command === 'channel-connect' - ? app === '' || entry['accountRef'] === app - : entry['accountRef'] === app, - ); - if (binding === undefined) - throw new CliLiveError( - 'unknown-binding', - command === 'channel-connect' - ? `Bot ${slug} has no IM app binding to connect through.` - : `Bot ${slug} is not bound to IM app ${app}.`, - ); - if (command === 'im-unbind') - return rpc('messagingIdentity', { - slug, - input: { kind: 'unbind', id: binding['id'], expectedRevision: binding['revision'] }, - }); - return rpc('channelIngest', { - channelId: target, - input: { - kind: 'add', - bindingId: binding['id'], - conversation: { kind: 'group', id: values.conversation!.trim() }, - }, - }); -} - -async function runDeviceCommand( - command: (typeof DEVICE_COMMANDS)[number], - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - const direct = command === 'browser-tool' || command === 'computer-tool'; - const computer = (command.startsWith('computer-') && !direct) || command === 'browser-tools'; - const positionals = direct ? 2 : computer ? 0 : 1; - if (rest.length !== positionals || rest.some((value) => !value.trim())) - usage( - direct - ? `${command} needs a bot id and a tool name.` - : computer - ? `${command} takes no positional arguments.` - : `${command} needs exactly one bot id.`, - ); - let toolArgs: Record = {}; - const input = command === 'browser-input' || command === 'computer-input'; - if (input && values['args-stdin'] !== true) - usage( - `${command} needs --args-stdin with a JSON input such as {"kind":"click","x":10,"y":20}.`, - ); - if ((direct || input) && values['args-stdin'] === true) { - try { - const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error('not an object'); - toolArgs = parsed as Record; - } catch { - usage('--args-stdin must be a JSON object of tool arguments.'); - } - } - if ( - direct && - values.out !== undefined && - !values.out.startsWith('/') && - !/^[A-Za-z]:[\\/]/u.test(values.out) - ) - usage('--out must be an absolute file path.'); - const slug = rest[0]?.trim() ?? ''; - if (command === 'browser-frame' || command === 'computer-frame') { - const out = values.out ?? ''; - if (!out.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(out)) - usage(`${command} requires --out .`); - } - if ( - (command === 'browser-takeover' || command === 'computer-takeover') && - values.enabled === values.disabled - ) - usage(`${command} needs exactly one of --enabled or --disabled.`); - if ( - command === 'computer-start' && - values.target !== undefined && - values.target !== 'local' && - values.target !== 'container' - ) - usage('--target must be local or container.'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - const { http } = await connectLiveHost(values, io, signal); - const json = async (path: string, body?: Record) => { - const { bytes } = await http(path, body); - try { - return JSON.parse(new TextDecoder().decode(bytes)) as unknown; - } catch { - throw new CliLiveError('host-protocol-error', `The Host returned no JSON for ${path}.`); - } - }; - let saved = false; - const savedImages = (items: unknown[]) => - items.map((value) => { - const item = record(value); - if (item['type'] !== 'image' || typeof item['data'] !== 'string') return item; - const bytes = Buffer.from(item['data'], 'base64'); - if (values.out !== undefined && !saved) { - writeFileSync(values.out, bytes, { mode: 0o600 }); - saved = true; - return { - type: 'image', - mimeType: item['mimeType'], - path: values.out, - bytes: bytes.byteLength, - }; - } - return { type: 'image', mimeType: item['mimeType'], bytes: bytes.byteLength }; - }); - if (command === 'browser-tools') return json('/api/browser/tools'); - if (command === 'computer-tools') return json('/api/computer/tools'); - if (command === 'computer-tool') { - const result = record( - await json('/api/computer/tool', { slug, tool: rest[1]!.trim(), args: toolArgs }), - ); - const inner = record(result['result']); - return Array.isArray(inner['content']) - ? { ...result, result: { ...inner, content: savedImages(inner['content']) } } - : result; - } - if (command === 'browser-tool') { - const result = record( - await json('/api/browser/tool', { slug, tool: rest[1]!.trim(), args: toolArgs }), - ); - return { - ...result, - content: savedImages(Array.isArray(result['content']) ? result['content'] : []), - }; - } - if (command === 'browser-open') return json('/api/browser/open', { slug }); - if (command === 'browser-takeover') - return json('/api/browser/takeover', { slug, active: values.enabled === true }); - if (command === 'browser-input') return json('/api/browser/input', { ...toolArgs, slug }); - if (command === 'browser-stop') return json('/api/browser/stop', { slug }); - if (command === 'computer-start') - return json('/api/computer/start', { - authorize: true, - ...(values.target === undefined ? {} : { target: values.target }), - }); - if (command === 'computer-status') return json('/api/computer/status'); - if (command === 'computer-stop') return json('/api/computer/stop', { authorize: true }); - if (command === 'computer-takeover') - return json('/api/computer/takeover', { active: values.enabled === true }); - if (command === 'computer-input') return json('/api/computer/input', toolArgs); - if (command === 'computer-frame') { - const { response, bytes: answer } = await http('/api/computer/frame'); - if (response.status === 204) - throw new CliLiveError('no-frame', 'The Computer has not produced a frame yet; try again.'); - let desktop: Record; - try { - desktop = record(JSON.parse(new TextDecoder().decode(answer))); - } catch { - throw new CliLiveError( - 'host-protocol-error', - 'The Host returned no JSON for /api/computer/frame.', - ); - } - if (typeof desktop['data'] !== 'string') - throw new CliLiveError('host-protocol-error', 'The Host returned no Computer frame.'); - const bytes = Buffer.from(desktop['data'], 'base64'); - writeFileSync(values.out!, bytes, { mode: 0o600 }); - return { - path: values.out, - bytes: bytes.byteLength, - mimeType: desktop['mimeType'], - width: desktop['width'], - height: desktop['height'], - seq: desktop['seq'], - target: desktop['target'], - }; - } - const frame = record(await json(`/api/browser/frame?slug=${encodeURIComponent(slug)}`)); - if (typeof frame['data'] !== 'string') - throw new CliLiveError('host-protocol-error', 'The Host returned no Browser frame.'); - const bytes = Buffer.from(frame['data'], 'base64'); - writeFileSync(values.out!, bytes, { mode: 0o600 }); - return { - path: values.out, - bytes: bytes.byteLength, - mimeType: frame['mimeType'], - ...(frame['viewport'] === undefined ? {} : { viewport: frame['viewport'] }), - }; -} - -const MEMORY_COMMANDS = new Set([ - 'memory-snapshot', - 'memory-file', - 'memory-image', - 'memory-history', - 'memory-diff', - 'memory-save', - 'memory-file-target', - 'memory-graph', - 'memory-commit-diff', - 'memory-changes', - 'memory-working-diff', - 'memory-checkpoints', - 'memory-restore', - 'memory-restore-commit', - 'memory-versions', - 'memory-version-name', - 'memory-version-unname', - 'memory-repair', - 'memory-move', -]); - -const SHA = /^[0-9a-f]{40}$/u; - -async function runMemoryCommand( - command: string, - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one bot id.`); - const slug = rest[0].trim(); - const channelId = dmChannelId(slug); - const required = (name: keyof LiveValues, flag: string): string => { - const value = values[name]; - if (typeof value !== 'string' || !value.trim()) usage(`${command} requires --${flag}.`); - return value.trim(); - }; - const sha = (): string => { - const value = required('sha', 'sha '); - if (!SHA.test(value)) usage('--sha must be a 40-character lowercase commit sha.'); - return value; - }; - const expectedHead = (): string => { - const value = required('expected-head', 'expected-head '); - if (!SHA.test(value)) usage('--expected-head must be a 40-character lowercase commit sha.'); - return value; - }; - let method: string; - let args: Record; - switch (command) { - case 'memory-snapshot': - [method, args] = ['memorySnapshot', { channelId }]; - break; - case 'memory-image': { - const path = required('path', 'path '); - const source = required('image-source', 'image-source worktree|index|'); - const revision = required('image-revision', 'image-revision '); - const out = required('out', 'out '); - if (!isAbsolute(out)) usage('--out must be an absolute path.'); - const host = await connectLiveHost( - values, - io, - AbortSignal.timeout(timeoutMs(values.timeout)), - ); - const reference = values['image-reference']; - const { bytes, response } = await host.http( - '/api/botharness/memory/image?' + - new URLSearchParams({ - slug, - path, - source, - revision, - ...(reference === undefined ? {} : { reference }), - }), - ); - const mime = response.headers.get('content-type'); - if ( - !['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'].includes( - mime ?? '', - ) - ) - throw new CliLiveError('host-protocol-error', 'Expected a supported Memory image'); - writeFileSync(out, bytes, { flag: 'wx' }); - return { - path, - source, - revision, - ...(reference === undefined ? {} : { reference }), - mime, - bytes: bytes.length, - out, - }; - } - case 'memory-file': - [method, args] = ['memoryFile', { channelId, path: required('path', 'path ') }]; - break; - case 'memory-history': - [method, args] = ['memoryHistory', { channelId }]; - break; - case 'memory-diff': - [method, args] = ['memoryDiff', { channelId, sha: sha() }]; - break; - case 'memory-file-target': - [method, args] = ['memoryFileTarget', { slug, path: required('path', 'path ') }]; - break; - case 'memory-graph': { - const offset = values.offset === undefined ? 0 : Number(values.offset); - if (!Number.isInteger(offset) || offset < 0 || offset > 10_000) - usage('--offset must be an integer from 0 to 10000.'); - [method, args] = ['memoryGitGraph', { channelId, offset }]; - break; - } - case 'memory-commit-diff': - [method, args] = ['memoryGitCommitDiff', { channelId, sha: sha() }]; - break; - case 'memory-changes': - [method, args] = ['memoryWorkingChanges', { channelId }]; - break; - case 'memory-working-diff': { - const kind = required('kind', 'kind staged|unstaged|untracked|current'); - if (!['staged', 'unstaged', 'untracked', 'current'].includes(kind)) - usage('--kind must be staged, unstaged, untracked or current.'); - [method, args] = [ - 'memoryWorkingDiff', - { channelId, path: required('path', 'path '), kind }, - ]; - break; - } - case 'memory-checkpoints': - [method, args] = ['memoryRecoveryHistory', { channelId }]; - break; - case 'memory-restore': - [method, args] = [ - 'memoryRestore', - { - channelId, - checkpointId: required('checkpoint', 'checkpoint '), - expectedCurrentId: required('expected-current', 'expected-current '), - }, - ]; - break; - case 'memory-restore-commit': - [method, args] = [ - 'memoryRestoreCommit', - { - channelId, - sha: sha(), - expectedHead: expectedHead(), - restoreId: values['restore-id']?.trim() || randomUUID(), - ...(values.name === undefined ? {} : { name: values.name }), - }, - ]; - break; - case 'memory-versions': - [method, args] = ['memoryVersions', { channelId }]; - break; - case 'memory-version-name': - [method, args] = [ - 'memoryNameVersion', - { channelId, sha: sha(), name: required('name', 'name ') }, - ]; - break; - case 'memory-version-unname': - [method, args] = [ - 'memoryRemoveVersionName', - { channelId, tag: required('tag', 'tag ') }, - ]; - break; - case 'memory-move': { - const location = values['memory-location']?.trim(); - if ((location === undefined || location === '') === (values['default-location'] !== true)) - usage( - 'memory-move needs --memory-location or --default-location, not both.', - ); - if (location !== undefined && location !== '' && !isAbsolute(location)) - usage('--memory-location must be an absolute folder path.'); - [method, args] = [ - 'memoryMove', - { slug, location: location === undefined || location === '' ? null : location }, - ]; - break; - } - case 'memory-repair': - [method, args] = [ - 'memoryRepair', - { - channelId, - expectedHead: expectedHead(), - repairId: values['repair-id']?.trim() || randomUUID(), - }, - ]; - break; - default: { - const path = required('path', 'path '); - const head = expectedHead(); - let body = values.body; - if (values['body-stdin'] === true) body = await (io.readStdin?.() ?? Promise.resolve('')); - if (body === undefined) usage('memory-save needs --body or --body-stdin.'); - [method, args] = [ - 'memorySave', - { - channelId, - path, - body, - expectedHead: head, - editId: values['edit-id']?.trim() || randomUUID(), - }, - ]; - } - } - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc(method, args); -} - -export interface ReviewedSettings { - expectedRevision: number; - previousValue: Record; -} - -export function parseReviewedSettings( - values: { 'expected-revision'?: string; 'previous-value'?: string }, - patch: Record, -): ReviewedSettings | undefined { - const revision = values['expected-revision']; - const previous = values['previous-value']; - if (revision === undefined && previous === undefined) return undefined; - if (revision === undefined || previous === undefined) - usage('--expected-revision and --previous-value must be supplied together.'); - if (!/^(0|[1-9][0-9]*)$/u.test(revision) || !Number.isSafeInteger(Number(revision))) - usage('--expected-revision must be a nonnegative safe integer.'); - let previousValue: unknown; - try { - previousValue = JSON.parse(previous); - } catch { - usage('--previous-value must be a JSON object of the reviewed settings fields.'); - } - if (typeof previousValue !== 'object' || previousValue === null || Array.isArray(previousValue)) - usage('--previous-value must be a JSON object of the reviewed settings fields.'); - const keys = Object.keys(patch).sort(); - if (keys.length === 0 || !isDeepStrictEqual(keys, Object.keys(previousValue).sort())) - usage('--previous-value must contain exactly the fields in the nonempty patch.'); - return { - expectedRevision: Number(revision), - previousValue: previousValue as Record, - }; -} +import { + WINDOW_ZOOM_COMMANDS, + WINDOW_ZOOM_OPTIONS, + runWindowZoomCommand, +} from './cli-window-zoom.js'; +import { PHONE_ROUTE_COMMANDS, runPhoneRouteCommand } from './cli-phone-route.js'; +import { DEVICES_COMMANDS, isDevicesCommand, runDevicesCommand } from './cli-devices.js'; +import { + DEVICE_NOTIFICATION_COMMANDS, + DEVICE_NOTIFICATION_OPTIONS, + runDeviceNotifications, +} from './cli-device-notifications.js'; +import { + BROWSER_NOTIFICATION_COMMANDS, + runBrowserNotifications, +} from './cli-browser-notifications.js'; +import { APPEARANCE_COMMANDS, APPEARANCE_OPTIONS, runAppearanceCommand } from './cli-appearance.js'; +import { prepareCloud, prepareCloudWork } from './cli-cloud.js'; +import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { dmChannelId } from '../channels/channel.js'; +import type { BotCreateCliIo } from './bot-create-cli.js'; +import { CliLiveError } from './cli-live-error.js'; +import { BOT_TOOL_COMMANDS, runBotToolCommand } from './cli-bot-tool.js'; +import { + channelOperationKey, + CHANNEL_COMMANDS, + CHANNEL_OPTIONS, + isChannelCommand, + runChannelCommand, +} from './cli-channels.js'; +import { + BROWSER_COMPUTER_COMMANDS, + BROWSER_COMPUTER_OPTIONS, + isBrowserComputerCommand, + runBrowserComputerCommand, + type BrowserComputerValues, +} from './cli-browser-computer.js'; +import { + IM_COMMANDS, + IM_OPTIONS, + prepareImAuthorization, + runImAuthorization, + type ImValues, +} from './cli-im-authorization.js'; +import { + MESSAGING_COMMANDS, + MESSAGING_OPTIONS, + prepareMessaging, + runMessaging, + type MessagingCommand, + type MessagingValues, +} from './cli-messaging.js'; +import { + isModelsUsageCommand, + MODELS_USAGE_COMMANDS, + MODELS_USAGE_OPTIONS, + runModelsUsage, + type ModelsUsageValues, +} from './cli-models-usage.js'; +import { + HOST_ADMIN_COMMANDS, + HOST_ADMIN_OPTIONS, + isHostAdminCommand, + runHostAdminCommand, + type HostAdminValues, +} from './cli-host-admin.js'; +import { + INBOX_COMMANDS, + INBOX_OPTIONS, + isInboxCommand, + runInboxCommand, + type InboxValues, +} from './cli-inbox.js'; +import { + SCHEDULE_OPTIONS, + isScheduleCommand, + prepareScheduleCommand, + runScheduleCommand, + type ScheduleValues, +} from './cli-schedules.js'; +import { + PROFILE_COMMANDS, + PROFILE_OPTIONS, + isProfileCommand, + runProfileCommand, + type ProfileValues, +} from './cli-profile.js'; +import { + TODO_COMMANDS, + TODO_OPTIONS, + isTodoCommand, + runTodoCommand, + type TodoValues, +} from './cli-todos.js'; +import { + APPROVAL_TEMPLATE_COMMANDS, + APPROVAL_TEMPLATE_OPTIONS, + isApprovalTemplateCommand, + runApprovalTemplateCommand, + type ApprovalTemplateValues, +} from './cli-approval-templates.js'; +import { + APPROVAL_REVIEWER_COMMANDS, + APPROVAL_REVIEWER_OPTIONS, + isApprovalReviewerCommand, + runApprovalReviewerCommand, + type ApprovalReviewerValues, +} from './cli-approval-reviewer.js'; +import { + isStorageCommand, + runStorageCommand, + STORAGE_COMMANDS, + STORAGE_OPTIONS, + type StorageValues, +} from './cli-storage.js'; +import { + IDLE_COMPACTION_COMMANDS, + isIdleCompactionCommand, + runIdleCompactionCommand, +} from './cli-idle-compaction.js'; +import { + CONNECT_COMMANDS, + CONNECT_OPTIONS, + isConnectCommand, + runConnectCommand, +} from './cli-connect.js'; +export { CliLiveError } from './cli-live-error.js'; + +export const LIVE_OPTIONS = { + 'remote-host-id': { type: 'string' }, + ...DEVICE_NOTIFICATION_OPTIONS, + ...WINDOW_ZOOM_OPTIONS, + ...CONNECT_OPTIONS, + ...APPEARANCE_OPTIONS, + ...IM_OPTIONS, + ...MESSAGING_OPTIONS, + ...MODELS_USAGE_OPTIONS, + ...CHANNEL_OPTIONS, + ...HOST_ADMIN_OPTIONS, + ...INBOX_OPTIONS, + ...SCHEDULE_OPTIONS, + ...BROWSER_COMPUTER_OPTIONS, + ...PROFILE_OPTIONS, + ...TODO_OPTIONS, + ...APPROVAL_TEMPLATE_OPTIONS, + ...APPROVAL_REVIEWER_OPTIONS, + ...STORAGE_OPTIONS, + host: { type: 'string' }, + 'token-file': { type: 'string' }, + 'image-source': { type: 'string' }, + 'image-revision': { type: 'string' }, + 'image-reference': { type: 'string' }, + timeout: { type: 'string' }, + 'message-id': { type: 'string' }, + outcome: { type: 'string' }, + 'answer-stdin': { type: 'boolean' }, + 'values-stdin': { type: 'boolean' }, + since: { type: 'string' }, + workspace: { type: 'string' }, + mode: { type: 'string' }, + scope: { type: 'string' }, + 'acknowledge-risk': { type: 'boolean' }, + 'patch-stdin': { type: 'boolean' }, + 'previous-value': { type: 'string' }, + member: { type: 'string', multiple: true }, + app: { type: 'string' }, + bot: { type: 'string' }, + conversation: { type: 'string' }, + out: { type: 'string' }, + 'args-stdin': { type: 'boolean' }, + session: { type: 'string' }, + turns: { type: 'string' }, + offset: { type: 'string' }, + kind: { type: 'string' }, + checkpoint: { type: 'string' }, + 'expected-current': { type: 'string' }, + 'restore-id': { type: 'string' }, + 'repair-id': { type: 'string' }, + 'default-location': { type: 'boolean' }, + tag: { type: 'string' }, + target: { type: 'string' }, +} as const; + +const DEVICE_COMMANDS = [ + 'browser-open', + 'browser-frame', + 'browser-takeover', + 'browser-input', + 'browser-stop', + 'computer-start', + 'computer-status', + 'computer-stop', + 'browser-tools', + 'browser-tool', + 'computer-tools', + 'computer-tool', + 'computer-frame', + 'computer-takeover', + 'computer-input', +] as const; + +const IM_CONTROL_COMMANDS = [ + 'im-app-add', + 'im-app-status', + 'im-app-remove', + 'im-bind', + 'im-unbind', + 'im-status', + 'channel-connect', + 'channel-ingests', +] as const; + +const TOKEN_IM_PLATFORMS = new Set(['discord', 'slack', 'qq', 'dingtalk', 'wecom', 'matrix']); + +const CONTROL_COMMANDS = [ + 'browser-access-set', + 'computer-access-set', + 'access', + 'access-set', + 'settings', + 'settings-set', + 'workspace-create', + 'group-create', +] as const; + +export const LIVE_COMMANDS = [ + ...BROWSER_NOTIFICATION_COMMANDS, + ...DEVICE_NOTIFICATION_COMMANDS, + ...WINDOW_ZOOM_COMMANDS, + ...PHONE_ROUTE_COMMANDS, + ...DEVICES_COMMANDS, + ...CONNECT_COMMANDS, + ...IM_COMMANDS, + ...MESSAGING_COMMANDS, + { + command: 'cloud', + description: 'manage an explicit cloud Agent connection (private JSON through stdin)', + }, + { + command: 'cloud-work', + description: 'run the cloud_work Tool directly for a Bot using JSON from stdin', + }, + ...MODELS_USAGE_COMMANDS, + ...CHANNEL_COMMANDS, + ...HOST_ADMIN_COMMANDS, + ...APPEARANCE_COMMANDS, + ...INBOX_COMMANDS, + ...BROWSER_COMPUTER_COMMANDS, + ...PROFILE_COMMANDS, + ...BOT_TOOL_COMMANDS, + ...TODO_COMMANDS, + ...APPROVAL_TEMPLATE_COMMANDS, + ...APPROVAL_REVIEWER_COMMANDS, + ...IDLE_COMPACTION_COMMANDS, + ...STORAGE_COMMANDS, + { + command: 'send', + description: 'send a Human DM and collect its committed Bot reply from a live Host', + }, + { + command: 'send-status', + description: 'inspect a Human DM receipt and its exact Bot replies without resending', + }, + { + command: 'tool-approval-status', + description: 'inspect a pending tool approval on a live Host', + }, + { + command: 'tool-approval-decide', + description: 'allow once, always allow a secret_cli kind, or reject an owned tool approval', + }, + { command: 'user-question-status', description: 'inspect a formal Human question' }, + { command: 'ask-secret-status', description: 'inspect a private input request without values' }, + { + command: 'ask-secret-submit', + description: 'submit confidential field JSON from stdin through the authenticated Owner', + }, + { command: 'ask-secret-cancel', description: 'cancel a private input request as Owner' }, + { command: 'ask-secret-account-status', description: 'inspect current private account access' }, + { command: 'ask-secret-replace', description: 'open a blank replacement private input card' }, + { command: 'ask-secret-revoke', description: 'revoke local private account access as Owner' }, + { + command: 'ask-secret-accounts', + description: "list a Bot's private accounts as Owner, without values", + }, + { + command: 'user-question-answer', + description: 'answer a formal Human question with JSON from stdin', + }, + { command: 'release-info', description: 'read the running Host release lifecycle' }, + { command: 'workspace-options', description: 'list live Host workspaces for grants' }, + { + command: 'folder-browse', + description: 'list folder names on the Host computer, as the phone folder picker does', + }, + { command: 'grant-create', description: 'authorize a Bot workspace through the live Host' }, + { + command: 'browser-access-set', + description: "turn a Bot's Browser Access on or off through the live Host", + }, + { + command: 'computer-access-set', + description: "turn a Bot's Computer Access on or off through the live Host", + }, + { command: 'access', description: "read a Bot's access preset (workspace write or full access)" }, + { + command: 'access-set', + description: "set a Bot's access preset; full access needs --acknowledge-risk", + }, + { command: 'settings', description: 'read one live Host settings namespace' }, + { + command: 'settings-set', + description: 'patch one live Host settings namespace with JSON from stdin', + }, + { command: 'workspace-create', description: 'register an absolute folder as a Host workspace' }, + { command: 'group-create', description: 'create a group Channel with optional initial Bots' }, + { + command: 'im-app-add', + description: 'add a token-based IM app (Discord, Slack, …) with credentials JSON from stdin', + }, + { command: 'im-app-status', description: "read a token IM platform's apps and connection state" }, + { command: 'im-app-remove', description: 'remove a token IM app from the Provider' }, + { command: 'im-bind', description: 'bind a connected IM app to a Bot' }, + { command: 'im-unbind', description: 'unbind an IM app from a Bot' }, + { command: 'im-status', description: "read a Bot's IM apps and bindings with reception state" }, + { + command: 'channel-connect', + description: "connect an external group conversation to a Channel through a Bot's binding", + }, + { command: 'channel-ingests', description: "list a Channel's connected external conversations" }, + { command: 'browser-open', description: "start a Bot's Browser and open its tab for viewing" }, + { + command: 'browser-frame', + description: "save the current picture of a Bot's Browser to a file", + }, + { + command: 'browser-takeover', + description: "pause a Bot's Browser for Human input (--enabled) or hand it back (--disabled)", + }, + { + command: 'browser-input', + description: + "click, scroll, type or press a key in a paused Bot's Browser (input JSON from stdin)", + }, + { command: 'browser-stop', description: "stop a Bot's Browser" }, + { + command: 'computer-start', + description: 'start the profile Computer with explicit authorization', + }, + { command: 'computer-status', description: 'read the profile Computer target and state' }, + { + command: 'computer-frame', + description: 'save the current picture of the profile Computer desktop to a file', + }, + { + command: 'computer-takeover', + description: + 'pause Bot use of the profile Computer for Human input (--enabled) or hand it back (--disabled)', + }, + { + command: 'computer-input', + description: + 'click, scroll, type or press a key on a paused profile Computer desktop (input JSON from stdin)', + }, + { command: 'computer-stop', description: 'stop the profile Computer' }, + { + command: 'browser-tools', + description: 'list the Browser tools a Bot can call, with input schemas', + }, + { + command: 'browser-tool', + description: + 'run one Browser tool directly for a Bot, without the model (args JSON from stdin)', + }, + { + command: 'memory-image', + description: + 'export version-bound Memory image bytes (--path --image-source --image-revision [--image-reference] --out)', + }, + { command: 'memory-file-target', description: 'resolve a memory file path for a Bot' }, + { command: 'memory-graph', description: "read a Bot's memory commit graph" }, + { command: 'memory-commit-diff', description: 'read the full diff of one memory commit' }, + { command: 'memory-changes', description: 'list uncommitted memory changes' }, + { command: 'memory-working-diff', description: 'read the diff of one uncommitted memory file' }, + { command: 'memory-checkpoints', description: 'list memory recovery checkpoints' }, + { command: 'memory-restore', description: 'restore memory to a recovery checkpoint' }, + { command: 'memory-restore-commit', description: 'restore memory to an earlier commit' }, + { command: 'memory-versions', description: 'list named memory versions' }, + { command: 'memory-version-name', description: 'name a memory commit as a version' }, + { command: 'memory-version-unname', description: 'remove a memory version name' }, + { command: 'memory-repair', description: 'repair a memory repository against its expected head' }, + { + command: 'memory-move', + description: "move a Bot's Memory repository to another folder, or back to the default", + }, + { + command: 'bot-trace', + description: 'show which tools a Bot called in its recent turns, with outcomes', + }, + { + command: 'computer-tools', + description: 'list the Computer tools a Bot can call, with input schemas', + }, + { + command: 'computer-tool', + description: + 'run one Computer tool directly for a Bot, without the model (args JSON from stdin)', + }, +] as const; + +interface LiveValues + extends + ImValues, + MessagingValues, + ModelsUsageValues, + HostAdminValues, + InboxValues, + ScheduleValues, + BrowserComputerValues, + ProfileValues, + TodoValues, + ApprovalTemplateValues, + ApprovalReviewerValues { + 'client-id'?: string; + 'device-file'?: string; + 'remote-host-id'?: string; + 'preferences-stdin'?: boolean; + host?: string; + 'token-file'?: string; + 'image-source'?: string; + 'image-revision'?: string; + 'image-reference'?: string; + timeout?: string; + 'message-id'?: string; + body?: string; + 'body-stdin'?: boolean; + 'answer-stdin'?: boolean; + 'values-stdin'?: boolean; + outcome?: string; + since?: string; + workspace?: string; + limit?: string; + before?: string; + enabled?: boolean; + disabled?: boolean; + mode?: string; + scope?: string; + 'acknowledge-risk'?: boolean; + 'patch-stdin'?: boolean; + 'previous-value'?: string; + member?: string[]; + 'operation-key'?: string; + app?: string; + bot?: string; + conversation?: string; + out?: string; + 'args-stdin'?: boolean; + session?: string; + turns?: string; + offset?: string; + kind?: string; + checkpoint?: string; + 'expected-current'?: string; + 'restore-id'?: string; + 'repair-id'?: string; + 'memory-location'?: string; + 'default-location'?: boolean; + tag?: string; + path?: string; + sha?: string; + name?: string; + 'expected-head'?: string; + 'edit-id'?: string; + target?: string; +} + +function usage(message: string): never { + throw new CliLiveError('usage', message); +} + +function isLoopbackName(name: string): boolean { + return name === 'localhost' || name === '[::1]' || /^127\.\d+\.\d+\.\d+$/u.test(name); +} + +function hostUrl(value: string | undefined): URL { + if (!value) usage('Live commands require --host or DEEPSEEKBOT_HOST.'); + let url: URL; + try { + url = new URL(value); + } catch { + usage('--host must be an HTTP(S) origin.'); + } + const name = url.hostname; + const loopback = isLoopbackName(name); + const tailnet = + name.endsWith('.ts.net') || /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/u.test(name); + if ( + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/' || + !( + (loopback && (url.protocol === 'http:' || url.protocol === 'https:')) || + (tailnet && url.protocol === 'https:') + ) + ) { + usage( + '--host must be a loopback origin or a tailnet HTTPS origin, without credentials, query or path.', + ); + } + return url; +} + +function timeoutMs(value: string | undefined): number { + const seconds = value === undefined ? 120 : Number(value); + if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 600) + usage('--timeout must be between 0 and 600 seconds.'); + return Math.ceil(seconds * 1000); +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +export async function connectLiveHost(values: LiveValues, io: BotCreateCliIo, signal: AbortSignal) { + const base = hostUrl(values.host ?? io.env['DEEPSEEKBOT_HOST']); + let token = io.env['DEEPSEEKBOT_HOST_TOKEN']; + if (values['token-file'] !== undefined) { + try { + token = readFileSync(values['token-file'], 'utf8').trim(); + } catch { + throw new CliLiveError('host-unauthorized', 'Cannot read the Host token file.'); + } + } + if (!token?.trim()) + throw new CliLiveError( + 'host-unauthorized', + 'Set DEEPSEEKBOT_HOST_TOKEN or provide --token-file.', + ); + const secret = token.trim(); + const login = new URL(base); + login.searchParams.set('token', secret); + let cookie = ''; + const refused = (status: number): CliLiveError => + status === 403 && !isLoopbackName(base.hostname) + ? new CliLiveError( + 'host-untrusted', + `The Host does not accept requests addressed to ${base.hostname}; restart it with --trusted-host ${base.hostname}.`, + ) + : new CliLiveError( + 'host-unauthorized', + 'The Host refused this authority cookie; refresh its launch token.', + ); + const request = async (url: URL, init: RequestInit = {}): Promise => { + try { + return await fetch(url, { ...init, signal, redirect: 'manual' }); + } catch { + throw new CliLiveError( + 'host-unreachable', + 'The Host could not be reached before the command deadline.', + ); + } + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await request(login, { headers: cookie ? { cookie } : {} }); + const cookies = response.headers.getSetCookie(); + if (cookies.length > 0) cookie = cookies.map((entry) => entry.split(';')[0]).join('; '); + await response.body?.cancel(); + if (response.status === 401 || response.status === 403) + throw new CliLiveError( + 'host-unauthorized', + 'Host login was refused; refresh its launch token.', + ); + if (cookie) break; + } + if (!cookie) + throw new CliLiveError('host-unauthorized', 'Host login did not issue an authority cookie.'); + const readResponse = async (response: Response): Promise> => { + if (response.status === 401 || response.status === 403) { + await response.body?.cancel(); + throw refused(response.status); + } + try { + const body = await response.text(); + try { + return record(JSON.parse(body)); + } catch { + throw new CliLiveError('host-protocol-error', 'The Host did not return a JSON response.'); + } + } catch (error) { + if (error instanceof CliLiveError) throw error; + throw new CliLiveError( + 'host-unreachable', + 'The Host response was interrupted before the command deadline.', + ); + } + }; + const scrub = (value: string) => + value.replaceAll(secret, '[redacted]').replaceAll(encodeURIComponent(secret), '[redacted]'); + const rpc = async (method: string, args: Record): Promise => { + const name = method.includes('/') ? method : `botharness/${method}`; + const response = await request(new URL(`/api/${name}`, base), { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ + type: 'client-request', + rpcId: randomUUID(), + method: name, + payload: method.startsWith('dsh-im/') ? args : { args }, + }), + }); + const payload = await readResponse(response); + if (!response.ok || payload['type'] !== 'server-response') + throw new CliLiveError('host-protocol-error', 'The Host did not return a DSH RPC response.'); + const result = record(payload['result']); + if (result['ok'] !== true) { + const error = record(result['error']); + const code = typeof error['code'] === 'string' ? error['code'] : 'host-protocol-error'; + const message = + typeof error['message'] === 'string' ? error['message'] : 'The Host refused the command.'; + throw new CliLiveError(code, scrub(message)); + } + return result['value'] as T; + }; + const importBotZip = async ( + archive: Uint8Array, + metadata: { name?: string; displayName?: string; roles?: string[]; description?: string }, + ): Promise => { + const url = new URL('/api/botharness/bot-zip/import', base); + for (const key of ['name', 'displayName', 'description'] as const) { + if (metadata[key] !== undefined) url.searchParams.set(key, metadata[key]); + } + if (metadata.roles !== undefined) url.searchParams.set('roles', JSON.stringify(metadata.roles)); + const response = await request(url, { + method: 'POST', + headers: { cookie, 'content-type': 'application/zip' }, + body: new Uint8Array(archive), + }); + const payload = await readResponse(response); + if (!response.ok) { + const error = record(payload['error']); + const bot = record(payload['bot']); + throw new CliLiveError( + typeof error['code'] === 'string' ? scrub(error['code']) : 'host-protocol-error', + scrub( + typeof error['message'] === 'string' + ? error['message'] + : 'The Host refused this Bot Zip import.', + ), + undefined, + undefined, + typeof bot['id'] === 'string' && typeof bot['name'] === 'string' + ? { + bot: { id: bot['id'], name: bot['name'] }, + steps: [{ name: 'create-bot', status: 'ok' }], + outcome: 'created', + } + : undefined, + ); + } + return payload as T; + }; + const refuse = (path: string, status: number, bytes: Uint8Array): never => { + let detail: Record = {}; + try { + detail = record(JSON.parse(new TextDecoder().decode(bytes))); + } catch { + detail = { error: new TextDecoder().decode(bytes).slice(0, 200) }; + } + const nested = record(detail['error']); + const code = + typeof detail['code'] === 'string' + ? detail['code'] + : typeof nested['code'] === 'string' + ? nested['code'] + : undefined; + if ((status === 401 || status === 403) && code === undefined) throw refused(status); + const packages = Array.isArray(detail['packages']) + ? detail['packages'].filter((name): name is string => typeof name === 'string') + : []; + throw new CliLiveError( + code === undefined ? 'host-refused' : scrub(code), + scrub( + (typeof detail['error'] === 'string' && detail['error'] !== '' + ? detail['error'] + : typeof nested['message'] === 'string' + ? nested['message'] + : `The Host refused ${path} with HTTP ${String(status)}.`) + + (packages.length === 0 ? '' : ` Packages: ${packages.join(' ')}.`), + ), + ); + }; + const http = async ( + path: string, + body?: Record, + ): Promise<{ response: Response; bytes: Uint8Array }> => { + const response = await request(new URL(path, base), { + method: body === undefined ? 'GET' : 'POST', + headers: { + cookie, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await response.arrayBuffer()); + } catch { + throw new CliLiveError( + 'host-unreachable', + 'The Host response was interrupted before the command deadline.', + ); + } + if (!response.ok) refuse(path, response.status, bytes); + return { response, bytes }; + }; + const stream = async (path: string, init: RequestInit = {}): Promise => { + const response = await request(new URL(path, base), { + ...init, + headers: { ...(init.headers as Record | undefined), cookie }, + }); + if (!response.ok) refuse(path, response.status, new Uint8Array(await response.arrayBuffer())); + return response; + }; + const socket = (path: string): WebSocket => { + const url = new URL(path, base); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const Socket = WebSocket as unknown as new ( + url: string, + init: { headers: Record }, + ) => WebSocket; + return new Socket(url.href, { headers: { cookie } }); + }; + return { rpc, importBotZip, http, stream, socket }; +} + +export async function runLiveCli( + command: string, + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + if (command === 'device-notifications') return runDeviceNotifications(rest, values, io); + if (BROWSER_NOTIFICATION_COMMANDS.some((entry) => entry.command === command)) + return runBrowserNotifications(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (IM_COMMANDS.some((entry) => entry.command === command)) { + const operation = await prepareImAuthorization(command, rest, values, io); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + try { + return await runImAuthorization( + operation, + (await connectLiveHost(values, io, signal)).rpc, + signal, + ); + } catch (error) { + if ( + error instanceof CliLiveError && + !error.authorization && + command !== 'im-apps' && + command !== 'im-authorize' + ) + throw new CliLiveError(error.code, error.message, undefined, { + attemptId: operation.target, + }); + throw error; + } + } + if (MEMORY_COMMANDS.has(command)) return runMemoryCommand(command, rest, values, io); + if (isModelsUsageCommand(command)) + return runModelsUsage(command, rest, values, io, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isConnectCommand(command)) + return runConnectCommand(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (WINDOW_ZOOM_COMMANDS.some((entry) => entry.command === command)) + return runWindowZoomCommand(command, rest, values, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (PHONE_ROUTE_COMMANDS.some((entry) => entry.command === command)) + return runPhoneRouteCommand(command, rest, values, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (isDevicesCommand(command)) + return runDevicesCommand(command, rest, values, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (command === 'cloud-work') { + const input = await prepareCloudWork(rest, values, io); + const host = await connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))); + return host.rpc('cloudWork', input); + } + if (command === 'cloud') { + const input = await prepareCloud(rest, values, io); + const host = await connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))); + return host.rpc('cloudConnections', { input }); + } + if (MESSAGING_COMMANDS.some((entry) => entry.command === command)) { + const operation = await prepareMessaging(command as MessagingCommand, rest, values, io); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return runMessaging(operation, (await connectLiveHost(values, io, signal)).rpc); + } + if (isInboxCommand(command)) + return runInboxCommand(command, rest, values, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (isScheduleCommand(command)) { + const prepared = prepareScheduleCommand(command, rest, values); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return runScheduleCommand(prepared, (await connectLiveHost(values, io, signal)).rpc); + } + if (isBrowserComputerCommand(command)) + return runBrowserComputerCommand(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (APPEARANCE_COMMANDS.some((entry) => entry.command === command)) + return runAppearanceCommand(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (isProfileCommand(command)) + return runProfileCommand(command, rest, values, io, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isApprovalReviewerCommand(command)) + return runApprovalReviewerCommand(command, rest, values, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isStorageCommand(command)) + return runStorageCommand(command, rest, values as StorageValues, io, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isIdleCompactionCommand(command)) + return runIdleCompactionCommand(command, rest, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isApprovalTemplateCommand(command)) + return runApprovalTemplateCommand(command, rest, values, io, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isTodoCommand(command)) + return runTodoCommand(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (BOT_TOOL_COMMANDS.some((entry) => entry.command === command)) + return runBotToolCommand( + command as (typeof BOT_TOOL_COMMANDS)[number]['command'], + rest, + values, + io, + () => connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (command === 'bot-trace') { + if (rest.length !== 1 || !rest[0]?.trim()) usage('bot-trace needs exactly one bot id.'); + const turns = values.turns === undefined ? undefined : Number(values.turns); + if (turns !== undefined && (!Number.isInteger(turns) || turns < 1 || turns > 20)) + usage('--turns must be an integer from 1 to 20.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc('botToolTrace', { + slug: rest[0].trim(), + ...(values.session === undefined ? {} : { sessionId: values.session }), + ...(turns === undefined ? {} : { turns }), + }); + } + if (isChannelCommand(command)) + return runChannelCommand(command, rest, values, io, connectLiveHost); + if (isHostAdminCommand(command)) { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return runHostAdminCommand(command, rest, values, io, { + connect: async () => (await connectLiveHost(values, io, signal)).rpc, + signal, + }); + } + if (DEVICE_COMMANDS.some((entry) => entry === command)) + return runDeviceCommand(command as (typeof DEVICE_COMMANDS)[number], rest, values, io); + if (command === 'folder-browse') { + if (rest.length > 1) usage('folder-browse takes at most one folder path.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const path = rest[0]?.trim(); + return (await connectLiveHost(values, io, signal)).rpc('folderBrowse', path ? { path } : {}); + } + if (command === 'grants') { + if (rest.length !== 1 || !rest[0]?.trim()) usage('grants needs exactly one bot id.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc('grants', { slug: rest[0].trim() }); + } + if (IM_CONTROL_COMMANDS.some((entry) => entry === command)) + return runImControlCommand(command as (typeof IM_CONTROL_COMMANDS)[number], rest, values, io); + if (CONTROL_COMMANDS.some((entry) => entry === command)) + return runControlCommand(command as (typeof CONTROL_COMMANDS)[number], rest, values, io); + const noTarget = command === 'release-info' || command === 'workspace-options'; + if (rest.length !== (noTarget ? 0 : 1)) + usage( + `${command} ${noTarget ? 'takes no positional arguments' : 'needs exactly one target id'}.`, + ); + const target = rest[0]?.trim() ?? ''; + if (!noTarget && !target) usage(`${command} requires a nonempty target id.`); + const messageId = values['message-id']; + if ( + [ + 'send-status', + 'tool-approval-status', + 'tool-approval-decide', + 'user-question-status', + 'user-question-answer', + 'ask-secret-status', + 'ask-secret-submit', + 'ask-secret-cancel', + 'ask-secret-account-status', + 'ask-secret-replace', + 'ask-secret-revoke', + ].includes(command) && + !messageId?.trim() + ) + usage('--message-id is required.'); + let body: string | undefined; + let answer: unknown; + let confidential: Record | undefined; + if (command === 'ask-secret-submit') { + if (!values['values-stdin']) usage('ask-secret-submit requires --values-stdin.'); + try { + const input = await (io.readStdin?.() ?? Promise.resolve('')); + if (input.length > 140_000) throw new Error('Private input exceeds the limit'); + const parsed: unknown = JSON.parse(input); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error('Invalid private input'); + const entries = Object.entries(parsed); + if ( + entries.length < 1 || + entries.length > 8 || + entries.some( + ([key, value]) => + !/^[a-z][a-z0-9_]{0,47}$/.test(key) || + typeof value !== 'string' || + value.length > 16_384, + ) + ) + throw new Error('Invalid private fields'); + confidential = Object.fromEntries(entries); + } catch { + usage('Private input stdin must be a JSON object with 1–8 declared string fields.'); + } + } + if (command === 'send') { + if ((values.body !== undefined) === (values['body-stdin'] === true)) + usage('send requires exactly one of --body or --body-stdin.'); + body = values['body-stdin'] ? await (io.readStdin?.() ?? Promise.resolve('')) : values.body; + if (!body?.trim()) usage('send requires a nonempty message.'); + if ( + messageId !== undefined && + !/^human-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(messageId) + ) + usage('--message-id must be human- followed by a UUID.'); + } + if ( + command === 'tool-approval-decide' && + values.outcome !== 'allowed-once' && + values.outcome !== 'allowed-always-tool' && + values.outcome !== 'allowed-always-kind' && + values.outcome !== 'rejected' + ) + usage('--outcome must be allowed-once, allowed-always-tool, allowed-always-kind or rejected.'); + if (command === 'user-question-answer') { + if (!values['answer-stdin']) usage('user-question-answer requires --answer-stdin.'); + try { + answer = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); + } catch { + usage('Question answers must be valid JSON on stdin.'); + } + const answers = record(answer)['answers']; + if ( + !Array.isArray(answers) || + !answers.every((entry) => { + const item = record(entry); + return ( + typeof item['id'] === 'string' && + Array.isArray(item['selected']) && + item['selected'].every((choice) => typeof choice === 'string') && + (item['custom'] === undefined || typeof item['custom'] === 'string') + ); + }) + ) + usage( + 'Answer JSON requires answers with id, selected string array and optional custom text.', + ); + } + if (command === 'grant-create' && !values.workspace?.trim()) usage('--workspace is required.'); + const duration = timeoutMs(values.timeout); + const deadline = Date.now() + duration; + const signal = AbortSignal.timeout(duration); + const { rpc } = await connectLiveHost(values, io, signal); + if (command.startsWith('ask-secret-')) { + try { + if (command === 'ask-secret-accounts') { + const result = await rpc<{ + status?: unknown; + accounts?: unknown; + replaceable?: unknown; + }>('privateAccounts', { + slug: target, + }); + if ( + (result.status !== 'ready' && result.status !== 'unavailable') || + !Array.isArray(result.accounts) + ) + throw new Error('Invalid private accounts'); + const optional = (value: unknown) => (typeof value === 'string' ? value : undefined); + const accounts = result.accounts.map((entry: unknown) => { + const row = record(entry); + const status = row['status']; + if ( + typeof row['account'] !== 'string' || + (status !== 'configured' && status !== 'replacing' && status !== 'unavailable') || + !Array.isArray(row['fields']) + ) + throw new Error('Invalid private account'); + return { + account: row['account'], + purpose: optional(row['purpose']), + fields: row['fields'].map((value: unknown) => { + const field = record(value); + if ( + typeof field['id'] !== 'string' || + typeof field['label'] !== 'string' || + typeof field['required'] !== 'boolean' + ) + throw new Error('Invalid private field'); + return { id: field['id'], label: field['label'], required: field['required'] }; + }), + status, + configuredAt: optional(row['configuredAt']), + channelId: optional(row['channelId']), + messageId: optional(row['messageId']), + replacementMessageId: optional(row['replacementMessageId']), + }; + }); + return { + status: result.status, + accounts, + ...(typeof result.replaceable === 'boolean' ? { replaceable: result.replaceable } : {}), + }; + } + if (command === 'ask-secret-status' || command === 'ask-secret-account-status') { + const result = await rpc<{ status?: unknown }>( + command === 'ask-secret-status' ? 'privateInputStatus' : 'privateAccountStatus', + { + channelId: target, + messageId, + }, + ); + const statuses = + command === 'ask-secret-status' + ? ['pending', 'configured', 'cancelled', 'expired'] + : ['configured', 'revoked', 'superseded', 'unavailable']; + if (typeof result.status !== 'string' || !statuses.includes(result.status)) + throw new Error('Invalid private status'); + return { status: result.status }; + } + if (command === 'ask-secret-replace') { + const result = await rpc<{ messageId?: unknown }>('privateInputReplace', { + channelId: target, + messageId, + }); + if ( + typeof result.messageId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(result.messageId) + ) + throw new Error('Invalid replacement card'); + return { messageId: result.messageId }; + } + if (command === 'ask-secret-revoke') { + const result = await rpc<{ + accepted?: unknown; + stopped?: unknown; + completed?: unknown; + uncertain?: unknown; + }>('privateAccountRevoke', { channelId: target, messageId }); + if ( + result.accepted !== true || + [result.stopped, result.completed, result.uncertain].some( + (count) => typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0, + ) + ) + throw new Error('Invalid revocation result'); + return { + accepted: true, + stopped: result.stopped, + completed: result.completed, + uncertain: result.uncertain, + }; + } + const method = command === 'ask-secret-submit' ? 'privateInputSubmit' : 'privateInputCancel'; + const result = await rpc<{ accepted?: unknown }>(method, { + channelId: target, + messageId, + ...(confidential === undefined ? {} : { values: confidential }), + }); + if (result.accepted !== true) throw new Error('Private request was not accepted'); + return { accepted: true }; + } catch { + throw new CliLiveError( + 'private-input-not-accepted', + 'Private input operation was not accepted. Check Owner authentication and request status before retrying.', + ); + } finally { + if (confidential !== undefined) + for (const key of Object.keys(confidential)) delete confidential[key]; + } + } + if (command === 'send') { + const channelId = dmChannelId(target); + const receipt = { channelId, messageId: messageId ?? `human-${randomUUID()}` }; + let sent: unknown; + try { + const bot = await rpc<{ bot: { displayName?: string } }>('get', { slug: target }); + await rpc('channelDm', { slug: target, displayName: bot.bot.displayName }); + sent = await rpc('channelSend', { channelId, body, messageId: receipt.messageId }); + while (Date.now() < deadline) { + const status = await rpc<{ state: string; replies: unknown[] }>( + 'channelSendStatus', + receipt, + ); + if (!status || !Array.isArray(status.replies) || typeof status.state !== 'string') + throw new CliLiveError( + 'host-protocol-error', + 'The Host returned an invalid send status.', + ); + if (status.state === 'handled' && status.replies.length > 0) + return { receipt, ...record(sent), ...status }; + if (status.state === 'handled' || status.state === 'needs-repair') + throw new CliLiveError( + status.state === 'handled' ? 'reply-not-produced' : 'send-needs-repair', + 'The Host has no committed Bot reply for this request.', + receipt, + ); + await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal }); + } + } catch (error) { + if (error instanceof CliLiveError) throw new CliLiveError(error.code, error.message, receipt); + throw new CliLiveError( + 'reply-timeout', + 'The command deadline elapsed; inspect send-status with this receipt before retrying.', + receipt, + ); + } + throw new CliLiveError( + 'reply-timeout', + 'No reply before the deadline; inspect send-status with this receipt before retrying.', + receipt, + ); + } + if (command === 'send-status') + return rpc('channelSendStatus', { channelId: dmChannelId(target), messageId }); + if (command === 'channel-messages') { + const limit = values.limit === undefined ? undefined : Number(values.limit); + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 200)) + usage('--limit must be 1–200.'); + return rpc('channelMessages', { + channelId: target, + ...(limit === undefined ? {} : { limit }), + ...(values.before === undefined ? {} : { before: values.before }), + }); + } + if (command === 'tool-approval-status') + return rpc('toolApprovalStatus', { channelId: target, messageId }); + if (command === 'tool-approval-decide') + return rpc('toolApprovalDecide', { channelId: target, messageId, outcome: values.outcome }); + if (command === 'user-question-status') + return rpc('userQuestionStatus', { channelId: target, messageId }); + if (command === 'user-question-answer') + return rpc('userQuestionAnswer', { channelId: target, messageId, answer }); + if (command === 'workspace-options') return rpc('workspaceOptions', {}); + if (command === 'grant-create') + return rpc('grantCreate', { slug: target, workspaceId: values.workspace }); + return rpc('releaseInfo', values.since === undefined ? {} : { since: values.since }); +} + +async function runControlCommand( + command: (typeof CONTROL_COMMANDS)[number], + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one target.`); + const target = rest[0].trim(); + let patch: Record | undefined; + let reviewed: ReviewedSettings | undefined; + if (command === 'browser-access-set' || command === 'computer-access-set') { + if (values.enabled === values.disabled) + usage(`${command} needs exactly one of --enabled or --disabled.`); + } + if (command === 'access-set') { + if (values.mode !== 'workspace-write' && values.mode !== 'danger-full-access') + usage('--mode must be workspace-write or danger-full-access.'); + if (values.scope !== undefined && values.scope !== 'chat' && values.scope !== 'assignments') + usage('--scope must be chat or assignments.'); + if (values.mode === 'danger-full-access' && values['acknowledge-risk'] !== true) + usage( + 'Full access lets the Bot run any command and use the Browser and Computer without asking. Pass --acknowledge-risk to confirm.', + ); + } + if (command === 'settings-set') { + if (values['patch-stdin'] !== true) usage('settings-set requires --patch-stdin.'); + try { + const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error('not an object'); + patch = parsed as Record; + } catch { + usage('--patch-stdin must be a JSON object of settings fields.'); + } + reviewed = parseReviewedSettings(values, patch); + } + if (command === 'workspace-create' && !target.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(target)) + usage('workspace-create needs an absolute folder path.'); + const members = (values.member ?? []).map((member) => member.trim()).filter(Boolean); + const operationKey = + command === 'group-create' && values['operation-key'] !== undefined + ? channelOperationKey(values['operation-key']) + : undefined; + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const { rpc } = await connectLiveHost(values, io, signal); + if (command === 'browser-access-set' || command === 'computer-access-set') + return rpc(command === 'browser-access-set' ? 'browserAccessSet' : 'computerAccessSet', { + slug: target, + enabled: values.enabled === true, + }); + if (command === 'access') return rpc('assignmentAccessGet', { slug: target }); + if (command === 'access-set') + return rpc('assignmentAccessSet', { + slug: target, + mode: values.mode, + ...(values.scope === undefined ? {} : { scope: values.scope }), + ...(values.mode === 'danger-full-access' ? { acknowledgeRisk: true } : {}), + }); + if (command === 'workspace-create') return rpc('workspace/create', { request: { path: target } }); + if (command === 'group-create') + return rpc('channelCreate', { + name: target, + members, + ...(operationKey === undefined ? {} : { operationKey }), + }); + const described = record(await rpc('settings/describe', {})); + const namespaces = Array.isArray(described['namespaces']) ? described['namespaces'] : []; + const found = namespaces.map(record).find((entry) => entry['ns'] === target); + if (found === undefined) + throw new CliLiveError('unknown-settings', `The Host has no settings namespace ${target}.`); + if (command === 'settings') + return { ns: target, revision: found['revision'], value: found['value'] }; + if (reviewed !== undefined) { + const current = record(found['value']); + if ( + found['revision'] !== reviewed.expectedRevision || + !Object.entries(reviewed.previousValue).every(([key, value]) => + isDeepStrictEqual(current[key], value), + ) + ) + throw new CliLiveError( + 'stale-setting', + 'The reviewed settings changed; read and review the current value before retrying.', + ); + } + const updated = record( + await rpc('settings/update', { + ns: target, + patch, + expectedRevision: reviewed?.expectedRevision ?? found['revision'], + }), + ); + return { ns: target, revision: updated['revision'], value: updated['value'] }; +} + +function appSummary(value: unknown) { + const app = record(value); + const bot = record(app['bot']); + const error = app['error'] === null || app['error'] === undefined ? null : record(app['error']); + return { + id: app['botId'], + name: bot['name'] ?? app['name'], + state: app['state'], + connected: app['connected'] === true, + error: error === null ? null : { code: error['code'], message: error['message'] }, + ...(app['stats'] === undefined ? {} : { stats: app['stats'] }), + }; +} + +function connectionSummary(platform: string, value: unknown) { + const status = record(value); + return { + platform, + state: status['state'], + apps: (Array.isArray(status['bots']) ? status['bots'] : []).map(appSummary), + }; +} + +async function runImControlCommand( + command: (typeof IM_CONTROL_COMMANDS)[number], + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one target.`); + const target = rest[0].trim(); + const app = values.app?.trim() ?? ''; + const platformCommand = + command === 'im-app-add' || command === 'im-app-status' || command === 'im-app-remove'; + if (platformCommand && !TOKEN_IM_PLATFORMS.has(target)) + usage( + `${command} supports ${[...TOKEN_IM_PLATFORMS].join(', ')}; use im-authorize for feishu or weixin.`, + ); + let credentials: Record | undefined; + if (command === 'im-app-add') { + if (values['credentials-stdin'] !== true) usage('im-app-add requires --credentials-stdin.'); + try { + const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error('not an object'); + credentials = parsed as Record; + } catch { + usage('--credentials-stdin must be a JSON object, for example {"token":"…"}.'); + } + } + if ((command === 'im-app-remove' || command === 'im-bind' || command === 'im-unbind') && !app) + usage(`${command} requires --app .`); + if (command === 'channel-connect' && (!values.bot?.trim() || !values.conversation?.trim())) + usage('channel-connect requires --bot and --conversation .'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const { rpc } = await connectLiveHost(values, io, signal); + const provider = (method: string, payload: Record) => + rpc(`dsh-im/${target}`, { method, payload }); + if (command === 'im-app-add') + return connectionSummary(target, await provider('bot.bind-credentials', credentials!)); + if (command === 'im-app-status') + return connectionSummary(target, await provider('connection.status', {})); + if (command === 'im-app-remove') + return connectionSummary(target, await provider('bot.delete', { botId: app, confirm: true })); + if (command === 'channel-ingests') return rpc('channelIngests', { channelId: target }); + if (command === 'im-bind') { + const apps = record(await rpc('messagingApps', {})); + const found = (Array.isArray(apps['apps']) ? apps['apps'] : []) + .map(record) + .find((entry) => entry['ref'] === app); + if (found === undefined) + throw new CliLiveError('unknown-app', `The Host has no connected IM app ${app}.`); + return rpc('messagingIdentity', { + slug: target, + input: { + kind: 'bind', + providerId: found['providerId'], + accountRef: found['ref'], + fingerprint: found['fingerprint'], + }, + }); + } + const slug = command === 'channel-connect' ? values.bot!.trim() : target; + const snapshot = record(await rpc('messagingSnapshot', { slug })); + const identities = (Array.isArray(snapshot['identities']) ? snapshot['identities'] : []).map( + record, + ); + if (command === 'im-status') + return { + apps: (Array.isArray(snapshot['accounts']) ? snapshot['accounts'] : []) + .map(record) + .map((entry) => ({ + ref: entry['ref'], + platform: entry['platform'], + name: entry['name'], + connected: entry['connected'] === true, + })), + bindings: identities.map((entry) => ({ + id: entry['id'], + app: entry['accountRef'], + platform: entry['platform'], + name: entry['name'], + enabled: entry['enabled'], + availability: entry['availability'], + reception: entry['reception'], + conversations: entry['grantCount'], + })), + }; + const binding = identities.find((entry) => + command === 'channel-connect' + ? app === '' || entry['accountRef'] === app + : entry['accountRef'] === app, + ); + if (binding === undefined) + throw new CliLiveError( + 'unknown-binding', + command === 'channel-connect' + ? `Bot ${slug} has no IM app binding to connect through.` + : `Bot ${slug} is not bound to IM app ${app}.`, + ); + if (command === 'im-unbind') + return rpc('messagingIdentity', { + slug, + input: { kind: 'unbind', id: binding['id'], expectedRevision: binding['revision'] }, + }); + return rpc('channelIngest', { + channelId: target, + input: { + kind: 'add', + bindingId: binding['id'], + conversation: { kind: 'group', id: values.conversation!.trim() }, + }, + }); +} + +async function runDeviceCommand( + command: (typeof DEVICE_COMMANDS)[number], + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + const direct = command === 'browser-tool' || command === 'computer-tool'; + const computer = (command.startsWith('computer-') && !direct) || command === 'browser-tools'; + const positionals = direct ? 2 : computer ? 0 : 1; + if (rest.length !== positionals || rest.some((value) => !value.trim())) + usage( + direct + ? `${command} needs a bot id and a tool name.` + : computer + ? `${command} takes no positional arguments.` + : `${command} needs exactly one bot id.`, + ); + let toolArgs: Record = {}; + const input = command === 'browser-input' || command === 'computer-input'; + if (input && values['args-stdin'] !== true) + usage( + `${command} needs --args-stdin with a JSON input such as {"kind":"click","x":10,"y":20}.`, + ); + if ((direct || input) && values['args-stdin'] === true) { + try { + const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error('not an object'); + toolArgs = parsed as Record; + } catch { + usage('--args-stdin must be a JSON object of tool arguments.'); + } + } + if ( + direct && + values.out !== undefined && + !values.out.startsWith('/') && + !/^[A-Za-z]:[\\/]/u.test(values.out) + ) + usage('--out must be an absolute file path.'); + const slug = rest[0]?.trim() ?? ''; + if (command === 'browser-frame' || command === 'computer-frame') { + const out = values.out ?? ''; + if (!out.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(out)) + usage(`${command} requires --out .`); + } + if ( + (command === 'browser-takeover' || command === 'computer-takeover') && + values.enabled === values.disabled + ) + usage(`${command} needs exactly one of --enabled or --disabled.`); + if ( + command === 'computer-start' && + values.target !== undefined && + values.target !== 'local' && + values.target !== 'container' + ) + usage('--target must be local or container.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const { http } = await connectLiveHost(values, io, signal); + const json = async (path: string, body?: Record) => { + const { bytes } = await http(path, body); + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + throw new CliLiveError('host-protocol-error', `The Host returned no JSON for ${path}.`); + } + }; + let saved = false; + const savedImages = (items: unknown[]) => + items.map((value) => { + const item = record(value); + if (item['type'] !== 'image' || typeof item['data'] !== 'string') return item; + const bytes = Buffer.from(item['data'], 'base64'); + if (values.out !== undefined && !saved) { + writeFileSync(values.out, bytes, { mode: 0o600 }); + saved = true; + return { + type: 'image', + mimeType: item['mimeType'], + path: values.out, + bytes: bytes.byteLength, + }; + } + return { type: 'image', mimeType: item['mimeType'], bytes: bytes.byteLength }; + }); + if (command === 'browser-tools') return json('/api/browser/tools'); + if (command === 'computer-tools') return json('/api/computer/tools'); + if (command === 'computer-tool') { + const result = record( + await json('/api/computer/tool', { slug, tool: rest[1]!.trim(), args: toolArgs }), + ); + const inner = record(result['result']); + return Array.isArray(inner['content']) + ? { ...result, result: { ...inner, content: savedImages(inner['content']) } } + : result; + } + if (command === 'browser-tool') { + const result = record( + await json('/api/browser/tool', { slug, tool: rest[1]!.trim(), args: toolArgs }), + ); + return { + ...result, + content: savedImages(Array.isArray(result['content']) ? result['content'] : []), + }; + } + if (command === 'browser-open') return json('/api/browser/open', { slug }); + if (command === 'browser-takeover') + return json('/api/browser/takeover', { slug, active: values.enabled === true }); + if (command === 'browser-input') return json('/api/browser/input', { ...toolArgs, slug }); + if (command === 'browser-stop') return json('/api/browser/stop', { slug }); + if (command === 'computer-start') + return json('/api/computer/start', { + authorize: true, + ...(values.target === undefined ? {} : { target: values.target }), + }); + if (command === 'computer-status') return json('/api/computer/status'); + if (command === 'computer-stop') return json('/api/computer/stop', { authorize: true }); + if (command === 'computer-takeover') + return json('/api/computer/takeover', { active: values.enabled === true }); + if (command === 'computer-input') return json('/api/computer/input', toolArgs); + if (command === 'computer-frame') { + const { response, bytes: answer } = await http('/api/computer/frame'); + if (response.status === 204) + throw new CliLiveError('no-frame', 'The Computer has not produced a frame yet; try again.'); + let desktop: Record; + try { + desktop = record(JSON.parse(new TextDecoder().decode(answer))); + } catch { + throw new CliLiveError( + 'host-protocol-error', + 'The Host returned no JSON for /api/computer/frame.', + ); + } + if (typeof desktop['data'] !== 'string') + throw new CliLiveError('host-protocol-error', 'The Host returned no Computer frame.'); + const bytes = Buffer.from(desktop['data'], 'base64'); + writeFileSync(values.out!, bytes, { mode: 0o600 }); + return { + path: values.out, + bytes: bytes.byteLength, + mimeType: desktop['mimeType'], + width: desktop['width'], + height: desktop['height'], + seq: desktop['seq'], + target: desktop['target'], + }; + } + const frame = record(await json(`/api/browser/frame?slug=${encodeURIComponent(slug)}`)); + if (typeof frame['data'] !== 'string') + throw new CliLiveError('host-protocol-error', 'The Host returned no Browser frame.'); + const bytes = Buffer.from(frame['data'], 'base64'); + writeFileSync(values.out!, bytes, { mode: 0o600 }); + return { + path: values.out, + bytes: bytes.byteLength, + mimeType: frame['mimeType'], + ...(frame['viewport'] === undefined ? {} : { viewport: frame['viewport'] }), + }; +} + +const MEMORY_COMMANDS = new Set([ + 'memory-snapshot', + 'memory-file', + 'memory-image', + 'memory-history', + 'memory-diff', + 'memory-save', + 'memory-file-target', + 'memory-graph', + 'memory-commit-diff', + 'memory-changes', + 'memory-working-diff', + 'memory-checkpoints', + 'memory-restore', + 'memory-restore-commit', + 'memory-versions', + 'memory-version-name', + 'memory-version-unname', + 'memory-repair', + 'memory-move', +]); + +const SHA = /^[0-9a-f]{40}$/u; + +async function runMemoryCommand( + command: string, + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one bot id.`); + const slug = rest[0].trim(); + const channelId = dmChannelId(slug); + const required = (name: keyof LiveValues, flag: string): string => { + const value = values[name]; + if (typeof value !== 'string' || !value.trim()) usage(`${command} requires --${flag}.`); + return value.trim(); + }; + const sha = (): string => { + const value = required('sha', 'sha '); + if (!SHA.test(value)) usage('--sha must be a 40-character lowercase commit sha.'); + return value; + }; + const expectedHead = (): string => { + const value = required('expected-head', 'expected-head '); + if (!SHA.test(value)) usage('--expected-head must be a 40-character lowercase commit sha.'); + return value; + }; + let method: string; + let args: Record; + switch (command) { + case 'memory-snapshot': + [method, args] = ['memorySnapshot', { channelId }]; + break; + case 'memory-image': { + const path = required('path', 'path '); + const source = required('image-source', 'image-source worktree|index|'); + const revision = required('image-revision', 'image-revision '); + const out = required('out', 'out '); + if (!isAbsolute(out)) usage('--out must be an absolute path.'); + const host = await connectLiveHost( + values, + io, + AbortSignal.timeout(timeoutMs(values.timeout)), + ); + const reference = values['image-reference']; + const { bytes, response } = await host.http( + '/api/botharness/memory/image?' + + new URLSearchParams({ + slug, + path, + source, + revision, + ...(reference === undefined ? {} : { reference }), + }), + ); + const mime = response.headers.get('content-type'); + if ( + !['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'].includes( + mime ?? '', + ) + ) + throw new CliLiveError('host-protocol-error', 'Expected a supported Memory image'); + writeFileSync(out, bytes, { flag: 'wx' }); + return { + path, + source, + revision, + ...(reference === undefined ? {} : { reference }), + mime, + bytes: bytes.length, + out, + }; + } + case 'memory-file': + [method, args] = ['memoryFile', { channelId, path: required('path', 'path ') }]; + break; + case 'memory-history': + [method, args] = ['memoryHistory', { channelId }]; + break; + case 'memory-diff': + [method, args] = ['memoryDiff', { channelId, sha: sha() }]; + break; + case 'memory-file-target': + [method, args] = ['memoryFileTarget', { slug, path: required('path', 'path ') }]; + break; + case 'memory-graph': { + const offset = values.offset === undefined ? 0 : Number(values.offset); + if (!Number.isInteger(offset) || offset < 0 || offset > 10_000) + usage('--offset must be an integer from 0 to 10000.'); + [method, args] = ['memoryGitGraph', { channelId, offset }]; + break; + } + case 'memory-commit-diff': + [method, args] = ['memoryGitCommitDiff', { channelId, sha: sha() }]; + break; + case 'memory-changes': + [method, args] = ['memoryWorkingChanges', { channelId }]; + break; + case 'memory-working-diff': { + const kind = required('kind', 'kind staged|unstaged|untracked|current'); + if (!['staged', 'unstaged', 'untracked', 'current'].includes(kind)) + usage('--kind must be staged, unstaged, untracked or current.'); + [method, args] = [ + 'memoryWorkingDiff', + { channelId, path: required('path', 'path '), kind }, + ]; + break; + } + case 'memory-checkpoints': + [method, args] = ['memoryRecoveryHistory', { channelId }]; + break; + case 'memory-restore': + [method, args] = [ + 'memoryRestore', + { + channelId, + checkpointId: required('checkpoint', 'checkpoint '), + expectedCurrentId: required('expected-current', 'expected-current '), + }, + ]; + break; + case 'memory-restore-commit': + [method, args] = [ + 'memoryRestoreCommit', + { + channelId, + sha: sha(), + expectedHead: expectedHead(), + restoreId: values['restore-id']?.trim() || randomUUID(), + ...(values.name === undefined ? {} : { name: values.name }), + }, + ]; + break; + case 'memory-versions': + [method, args] = ['memoryVersions', { channelId }]; + break; + case 'memory-version-name': + [method, args] = [ + 'memoryNameVersion', + { channelId, sha: sha(), name: required('name', 'name ') }, + ]; + break; + case 'memory-version-unname': + [method, args] = [ + 'memoryRemoveVersionName', + { channelId, tag: required('tag', 'tag ') }, + ]; + break; + case 'memory-move': { + const location = values['memory-location']?.trim(); + if ((location === undefined || location === '') === (values['default-location'] !== true)) + usage( + 'memory-move needs --memory-location or --default-location, not both.', + ); + if (location !== undefined && location !== '' && !isAbsolute(location)) + usage('--memory-location must be an absolute folder path.'); + [method, args] = [ + 'memoryMove', + { slug, location: location === undefined || location === '' ? null : location }, + ]; + break; + } + case 'memory-repair': + [method, args] = [ + 'memoryRepair', + { + channelId, + expectedHead: expectedHead(), + repairId: values['repair-id']?.trim() || randomUUID(), + }, + ]; + break; + default: { + const path = required('path', 'path '); + const head = expectedHead(); + let body = values.body; + if (values['body-stdin'] === true) body = await (io.readStdin?.() ?? Promise.resolve('')); + if (body === undefined) usage('memory-save needs --body or --body-stdin.'); + [method, args] = [ + 'memorySave', + { + channelId, + path, + body, + expectedHead: head, + editId: values['edit-id']?.trim() || randomUUID(), + }, + ]; + } + } + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc(method, args); +} + +export interface ReviewedSettings { + expectedRevision: number; + previousValue: Record; +} + +export function parseReviewedSettings( + values: { 'expected-revision'?: string; 'previous-value'?: string }, + patch: Record, +): ReviewedSettings | undefined { + const revision = values['expected-revision']; + const previous = values['previous-value']; + if (revision === undefined && previous === undefined) return undefined; + if (revision === undefined || previous === undefined) + usage('--expected-revision and --previous-value must be supplied together.'); + if (!/^(0|[1-9][0-9]*)$/u.test(revision) || !Number.isSafeInteger(Number(revision))) + usage('--expected-revision must be a nonnegative safe integer.'); + let previousValue: unknown; + try { + previousValue = JSON.parse(previous); + } catch { + usage('--previous-value must be a JSON object of the reviewed settings fields.'); + } + if (typeof previousValue !== 'object' || previousValue === null || Array.isArray(previousValue)) + usage('--previous-value must be a JSON object of the reviewed settings fields.'); + const keys = Object.keys(patch).sort(); + if (keys.length === 0 || !isDeepStrictEqual(keys, Object.keys(previousValue).sort())) + usage('--previous-value must contain exactly the fields in the nonempty patch.'); + return { + expectedRevision: Number(revision), + previousValue: previousValue as Record, + }; +} diff --git a/packages/core/src/database/schema-plan.ts b/packages/core/src/database/schema-plan.ts index 8b6a836c27..52b4466905 100644 --- a/packages/core/src/database/schema-plan.ts +++ b/packages/core/src/database/schema-plan.ts @@ -2466,6 +2466,21 @@ const PUSH_APP_RENDER_MIGRATION: SchemaMigration = { }, }; +const DEVICE_NOTIFICATION_PREFERENCE_MIGRATION: SchemaMigration = { + generation: 106, + module: 'push', + description: 'Keep the device-owned notification preference copy without a push token', + migrate(database) { + database.exec(` + CREATE TABLE device_notification_preferences ( + device_id TEXT PRIMARY KEY, + preferences_json TEXT NOT NULL + ); + INSERT INTO device_notification_preferences SELECT device_id, preferences_json FROM push_devices; + `); + }, +}; + export const BOT_HARNESS_SCHEMA_PLAN = defineSchemaPlan([ SESSION_OWNERSHIP_MIGRATION, MESSAGING_TRACER_MIGRATION, @@ -2571,4 +2586,5 @@ export const BOT_HARNESS_SCHEMA_PLAN = defineSchemaPlan([ USAGE_IDLE_COMPACTION_MIGRATION, THREE_ACTION_APPROVAL_MIGRATION, PUSH_APP_RENDER_MIGRATION, + DEVICE_NOTIFICATION_PREFERENCE_MIGRATION, ]); diff --git a/packages/core/src/push/delivery.ts b/packages/core/src/push/delivery.ts index ed1b7c9043..052856076e 100644 --- a/packages/core/src/push/delivery.ts +++ b/packages/core/src/push/delivery.ts @@ -301,10 +301,32 @@ export function createPushDelivery(options: PushDeliveryOptions): PushDelivery { | undefined, ); + const preferenceCopy = (deviceId: string): string | undefined => + database.read( + (db) => + ( + db + .prepare( + 'SELECT preferences_json FROM device_notification_preferences WHERE device_id = ?', + ) + .get(deviceId) as { preferences_json: string } | undefined + )?.preferences_json, + ); + + const settings = (deviceId: string): PushSettings => { + const current = row(deviceId); + const copy = preferenceCopy(deviceId); + return { + ...settingsOf(current), + preferences: preferencesOf(copy ?? current?.preferences_json), + }; + }; + const drop = (deviceId: string): boolean => database.transaction( (db) => { db.prepare('DELETE FROM push_sent WHERE device_id = ?').run(deviceId); + db.prepare('DELETE FROM device_notification_preferences WHERE device_id = ?').run(deviceId); return Number( db.prepare('DELETE FROM push_devices WHERE device_id = ?').run(deviceId).changes, ); @@ -322,7 +344,10 @@ export function createPushDelivery(options: PushDeliveryOptions): PushDelivery { ['push'], ); record({ phase: 'preferences', deviceId: current.device_id }); - return settingsOf({ ...current, preferences_json: JSON.stringify(next) }); + return { + ...settingsOf({ ...current, preferences_json: JSON.stringify(next) }), + preferences: preferencesOf(preferenceCopy(current.device_id) ?? JSON.stringify(next)), + }; }; const dropToken = (deviceId: string, code: 'device-revoked' | 'device-not-registered'): void => { @@ -385,7 +410,9 @@ export function createPushDelivery(options: PushDeliveryOptions): PushDelivery { dropToken(device.device_id, 'device-revoked'); continue; } - const preferences = preferencesOf(device.preferences_json); + const preferences = preferencesOf( + preferenceCopy(device.device_id) ?? device.preferences_json, + ); const enabled = enabledOf(device.preferences_json); const quiet = new Set(quietOf(device.preferences_json)); const registeredAt = Date.parse(device.registered_at); @@ -523,22 +550,29 @@ export function createPushDelivery(options: PushDeliveryOptions): PushDelivery { }, settings(deviceId) { - return settingsOf(row(deviceId)); + return settings(deviceId); }, setPreferences(deviceId, input, enabled) { const current = row(deviceId); - if (current === undefined) return settingsOf(undefined); - const next = preferencesOf(current.preferences_json); + const next = settings(deviceId).preferences; const patch = typeof input === 'object' && input !== null ? input : {}; for (const kind of PUSH_KINDS) { const value = (patch as Record)[kind]; if (typeof value === 'boolean') next[kind] = value; } - return save(current, { - ...next, - enabled: typeof enabled === 'boolean' ? enabled : enabledOf(current.preferences_json), - }); + database.transaction( + (db) => + db + .prepare( + 'INSERT INTO device_notification_preferences (device_id, preferences_json) VALUES (?, ?) ON CONFLICT (device_id) DO UPDATE SET preferences_json = excluded.preferences_json', + ) + .run(deviceId, JSON.stringify(next)), + ['push'], + ); + if (current !== undefined && typeof enabled === 'boolean') save(current, { enabled }); + else record({ phase: 'preferences', deviceId }); + return settings(deviceId); }, setQuiet(deviceId, input) { diff --git a/packages/core/src/push/http.ts b/packages/core/src/push/http.ts index a34f8cfc9b..dd03f44f44 100644 --- a/packages/core/src/push/http.ts +++ b/packages/core/src/push/http.ts @@ -34,10 +34,25 @@ export function createDevicePushHttp( : { status: 400, body: { code: result.code } }; } case `${DEVICE_PUSH_PATH}/preferences`: { + const preferences = body['preferences']; + if ( + preferences !== undefined && + (typeof preferences !== 'object' || + preferences === null || + Array.isArray(preferences) || + Object.entries(preferences).some( + ([key, value]) => + !['approval', 'question', 'private-input', 'reply'].includes(key) || + typeof value !== 'boolean', + )) + ) + return { status: 400, body: { code: 'invalid-input' } }; + if (body['enabled'] !== undefined && typeof body['enabled'] !== 'boolean') + return { status: 400, body: { code: 'invalid-input' } }; + if (preferences === undefined && !delivery.settings(deviceId).registered) + return { status: 409, body: { code: 'push-not-registered' } }; const settings = delivery.setPreferences(deviceId, body['preferences'], body['enabled']); - return settings.registered - ? { status: 200, body: settings } - : { status: 409, body: { code: 'push-not-registered' } }; + return { status: 200, body: settings }; } case `${DEVICE_PUSH_PATH}/quiet`: { const result = delivery.setQuiet(deviceId, { diff --git a/packages/core/test/appearance-control.test.ts b/packages/core/test/appearance-control.test.ts index cd5585a9e1..b39101724b 100644 --- a/packages/core/test/appearance-control.test.ts +++ b/packages/core/test/appearance-control.test.ts @@ -7,6 +7,33 @@ const request = (body: unknown) => }); afterEach(() => vi.useRealTimers()); describe('explicit Client appearance delivery', () => { + it('forwards notification choices only to the named Client without persisting them', async () => { + const host = createAppearanceControl(); + const lease = await (await host.fetch(request({ action: 'register', platform: 'web' }))).json(); + const poll = host.fetch(request({ action: 'poll', ...lease })); + await Promise.resolve(); + const notifications = { hostId: 'remote', patch: { enabled: false } }; + const response = host.fetch( + request({ + action: 'request', + clientId: lease.clientId, + notifications, + expectedRevision: 'before', + }), + ); + const { command } = await (await poll).json(); + expect(command).toMatchObject({ notifications, expectedRevision: 'before' }); + await host.fetch( + request({ + action: 'reply', + ...lease, + requestId: command.id, + result: { error: { code: 'notification-revision-stale' } }, + }), + ); + expect((await response).status).toBe(409); + host.dispose(); + }); it('delivers only to the selected leased Client, relays storage refusal, and drops targets on disposal', async () => { const host = createAppearanceControl(); const a = await (await host.fetch(request({ action: 'register', platform: 'web' }))).json(); diff --git a/packages/core/test/cli-browser-notifications.test.ts b/packages/core/test/cli-browser-notifications.test.ts new file mode 100644 index 0000000000..e942d58200 --- /dev/null +++ b/packages/core/test/cli-browser-notifications.test.ts @@ -0,0 +1,36 @@ +import { expect, it, vi } from 'vitest'; +import { runBrowserNotifications } from '../src/bots/cli-browser-notifications.js'; + +it('targets the selected live Client and Host with a revision, without requesting OS permission', async () => { + const http = vi.fn(async () => ({ bytes: new TextEncoder().encode('{"enabled":false}') })); + const connect = async () => ({ http }); + await runBrowserNotifications('browser-notification-clients', [], {}, {}, connect); + expect(http).toHaveBeenLastCalledWith('/api/botharness/appearance-control', undefined); + const target = { 'client-id': 'browser-a', 'remote-host-id': 'host-b' }; + const patch = { + enabled: false, + preferences: { approval: false, question: true, 'private-input': true, reply: true }, + }; + await runBrowserNotifications( + 'browser-notifications', + [], + { ...target, 'patch-stdin': true, 'expected-revision': 'rev' }, + { readStdin: async () => JSON.stringify(patch) }, + connect, + ); + expect(http).toHaveBeenLastCalledWith('/api/botharness/appearance-control', { + action: 'request', + clientId: 'browser-a', + notifications: { hostId: 'host-b', patch }, + expectedRevision: 'rev', + }); + await expect( + runBrowserNotifications( + 'browser-notifications', + [], + { ...target, 'patch-stdin': true }, + {}, + connect, + ), + ).rejects.toMatchObject({ code: 'usage' }); +}); diff --git a/packages/core/test/cli-device-notifications.test.ts b/packages/core/test/cli-device-notifications.test.ts new file mode 100644 index 0000000000..754502c899 --- /dev/null +++ b/packages/core/test/cli-device-notifications.test.ts @@ -0,0 +1,73 @@ +import { createServer } from 'node:http'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { runBotCreateCli } from '../src/bots/bot-create-cli.js'; +import { createTempRoot } from './helpers.js'; + +it('reads and writes the admitted device copy with secrets only in a file, and reports coded refusals', async () => { + const calls: Array<{ method: string | undefined; url: string | undefined; body: string }> = []; + let refused = false; + const server = createServer(async (req, res) => { + expect(req.headers.authorization).toBe('Device paired.private-secret'); + expect(req.headers.cookie).toBeUndefined(); + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk); + calls.push({ method: req.method, url: req.url, body: Buffer.concat(chunks).toString() }); + res.writeHead(refused ? 401 : 200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify( + refused + ? { code: 'device-revoked' } + : { + registered: false, + preferences: { approval: false, question: true, 'private-input': true, reply: true }, + }, + ), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + const file = join(createTempRoot('device-cli-'), 'pairing.json'); + writeFileSync( + file, + JSON.stringify({ + hostId: 'host', + hostName: 'Host', + deviceId: 'paired', + credential: 'paired.private-secret', + routes: [`http://127.0.0.1:${address.port}`], + }), + ); + const preferences = { approval: false, question: true, 'private-input': true, reply: true }; + const run = async (args: string[], input = '') => { + const output: string[] = []; + const errors: string[] = []; + const code = await runBotCreateCli(['device-notifications', '--device-file', file, ...args], { + env: {}, + readStdin: async () => input, + stdout: (text) => output.push(text), + stderr: (text) => errors.push(text), + }); + expect(output).toHaveLength(1); + expect(output[0]).not.toContain('private-secret'); + expect(errors.join('')).not.toContain('private-secret'); + return { code, value: JSON.parse(output[0]!) }; + }; + try { + expect((await run([])).value.preferences).toEqual(preferences); + expect((await run(['--preferences-stdin'], JSON.stringify(preferences))).code).toBe(0); + expect(calls[1]).toEqual({ + method: 'POST', + url: '/botharness-device/push/preferences', + body: JSON.stringify({ preferences }), + }); + expect((await run(['--preferences-stdin'], '{}')).code).toBe(1); + refused = true; + expect((await run([])).value).toMatchObject({ error: { code: 'device-revoked' } }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/packages/core/test/push-delivery.test.ts b/packages/core/test/push-delivery.test.ts index 82d94f3150..5b19ea81ee 100644 --- a/packages/core/test/push-delivery.test.ts +++ b/packages/core/test/push-delivery.test.ts @@ -124,6 +124,48 @@ const approval = (id: string, createdAt: string): PushCandidate => ({ }); describe('Push delivery', () => { + it('accepts a device preference before token registration and uses the copy only for sends', async () => { + const f = fixture(); + const phone = f.device('Phone'); + const http = createDevicePushHttp(f.push); + const answer = await http({ + method: 'POST', + path: '/botharness-device/push/preferences', + deviceId: phone.device.id, + body: { preferences: { approval: false } }, + }); + expect(answer).toMatchObject({ + status: 200, + body: { registered: false, preferences: { approval: false } }, + }); + f.push.register(phone.device.id, { token: TOKEN_A, platform: 'ios' }); + expect(f.push.settings(phone.device.id).preferences.approval).toBe(false); + f.advance(1000); + f.setInbox([approval('blocked', f.at(0))]); + await f.push.reconcile(); + expect(f.expo.flat().filter((message) => message.data.itemId)).toEqual([]); + expect(f.push.settings(phone.device.id).registered).toBe(true); + f.push.setPreferences(phone.device.id, { approval: true }); + f.advance(1000); + f.setInbox([approval('new', f.at(0))]); + await f.push.reconcile(); + expect( + f.expo + .flat() + .filter((message) => message.data.itemId) + .map((message) => message.data.itemId), + ).toEqual(['new']); + expect( + ( + await http({ + method: 'POST', + path: '/botharness-device/push/preferences', + deviceId: phone.device.id, + body: { preferences: { approval: 'false' } }, + }) + ).status, + ).toBe(400); + }); it('sends one notification per enabled device for a new approval', async () => { const f = fixture(); const phone = f.device('Phone'); diff --git a/scripts/cli-ui-inventory.json b/scripts/cli-ui-inventory.json index 3bb9df83ba..f242162b21 100644 --- a/scripts/cli-ui-inventory.json +++ b/scripts/cli-ui-inventory.json @@ -783,6 +783,17 @@ ], "commands": ["window-zoom-clients", "window-zoom-get", "window-zoom-set", "window-zoom-reset"] }, + { + "id": "remote-host-notifications", + "description": "Browser-owned Remote Host notification choices on an explicit online Client; phone send copies use device authentication.", + "scope": "Client / Paired Device", + "surfaces": [ + "storage:packages/client/src/client/remote-notifications.ts:setItem(REMOTE_NOTIFICATION_KEY)", + "setting:packages/client/src/client/remote-notification-settings.tsx:{t('remoteHosts.notifications.enable')}", + "setting:packages/client/src/client/remote-notification-settings.tsx:{t(labels[kind])}" + ], + "commands": ["browser-notification-clients", "browser-notifications", "device-notifications"] + }, { "id": "deepseekbot-local-appearance", "description": "Explicit Client-targeted device-local DeepSeekBot themes; authenticated Host forwards requests without a preference mirror.", From d838c3dffaaab2aa8f1d4d372d1453d9ec5c789e Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 00:49:12 +0900 Subject: [PATCH 02/18] fix: preserve icon Host menus and cover preference lifecycle Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../features/remote-hosts.md | 108 +++++++++--------- .github/pr-screenshots/n4/b-d.png | Bin 0 -> 71144 bytes .github/pr-screenshots/n4/b-l.png | Bin 0 -> 70297 bytes apps/mobile/src/ui/strings.ts | 5 +- .../device-notification-preferences.test.ts | 13 +++ apps/mobile/test/host-session-unpair.test.ts | 7 ++ .../test/notification-settings.test.tsx | 26 +++++ packages/client/src/client/host-tabs.tsx | 3 +- 8 files changed, 105 insertions(+), 57 deletions(-) create mode 100644 .github/pr-screenshots/n4/b-d.png create mode 100644 .github/pr-screenshots/n4/b-l.png diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 0cb4968d8d..ece14e1dd8 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -1,54 +1,54 @@ -# Browser Remote Hosts - -The browser pairs a Remote Host as a Paired Device and reads its Host Feed through the shared mobile Host client. This Host remains the management context. - -## Sub-features - -- `pair`: Add remote redeems the other Host's pairing link. -- `tabs`: the icon strip filters All, This host and each Remote Host; tooltips name the Host, and Chinese uses 全部、本机、远程. -- `open`: a remote row opens a Host-scoped conversation. -- `avatars`: remote Bot portraits and group images/member stacks appear in rows and conversation headers; Bot message authors and live drafts use the same remote portrait. -- `activity-unread`: remote Bot rows display received activity; DM/group unread counts come from their Host Inbox. -- `read`: a visible remote conversation at its latest message marks that message read on its owning Host and refreshes the shared Inbox. -- `message-body`: remote images, files, rich text and request cards use the existing message body, with authenticated resources and decisions on the source Host. -- `upgrade`: existing browser pairings and the selected tab survive the storage-key rename. -- `remove`: removing a Host forgets its rows, stops its Inbox and releases cached avatar object URLs. -- `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`: select a Remote Host tab and open Remote notifications (bell); 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. - -## How to get to it (user POV) - -Enter Bot mode, then Messages. Open the channel header's add menu and choose Add remote. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host icon, then a Bot or group row. Right-click the Host icon and choose Remove to remove it. A remote Bot DM with Browser or Computer access shows the corresponding header button. Open its viewer, choose Pause Bot and take control, interact with the screen or send text, then Resume Bot or Close viewer. Groups have no viewer entry. - -## Driving it with verify-app - -Launch two independently named instances with `node scripts/verify-app.mjs launch --name `. Run `doctor` for each. Open both local login URLs. Pair from Host A's browser using the link created on Host B. Verify the new tab, All rows, remote conversation header and canonical device record on Host B. Capture tabs and the Add remote dialog in English and Chinese, light and dark. For the rename baseline, run the pre-change Client with the same browser storage, upgrade, and verify the pairing and selected Host remain without another offer. - -Read the browser console and run `node scripts/verify-app.mjs observe --name ` after startup and relevant actions. For a reply test select `deepseek-v4-pro`, send a uniquely marked message and compare the visible response to Host B's canonical timeline. Inspect the remote timeline in reverse DOM order: newest is first. - -For avatars, give Host B one uploaded Bot image, one seeded or composed Bot, a group with those members and a group with an uploaded image. Open each from All and the Host icon. Compare the row, header and Bot message author with Host B's canonical roster and timeline. A direct `bot-tool channel_send` needs an existing trusted Source Event in that Channel; seed a Human group message with the members' wake policies set to silent before the direct call. Inspect the browser's device-authorized avatar GET and CORS response, the loaded object-URL image dimensions and both themes. Equal slugs on Host A and Host B must retain Host B's portrait, including group member stacks. Change Host B's avatar and verify the visible portrait updates; remove the Remote Host and verify its rows and portraits disappear. Streaming draft authors also use the portrait; mark the live-model draft path untested unless driven with the specified model. - -For viewers, start a disposable Browser and Computer on Host B through the existing CLI. Open a harmless fixture in each; capture the actual remote frame in light and dark, take control and verify a click/text change against `browser-observation` / `browser-frame` or `computer-frame`. Confirm Host A is untouched, and inspect the gateway requests: frame/input HTTP uses the device header without cookies; the Host Feed mux uses a one-time stream ticket. Close during takeover, switch between same-slug Bots on different Hosts, stop/restart Host B, and revoke the browser's device credential. Frames and input must stop after disposal/revocation; reconnect must not replay input. Preserve actual missing runtime prerequisites as blockers rather than replacing the frame with a fixture response. The Computer may use an isolated container; this does not qualify the Human's local desktop. - -For control races, delay an input response, queue text, Resume, then take control again before completing the first response: the old queued text must not arrive. Interrupt only frame requests while the Feed remains online; recovered frames must be read-only and require another takeover. Delay a takeover response after the Host accepts it, then Remove the Host: input stops immediately and release must precede unpair. If cleanup cannot finish within five seconds, removal continues; Browser may require the Host operator to resume it. - -Keep the remote screen stationary during frame recovery. The Host must keep its sequence unchanged and answer matching `after` polls with 204 after ten seconds; the viewer recovers by fetching the current frame without that stale cursor, not by requiring a screen change. In both UI languages, inject a frame/input failure: the Web panel must show localized recovery guidance and a reconnect action, while the viewer snapshot retains the original diagnostic notice rather than rendering it as product copy. - -For Remote settings, use two disposable Hosts with different home basenames and distinguishable model presets and chat data. Open Host B's icon menu in Host A's browser. Capture the menu and all three settings sections in light and dark. Change B's default model or a preset, then read both Hosts with `model-catalog` and `model-presets`; A must be unchanged. Compare Usage and Memory with B's `overview-usage` and `overview-memory`. In Storage, select a disposable chat, preview media or whole messages, verify Clear/Delete is disabled before acknowledgement, then confirm. Read B's storage and timeline and verify A's data is unchanged. Exercise both cleanup targets; use their matching CLI preview/confirm commands against B as well. Disconnect B and revoke its browser pairing in separate runs: settings must show an unavailable state or refusal, with no local data fallback or replayed write. Never clean up real conversations for this journey. - -The default-model proof must change the model value on a Profile whose native settings owner can save it; a same-value no-op is insufficient. Keep a page's observed revision, change the default from another client, and verify the old page refuses without overwriting it. Explicit refresh must permit a subsequent save. Omit the catalog revision at the test transport boundary to verify the shared client refuses before any write; label that case as fault injection. For existing presets, verify Enter plus blur increments the revision once, a later blur saves independently, composition Enter does not submit, and a delayed write remains on its original Host/preset after closing and switching Hosts. Preserve the draft/error on a rejected save. New presets retain an explicit create action. Reject a remote storage preview token on the other Host, and verify neither cleanup command proceeds without its confirmation flag. - -For message bodies, upload an image and a text file to Host B through its attachment API and send them in a disposable Channel. Open that Channel from Host A: the image may load immediately, but files and audio must not fetch until Download or Play is selected. Repeated pending clicks must share one request; downloading audio must not start playback. Verify that GET requests use Host B's device gateway. Switch source or close during a delayed response and verify that it cannot trigger a download or playback afterward. Refuse or remove source access and confirm the conversation retains its title and usable Close control without a timeline, composer or viewer. Request a real tool approval with `deepseek-v4-pro`, decide its card from Host A, and compare Host B's decision record with the unchanged Host A history. Capture the same conversation in light and dark before and after the renderer change. Switch between Hosts and Channels to verify draft/card isolation and media cleanup. A revoked device must never load an attachment through Host A instead. - -## Gotchas - -- The workspace hides `data-panel-empty-action` descendants. Viewer reconnect must stay in its own controls; verify the button is visible and clickable inside the real workspace, not only in a standalone panel render. -- Remote avatars consume the authenticated image cache; This Host retains its current read adapter. -- With unread messages on both Hosts, compare each row and tab with `channelUnreadCounts`. Open the remote DM/group, verify its count clears on that Host while the other Host stays unread. Keep an active Bot running to inspect its activity indicator. Stop only the task-owned remote Host and verify cached rows/counts remain with an offline tab. -- Activity timestamps are receipt times; they are not authoritative tool start times. -- The remote message renderer replaces the earlier text-and-attachment-names-only view. Qualify images and cards through the two-Host path above; test results remain separate from Human acceptance. -- 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. +# Browser Remote Hosts + +The browser pairs a Remote Host as a Paired Device and reads its Host Feed through the shared mobile Host client. This Host remains the management context. + +## Sub-features + +- `pair`: Add remote redeems the other Host's pairing link. +- `tabs`: the icon strip filters All, This host and each Remote Host; tooltips name the Host, and Chinese uses 全部、本机、远程. +- `open`: a remote row opens a Host-scoped conversation. +- `avatars`: remote Bot portraits and group images/member stacks appear in rows and conversation headers; Bot message authors and live drafts use the same remote portrait. +- `activity-unread`: remote Bot rows display received activity; DM/group unread counts come from their Host Inbox. +- `read`: a visible remote conversation at its latest message marks that message read on its owning Host and refreshes the shared Inbox. +- `message-body`: remote images, files, rich text and request cards use the existing message body, with authenticated resources and decisions on the source Host. +- `upgrade`: existing browser pairings and the selected tab survive the storage-key rename. +- `remove`: removing a Host forgets its rows, stops its Inbox and releases cached avatar object URLs. +- `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. + +## How to get to it (user POV) + +Enter Bot mode, then Messages. Open the channel header's add menu and choose Add remote. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host icon, then a Bot or group row. Right-click the Host icon and choose Remove to remove it. A remote Bot DM with Browser or Computer access shows the corresponding header button. Open its viewer, choose Pause Bot and take control, interact with the screen or send text, then Resume Bot or Close viewer. Groups have no viewer entry. + +## Driving it with verify-app + +Launch two independently named instances with `node scripts/verify-app.mjs launch --name `. Run `doctor` for each. Open both local login URLs. Pair from Host A's browser using the link created on Host B. Verify the new tab, All rows, remote conversation header and canonical device record on Host B. Capture tabs and the Add remote dialog in English and Chinese, light and dark. For the rename baseline, run the pre-change Client with the same browser storage, upgrade, and verify the pairing and selected Host remain without another offer. + +Read the browser console and run `node scripts/verify-app.mjs observe --name ` after startup and relevant actions. For a reply test select `deepseek-v4-pro`, send a uniquely marked message and compare the visible response to Host B's canonical timeline. Inspect the remote timeline in reverse DOM order: newest is first. + +For avatars, give Host B one uploaded Bot image, one seeded or composed Bot, a group with those members and a group with an uploaded image. Open each from All and the Host icon. Compare the row, header and Bot message author with Host B's canonical roster and timeline. A direct `bot-tool channel_send` needs an existing trusted Source Event in that Channel; seed a Human group message with the members' wake policies set to silent before the direct call. Inspect the browser's device-authorized avatar GET and CORS response, the loaded object-URL image dimensions and both themes. Equal slugs on Host A and Host B must retain Host B's portrait, including group member stacks. Change Host B's avatar and verify the visible portrait updates; remove the Remote Host and verify its rows and portraits disappear. Streaming draft authors also use the portrait; mark the live-model draft path untested unless driven with the specified model. + +For viewers, start a disposable Browser and Computer on Host B through the existing CLI. Open a harmless fixture in each; capture the actual remote frame in light and dark, take control and verify a click/text change against `browser-observation` / `browser-frame` or `computer-frame`. Confirm Host A is untouched, and inspect the gateway requests: frame/input HTTP uses the device header without cookies; the Host Feed mux uses a one-time stream ticket. Close during takeover, switch between same-slug Bots on different Hosts, stop/restart Host B, and revoke the browser's device credential. Frames and input must stop after disposal/revocation; reconnect must not replay input. Preserve actual missing runtime prerequisites as blockers rather than replacing the frame with a fixture response. The Computer may use an isolated container; this does not qualify the Human's local desktop. + +For control races, delay an input response, queue text, Resume, then take control again before completing the first response: the old queued text must not arrive. Interrupt only frame requests while the Feed remains online; recovered frames must be read-only and require another takeover. Delay a takeover response after the Host accepts it, then Remove the Host: input stops immediately and release must precede unpair. If cleanup cannot finish within five seconds, removal continues; Browser may require the Host operator to resume it. + +Keep the remote screen stationary during frame recovery. The Host must keep its sequence unchanged and answer matching `after` polls with 204 after ten seconds; the viewer recovers by fetching the current frame without that stale cursor, not by requiring a screen change. In both UI languages, inject a frame/input failure: the Web panel must show localized recovery guidance and a reconnect action, while the viewer snapshot retains the original diagnostic notice rather than rendering it as product copy. + +For Remote settings, use two disposable Hosts with different home basenames and distinguishable model presets and chat data. Open Host B's icon menu in Host A's browser. Capture the menu and all three settings sections in light and dark. Change B's default model or a preset, then read both Hosts with `model-catalog` and `model-presets`; A must be unchanged. Compare Usage and Memory with B's `overview-usage` and `overview-memory`. In Storage, select a disposable chat, preview media or whole messages, verify Clear/Delete is disabled before acknowledgement, then confirm. Read B's storage and timeline and verify A's data is unchanged. Exercise both cleanup targets; use their matching CLI preview/confirm commands against B as well. Disconnect B and revoke its browser pairing in separate runs: settings must show an unavailable state or refusal, with no local data fallback or replayed write. Never clean up real conversations for this journey. + +The default-model proof must change the model value on a Profile whose native settings owner can save it; a same-value no-op is insufficient. Keep a page's observed revision, change the default from another client, and verify the old page refuses without overwriting it. Explicit refresh must permit a subsequent save. Omit the catalog revision at the test transport boundary to verify the shared client refuses before any write; label that case as fault injection. For existing presets, verify Enter plus blur increments the revision once, a later blur saves independently, composition Enter does not submit, and a delayed write remains on its original Host/preset after closing and switching Hosts. Preserve the draft/error on a rejected save. New presets retain an explicit create action. Reject a remote storage preview token on the other Host, and verify neither cleanup command proceeds without its confirmation flag. + +For message bodies, upload an image and a text file to Host B through its attachment API and send them in a disposable Channel. Open that Channel from Host A: the image may load immediately, but files and audio must not fetch until Download or Play is selected. Repeated pending clicks must share one request; downloading audio must not start playback. Verify that GET requests use Host B's device gateway. Switch source or close during a delayed response and verify that it cannot trigger a download or playback afterward. Refuse or remove source access and confirm the conversation retains its title and usable Close control without a timeline, composer or viewer. Request a real tool approval with `deepseek-v4-pro`, decide its card from Host A, and compare Host B's decision record with the unchanged Host A history. Capture the same conversation in light and dark before and after the renderer change. Switch between Hosts and Channels to verify draft/card isolation and media cleanup. A revoked device must never load an attachment through Host A instead. + +## Gotchas + +- The workspace hides `data-panel-empty-action` descendants. Viewer reconnect must stay in its own controls; verify the button is visible and clickable inside the real workspace, not only in a standalone panel render. +- Remote avatars consume the authenticated image cache; This Host retains its current read adapter. +- With unread messages on both Hosts, compare each row and tab with `channelUnreadCounts`. Open the remote DM/group, verify its count clears on that Host while the other Host stays unread. Keep an active Bot running to inspect its activity indicator. Stop only the task-owned remote Host and verify cached rows/counts remain with an offline tab. +- Activity timestamps are receipt times; they are not authoritative tool start times. +- The remote message renderer replaces the earlier text-and-attachment-names-only view. Qualify images and cards through the two-Host path above; test results remain separate from Human acceptance. +- 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. diff --git a/.github/pr-screenshots/n4/b-d.png b/.github/pr-screenshots/n4/b-d.png new file mode 100644 index 0000000000000000000000000000000000000000..3221f5a46c0cb47a5771cfdaefc5cb2ed9d4ebb7 GIT binary patch literal 71144 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 literal 0 HcmV?d00001 diff --git a/.github/pr-screenshots/n4/b-l.png b/.github/pr-screenshots/n4/b-l.png new file mode 100644 index 0000000000000000000000000000000000000000..4f2c5b4807c51711cff887910be9a7cfe09a6402 GIT binary patch literal 70297 zcmd43by$?$_cw}xJW~1~A+6G_NVft~(%miT(4C5abc0HF3^4T2Lr8-lT|J=RV3;f3X$|oY&04A{t=}sB zcw=1x*0I^HC>d8SnXb2h>pCp9VHSMteXO*IK@{oYW=|t{?#ChO?%iEan?yn44tpQP zpqO?gBy)&dIYkPeBx*o!zlwp;ZJ~jQ`Vsx-A3oF%u{oLr)DH}gL8{lCCY4jz*tSJ| zd&q#dh4%d{`XJR$dUBVpN+&#xl(%xW$h)76o<06bW;In2rySO|s-z#=wLDXyG~vQa z)Xg2$Mb6#1>d1U6I+-Ss5ME=MWm4$^Pxs5NDPgf=jiz{OhqXa|ttt7^?-GA*-Kl~e$0`ri>nZr*KrTB_b zk1#4S#qR{u#excsHb~pQP@o<@BNrHzpsRGI(XG6PdSLl|VnO;<+Mc_QokBYDQa6n3 zhx$#k`jbvI>X*jSlG>SR`V-4a=m9CCJ@6uF_unlCtx;=~egrx6?Oj|%1WE?xQ#K)2 z9DHEe#woWyVwor@(uIP2oFJQE@c!hNYUQn-^|gRKFsDSmy~VK$!I z2Rp^ns!^p%so?6dt81*+u9SlG)=xchO2n>vZ8%@rt{a_uwZpD`M*j__a`|b@{Ak0> zm<4o+$9vQYGl6S=x~Si5tuT>5pB$u-9`nGsJ?NX1Y=0)sALZqkuPbk2Epf>O zB)Y^MXOCX>Z*9K3Ra>-2%fHycF#Y<2fk(|Ar*TBr(JW7~gWG`9#7lvX<^2XcwxeF; zYjwD^3x^D9i5NSdKaX=|Vq~P`E_2>>7!y2_g=|Vl30mM=L^NthnROkQHn0aQg1#{?Ok{W1_qY5i@-$B z31~acEIa#0{9KD-KoT+QX1(#^EDf5wHON|z!?mrLwG-C*ljhQyo1e>y@8nY~8~0-k zq-`tX0ejfg4p+BrpWd{TxPX6^ghvq4$?{f7gVW^))YY)QLA;l<5^RoZmlfl~^w{lu z>c})9pl$Q_HCKAWxB9MNIrKL@A(;p1!SZq)^d%)bc9z{-2(_>gHJfP(f{B4P7YHRa zwb@FGgb{Vi>go6jzA#ym(Hf!ii!4Y|DN7kiiC&$7OJbY?-%jy}VnQ%sKrk+)8? zkLjWP%-M*dFw;4Kj*d?9j~|{Vi0_u4k<{*cem7pVzE_(}s`&LAYr4+W_z#(g*-|)Y z(mgIh^3#1g8QVHi`F7kLt}#jEQ(hXp^M#sh6sfx?Q&L}UCF=TJj4U66v)!mlYFxr# z*F4u}Kc}R0Uaa0|s(Sw@U0Wk=y1!h$o@}@JFSp8SEJcg4cra~Y#&eFI`^=bKH%GKI z9noF-Zt(1`K042HJBYX4#Z0H9%?hDBId8Mb_Q2`9my|)%VQGV+;I?z!g87d*pNqz` zB{}9;Pb&>Xc7ZxaNy!*wr@G|yL2S;C&Lz8pYu|F+KnExs7gWnqp8E4~=Q0WGtd5E! zX`RE*VQ&3*_UKyJNjB+gmr#b?&vG2+5#bbk_AS73y5u+wNEyFv!Ov)lS_>CbMtX z#f`;5UBgwkDQ^)uohzA3L{2u&ZZLkfy2PCwd}5$|>XSYvthPhyWAA-<%)7d3{z&Lj zbYw4^IZ%WzG%QS3PHv)9A3XeAR3zPFoWHud&&<8W|C-sSqSI|YRmA%zqiMgg6s6V9 z@iMg^>;wQrHduP!nIH(pt&H>IV`o`rfL-nRe3IdT&7m|?%AjG{UJ(nsIwi4s_4D%) z=Y)7dDGp=s9R2vZgE%>lv-Iev0e`&|u*Mjr^TM6ZZ)w6_KU9U}*+&hUtRLW!J>^Em z(#aWmZkT&KH4UG=f2)Y;1i*F0&^&P2nrg_=E#zJ~hUjZ8l=v)F4_+g?XG-MdI}+F4 z+Axk*wL%@1rxRS7$Mck%J|@8Fa5t=~1@rwX+P04GUtZhu6*~^D2efay3Q|T+F1jKY zhD15L#?z$V4o8SyokJi^0_vJIyYn!ZY&s#nBLDHqt_CZ&FN5FSC$DT3+#Wu&M%%e_ z61}Q{Q+F~evEPXf>v^GtQe^LX4e+uFBTq)p7$uH4_qVqD}Qmn{o*#~!CNGSTUl*0k(ijc=?K3(KTKlN z%^T*mU+`bcTkE3oda(nppo7jH*fj~4iw4<&rv=)CE|o|v!}tw6hB*kHVxP@M9OEr2@u@9b zo(hIF8`RHzs4Vz3JT&>tkA=B6J4jVceavaPYGP$#gA$29+Qck!*`2*i;O3QSIQ*n9 z{`=TMw8^3<txOyYQ96Z_ zzSgmzsP0Q-u~m;_*!61{g(O$a-*MFL?cjH4J4?U0Od4zDw)7P8C4U-NKuUhKF<@d>Sq0w#w2X^CEYiEn_(A0>5Pq{2j zHWS5#CHgzg(Eg80f*_+B%KStDU042ghY1X;X&9S3SP>(tRB?qKuKq_VMaI z;vhIVwyfOEalWY({!FHmwLQkn(>eUx-HT2oqY(BwVs+J)Yan9NpRr|eOw=Uw7$1f9 zpS?KJhZkYWwFfFdn}&v+ETII|&K}DlQiy}8{IF1)po_~-Yc4F?btO9>l~S8YZ{M!i(jzxNUb=iBxD%ytv=cDLpXbo73E zeVw3QHy{vMos?-0K4D{b9D~?_W`|J0JZe}niuRx*{?+^^%sAds2vON651Y=3K@KgC zwl~=g$2(S5)dlu^2t0_+*uhT<$c)C?BQ9H8+X7}=N`7xd#+|0Oi$*f#E=o7d=#*^e z)pslk1&h{@F2xE=ALbQx-}xm-qVTKZFcHC{qmzR3UDelNVPTOX-n$2L>ecgp)hoe7 zt@Z&wDYrD)@J7{|j(^Yg?yTltn%PlOxoEkOr4&woO6K~CA4hyAl z3^9^7Eq73I2UK^^kGj3SyK&aLd{3UjnBhmiA%=(AeT?YSgX#xZ(1;e-$c6&cTW-qe zw`U0N{zv4$dF7T8HN~~j{2uS=zMhj8}7YGF(vLg z@shM?S;LpHLGIqxreVyE+HyE0uE}VjZY}ydQecMJAqusrXa9x>G0WOYvKKkpE}dfO z=;)fa{5j0$&Tdy$GO`oQ?C6lZ&A2iAw`Hd9NW}?OmD9zAlFLZz5#U1ASCwX(I`POy z2DouKv(qN1|A|j>QAMr8mUp<1P)B&NIG_9ns|ImtjUA;7Z@<~|3P#l4w7Akl_|rUA zo3j2+ki;q!-QMBm!p2sjEyK&D9Y0Tv&Khm0hyUUGG0R`r@Vf^*>AM~&gc^F1Du>Me zEMT#J=X)%%cA#i&Dv#PeR)IZAabiI#+b&i?=l|gX%tn{Da%SIXs40B|3l^~&UhB{9 zUtMe$)YP!nDE9omi+X9jLIsacprmZJChKhiPL$AzbFBZeMgFClx%FZj1g5PsvtuUy z0RQPLlLE8?rxLAOV&3${NV0ezxValj={T(xgoH|vAJksBCH?d{p7xt%g?!M6GClrL zJ=pw`J7MK5J<{VeU0w57&FaUw&zr2I&RsEXJk)wr@cBMlt>JU@JB!{Zhv8DZE2K_L(X{D;n&R5$q`^YIZf~K^ z$zbQ^Q_*izkX?NJ%t7VKhd$mihA)IrL)-4EG|AgC8+_YHbT<7Ni5YADbR!72Ta`3A z$?Wdfs_?<&2+Be*RuYQG7KTzm()A9jA?C^)c)MHf>lIY*AQZaLy557;`KFuS#gqH> z+Lis;7$@Z(85?aG11*Mb6R?qm9aC z3K5VEFCyjqbe|&3_aFRSa1FK$?+M)N5*Sg_gN_hCxb2}KzzhNZY)>5>NfIPkYSsxW zS&rDIA@h+0AD>cw6#?cuRaC94rbg=ryRa_rFnRTkhl@+6WW1sR)cMVxHpE^am6`Hd zX@)b(ue1@~gI!FEB5M4%H5KmS`_K@H&qneKq$@j^o*(S4WL8KJ#O-@+@6U!osswZ2 z^Vl2tB}Dr9O(&+!dQEoHj-(*1dB2AXcA#`wW5NHBPi=*#yd62Img5x}IDf9L`6A|v zQ0Z0Ifb#NdP?KW|5LLDdt>tRYMZ=^heakk#k7#qtDf3YaoCK{h zFDNDJ=HcEvpB%f3Pqd-VRYcHrKr|19i>@~8s$&%!G@UF@IhidWcm0%-O(TU}DQCc% z)xF(cDDU$AVir0`r1C&)OeU$2UdxXWmn`eKa^~s1a!gsJtlV9MOXCwgL*bEYy&RJg6;pd2#@eS#MQlzStXN_o#VkRWH^vQNU$BB_-LjAdh z+=@$?$x&NZ_<4d{Md)@-NGN@)1Q1{0p56Q8jHDP_ly*>7esibE4lT}M75_yJ$BD|m zn|ahVDvu8JG>0WZtQDk6M8}ixy29QIpX3o*SbJma8>6FkJ<*?>*uYyzL_NkBPI9%t z%MH{>?ijv>V^w7n8aVndqkt(@xNLRy~c(indtfSDFF! zDm0^4-_Z+dh)U+QYd#+Xm_0?9gcA{JI-Jq(T4J*6$x|CcOMp!9kfXLh6Y}HY7`*BL zS7&r2IFA#TsEY+7lz!vsPof>)Li?l1utPt_SUMH+fY|5zVKBGJ#%*S~Uz$~xgPt9eSA**U8 ziCI*6xw^}{@ks_5o?bP0KY^S;my4Ubx~fXH$~ZjS%bph73S^Bow`Y`0SQMGjvQt|l zbFt3LGXva&_-T#{&sFi?JIqySj5_3}dw#z3*$w#Km*^*IMV&x%4-21>jqPfJ4SK!2 zJJCA=B$20;Q|Y8^8hP?whw*WWWWt`KKe%lm4Lj+Y2IsnH10RmDZuu8Kf0{cehurSH zm(5$?S{+iFuyI%zx2Go?4k`F3@6;bZVJLpTf|whRE4fVBC#9;TSq2>ZJwWmUH9D>zAg z-!1F&)`%1Crh+8Yr@jhuh789p)h-KD*)b=-$3Y{2j4nSFe=t1odXLGy}Kt@A|h-68>!uHgFotP=cB*Nl!04(Y+CxNtoO;Ly5Jm2-kY94E^i2Ah7w;JC`LH)P zHT=$3FJGBD5@kC(M2Q|>DzpdLLK{%nziy@tZhck2LPuhp%gPLG+mg~! znIH|*enwrQ>KBwF>pcKpP&|j=o%gkaUwCTUj{Y2k9Zkp5RUe!kRdR%TppdycRqY;r zSO)<6Bab)CVbXZd^NvV))I9)7F|AlkpdwTvh%`jNZD`oOSa$cSYGXA&u4-Ph?p9eJS z*RwK9O!^H=c*s1c*VI5X&uEcG6Pg0bBh{(|T>+Y#xL*DsyqfXm9<8u;`I1)%k+bl> zA#(kS35A`(1`UxqM<(WM05k1Bvs?nP^F9i(I5xw?#N5$v`_#U_)CZ9p&EoAGg3d># z`Z+O{Wi6&^8Zzt(r`mrZ%?dPW@<;TkMQD6qLTa22> z$%bZE;OZwYX9lN7C-o1!H~Zmx<*HYHDJY}c8BSH$)dfO3wp6CEO#)Z1QqQgK-=V|S zN^vJ`s%a{{H&_VRU?V2Zl}LK%XxTTIk@3fEN1_d!W^V}JrYqBm7Y$s-ENNshIuM*WC&7=A@-qXP|6hw>`wo#zB7OPC3Ku;T9 zHf3v&4q5hzoZfcLZ;uH}M1!pnGR7GEID{5k|Cdttb5bd#FK-J^6^+ZaY&F0Vv{OwM zXyl&+xfIOcgfLJ<-p$1(L=D_B9@nXlif;t*yJ`Q!1z=#*)Bm5W%5Btp`_CiFA6?g8 z5&l1gD#7@BSnX7XlYi{U|9Q~P^vk?b8{3vx>XRe?^aJ!a#k$O%+TZ_N)I(`~{6+2J za}3wdWmR%rwU|B~9n0#Oze1gy5BUIEBxo(8_d$sCin6i^i=%P^`om_7+adtpk!hPt z{y;l}RReW~G0bS2{IkcN%uU9|(Y_^S!O=udcSDW_D9@;mZ-Kt`wB6&^`^$}d$o<#e z`7H|pSrJOiANPOfN~Fcnhch}2K$q%=Zmqkv_)1jybEj%<4^vukZ z9R2plyU8EU-DBLw-`a(~9!A3=Mh?rEKg~CVotJKfyXT~AEE;z8482k~K9w-}YLj=- zf0dcT6wy$SQ<_;b6Zcr*EPU}Op{dh0Qm zrS1gJkd(4kYttm~ko}k@D^iR9%fC^SS5?(VFt8`R{_QMUj8l+9^=(|pr?jilEO+TB z?-i(a>n75YVS9aq#uu@)@2)zfX5`f}gHmQcT{b{bJS`7s%Bs?OhjOaTzJ zv;-k_m8TOyU)qYAnY7jyexddKEVzQ&zSbwRS0$M{){2V@bJfoLp>Q}ldZg&}ZBd>l zoGZ5;F(LmkT~Ar^Yymydx_Ijr<4Wz%t@g~SJ0i6$6?HPQ^VT!3o`kwltt&Cb5ur!- zcg~saS-*&Q9>tO4IJjEox=Re}#A;%``#%I~?zH<)c4#{@>XWVxqavr{P#ezZu^T%ZaVN` zBjk6KzzjJ8dEMp-KfnCNrwkh4FpuV4^A*zt5=U_$4q5zOQs4%urc8XOB%GvuVbwsx zdiowcdCDZ?mP{ETWhG1HKs0O_u`Jb~+im`rR-FIl+y6rz-~YSJ|LNEHuTYx4ix?vz z{{6deo{KNqhdODq5+{#YS56^Z8^)~B`5FdgoN>$ZPeBuZ;+(~E6|){clw5f(7aiWd zVgDGNbo)nk(=Pl)Xz^!ltIouP&IRlbQM)jimP^S3V(XI8C%NYJ`ED^CGg0 zxyy`5Z3%5k8^G?ae{lw6?xP;mkODG5mq)HGko_Dek;>Req6L|cXMR2q&vea?QCdE> zvh%SC<@uXIs%=P{{}uWGyu4^HsBbD=wcl9?XR`=i4@VTRiVsK3+<3Vm&Q@gQLYE^?-5U#SnahO#$8nKCLq2NShQ4A{ z$y}bnckV&2Y^W5-LV$d8YvI1nN9oSY7wSTPbpXnl#*!kackPUpj{bLI;U@?B1!p~; zi;-rRK6IeV|fBit} zN7lNr46liJ1YNS%Dq64WwZPQtBZKfcn?pvIqr=71I`f{+OMHGbjHaohC4tnZ--RnS zm_z`HCg?Gy;;QMHm5)kPmQHH-OA6D`B@6$^KJ}JzvmOM+D^u!zvT=` zba=DsNdKd>E|;4EAM6YyS!{=t#sk7p*IS)i1Ow*fTC$;GOU*0$?&{dd*tU<*0Ont3 z2EfgttJmGuj9zIS7hVL9u5l*Ij$HgY#yxc0^D-&CnCX{k<&;`pf5^KAHdTR&7pT>h z`2glsHsds=+co}o-%$=vt0Yn3E0q~h#f!hXjRgGSqBEabq)Qt!>dlrKh6w+5>+0GR zeng)VvBL3RZG7Uoiw{&t29@b>w2N8)LR1dN_WL)V{|W0@+V@mHsZCM%kM7Ffn_xbq zvE@mg_=9u*ZjmYCq=xx<)qk)d#<=O>{}$#f+VgdIun~t1SSNh_@;@w|Qv2iodzfSJ zx1A3||M7pzP5&>5@&5oTQ|ee>-Y$wgdrZhlh@a1w)L~MtUBZ(*0D|h45Tms+^osNW z1%aizkBAJf+666kjFJRZ#r+<>6nv~#XlPyk`u;9HOmqQQHqZSRdI}bA-UM&PqU~~9 zd%FDd;GfeAZ)Pwp`@`UNe5t<~xU`{XYWT&~r~ed*(1tji^bTkf3#=UDq+{2vEq;qW zC_o_LPb1~RE8y{8khgBh-2Dga_+H};-CyG>Rl66dAPvY!qJ}a`z9mIIw;F}2vTIjx z?VP;{cu=IK`Ey$h^=C0MAU%3}izJpPBuRd@@EA*xe=nK|s|X*0_CqbKK$JrP%D2Il z;RW67!UM4I8W%MGbfk(^MDgY|ahnabtf=!(>;J`)cwhpqGQQ9K1{I@0>q+d*2-)wf zJzVI8kxDP~l*_CqGGRd{E>}Jqrk;MxAw`#OFuK#wz`>Gm%?vx#X-(pbg`s0k=SSHW#LU3#m0pBPv>)(x=ysxyea;KP z)Nm@!RWUeTm(hs43j5iZ$2_a{1%Qf2CF)0IX*3hVSvs#ucL*C$YwiMz_3^I4t6Fq-Omk%5MBwdwzX1$@XqA+sKf%pt#s) z1Kj0-eB^M%dbM+#!0ktEsT~RIF}iJ1<3P4LB&4_2=5M<`>MuTlP{OFMiL}69EW-|L zYc*ng{8i|4rV%5n=@O63bCgpVa`<1crm#+uuG*|;7(jKX^r3Tme&v)q zNL?W)hlqR&9?($NA8F83H-#@%MpBg{*~PIDJcq2tAn@(hJ@ff~p8^DBc#K;#TRCGJ z7+6}%0r2uc>;(4r#WQO<85YYrtjo1OYS_#avuKtiA8)iRwqFpF5S41roGl`zC*~l> zx^kY!bD4_ibH?-2J%WPy2v`_A-U00}y2a@41f!4TX$rpb<5Rl3_$|>5&;V|$=I5^@ z3Q9O4Y7BUE;A!S8o5buoYx`%ive<6(=32W3!o?ck94dlX>FMVl!9eIR&#LcoE^2VB z-50dWRa7>_pQUKuRuG#RaPh4&9*s->T;>G!(jj@dKz45c)N8K)Wwd??oURRq zY|!zAb~?L~@#-lSo5n=O9Um~5xn5J?UTez%5%_~&b3smRxAKBgV)TgeOJj&~M@L|+ z$^Cn+(0SwmhnsJGyMOBy7VguEY;cj$@xNFR+%Ujb-I`BMAJBD@&leRhj&SvQ_E@Qo z7AzeP!k7%Dow1x95^|e^4wTIG#B~zYV+*AXO&=R1%O5;WmU!w4_6eTz>Fs6S+WgU= zsWFMe%5GtFfiwj8vv5-3+7{)P9>d>mn)oaN8q`g2?ZoQDQ-81AdWM!0nlT_|B#T-R zScq0<#Ly2P{NuMhpvcG;!svuG!RTpR@DAjD_8K0%t#m)OqMDvLa2?h6aTNAQ{{ zapYG@YpXHl6chisgWSGu@8#xbK3L3DJ#pm2cAcr)#X7tfn=s0*%e@iJ48q5`HIpzP zxGL5-ml@vxz!%irR@i*UnQ75p7ptA4S-=KZ1q(~>>h{gp(rzNVU5YTowdc{mF?nNV z8QsEZ0boMaM0}d@L^DYBBt+#O|4&yJTC8+*bfpIsR9*<3y>pUz*_6f8U}i7pQ=XDE zu3iyudup7AsB?&rseXhn9_dLgKMXDSB*KRwu7F(lhlL0`=x(c#9QZVDu-Tx-WVumt zeyntMauYh0+7WB^aBWs8xqgnM7ZNWbe0y33=pjqdmMl#)Ke(-k7!+_36s?qEU zFDOAUywo6-2U-0HR!(%xw>{+Rwafitlczis_|)y*a>OaT8@Rj{bKP>NP5Jft^T}2$ zVA??x&6I7i8ecwmAB>UjvRxSaoCR02U1tm!3>?P-TZFzN07Sfvn3(usV|lE-TcC1n zoHk~{Jr7!J2SVZ*#0A?dag2~3mPvpfl5a#zE;`oHVl(ZG zH_hpLw+rt!o$TaCkdvj9w!yn_MUKA&05oz=A%D9rCs1y=Z5=;|zGJ!~d^*c(-kWJN zUM$Zw(dcpLHsh1>K93>Mm^6F%ISy=Pc>aX?C9U*rFFzcy zG(7ygJ$MpPV=f1?(S!DCC@E=aSyl@2jLPLbPLyc3jwRcDzF2gp<7%@MjMFU*gSPP+9u+WTj-ZSL^GE zme;dx!9dNfCX+u6WZ5%UT0@fsTH+mp~vlj_k4-dbH9YtLP+xssfrB@r)^ z{a@`Dc%(cppZ-HgM3k6F{D^9Lez9!kBTrZdeL9=5c}$Y6tCvTVL0_|eV}0HFA_;M^ z!OV1J@Yc%mBk$8I4&Vsz&0K-bfC~*&?v|HlKCTGCy>-*5n@w<_481|+hiz`Z%ft0l zw-SVb@VP$|r1?N+cANjO*lD+ZD{U%$fK5PO~$6tix9fNJBgxUFkwZ#(iQ`Bp9=S6N7W`C&!%d6TP8Rpt(ov%R9`e7<)5 zhMSZVl8_Kj7W=ra>+9%nTCV#S{YYyzldlc)2KF+e{W5yFKQO{)4Fo=f|4{kW3lDlqn856%bkW@ zmlkDBP3VX1>14ZRNvh7Dtx7L<-X@NZYpBJaZck{6CX{R&XirT}t{;2OG)byKzZS&E z%MZ+#XaSW9enru%;#ZuH|5^Xk+biqCYiPQtOupbKP>19O%wXtWAb+a?;tMKB(`#^J zqlNtL+}t?vL%v!0E(Hhp(&^JW2>^j$Dz6Clgt|mEksK;iN4Q%Vp zW7UwWEp%uP3i2csaD`GC)`ubRHPb0=#v$6OT52oD?xNpMuS6Q~HZ{oDnX9nwzG;Bx zRpjL5Ud(SSLwMJEWedV?K`?6s(*C|@5D`g>HCKsaxo~*G1VA> z#A9>S5${m&GXA7a)q(F717CB4^5NKyv7a_CV-;o$Zae6z)#^fHpSNR8BF2N(TA%X1$hCX#sdbNCI-rZ|eSFmYOH)3+ukqf1ayc-7i z(0X2t`1;O>Vy&5#nEXYfy%|Un^6JxYY!;PpgYI@|)CWKKEWxI_{&7d6-4pKTJ^nvm zB^kQ*DEfI`8bYp+-7%)@7snl#t_{D{rQ)fC>u*jYXI!Z&56zPdU#%{*13eM0K&SWn zZ+yUwE<8hD{?m+m^yKnJDsIzO`FgG!9Z@0|XMiseUi_)51OK?~Od#L594UCYZRb}l zNFgeCRot-KbkqOZ7l@;7QB7=kX?uZuUOdXJCMK+w3_zzA%{R|rh ztRG=r9?w(q)_F~;A2g89mmriyD$=_aH*-HxDZkA5!NU_dZ_!wxC6dbDP;Gvkb0tN=3;Owp#pcnh zA1_>%E`D3L^TXPVU6P*n#wzceJ-jRvRTA+p9nj;rHI&He^6bY$6qx~~|alGqWkySgXuA`!*t$cQ9`XFl4P)o&Ee-cuT=z8Yvw_p4brby0~NHVCUa@zO5t$YVa4{b=Pd&pVg;>cgK_;xI`Ux&W+4 zp64BZqQ6r)>HgR8tLnh>w?NGl3;rDPYah?^UPkzpt=i+9Z25 zp7GdTI>N_W*N!v-O2v=S6_ z+;Hste)e~TS;(J^R&zlv-WrW#LnYlBw@Ot`Qoa&|y&f=J&{+RFIlWBhYha0N1S({Z27Im$OuZ`cecy z0?ZJEO22;cP|)D~tRdLYKu1k$?=X)3WT$c^jDrO3I(P_2ZdbDtva_+3YqL_N4?gX_=+F33w6KyEn=vKN5on ztZ+^t6%p%(YX+5|v8-vMmuBVK?J>?k_m|(H&vM+z#L;wS*VpH{TRcTkq7dOfM>NfP zRo(*!fr<8IWd+h%(KRn=7g+Fh<$marh+HE^H7$_$v>U>1;n9DalpFAAi*1G!TZa!y zco_l1&t`a9i>OhXuO8)FFqvne30 zgK|8rm}b2edxkh=3RW%5pj&5RFFYH3Vz>j7f(Un=fEhW|0aai`GXa^EvUim5@wgns z$EuQ~pvITyGnN9GLrYHiBiKO?X%}L3ep0~NQZyg_LAgk)Fcw5d?ZcH{n3r{0@EmLV zcf+ZGqQ2#yv65p8%8>>a(?@bI7yfy`GwmsG*H~_bA~7$h+7%o+>mwQX`A$B~WIf13 z+(+3*{0WLl^^ZhBwq%%1y4Qv(OccRbB}o>kW$0zo8k=j=6^>}Imw}fRfiOD)iD8DZ zkHR`OSsjkI=ktpVIl{07Kev|uy0}>L94nb3W&BZls*ny7?VGch*b$ZzNRh0_SR;0; zYlkn`Yq2KU&mektt+cD(Wkn5TTsNT#2G0K5r!d&Gy9v~7qmjmFGrTP9_O5rGmmpti zrBoK8__iH`IJ(I#?y7^-lMfTb|7}W9W zt;L(&qJs}mySaJN>zGEz!V{(&=&crEhoz1|lkqWJKi(3VWdDbwJ zGGV+d*I`Azr|`JwiA(cayz2Wk2W!;Lba6T|QeISIyiCvkBZ{b?;K}s6xo)y8dRb<# z{^k>0Md|TL;Pz{v$qdZ}oCE!T*`7dB0q69~1ul}mG{YexCc+Fz8`p5N>3ipP6>9I! zA;Se^e^dfxgWNFTLI+bPtMU>**bchDEAZ9-o)}kt_Ci%019juzo(LVWd1aBbE$bNI1DlM@6}&tkZ$$N}M(LrV;W`T|)2@c{ z@aSwsdFI@fRf%C!{|A>}-y7W1_i1)LR}uo~w`=Bq{uaaoI|_XKf+{WxL;~NYz?XWH z{^_dCH=S+;djD$CA7J8z4|}^5VLXG|u8+u74wG*LH=3f>M2=_sH5AJlb$>^{X;Ku} z@sEu?xOyaFxqWc~(QGrhcGcSJvtL6A20m}%SXNG!duABBWxaqq%yVkOmbtv zi38o9&TitrWiyuzg=--y$gA0o+yAcMzc*sE(KNkS+W)8iivNEJIR_mdzF=mwKruExIuQcUQ zUS8;)hv~az>s_Ya`1X%80JO>^J;2>gzE!)!K^-=tvJ*R;6hEXk@vQ4`*3h7UI#t_! z2YZF}Dbn{m!B5)v`Re*lm+GzOuj^*Qkkl83^&YLSq$u~1EF3JXtZEx^`a_Ff0gAYr zr{OL}91|AeX77e--fcGo&cd>?Q3ahBKG+jKdO1v&TbpdxB>n(|oDg-f+5=j4;B(A| zh>E^%hkg#Fq4$Lqg|3RJ-jO`l%$`}E%2c!i&_DX-Hm5S~UUyPh%(pumW!RMi&JkOx&-~1-w$u2QouAr0;n!0XMP|+E_^i_^ z##@P`UQN9oY$~YncKXiI2Gp!Nb^;ynD4bh-JzpRFdo}V1y-&3Z-_H*X zY2aU82PYh8`u6(n21FeVJ3FZLCMpZkE$3#GLJd#MwR6@1a*i#o=fh7Q^zh@ zgxYkH!(IH=s_Hp8)*`H(1Ur6bzYC5es3RJ7efEx26}gtkf$qwU)9mlmd zNroQ!k^YU$a?zDe!-`RH@RV1}%Ud_sBs7B2+cGLiQjy{ZCYcmY{ABgcl&7Jb>3oN8 zBK6f~<)k}#xj>WES+m~TJr9yez<&~8oi21;Qt3;y;guX)v0vYW_Ihd=Qb`7uPCl`q zVASV-ORdXarI26Q+-zWwko|ljb8d|exa$6ib65XV1h52Irj}%mw>=VBCvW56N$v`U z{Rht1nmBH7mtxzf_urSuH8e66zn`aOg9jO+E)NjU8;7#H3FT8sfx8gV!{?Eaw zrUCtv!E~5dI=&1Y=&Z}|D7drn9LFrNfaXKv#{tz|InG>%JL>^7F?ypr`Ht1V=9Y#4 zOuEEK0w0z8nXmCL-!l3bxpeK==se!u1vSOlnb|w+MIY!i(e!R6n^3CwY>+a z>`8<-iLBZ$Fq!phV(_B(RqBV1DV>?vf8WjJV<012f+B~=ANG%oX-}^u7e0blr&N?5? zd|~nAe)jY1z3=KN&(T|&7uaRB7d5CEDx$TqAq&Bvvo0z;&{m&77 zq;eUJcNUBR^`vjyZJ{E~n+FB$FVriHtKDc&GI8&XkxOJuE@sZxQ7fA#oi0*5*)FBe zm(KDhW2oNXPg!-K5X{3~`Nsuy&|%{sgon3URq!33mXHl^_09_H9vc&6DP$i&Mou5% zt-={&VuMyqjZYI^WF$)X92JG7jTMZP(btYIajT*)mc3VchO05-uu+S)5|3%jU7qvO zUAKiWUqxOy(%0y~TI;N^C8@vG-|kVs3(^hM72flEL@Am@u)2IDX5zUa^3dfOv4ibg zJ3SmL?024b3Zbg^_~`93t!8{Is7}j}k&du_Vq5#To6c6)va-+uzj8p8@jT#66=t9` z$DyQ2V$RK0Lhb#RPS40))&3)S(l27AU1(4FF=D|h!h26Uq%-sBUh(milD=`(Q~#FU z3b86Uny+RvnDUXt+_o&ZcIadE<(C$R!Ggm{FxiMl_g5E&d$hED+MYsMxf64UEm$1MEx?tg@lf;fiM`M+qh$546rc!nxllJff9dD5%mmKqY${ zE1Sr&%P7Rj-4W9Cf;3uU_3QT#g1rd~elm~T+uZ{vRktlQG$fe2w!WK=w4GN=ofzHu z;vki-m)KtsetK9;>N4MGwcbUJ8F$-UAE*pUq!5@+S8Tw;UoBkXT{h9qneh+u;!kcDkwSn#PK|G4T0MJQO>_!&F%Ca>TF)RI1i)a- zX$5%JArFViDTJHP(X+=mlVNh1m90o|8gk*rS`kwjG)PcS6X_p(D1X#D zSLFcvTN?Na0N-JbSx(=~@3NJzZ-La$7;^gYj+U(LR$ukR+68AsOIHe`r$$&)VP%MH z%!2t5l3C?3irrOiBOJkRt$e(Vvw7|+a=~(nDGr@&R~AdE_WkB_KokU9j`h2g&butId#hVldIGI zw@vmBSr(jgL;76U?_?at~sz}&@ljg1{IsXyRO)^ayJwt7^0=@BpL)jjX;7!f@W7amCc6@spT)8ko z4@aF^52AJ4iyt60Z&q$MY|IZ!U1;9#7c8IM0oaVgRaNbG%^m+ziLG%>0>3&o5blem z`SH5~_kS#TL}yMNKS1zBNknj9%E)cL^TiB!?=1kR&C!qI&cG;4kp30#69a}e>RRIs zu%&|FN$Zs78T2D?<$nHSi#uqReXstkjLZW-F{`XDIA+1?rZK@{ckq9YQC$uJJu%qy zbma)&F)R3M;LyjeYe8k3kWrN{B^q*G+KdJGEHx9tDBtn3UXv zVi(YO1=f#l45OSKm(A4s>DZm47$1#F5)>_E2RZ8R{*IMq#OcmwhvMk$@5&e*?TL=^ z8V>koO8Z7{li`kdI(COy6NIr<1VW{7$80K5^D?gaFgT9Iv7)EKFORV<%%|9rqkzQ7 zpZhdRW(^m2gWL&RTfY{&G=9M#e!q3}S$BJC%q>AlVcS9Vv?3oQc>jw9RM+z19-&Yv z(=jDWsjRZHQ1Ql#Y*hx37J2Z@**UEJ_a*faMvb-9yzkWlIdcPn4Ze|5Lt%zBYvGAo zp2ufefEkEqsF0UD!Z_L$OhDl;*(=-}Z>CW2{4)+)MG-CCZN{ei8T#9odk)UHv&Xw7 zCb?_z^2%?H%Xk|18Xtmp`AdH78%1TU(2MK}6gde=5)K<|6Z%d>x3CAaw#4J)R*|rz z^)K{_WusWK5FPwynrz=Fh*xK}t|OZ~^M77`RI2fj(yf0&US#jUF94Pw)%909{9eX8 zFhL2~9Q|Z*4zYZ_)b%ZjLYUYATpe%rDiS`AZWReA>5KD`h^Ew8{0iW3p??t$O=z); z!t=J3gEZxvUDvjLF7?PRa~vr1`h;EWjES{Dhj7D zBA+9F;nmr#{iNalcH})PDX*y_jEg!SG$7NnZkXj)*0aUp>zQWbM*0Z8>&j5V zh4!~dZ>`>^l!PIOjf5*7#+NnM^!j?tm~fqAr~t@hrxP1{$%%+S(>d|PWHCz zzunRdzd`3;G8x`_3Zw{tha2W|o6j+G%yEZ;*uhKOF_lxJz!V+%GR}Zbl_-1$2c$ns zt4eaTc5n{Uyje^7w#V}?8m(#^m9`hrKqg;epGQ3>d%Z0tUWOX2LJP$hl~=!W`@P2n<(Rjf#PHgL|nQp@dT?=7{Z1FYFyLbg7oQ!qYW zC!mfZ1a0!&zo3Zpn?3E1wvmOGnc+1Y@{__fI0haOt;_g3i=?OVk-c|7+eQ};*=^4` zvWygce%`uvMgQJY3)%NV|FX}CZR^t`qm?T?V;Ga3x*(N;z&xCuC}qdlo4?;MYx4#3 zJA{VH88wP&*gmGvwv9hJ!VP3vW5G#5^X^DRyU&xwh#E0fXhrPU*-IB9+@w95t1YQ& zmY`?OU5+T`qtEUllBZ(0st0jyJmo4ciEqwDHZt;NUykcoE@FqxmZMLV=H1XF=Mu=c z8UbN$N-UH$PNtjT{^fUcT!}G1h?1#gzw+Kf_*L9H>71xS9(cvk{DavW5Fg0{A4+}C z;j{a#r|Wh_$_7<6hvgT4t^B3UALVZfxV*+m<<-5c)2hRbFxs0lsxH&1XT7Ot+l=1|i@J)L86K9@qGqrCBTKk>9p0!yy_Sia8dCD6 zo(HQ5Ljujy82`bXL2hUcKh-n}`gvP8C`z^s|7(V0RIcw0&D22cEeYoj)S;yfan0-@ zC}wucrbw!4>rsT9O=Fp(J2I_Sssu<8>P=1(&`3@!vujVypeO-*&k4RP=bf0G+=3l) z12g@*FT7(>bu8%{uRxd9a1>R#LK3LS3g{gZ)J~X-^kp9I&VYfak`a#1#e}uTX%+Me>f!l z@H1j*4dK~+_hLufHJaadXvO7KWZco*#n)4*RPXe`o4;FcQj4yX@b3@xCB~C&wQg{C zy3{v5QtTGry zI&}*bKD)~XIj9-{2E;lXi2TJ{`<=_qh8*1I*bs-5Gt&C>rDhFD9#OW#zk zG*S=TSC5Y6CA~O4)u#z$A2$i;m5t|7AUd0ox}Xz6##XhcoF+>_j5Q$K=8U|JyD)fP z3dl6|JH(RiCq_{S{p2=$!^z(nz&hZ{W!U-x9lxx+{Z&{!{#LJQjzOT81Y~*;eztZ5 zd(puVBViMub7*jAjIbDOjQK#NAz4?3Ms-6pE8b7XcEHdI8JNygFxT7AiQpGi4aOT}1 z8I1syP1cx=QQ3=LD!1E4=Snt4V&4lo}1!7(8$t= zY0a1AWo2qrkJyIAjLSYU@36F2wY9KBCLaI|y-`@eC(e4%8)lZGpdhPaasLVUX&s#3(Oe;)#ws>TmC=l;&?;kPoj^30Z z3{v^}F&h=R#P&e>3EhQBLCn^>bN_^zGhQKeT^9V2BA9Aau-gT5p`JaMBc9!uDb3KR zhpAb9)Ua4RHBq<8x$>H#X8h0Z+J0Sy!r84|C7Zd>tBgCbSG+g(`J$jCw*9&+J}UH@ znLKVHxqW-pD}H6OmIVq;@df%8W?>}SX#=jk!NwlTG?B?b@gU#cd#;h4o!y8xqk27! zH2&A>oV@JeX!A9Zr}1Ks1&xS#qgQ^&MzCxOI(C)B98?N+i}t1te?T!54&zw)j4#^o zX;V47%6xYucH48KN3X6QQoQEWo%jz9J;Uh%$c19u2bh>&f%3j19%UMwd%Md^q zon!yJsQ<}}>u>wX_!-NpWzo(*KH_L{Cbl;OUAvrauo?E0#N^(J@R|=AYY{UHpoMPH zLxmYg8>+Xgz|{`TlaBf%5y>1SzIkX-X=9i})JVEHkvw~x9~)`Dt~|VfzjT8`pN{Fz zi{!na?3z8>Fhzz;NBrORrGs^H0x<0<2heb`4LD6zXe|8AZOz_oC*ue6 zprVFJIXCHAaEUXJBVrU)6nJmnvaKj%mb?P-VypWeDqdPx7_fp|YY(|!TEdEO@P>Ff z7diIQ>nkGH1*;ypvM^V{cvgmDi~%~AHoP+A?kiFCk4T*f*ZIt{OX>-$A`;A8bx}xG zQm}a3N3puyN}tVl(=0Q^nTMQvJ}pBjT|_fUdPV^q8r=#7m0(e_e6^q;OMjnXO`)a@ z)u?FXARKXaY?#|BCo3zPJ)U}cqmpFVGKAW11Sl% z&pYuJ?C05iE`=fSGj0F=q`LvGso@C8yd{e$s|@Ef`!L7rxlS@>nQdz>x`{~?6#vxg z-4&s&mOR%!RFIk+H`-gVOIjP^;cLDKIO#J$(e;Nl@x*_8m1x=V$CO=DUTzUO^-~z8 zb${g*7_a3p21&=9{Uy7h$ROE&C$u%#QjAH2otmVR7zg zUBhTFGw&x9QJ9rOamVN@xZM5vT9s+v9PvT39SVi*jI|xrDLZvscG5cs6?Ywmi7q{Tj-w*G`HKub^`LI4UdP~! zlEI`J=L{4Px884<`-XOiB8)B~#}}98_|;e}2IvI?P6y)lk)k3lB3ct2_(YKrq!Gm* z3C(baDa<(vui?C+*zooBb;`+Vg@B8Ps#gfD@BvQXrl@EZ{Xu~s8kG_DIniV9N&tnId4JpfzkI5{aSf4D` z5t6YDs#(CuwhoXcKddKkI8PG5xE3ao52)*u%~s&NW^}iaxiV9y>;Xa4)(~~?(-||u znAuW#Qy_d9nlaFz9_E2FwVJof$fRa>G=Qb)B;4Ik`$5m%{p&wT)A{ zmjr{G{(igC_%p6v+CJ)@xynS_e+>YeoUyshBWyywCeA3Xa39PNQ@>vVsTS23C&{qX z*6W4qtQ{^^0*c%f`R6u@in9rIcPP>ga#xEe&|PewxyUQ*d0#8kcm0Q3K-fx^!$-I` z?lmfR`c66%NVADj5+dQaXM4A$s->0i@IJ^6RP~kW*U(7+H(WrW@%QmUTyGe&%jd!&7p+>eB@JVdl10U>6;p0acMQJ`SxZgfy z@9Y0rZld0WkH<;O^7}tn03dVgL2uEn1-E+l#i*)j{~#x$a*7(`1|~UM*!lkS>BMih zzK}EvN|+NJXdT|HxS}vP{`&hVpeL2(K0l~+59eS#B1V!5(U+JAejB~oDJ#qFV#kVA z4#c@kDut{(8H#x{XYk>(@x z7O1aLh*qx8#hokEyRx?yJ}Zh0-G=hmi!1-Ew5F#Tmc)TGicFZ$P?Ip}3obV9&yl$z zIZ$ZFnwu!B4OxNf54TT4f~a!8mU_}D_v*x_o&gT!$HDqQ01|v@p^r#BY7E2L#asNQ zNJvSpp9YjKzyr>F$d*E5@q5Mg1)$X63eON0t-kL@`!mn$Of(=s2sL!v$=Y{PI46IY z4D?QVhwh@n%TjaKscg>Q3JS$4IwJe;{QmUj7F4`sq5A&vWtS63dpSQo3-}k5VP|C> zxq5h|sofD_va>XSm=cEyEr>H|0D5ynVBa?aUA#JFwt7XZvzaSXG$Vn|fV%rww%e*< zgN=>i&-7zv+(Y%K{Fq?4v1rDe_T znm=>E{w6WBlEIy}Dx*jq-=RA_Mc@2HXCYKU*FX0`>-vMYcz-qGZK-ZM7FEegC&^^X zO}u+V;n9)v0taf1IFrqFFly9fFXmCeoRz{SgjMssJ%V%1O|U2)fe!WS2x^xS($nB2 z5w0R}7{R$ec%ezoezD@{&{iv@S<(P=zo#0s@w-L*a&7pu2JuRi>^2V3bsp^B(CqBb zVWt4QC9oETe}&;5F7~|#T!E#;!L+P_hzF^lZI?S<XicfA>u5)`rmR2y1S}l9 zXZcSMqoI3a%w6@zD(iM`2C!`0kSaz-N_Waou+KYaaqeyt*_F-VzMijIsbf(oBXp)^ zL?;5JZVaB_&{I}NCc-2E#7jdosUn2$)A-uEkK4G_?_IQlh-K*U=gcSh3dE++ct;fX z0RUPnf*$e{PC`Bt9Tj@}NPoCxzTx@f9yr|Ia8W=(SW5ZWHYBr7Op*h)0rEvNPlZa? z5$!H0MpG9z&KV9OrJ15dw3$JwzB=xHwE1HM%$hXQ+se~8`??iH780(=8Xu{>yLgWt zcfeN;B(S=gUs-Zb>)9VsGSd~x8Z;F-_l)v$##=Wdk(h;R`pOTEPCQ^4-sy1Rm?Oi z=nz&>g&I+ zQ(dOiVp*$W&5AD!6x(pX_~fXvtO=uyX9`(HpLB-3WB(o0M}YKEq2#xGfi?9 zDqbfCop15NCFaV#U^ zC^{tfzMNB%EJscm|GBjek9V{0OuIjHopD|mm_ zCO5!vj}|Khd+@xUcT+}x_@K6(nOnSn{mR$=c(?YIBPZmgjK#BnxEC@Y^)Vw9Aiwdw zO4T_36>pY~P00=Ipo@zyXV-mZ`xoZro75!}pHfWv4zed18ENEjupgc%?LV7P8db2V z1JS#+jN4?N@FM%=%0q7O#2xZWmlxEh3yM&)F2H>{g#W^>Jvre2AHcxwc>ZtFy#B*G z8XvDd%g;T&T+B=Q-8pcmA?_FJ+US_~0z_m+t+Q|sOS^%C;Xs4yMs8o{` z9P)QJ`)yIyQ!Xy9m5if)P`!cLxkJMaQKl>~y?->HU{ltJR<{+alc2mdEjBH`M284A zxn&`y^7J5F_sL$8rAn;q`ECckTowWMCo`zC$DUrlYw>z$#f0;(?fX6&&4*b} zoU^XqZPhfKJ-aW?s{gKo#@4Q9Z6?1JvwnbBh_$$KyOWWe;U;*+p%_eYc^-uhKv!DM z4l}&Q-4TT3Pum_BYQ^BZ7ID#de+#{Jc>WuZ5%nxs60neZpAJc^*pmgUYwGnx73pZ_TZ9#E}rA#ILG|E7cyDr?p_oyms*DF9bxUnUXEF;db_d!J*g`C@N zS8_NkS06D^$D9C+?#yNT6o+ozKo#;j11vAQf6axYu1O%H=<$+Cz(MVl4^0)!D%1bD zZgzgMcP{hx9WJBGVOy_GCg_5qZrW+xC~G1w)rS5FoYc|l&XB!RshRUx0Ob_eq?_z3 zJDe$D;h{g%o#mG!gt!woA`|{y&%{59xBQ;GC6lwn$?0Ad6Pj1CPCh~NOy4TQVSvey zm1AA%D?B`LY+0w*Q5#zIvFw>HEFh1qy`_mRp$por?ywS}Ty7 zhUSXH_a;wrmZ5soZk6SK_;c2&zwP+opt5OvW*yS#lE+&~85?w$l*z<*LalHxHRv-G z8`i-TZ`LCR@!c{x$0SR2I=Y7E2Cz#84r zYx#Z8w7<}O4^g9eGmI7A9*aKjC8{b=4siXweTG|!_e8bGxbBCMB}3nK({@g7Y(${{`5sRz<>bu}$V@>-aIwN5k)o(J zVD^#h-sVra8m?S=K$cF$WERh-hy9Q3Z!~tZOr2GQ5F&Hh2y8 zbVTlUDd4?K44!)ru)Y#e0mVe4=YtpsVn7^wnWGDo=apT+E zqDH6r~VuT3kt0xi1AULRft0J%#l+#pO@U}m0)JjqWXn?V=)!0*sEgJ3(E7N-*~!8kA+R&_`DfOO?};i0*qSKKNOp&v3QsmA==`e2ee? zD&)bMxFYF^)+PhLt?#1l)losx{UwktxQQq)m${)ym?h>}b}y2mKHq-68M|lMmnq>B zAnIGU8JfvdXB(Vac~@C$yV_tUk%+F7nW1p5n%UM9r!zYp5Jv6R89QMmU_i|E>Q54s z1XdSR78GZseKV&rzp9|%C0K}PRngb7v$LOT2Isu6V(0HFH8h2!#hEEsgR&*onY@li zd1>}-op4DL#Dh3_dx}&woHyXmb*2^hqr!2Mw$lr#T%b`Zjk%@AILF1s-ME@#a1Tu9yE0uj6)?6)l6-%fty;3^lSGelEIHXukzNcTXU?X6^DS z9zhn?B-ZsjLE2Wb2wfZVm{)w;c_h+amO0j6Q6M$7WcU5e?PN00RZCBBBZma6qsgb} z7mD<539+DB3d4Odf!=*kn+|b`HAuxsc`kS!l1W9tR7*IwO8C`$f$FT~hjgb#2e{(N z>TcD5^O6UGU-Hk$#SmZ{!la@bEEX}Cs{XQ5>6|hZvDCcIiGOC6l?q+@e=hExPBj$# zCxIm;dCq3PRArrl92&{Be96mrO_9`uxr@tuQiZh-@tV&%3UI(z{wvWS(is04=|7X1 zIAb4g3>b;DJPzhKdJiy(SpGx9iT!_-KLUh7-0xlU|5jD|Z#0tr+k@V5=K~OaUFzKZ z@XN5}FA#EF0Ytr`s}NvkTpE{|_A5IVf0GFv9UuwPT=#2{vC0D2JSNhQMr|%+#cH6a zg*3b9c_r;mA%$b>gfSo=IE`i4jI`kgp6m%?gb(u~l`{=3<*U?8o}eRSg88|tnUb5BR{C$) zlgzF_Sj;5J6(t{Ox}i)%AYlFH)-&BaDd0%B&)r)XHj9UsF;E6nXVj867EQ9s;kEsG zQ{ZiHfz{{~J8i{I8=N?JxZt$lnGct;&HoMiB4qxPrJLG6@g4`>EG1SdNH(GsR6wh% zsjI7^-zy-;d*=qaZMC2;9D_3^s66LeENn_;s6}_ILDcj`?1k74CDn(Xa&I7)W_Y7( zQ(S3<79)lNrJdNXE?ZjioyhZBff(QK11}-_DX&9)EAivM4*+I99v*%t^(&yXUd;Jt zHBXf9M3bH`c*C?gH^vwaHmeAH%%Ok$W&^h&t&2?{u5s~qX&Mtx9SFkXeUHhW?(J*2 zBV%QhMk$YlefMbMDc8nv_Ygl>kIgm&+Uo zJoi7=ZPg;oYtkqHV;R#B3$DfGl8n=^G!Pl=mXRt1#C(jo36GCL$rzS5@2w(+^;aW(;}mM>@Vid|V=^ug z9>kr`Iqnx5rPB}TAsWAF#7#`~Wo>J7p?s%1Yd}a%ej+uAsmH|sHBS@fzE9PZs|Dph z`hV~8s7;EQm9TCX@|LOii>xs)JlGVr$tpk!yg^XB)R^}O1-b=6lFeP6G`?OBGH&sk zt49gHn3yNiTK9Z^ug3y}mm+*KHU!uEb(|WF-XFir_tGTZs>N31xALIt-_4wU8&9sM z{EYb4VtIZow1ql{`#k4GJ|%wGWTbGDAzh>5AJVPsjzifz13XYc5k{Hh9vb5qHx0ZzlJhqAhMcM ze^;-qcE*jOV$45!;rx=_FZ^Z|4rw+GbJ36%1_^70=kMVEs@$# z6sO6zD98;Iiy0e@kU*D>DRmS4O%+c!jc3g~1KU-`X@#VB-XGnczliQ7H5uY3Or#9N zifD)LvR_}eM54M)NkmLA90i*ibGWG_RlaXQW-a&|DQn^TvR1l1CDE^*`rR~DJiYZ= z;rkj4xoS#@Qx1edTvZ-y+j8Oi{m_x(kb`SsYEjOXX)%#TH7*3jhfpuCB{dsJbjkK# z^T3>#q=!hIMUFp72uQLyrdmr)Td`cT<%^EH18G>RwnfWYW@5vPvBGAnb(i6@Xpf5k zhkQAvzt>)V+n^imlVfcjK&EwUKoXo`R4eC^-qzeDig+0`nBv|I@S}2f+8sS8ApnOUV>Q5;<*&#ms}G>h*CIb>?c2BW1kP1w<4@R58JP zA9fm=Vy&ZO;|yN&(cbJ{+U=Q6G@ZutUgCfy`oa`58SYRPMB;=xSvV#k9U5h>KnBEMpdsQD1C`_ni;+c z-85w5HtCZ+e!Iqt_O{e=ch=5hIE2cm{?jxFn`wUd$%q^AsikW$<)HFjf0277JGPL> zC6sSNlo9(|FpF(3@r~SdUy?!%nxi|33uW*Wv5Si#vF?JA#8q~kIN}|Sa`goBGLEOK z`l11v#9SSIb2*1hSmFR9?GN>Ga}g#iL@q9tudHlcT$UhHY4)Z~S<22$js zyHumwhv_kp<_8mxm{P2gZBT|hy~`HOs;E*@cb8?C_<|RK+o{FrosOD0g5=I0 zBjf%V52em|G`1a*xvLxhRQmAA`cq$t`M|Ri!X=xzBQa2X8bFvmSC&}B*xb7vlDHQ1 z-ASyGD$iQ&y3TY&MA$O=NX^a&nwC_*C2&5kKb6{u9C!02n|>n!`G%0NUiHBoJM#D} z7Ptg?2j-@Q_b6byoV1IFev!J6VA%|n(CnJ?p9if^3dc7D`O>t*ZF@U2Wu`Z#7?_#J z_IB#|zKHL{a>#^Bi^h$PQ}C>5THly{KA{-qBj}O7{Hk}i+_=sU&;&Gp#W}>98aJyi zPpsZy@Dbxb?-f4$Q|C*efh77kN-rG~*>YvMeq(p(5I5<9` zOrWls5+SZ!nFg!W{(+0$=&zgXgnf|&F6pP zzv%93ULH~-xj*p04oohKH%5RtD+j<-sys21SQ9KNJtxsPndgFgH0qwa!^+Dkda03? zKa1MNlc!+uOPEu78DQaSb?nQIIXh@1g^XR-!2T55SW=(mdcYj#cN^yT;k;3uKD|J9 zm&(4FKF=)o(X%&e!Qt91pG}OGec@$hrNtlmcSjAGI&ZUy^axbl1@70#WZirWY-w02+ttAcU2Dahp9k1!%}i7S}sa407BOkeg> zysb)@@g<{fNmq-j9Y#61T^Pr}K>E?Kh&KIbx4T;BI36xf%=?4E_?@=lb{zK3oO^O5 zD0maF$5o%Ypc_`Espvf)aO58*;^r;L!NN59Vg?E3o~)9(qDTGk@#nAR)baMlh>cIv z>nuhQn%k2RN&zT$u^>j=x3BI3(aaWx?0k*IpEgN~^Qd3==jfX3prgglE~Bpe`Y%_O zth>i{2o-NL?T_BSO}5mU9$%sn_?FA4=GBe;!V=@wxn^GWHSZV_LEPe!?D?sAI?Mn3 z`kn>Z!Y~Eii2F+bf-|Fvp&E_#%_W;hdPZ+fjr@L+&8`v8^Bh7Y z_NJ!N8>ZtoaVE%E*KkN9ye`CvA()@r${HQ`K4o%tV6Y&&{I&N4d6?A%Yz)Oz&yG{& z+(ZKCE2iu0OLtrBFC>nn3O0r*wggdhVmY3!aR0;4VBK+6H=AR4L(P^DwP5a<#Fwf^ z7+v`&7r(P(cPZ~Jcenh)H>k&S731A7QMM}hu?JVzmbh8H{zZior6}8P0N6wkR?kQX zq8Spb_Xa5bIXXf$gy_}EFUUxH=yJ3BQxBFF5_pD?Tml0nJv=YQK2RIaR`8!)|=wlxtuW+olLP6E$ zEQ39TJ>%F(k2XJ(6_l3Jc12NHbiEWFt@?7=DJ~Y$Bc=H#Axpq~hy4n4IX9Jm`N#uC zw|a-T_IsG!@0?DUF^Ic9$ql=Mhe`$h=AZt?ydWg%*%r+QQcmmy7O`T-TO}b6 zz!?W%4c7!-{|#IYiww9r_+dkt!9(GGxBpuY_i|T{F9=rQe*c#;<(EU z@y6u-4OAHu0Am3f6^`c}Zh;Gd%|7{0nCb}N4YUq()UUz?widpDmP*2tI-1YrC3)^+_^%DD}X4}ivQe8|1i<>9~y9QRS<>=z%os_vEZ$A*U1-Hbc7!2(J)? z+4nG&<=_RXyi&jw`ME$M3W?`^di-PW|ob4?0A)?;<1~Tyry&$)*GzC#-Tdl_(ME(o}Fq02YBNGKHelgS90Tm?ipPS zQ&@bcS$}F_k1+O}?0y-&uCD%c{xp96wKu0pEB*HSaO7D5gT16RMlaYhGE!p$qlj(+J4e?Vu0G(mN51!rmyJiehRUm zoa~(o^jhARi}VQY)A6he_Hta!>5Jxb8c3DLI<^ZpzgFU7o^I{) zFYoTmZpV(5+YbY+$?85PHtOLt5jzb2Np~w_-W(9^!|VjYb@ilUdn4!RseMm(Be;)i zBa_We9qN&-3s6NTHD7h%Fm+FpSV#Df6k%=c&O;zuJ`fg-176yC%rKh%u6)Bn{f<;QYs!s-;zK6nX4XN zGho!{9)C(06cpqWNqPG7v#x(fd=@iqR=Ipbl`fO)`ThFFI(b=2WRRS2QWGC+1W#*X ze!SrR+W4&a*|3_J`(&REv1!jAd78mkIA3|<*2c!h#oGg&AlV?{`BNS;HgmW4fj>)P z0F9TdI`HrBNFXWB7Be$riT?fm>W$Ugl0d6YBDchBZEU86mB_=7uLp>pLseoyR)7=BymKEbpFW5;O_3;%OL}Ox|?5Z)xRL& zv0z-wq;zKMGg+cv&AgphrRY;^QI*{uBHO~=AGkT%ZE5EoPRC)a+oS?_+jQ<;Hy~+b zw)4jHb~9OoZM|CpvZS#e-vWfZd&rWKRpeHS@6l&Uc}pdlYhmyA&-=_m?or!wJ~@2a zQwjU^;etOp1)$TbKAU&&>Ta;L9c@o;^s-PV-TFle7Y}rYLXS7@4kw3XzQ$rmG&2$2 zA$8ybrq~cBv6g_8U%Sai+t|KS)^JLLSooTss88+ls}(R0*KM@r0{)s>PCQN?_eL(1 zCxryC+bG0OyY~|<6>h$nvt%Y}$|r7BD58%@YIo_wy=YN#pqs3&3?Ke4x&TyXy9nJacVb~clwyLU8>nR0uN@{sBzKWs(8Rzr9 zUC+gizEp$t`5CD+C=)HhSQ&&^nq=}*4Mvta8cQa>Xd1m^zB)qZ7<1BBT=ui{35xek zGBP)OS1Z5Ow%K6ZPTsitcSjaAPhOSD*`j!ukQ;1wQJH6>ju8wOEwZ)o{G+iMgS`|2 z3ZYD|vBIty_w+Ow#`7oN5<|)!o0Y{9>xeMv&Phn%2K5@y4!!`4siFsp`aVEAkxYj_ z=dnA@r%$wbolGJQb9BqL$o|5B4z8$;o?nSt>OTpry0!$lJ;>Q+;UZh^<{}v*%uGc3 zI}7I?4uIw0NIh0=0*~N;{6^*qWnettJwc&>`e|B2}kvD{@Hqf3X z0(k$(0nSgPLw=R^Ovt8pkWjmrE~N4M8|O`q)U}je3U-3)=eUM9L>#~Ey62ijK4Wrz z+6}VnHp-kh{?Paij85w4RI%!R6-U~YJGYEs!+bl`81{cyre3|Vk8qH#0i#Xd#3azN znfYp^MHl|JGKt8juHkv+AeW!B(;Q+Jb%?rP}vU|U)w2m2c2l~#ZVDf^y3WY zaIx*b<%w5dc&El@Ch9$>BRf3e?T5+G^#)9JCDmetVl)RuEMQp*wsk7SgAw=8IpBjh_Z70R*!=qH>pgPx*V;1*mP??Iey&9*vf6gly@^DB=>TDp zug4N2b0iwq8?YOZ)gQ?>SQ~ian-NeO>m19v|n!d6(IS5%R= ztYJ~9#Y2&d+Ur+YNN0QjDhcn&{XkH@ZCPjH^O!a4; z-=r$I|H4kWSR<3k)gwhs3!hXuA@oB1d_43b?t%K>LT7xy<9Wy3`+p`DND1Zd zET-Q<8EnrJqM2B#wvyg5O0JIQdt!oFrj0YgWu%J6*V2P&X^dS5v%pN0wF=}wfay-} zxl9W3uW-N1*wyR1x*RmPEL%Jm`ut7;kayzE=IL}ZYnTRb;MJuWPETbojebCWdkbp$ zvHJo`_N#PP%z`rA{r|FMt2uC|gDTL~23+h`fEo7sZlT|zkMsk{8t^g00z ztYLTQD1Kb#2xoatp+U|-k8)#x7<5Xh^X<6mMH;W`w|fJZe#W>2$hDFU!_h5AWKl2_ zprfJkjNnv$l7Xxbf%^lPW&|Ehpteb|eJ-d-v!zgZnSzN2r6*mJdkDsf^DBp+Mn`zK z8bq(fMn!_|f^EBsKhhSrE(4CVVQK}fI6^4n^M5O;QK48_UuJv&ZGHh1$HyfjardzI z-SkL*$f2E2`wK7jUWPMnTTkSv(TqrxR?U#%gLmSSKgmKm*Y{2?-7sjIZS9OhhMuQ1 zu){|rv9DdGzq}WEei`o!o8GD$Dgn>$LxKnGqW-t8GHqF@rgQtVHhfwMxdFQNf6J5C zIhL8??swaGS>-(V1E|6TmG2utzmEm0DPDy`9BsRRecXjMRHEsUspuB!_eH;v23NHe zxJhQXbc_Gli^1)~{NKh!aR>j8{t#~8|CcMBXSFopMdl97;z`0Bo$baL9oF-<^RC~y z9zMQE>G{<2p_oy0J7InM_FM3W>!CyTGpd3n;e&?KovEX&R5v)2qLx$j+8)FIV(+b^ zqHLqS(Lq!c5R{fwQ9?S0kd~H~mX@J==oAE`rMpYIJ0%quQo3tk=x%1tjnDIb@B4k< zT4$X<&id9m>%8}0INo_(``UY7zx~^L=R{8*rpCI$34L+`eeQ^efM9T>bo~bw00Ko( z0wVIyA>{^0#y`gk1VF<3=lIVH|2>)KJ&^m>tH5J}7&XQgc7Jbjk$Pw03v4Y? zX@H&xAFf5O9XE8&S*_417J?=hrvw*FHh3bh2&xtW-a?;>3l&v8i`ASXb}i-3X*Clu;FaCU?zHo}z)TZ9WF7BX zUy5d$D>oslZ7&?&zFec;ivkJm2bDG-9n~#(z!1>A@lQe8Ufolg5&lD?S^PkI-KciiLx7(jJc=yBYe2>d< ztL`NEOG`zdLhdCVtHRQn+)vQb0kAGXw%^v^>m^zZ#d(c48|hvyhq>N$ll{%y4AK#( zfH1Ojx2>Sg%IWTIaqBww>B=64(W9fIt)!%aMU*}gs7Si$T&7z;YN7qMkD=CbL6~-c zcjJQ#eA=5m@M$WxSDQCb@c^D48ykB}%$UM|PJFDu=WTEAu$f~Ba{3~lW>MM@JgDnd zhCFMcr#pHKx&{G7Kgk)PLJsQZ2Ai1x=WeiBi9>(sJ6zAuG!B>oQ+fS3%plxe8?+Hl z_>nf_&di#`ch}?ZYNU&4@TxJ$a8H*_>=>g-D%Wxd3ylKL#?6JB_H;p)q;{Js@D|-9 zdgsXl#tU{_bvNtrdM&r%O*pp#Qxn~|S~r^6=!8N(L8`1t@%Mv<$QJ33y^unbUYxqz znX#R-SM(!m6VBUj9^gfI8(uzgkW3QVFG)8$v`RbctiJfwZb4%IS?r+&6=!CCG-`EMF3* z5VO4b_z5aC_H|$&lvziHZju$1!??3mRF?B~@FseYs;=MITKndf_b}1w2r1n(x5nC2 zgFH!0YO_2yFQw__MNUV$SaW)7X}>dC)nW`2-zSr+bx^m7J^2LY{jKfMtA7UP!mFd* z?}3G=b1p6tfor<+E4#r~ZVq|Q?;Nmmi{S2Dpmtq=MhwF6zpM$^>l|+B(yZFxWa&jN z0tAK2GVthqg?GqJ7VlkcRB;O7#&S#YOf?Q!H{nrK#fuBSup>Cou3 z@&sxHS$NdP-TL~HjI@v8t?2-Osw}MR^v5%KZ^I_d270MH4>q$S#uA1EuP;u<6tab2 zNe@c)33l(5{PN&zO=Zer5NpI{Ez>ef4m&}#HV;Zk9x7oK3|o)Yjb07^J!{0%##lp63B7SX)g^kF#@E@*_K=v|OC5z8)>X1V**rQ9jZA$lN?W%I^3%B@aILUY z(K(NR+(fTcSRGz|zVYbib6~f=Fkw5bkjh6mc9xth$?tL~PR!n;qt;XJk!xgat>EFj znd5ogw*Sys#-j&8kG#yM3Kuvd;q%|*n))nWLiPRPQbLGRzf~SKi6s|zVV}2~dEE8R z@Q_~HgW~pPYzUfSW&|Hf_CD;6?+P?C3ldi!plCbrJ;vX#I?@%{@K~`p16nI&96~Wi zXhwAF>R~YF#H=hD8=k}Qnu}4R(DQ1W{12(fK+8KVte6qsw|@X522^+o=nj zaXBXQTmB|ARcJq+W?aM6O)LA7uH`9t&#|nH57a5q2!6{Q!F;6cS`?~q3DlEFRH?lP zuh=Myq|x#QTO$i*Rv4h-SIT6CJN+vHoGUgfy`OVm!0Bp zE|8~L_~L8Mkp~I=yNw5;p6Huoqh|T(GU(P3y>mG_FnMP|m|Rwf(@YM{Y}IUP7U8^Y zr{keyG8Ho)5A@rn@VwhB1>AnXcyyA-L3UGd7}AU7Ld6Uv=0m28;_3vj0evYv1cR9;9b&0`LPJg6xqqHK&Xy!K2eBUu11IFefK+Y~ zbsG4&%Zw=B*oxlD>ZajTitwVP(IvbGn{7{bR3uL^ zqv@Sb|92TOcFF`)9%<|CU=z^k1(Oqc4|M$yux>m2SB+wIrUSE+aGlf(XV1oU!Xd#; z-v((=?Y+g9X2tl7i!t?W!OV3uLM@nY8WJRv7FUA^(7v(hmjnzOo%HN4*S#r5jn>JRUwL94*k7p zn0XHo*=SeM)2_BFV>{Aw5Sh7zE(QiZT0NINO*m_2{-{Umz($O`mfPES7H#>Tk_-wH zY428>#Fw?A4Ie=@=zL_0>4ha34+nCJsAY5q3}%|?Ra)8fW=<++ipV-R?c|-#MveRF z;Tg%lgK&3$1ItaWHjxc;jlGjWfPI7zKARI0VsX$=Q=lVj^UQHLYSmg zy|JI}Zdgb4lxb#mb?(N@I21DgVuj=8Zl+uXQ4pPNaa-O2CS*vDx@q)gK7l*$GT;1I zeN&eUYi1>xyCf$3B|50QOwkt|g!AsoSM=OU%cdO#G^*jIjyPYq_GxwBRUNy6JDXNz zd4=E)bCJxeacTd|sQG|g>7O68UQprMCmn(Hz@3eqn^aEL$D2h=Cb&(UU@1PWKx$0y zs`gPoOWWRVCeP;qMW4OEUa}VLDoBYYG7*Q=Qzwnrg8NAVS)1u$6iuqdaQyxStcR$r za{o7Pm3FmqBY7@WRmN#)SA_S9tLTC|%1rP?atmqI3Gi=0#inZ zG@J_*WE)tAYHlUtu(t@W;O>f{k}#g`^6U=YxE{kc{&?ge*$=NKoXzWoF04nklByYg zlJrg#k5f?@r6oc-sX~g^x*AYUT;|Mo}nA6JM50zS+ARM$ue`&-$q{43Om5K zbP=%KODq%heUj?~QnRdX=T&%mAycd9x!N&!*YA%k7(~tsK!EKcc0=>dqw{Grun}5P z&w)(?k(EDFZnC?D@oS2e~p0Qfpw zv!)p=z7QRl(=o*Qvqmh-LgUr|+S7$mP zO2B$;r?vb8jw4w~;K-?!9--4e`0!6BqxCzZLTpy(IvHns^X_*poo7Bem#r{rqO3yO zqX*{nBrV&1UHB1~D$iU-5t-^sxM}m4x>T{e`z5%&V!Xye^cs#jlq2fvhVQ z32dqWDEdt6nrE*m*k%Sa?357+l!lOUgA{qm#xr{YzW8}HiT(@c-qL?*Xb4iXn{y+_fjp6H`=w2K>$`em+A2oR~V$BXW**cg>3tLGO&6m(@SfKyu600R4dc z>D_zyh&_J816p#%5g7*rLyFlg-rF68@Buy+WNNr5sCY&W;6xV`s-5S6T$-9w2~zxh z4vgZD0tO&szon$ku+{DQi^8vBsCgtCXITZ&Gry@ z&>83svf{C?IfPP^hH+oq_OxfVcI8rwp+0$u%c^FQ<0HuCr_0S3%Jur4!;x2Ez z8VC3k;1)csVP228IWJ~E#@^57l#D^+;aPAwE}3h-cWknhk*Oef3Z)}3#3|XGt<&>7 zO>eu|+SZXf=&Ux2*Rxyi3CjiURc&y$c9tIb*NUH~OcOjJXWm7Af(;IOZDwLDxNV=R zC_Fzu0?HNLp7EC`DA;xS6C(}Q`(g*YF82BNZxEvlZy8SS$(){^0wME)f`aMsuGv~E zy*4kg*Ld6kbxNd1gH8MFz*qESbWsBVxLLfg%=-J}nwH$#-+%hKpY4p4*|fPH!(-Jy zRtw(d$tBBzV+#v=b^>cGCRA0qOMd_=MCkTHi~(QoC@=)kOI})3q}OOU{XJ6*5Ft|| z+v7X7g_W6~4kUv(;`7OAoeJ`@qm5?41?zU0_4c)3SG7QJ*3H>$bJEOAMQ=o=5SLu? z)$REzZHo42uKQvu`dlwNYkcA zC94Zp0adQBqFyJ;BCob>V27On5h;LM0lS#lwqew$|DmvmtZrZLSI4HZJ0Y`axZco#X_1dvO>c73hM;n z6Ky^5!%gZ#Xy^|I6Sz3=MzEA&3A|M*(i=c$g&tZGzRz?e@e}g12i+UdJ>dc`X~8Bv z#hOV5edmkkJJVGP>Dj>C@sKNuKLDNKcs4X6@{*J5y3U5haDi@U=}8I-=S_DU3xML# z+zk{KUTSB@vY2B%9D0+=-z}*;D$-{5Av%lP>b;ZGSyzxZV)&)aX(3mH0J1Bo#=^SZ zYb8J3W1`7LMcfojntHc<0?ue^$J`xJo}zY68EX}zRzxbVdq zOg~$VAOpvp?W%w;`^)47FLQvrWfcN(tr*@fFbJWGAQu;G_pB>kc$<_P`YS)(Q%zG7 zVc+1D4E6?ch0M$f4j-E`Yq6B1*1i-F2%{twa3hdY)6vQP(Fb&S1o~u(YCQsw?m%=v zOHA=YSGR*1$`Wh`UA)DX8aDLk=mBiFVPp70zB`q~=Pau+?`*j@t+1phBtrV|abw+u5em+4FohjKTbLg}g-!r$jr8$r99LkE>eDP?tsN zXPgu<+kk|md@{T0DoD@IYPLge9(f+*g+3jRL(c#ZKC)SCZBa-UJS=~~4{jl9tFwx;GOJlO^)JQOal zeE(sFXQu3MKBfCN-e2((?jiR=Tp%I_y+GWzHo=-)LN<K4G-T?ob1#^=6ek(xv=f!R50a{*+I<0xx)u{=jP_*lI!l@cyX-K&FZRY zOtDc5WXCq-eYivdq@Tw1i8kbIt2;eaBF~{4X<}SlT2fM_^)8=g9D>@iu4r(GWplo1 zV@IoF&q}hX}NpB-J7{4AGxnp4aSFkJlfx z!Sr|xHAbOK26eM2r;U>HIlB-Tgrirn+;QMXB;3|oo;&*M06S@uTTE8i1@2hfaGj(t<<*+XP_b@Hk{~hi^oYSP5PR>B^df&BJ&-B} z^g{;vE7N`FCdVGN<>TDKI>(K1T#Ez^A0CYt2duWU)LBScOhm1_*qDIz}|mfYsk zbL^|AqJ7<8);5?R$YEy$SQTm7R8Av+(#T;FMlR@!w5+W0Z9gV&Gra6;aPXdPcEyro zb^Ei)(wgvr>P>zqzpDcKog*N1=Lnz)FUl>tp0@y{2)3$K6m9_s6`;LTIwP<5IEIkX z*74o>nvKwDFN7axZ_;9Ap;~J(;S$Dl~y$95~GypkW zt=GB6>j*@8A?^JZJhFMCtdEkZX+1|=m{lkLEO=ZCz@^+`N?R#C)*dH;1#huXUa`|c zSVWBPE_3jc2^KS?4$Hh-a@F@+0bJg3y|2f`dSYjqvxi)|LdsF~6lm|8_;5gy3Q#)0 zPW^@86WGGL_&JBg50`18Ut$RKq7SS0;mov@n0!8EZKrw;KH;q{A9?HzP^NXZLsx+DB zFpsPZ1)#@fN@dMgN!>q+fOZu4>>TXYjR?4=0|Y#s*6KD+Li|LLebmDuBqW6P1Bnn| zBK9Uap|^0a}a2BZbAfXiz@-<+l;dxsQ)lmpkk-1yAZ0$k$-!!T9BVA zssdD9Q?6E0R$e*1%=?hq2t|xK3_fq$(C#?)sIxwKQpM0~cM=xuHDDZ0>3HJ%082FU zV29TM9SvorATUyAL)9ZCQQEkbbithBzW4Pp{$QR>C(ylgIvwS)8@$dCPRSL8#>%WF zbmKCLAMs8Ke2E}EDnYfLPmqtrfE4 zrQgHR2fJ5Ka!t~>!>4fxln0W*`&|`sMFBwXYple?#Qr$utnVvn?t8fgq`Za~wxpdT z(!Dv->5F!~R=uH=a+i-T&VXrL=m*nD8eNA6-Q;qVGIG+7 z1F5U`wd<_%zW@mjqnR3w9uLG{a!%&_))PA2^mA%T4L`W1^76Q(V)0sDEKBtsoQB3) zQ??JLlCn&i`K~jljdXE-aF}=9mc!VWNt;mB)^`3_mUhVIlp1DzF~+)kZ}-tIj?S1e zj%XWw2?=M$B47z~=5u!QZ?otT(%!4<#3a=5nVsx2^&j)Lm%+&6y$1Zj96w=;OuvIV zej8()h9K|)-oDm)c@qFG)J$P=wWLRoIpf`OdeiYUY{S617IrrlWvzIq=usyJAH&GI zP^lLl?HdrS$;S>6U>C zMj#xYt*yeOB&|jr2oWsT$A`J}-WOppF(V6GJ@T|c=zA-Zr0-)InEGM3QOwP{=qxTbR1EUuy@ARw5#u4|GE<7+G~-kEn^ zOcyYbcXho4YVBX2kM$qh%xTEf?a-;RnLry-;2=2_O;z#uZzuSA#aZ;&J_fUA6i_8| zZd~cfaX{Ha#nXABnDr1xvndJ8rfoM@{1QSORsfqA8YH-WO@**e4KVv0*!&41RIF9y zw$%Q`tgS7aT~_y#YxjESh|AB5$0o_Sd$4k&zK``dM6LgU1qeV%+&d0WOi;+%1=oE6 z(v0=t?P@VEjQGw=poz2~YEEeE>~tp7Y1c18@V0+kUGd@Yjgo^mJx+$=>Sko;gw2!` zJ*XQaTLLGaK!3e(d~#vR#b58^R7GBGX>svQByAd7Zvw{mhj5tQ`@r)!GTNk~jAB5Z zTBqHr%B}|(ZARZYXFT#_$8zKDjFc*)oF6Es2GhAgUr_oyjxuszE%a0Fg;f~5zCzVVukRNADkyzks4K|w2FQ$O~!3~7pRww z^0+zdWdnh1HSRXv=o!-Z)=7CIsms-W{+xuK6!yJMT8gn-RV&dtUGl>f0MIPxSZLPw zQk^EjvRl2h!^e-7%n1kY{ zql7{_T-55%;$lF9xj_zH@cHN-66fDh>nHybb7(tR*-jE;mf0-o8J zf>49`xOpEJ$!)2ldfx@N&4Oq@@9_W!N%rpOR^irmY1xETv?W>LP|KgRm~E2ZEswKj_tpQr8n9?@N75F14g%Fr1 zrqsAD9O)c|xDXC8BfQ%A!0*i`Sv2gx=l&%>;KJt5`!uAD%d z?&gN{_1F7=JQ|-|WbGX@9$k9r*LGoebb{a@P@S%JJqEZ4S#<2^`WY`)J0oZlCx1Xa z>y0Fz>+Frjvg#Zr!5D_cy>xU0j6TgK@2XhmVgW3X`z6jpl+a`ECN|zsQf)ujr$2pk-_NYTts(RQ7E!=G5r-8t^ecr#J}v@mLV~qk z6K^0%ft;E40oA}lcE1ZIbbzX{Fh8A!qIh(#Y*7(tRfqfLUb+G@0Owgtii(OdwBd`NopEaJ?-`~ zeB~8&YJqmEA)!DfWH)xdH9d}pEtRzjg2G$W!nfLmfj`MQbk8F6L$f6j51Hb4pAO)W z2L)C?cKfws=KlqKIbkFl2+`mCmPC7L8y1%8pRF zFVZLj#I7VF^ zZSUJxR;}DdjGo$BS?LT>&YxAuh#RXCc0YBJ#Ckwf+PrcEEZF5`NlMtzKC9Qwv}&v> zf%Y(iMF;Q3NVZ9uV}Ox~f3dYrW9vnS?42U&kC2#M zg7=Y|QN}Mnw$h%VWLvdn(O}KBWfpZk5s?7R-uxfst4v_-i6d4A3^z>XmbH%byrCIW z=y#DS^7f8LTs1XS@EcJfcshIrKO|6*Pb`vXH(Zm^ACKYqam!5vvC!oX7Q~;xZFC>l zH+SI&D1J47Xmcm0#!2I=4A>3)cxKajtLs4o2Q?V1q@w_=Xw&(aS`6_};Okv&zZnK@ zk}DLXCHdTMJn#LhkqcY0#g9IIyGt$Pc=2<4W=Usr4lID8rY&nZv!5)*SZ~CcVXrE0 z&ood|=RK~rbGPU60)@P;{?S;NztN$Lbbe|mPfTCPzavs0{bJiDKcSm*(}P2S zw`%F(tu@=ll$iUqox^+248X>x@w-g2+zFdxTw&qXq^`H;$qyx7US0sbJ59D}bzBLe zB_bx~0AY+p8TV656ybj$JD86t02ZRz=3eG)mT9MDXeTxoNp^W?qBARi6z&oOhyzch zM#;(<1wZm;!vetT04J8Y@uH*clS2(%Fo4WI!wF3HIP-68ylS#;bH$zQsMcZ{F%9K? zghz%yU-aB8nna5GM=${3N|OFX?tf(6D@WlN6UMeEYpObazZdQaw`&rhguaFH?5YyR z`rTIrh@DG5Jiw=H0uT=7Qb`9X4(NDXlBUW5imf*wHl6AKV%+RVF%lg z>lOl(PY_7|9N3D1+Mmfpeacsoz5?phiKxg}uW z2PD1VBlLo&JGsi63A<-z-8n!cz&w6b06P zi6bPmE$jf2YzFL(ItP;EY;+@da69&WwShYs+8)_ssroVMGa>A*^ySW7Y8J>g$AS?R zAp^V!2=p7fuJG?4p#KB$37<~L(d6RX@i~Tt`Sywt@_GSd8*DkZ8+uH{nBua^1jl9i z%KOWdu&nh^?f9^I9ar$^BFz$*esaryki;chKmyV|xPzwMJ;S$7JE{}}56<0*m~>}k zx`dUaLZ-W{-we9U)JK;!?eY_o0dvqeIY!1JoXgeTO;&ca;}t zbb2hAb^Ym)@2h~F;(?h)J^%v`w*-O|z+ro#u{!}Yr10iLglr@2V_}eph=}(cLv1I0 zc`$82G$leF&^>xX9TRb~HB!>831`x{n3&xkO$RU94e6CO&M}(*BvF-3^E$h1ymBKI zwA)D->q#9TS*IBY;wM^*AZI%P3c&;q)f>3AWi zgf1;dSKjn*ZUmYMjbs~z^F#DeUZF~LZvB<$55HJn^!6I>GwQ2gl3DM(3Kc=_JrZM*8w9s z11F+oV19dbn2(4t95zG@HP7t;r@)48nhf>>!%~ zWj){MaWwrp8CF|&e6~Gn0wmSCbA@QCuHE6+oXfG`1xRQkZFS20U_w3f47>}ew=&?F zN#JJ~4@eAc0DEX;^FZ1))&mN9L7V)QELSX|TID;bJ0mB3e#hSS;a>i_8V(2_nJjp$ zywkd*s3_gM+C(Jp_QXNSKqNZnML1sI&Q5VllK%>JlK;b6D;5S87L(95!_f`BS`v-d z>}1yo0_W;|^je90l8qGNPk{*Djyl;Tr z5I#!m=?wG>j;7j5rxxLgUAg&=ix8OCmvy9P~RI|P5JQ;;&MEGjx7+9C2 zoEhE_35j#-c|qjoQ2>2KUd_@^YbFw>xXZ7E&N|0a!UZm@$)mdu?e?nv)80jBtg0&9 zX;iCre@aPL&FjW4+l+@iNb&nfo1Pv({5P8c@@Omux9ytph7xo%rYiQ8S`zalmGwl; z5O82kNp)6B>y~XSmNi@Ng=D?0j=K~749`2oZOM9qa=fwRW*7?)|6i;i0MS0JtD^!- zUSI@k9!6<}+=$J_-9KsXgG zxAj5YuT@on1?qF3yjPR)mBZ3&-L{PTf3*@I9ORhfz3doIW|n}zfgRL-hL=BZq3K<) zY^TPQ^XiNrb-h-_vI>-*BHL6T9U@ojk1P2wSE!R&lDnI~IIu8PZPpLeI+eL5p)xhT z1XeGOT+dDg5jZGLxPN+fcdkT0Z-|eCKaDqG_!k{$CmUFL6vpcgaHP|~W@OMR*lLE|>&W7%|M~n|==Pn%f^RqHaEfTTNgV}~mYQ0B2=N$wr zhcAN-J#{>f=M@xWvsH)pX>vc}@uuQ9RK&0=T8g+6;PN=}1K&v3ss}$TusN8VnD9P9 z5W}+%!}7nqu9y$&5;LL14;FpjS#_j3uWoYiyq9Q2i=6{qH zG_B`Zz}R0f4T{^L-wsE4UzkZmCsAWeER=q$#H%Wo@Cen!HLiQ5RM5Tp7#O|*o9bUY zfCr;Nr1b8GRY=dBn>ob-P4_+-=~OS)Vv=UN8ryX$(WvPuzOSUdSneferex5o1EUKj zs`~bxJeQ0E0t=hO$n5%33b2)KVK>nI}iem8=mF zldF(WPfJfTxWSFV4D?tbDJ}f7NZ{OGi9jj!7*uKEiY$KylpQ}0dJBpw3F;^E7zj0b z16nBm!ULY_O-jjSsL|}zb*D6B?EF-EQxDoAXap_?LO6GzoBlX%r(3$JI=3@qC}SsX zch@U3+BVcrT8Ng2ru4BBT@N|9FQwg*faB?-J@elL_urvLEkZ|r{TM??eR`y`OI!Ml z+a1t1(KOy--gy_zr8?DO&&oRSY;YZIly;5)1#~DO;cQ&Tz<$o@<$-se8*3%|PUi5p z*1I$i2;-~1BBAQ|$14?-wPG;d_l#X9BbBzr@d^wnv7{% zPSG5Xp46nkd$G#x23{oZ00BJ3 zD!u*hv#NtvH2|MF!%B<<7u4ogR{r6fpj|j_mKZ43mm1-PucST!VKYPUDJifc0*d_w~^RNI&2219ua~VtmC8nA+jY z3*frfSiler-wWS32m_w5Sq16_0~;L8C44N97Q`z%*7z?OC`z1Qq!!4#eEsZB`N;Z; zOuU2xgR7k2X_hHK1B^c3T?7|AkRYb|QB?(@a7(3X47@J|o7j_ZBZN@Q4Ddp)fsB99 z*(1-z^S1n{|{_IfwBJYasKad{$Dif z|DV<%?VZyO9K(2LsKBYk+vy(DFqElWFNj?FKWqgjyCHTh?BVmK(fXf{$*ge@Y=Mz( zFZO+?(eB8BcZa})RO2V3x`E~%@(Ioy^5MaMN6?3wG&#nf`z*X6g47Vlbn@RjwnrA} z5Y0^yRtaOUi$IX(Z5#9_BG!tZIwf0!&u z@=wduD?VpHo9}T_)F1>shc{R8oUY(GnthFmJr0RUi=$7x=-EZCZEUTN}a3}FxC*{Cv* zWN+W(OusqGJG9~GIwvo(l;|lg`Q8XLgU(HY*^4TlF8)A96!d=epWQTf^IuzaCQ`{+ zBt6m-Ivqme4Yv8c6+H98!ZELYPB*aMtZJG`4Qt3jnUnlS*~*%*TJUg_#l*Ll0#_$n zmKEOy1eLhnlYcM9E4#ChuiyN8L!#u@Z+HqVwy0cAU>9;YhC{LPlx z%I0y1!jP1LLVrdx4GGT2c&qtlH{J7l*K(8co734YRA-SZPo6x-cuflou{18r-5C0` z#1f$O*6zl7w_aYVYS$F|G8Zyh6lT$qk=rbtJoOzXxfSYJwsoT0+&sR#A3n+BG+!v5 z95=*^sz%0tvK7@TbS^t1K*M;=@?Y=SU3=-`HyXx3E7J6%clB#=%1(3hJ0${=IIM>^MR7Mt21QNq2qOvXU6`HwNdA5E z0^ul`W_D5P?7tHV3QT8Y*!N(4LmnjHHFWXiwlVKwjws}pU2&fINWw>*F;=6&(R0DH z3RkAF%pb(G2gS&5pK3iZ-cb09i-#8Skq>%_MU)iIxh>+S&pAZf@BBbww@2OwxVcey zx_>3xy(lbxNs+*K_lil}-*rN#j&SQdP}g-kWO6YXA+^79luL5mIg;kc$lNzLnAh%y zgt*Fov}i-E3E`r4qedjp=R57m0o+>}6Tpt6DF40b_Amx{Vc|o+NMTm;upXB$&b&L! z@j7Mll~e@csdf6@DlBrk4dU`cOze_JzPauZdpLn(rZRptv;G~`2QfoJjn{El?x%DM z(cQ0>{yH2l5X%Bm|yrEC;kOM}8yxSy!^AKi{xLDJVS#J*SmDMpeVw}pDDiP}*MwDyp$PSHg zidTP2ZGt-TeTuRB!59s%BA=fDk^V;Z&yu?KodqM+F^Iay+c>z zu^#rjTby?zjLN7cST?Q%Wp|aONY_=4El!3gSjg&_%XV|@8<>)#k9Z?HsdE#*G0*E+ z?1aH20B#eLmk%Fd#;~Zuq{SZeIIW40Cyn!5Zs^^2avoM;d`2huU4tqRYEV`66sN7t|XQv!*Go8%4dou6j&VqSa_=B%P1rU4PIe zFWyk{r^IzRRvLAsFRkXK9-yQ$HWtF~%&sOHiRs<3w1Z14=PW)ch#XD_QjZ?-ypYaJ z*7&=!cq+-iTZ%=kQRTC^oR+X$pY7f3WKR;WnUKC=*Wma~;t;B=NkQ~LSGDHiYV#Q; z&xw@S*i_H6D|n8}7}Beki!y@8ai*+*ej5GiEQ-KC&%WGJ*BV)ojS(QaY?C+GfLaD$ zo$uN&T&z~HtGKlKyD_ZFliVDtW2vgHyUa8v$XKw=ESGE4Clvd?>7nTdRwdfMn!bHc zsqwHcS+Dq$j@u-Y_vU;RzCS_aH&M-i@jeyHpB0v`-}YDb*d?`BNnAb=xO)0v&|q}{ zDyDR*{F<@%tEh~pUs8PXveU6inwy7XNzRYpk*D-xSj9HqI}CW4#`%^9j3UUm^!ut8 zDZS5H*s`zFJysOIY$RK%I;okLF+XoBDRXdBCJ-M{@3JRw$Z0Im?h`Yf?GJduq7Mqk z{@3z=oPzK$Fu5Qwol5<~MjxS@A==XO`cqQ6$Zwyt%QVCXtt}W+GR;1GP<|gd#(_=R zZTD^{@8W()Y<8gU%bK5MF7qkHKOAe=jj13l(GS~l=-#fs2s;%!(N(1TFmi+D4?n$p zw~;0QIq=z2KpyK9sgm37UGm>kNfG#j~G?PxpD8PT2m- zAPQsAIcrpyuUgo6s;cR7m3vQ$+%`t=LcR}es}Z(f1`B0ug{hTmz4)P6xMaKO&+%FF zNnmzlIVpDF_Lg6!qT*PGQn0}1J{Cs7G7gT;H;?4&#%Q$OWAYRht_S$MPH6 z7y3xer4&ZWwQw8UgGsKf zi9cU$Ft3!_eChfec;Dq056P<_gQRTs_y1z5pzoHVnZKH-%;Dx-f>Dwstv1*JffH_;vQ%?-8&w6AwbTHqt<>11jFB* zT|{%*0u;(Quj>+Ej|v>ZQQx|Ayjzf$Nj0-JDJ}(*yQ7>Xt-33&6C2M+L|Cj02x|3R z)1nED;X8}1S6+KPl|Q64470WZFlJd63Khl*ixyV0t)m?I7>!?iLj)B$u zG&?5l@r`YuU#Z8Jz@eDyUv<)8$|~G?@SaX4NZR~^p|Z;To?ncwly(QTnU^)V@WQO- zKAkPoj(H`u9>i_ax0Oppz3kmaMZvRKt&J& zt6@+#d9)Df#D`#AxGe!>_fN*t4&lTPSVbVd_a$s+s13P3m|PYN-ZCQyQG71p$Y(4a zRb*~B&Xwu74MuJFvDAH(N^D4dS70{s$Ws!lC@RfDktU0lFu0hWJ-Y(ZfE~W_WW=z7 ze}@@NJ2kL-G^rYAqdWg{%xhsf+o6)9l7pRnOvUtVnvsgi`=$lq0!pVp2hhvU;Pf4O{e=aEy?5NC5(#|P79OXsBJ`V7-jno8x$W) z`t4!grtm}Z2!XS&ysQpqDViK6;~(S44$Mqa0s3fQn=g1g%J0XQI`M*ZocnCdQ z)q-2-V5-Tx_2$MGXS$~5bUjve*6HK!j~^w4WW|(lqFdddm>nxtulU zn@#tX&_#Y@+$?JvhFJza|0z{@@(X_-^4YB}ejHiOuj*tph}-rMEKflS5KtT%^PJty`wa^Ky*f#nUm?37yv} zN$Bd(|1q7h0@ysF%fI~N1&6b>P0}Z?XMG8^8AO%js;63rngZAF2eWF#jWK+&CC@*P z>OjDJw&cRM*7;9RpT&d4Ipc`$x3nniIx~!ugsG}(s(#jtGJBphvJ{Z_1G{KCg;_jP z>ixu;DWisFiWRTJT*gY4uf9f~l$?T`^fU1u#F{T!i-=0P!mxYvX}@;u#Jt!3*fZEf z=Jn=4N{Z`P8|5g>Zu=C!vTBsqS|OxpXO0JO2x0&)8Rs(@dCVt8F9V#R6t?49{=KX( zV+zlmuXpPftV7@JhE~z<2>1WvJq|lbRgypYYGHAU6yr9;Av7Q6n~)_fU@;mw*=I?jW6E{V1;2k5VCwLEHz>*OOCIt1_t)Z)4?4%A&i&`TZ_Vrmm9)C9VScnlmi_3k!`W`_X13AiDPj`0OuOAO2M`OSaj~Sy_cnhfy`h#ASM|wH^+d ztWODxarE{hO%9q(-{Xcd_7^KPii8JnsXlv;)oU+-D(TV4h6cRqANihcz&m2TF~H4# z(nlBdO3D6}68ozIUsDH6A}m-2n_F_Wn**iW&CkS^ymARP+!p;uw?zmPPfF>{V2do- z4Q|UtXZv2Oiad#iin^xSPxv|nt2wuk1}~(*_ZI{*wcvp`y9&7Smz|JH*43GOT4?hq zC)e%oS6ch|v4QEOP=m)6hOraZU}nbg?+Y%q!BL=9y+E&`w6j zpPQ(1a^lB8G{&cWUAi~?27$lM={y>w3uLSOi8yicB;tT%Tu0hb4n zfow3|W6}&3H`5HxnwZ@AzQwKMI3z<)LQAX(WXkvShocuBDg9Q(;n(env=8}~k~ zT-xEObKAS4CDqiBKmE9@u=Y8OZwIknx zz~Ute6Mw#nsb|yTyH287rh|utLA~rl%QdzPEr7`3RrwKQo6D59=9T6#G8JXN_(l-c zmG!J;Hl_#kbxZ{#poVXrkx7w*ny4pKp42Ej^$S+NJ$G{0Jsiymh7QF)?GD9Dwi2%l zm`D$E*E!2?@L9auJjUVMrGiE}X0eRdjZS@ekI~V(A>)3}Vn;qPZ&0xa@`Ay);cd6u zU))PK7fM#s&PmaS1xI zAHUiXnb%dli zSK>-iLxXi1mW)HJmAZ6(%NnXV zGbT&0+u-WwN;A5Vl9Cn>_|5ydpXc+w$6v7XI(MDdaenJiVm+E>y)|e9 zw1~+X%B|A1O+p9WK6VRc=VbRgN1}t)8&e&fxZRU%+Scg71ft019h+D8bF$XO3?hmoLpdlzo1S&kTw*mm)O8wAl}=tt z@Vi0k6sV10kS>OdN)pq}8@HNe`58%?+a*8hY`#J((Cm~n>_ucCcE7%xm=O$w7ogyy zPr?bL!VGmiuxmsGexr|B_r5Vci+k+GT{Ah#$5j}<{FT<|9{|pDyplqPdS)yTh8|~kd zXmMzz@QJ(m1T@cWE{6q2u?l?g-VOA@lb`t$f3Lloaz@nyG&+XAy9|i3ZHtdHbBETt zai3&Y3p?nxZ;^&rqjLnuw6*PL5+e05RJCf%pZrO3gR7>0Ksm_CtVs&kqA>0Cjb!jh z@>oIC-SKUM@orHNcClYg#xDj{8omQ>-zqDS(_@g^0=fmi@e@(AzQ5{sMCWi8 zVu+Qc?6nqD^w{`DoL1<|sNW0`2c-y{Z1fdhcAY(rP*pkRxgj5RTiYL%k@3;jVZJPu zF)R_gJ~S9el_GC?enKbvV7S}=kGGQKNp7V2Y#Cul+rLNC7;fyWpek%T8B+T}Q`vdd z_2-I9Zqi%=^)VP!qo&os1ci+P9IR`*;@{TDRqb}z7qe4FS2`4qVTt0t1id!Xo2c>G z6sn7MdYId{a*`cF$-YXtlwpLB1EpBz6}VJ4kmc$)@WiZppzhrTvk9~x-YSgnbxxtd z{4$?Giqp*Hw}+Z;7D%;0P5db_H^#m+4#S}x}|(42)LNh2!p zm6|bZMwOrPWLN!Lax{KxZSH2@xtsZG+0FD_mb&#yX;&Igm+I9Y&40fwzc!}|7tvB_ zF1TY=oj?iL89hjwXL^pOl|Wk%dk3`zc+SZ`v3Ve7P+2q=YAZw>o_nF_bM8J@ZL9|| zvzw|srVO21I#Ews+|Qn-e;b|&{TwM1slbzAt-N}2jApZ-a>;$##)z1zNyJ~Rjt5K# zq>G!8c}P$RN%1Mp1e46GZ7oL&Pb3|mjY@qM{IJ21!=ggevO75mqFG~iHCuBTOj201 z`ZDS0kY$~I-F0SEV-AfxyR8Jm2!EsvSCaO_4-z1{?Z_IcBoVegLfZP3YiXLGUh?BfU5iq^06uudAn~ zXWEul)SjETAQnb~!hokLdpWo8G0oc9X6}og^YKx;F0~>kGg94G1`spm?Ne)t7e_7* zp&qlWFt@r2`QoT|PXQ!%IJH9!+rXw)DtJl4>&%j2L0c9^=12d<0wg+koJgHtIeZ-h zjzSY;id|cI>*smywjG#o05QevRiYY)MB+jU5oU*eZ{8fgx5&}KDMf}1OR)B7Lm-y| zhZFC3l44_%mYq@J6qa3o`y{327HZBf^Y~i{lRQb&V(Vez?K}T(_czs4%6q2tSvfeqO^VG4c?q{YmgG~Uf9hCDI6oM zqjr!(s@GQ)o0H@2sCH-hSTyDBr*(pco1_i&>N&=v^e-$H+&6iQ%zq-CxHtt1H5KKR zUJ1q9XLs4*@!d_6VFh!yoH=_|w%u=usV&rV{Ri+(k|3Pk@*;IOzfvr<9wwL$jRD=i z$bi|sjpv@*JsQ`-tHAOX%0HMIN-G8=o%hZdjgNx~B6<|CBB}KtN3Pt<_LX4$^WbO( zpP#9#?81CEu|*(uD@?3y;as*ceavav3S{ zkiQeCcvJP-kQ}M5h^lV^WtN()MD&*NAw*~MX}IENtg)L7x$TUk$Hx`8*y9&P;ew&osRJlmp;g$6k0f6uHPU%3a!pTyIn-k8=RBOYtf`#%-o9a zqa`Yc46!z67Ep~JAUK_M+Z^Tg*3w(IxG^mrWBU*6|M6sS`~LVw;%DBU0VQbk9e+~D zF)6`Zu&6=W`LwQ*q=rU4SG><lZpwTBU`C^M|#L6 z6-31JD`jFU=UWFTC3DcYN>7|}t*4BUorr}CI5;vElRPGTCo94Bs2HiFF*YD_Z0BH` zEK_V_S~{JY7Nb_e#UiPL8?)1^6x&JV554d^U8!g177Y|+pP=3e4{CK{Mv~Ew_?^tw zn0AqjhzH)ZZ2B)F^sre$2n5Xtk&aw-6ajqs3@doSU!|7$TN)y1|3dP~eK52^R5e17 z_A+Z*S`$}DH<4rEdgSRNp;{Bn82ppag7DfyX(47?OGWVD5mRYMkg0m7_H51LLR2M2 ze-T;$cJK+%2F9@vQKh`4Gd-F!iD4OLx<|w$8)~`AvF4$h`<(|KPSK!9rpu4&eMG>h zymAyybC>%bnT}GoQ7)~)ccrp=^Zwc>u2h{ueI4D}LcfLAp?MBI9O$4{I5sPBGyj-0 z(oqEQ@%FUv-?)#zB~RD@3ZxB{MJC+Fm;9TFm)@zxnE(tFhAlzlM-zg-x9D0kOU$`KjgQdW=%~chxY+0-HUu!# zQm%Y>GEdHwsw`}7lTwzKGIWH3e$c55gZEYUr^1d-f;C0na$i0Sx2`xYv*y5AXJ>Wj zE|9;!F)bLg_|J+yo_sCycLX)>ZX;zQz=Fd|CuRh6v_P)h(bXuFI!aN!ml-t6Lu9`3 zB}7|CGd41H-A#OpUHG`~m(gLaX#-cXdXJe!x$&RA5Bg?yA*-^VObgJ~qH@tOpuXwN zOiK>NgA}kswScaUX8&ftUtSsV1x*r~6&`>Ln_21U8(i~v20(uI@Bgku9LOjjEdb0C zkX`XaiNk!7WeY$Z%pYT2RCQ1g<*91;%pUQ*Fg+LZ7n>$KSfudrKsZ3Y5V^D7xaO zi0COxidwD+9{;*kqECZsAswly*nI{q}J=D?-F$~Qr<9e>UGpwc%%ZO$Slrfg( zCTeXrQLk%Lij>c&O@aU*ae`n$cp$DIP3`IH2e^r`ok{H?-lV2y89M3@kFo-W$J8>k z<5*BGUtqog$LIAh(Zq7|;J(Mc&mP3zk5m6qI=?ONpX)|fu`@OZD zBEZMZlcf_}!!E=Htz7M$G34avUiN&+)B({*7;f^CRt$DY<0B4L(_yI8tydCw>V$5} zjEj>i7!?Q{)vD`g-3)xGWoX3F@eDrs`YYYpZBu3{>t=>u!gsc;e`3)jn` z#)#*N57V4A4_dLxJb+A5akqhZ=0yJo;CbZ8!Ld15)Rd~?XD_XwtsWklZoZQIK48vj zdS=zhooICeC{DilKl=F9MSRjSjYP?dj{95V4pWBOYj1ldv^q?%X6vgzAyQfmH&q+< zNyoRpLJA6}t)0&pamfRcy@rs6v)a2&Q--VSoF}Vv*75O#VXr=|(oC(qdkZvlnloDw z6N_>5>1NA0An5?VSwu9>-s={;Ozg2ye|}iab#Smj;1vO z=_+IY)H>Sjr5P~89uf>}uEulesZa8b+D-v7=w^{52X8X#^|Vjj?d2pJCG7$k=Cr^zuC9TktRmX;ix?3nS! zx{7W$c*T>@IB69>VG$Lz0x*u@2yCo>7FhrEOK!3rNFlHhjxq2$JiH`iNY+&T%J<1~ zF2rQ-k>cKVtUar^E#;?CVyhvz2#%wzMI*!{W-+7i-4Uhe5gWU*2oM68Qeq9tA#9C& zls;0U=UcUn5LEwSnz%F5E8ruh0-->>o3+pVs~?WDmqh3k&n}+cW!2664kiywVl!V& z3diFH&8q-Q!n%I9OQYu!zc>6|p8Z)rd=bW58+y|+_)RA4IzBe%xnr2|JrjOG=-06Z6;Z>Q#r`PHfoAGWAn6?HZHF?_@>z@C@$hZ-Z7y8$ zIP~EM!mm1GHRZ@QUDxv4jJ)^fTy2Cnu3X*VZkdZp{Uv>Khnn%lngoX2@y3rmBlweN zhQc^HEW>2D7g}r(^U8#)tVhYW9{BmBwk?g-#o?-7`5bri!p1RSSM>C6bUxcss;Za? z+T}9$p)pWf{|@Vig>H2!E;aOcT#gG?o;3V^T2VLO(*0V@uV-^5=y}uqRd}Umk9?Nl!RZ0Ce(F>T{>C>XXZMxj|&!Bm*d71kQ zJ{&%V@n+RG(yUb=g*c<}#JDnTT{86qV;LmL;Ep7XEz^p#KczaFFATj!*`ti z54}p@@@z4dAshv)N;usaPP%~4dnjobZ>6%U=J*-`df!Im#w(6S5KMM zn3;5sta;FD`!xq*??;L@MDMpv8O)n)_5QS`yCq$L*R zZ#LxLnwA2q`zBSq+kDR2q`Bvazwsu`tc;A_RN3PRY`sPFR3!W%M}&Qws*zz6%3i)L zaYeO$sj`5de*`%^iRJb=IQJTbv_QrR8MvVU#YY}zh{M5ct(g6#u7#%4;Rk*Vt4TWP zKCIfB)+^_3TS>|GI9uIt$U7Otce)+;Mz-vckf;lz!!WfFWh}IzgwXCb)4i014XF-$ ztPPMIe9K4d!W@&=t+0Q}!KzSI95O5mm*;@9B%u*W?|4$;(q4x~vB0>gkbF}$n-zk` zOKP8Q(S4%2RQJUt9S0BsS^Ch-{KyZh6WPv&V6*U=K9W~;0e`dj@HKQ*XjrwGOvCPs zsMUP^$~YKA^ftbr^o%rU$uoj0{JX}ZBw+My+-<^>#?6xgem)I|iqHAk*EBynG*bl+ ze^vBos(D2n(B8pZdw^H|RLoav?_%$qse`47Q(tsHsL$DZd9o#6^o*v_MfFp#(_I*% z1L|`|>kzX@F4AfJkb|YDvK6m57Yr7c|;aiX+JHO%%vEW^V172MAjVf4jUfJLw0i+ouey~WkVB|v6DMM z6%Cyi;`9TS%0gZ_1eSjPzS{VnV&BbO`$fayBe`gkNDM~VkenK|pdB}S2UQ!QuJ<{d zz|e1Ho0`p zbC$$r?CPqx9HlPbh^9z`Y_LFHD7$1^^l~}9RP`^+W3MliT(tM;NTbW(fwa?F5qx+m z{yqO3&nd4Yf8gUgNv|RT8?0)GTDw0OwWC+8zJ#?vIW^pLyvWo{K^LE z{W0gyA@5Ha`Os5I&VyL;dr7@7eGZLiK(CW7^P|nwN4rrgW}I%3>$b}8T&{sF-q1Ml z_ZI`e>1WHuD9mU6k^67=b~-jx0vX95(afgu0-eTHxjCNU$S2kulO^?--8!)gEPvnC zWqST_9ve~X&x&MzehqTRS6+cacu_sRGA{XS>w!E^26ZNSRWe9^a^Q=Vx`?Wim z96V-F`l&gODd<#HF;_sAox2!TSt}u!E3fPJ)*O+tp8o`=M?V%z90c&=`E^+A3quSe z{nZ=jbiuKjAfsm-JcJxHi0bAC0xOq z(9nYvuDku;qJDDDXlE)6+hwy88eZjw4X~7tz>K^-yulTHE4sBNmkeCMwj1S|clj3a zS#H|i9_`%^|0S{MT6;^O9FLsp#A&k`v^vcrO?VaKSY3zu1TE!qn!n&D$l=|#z%D!X z;`=@0QLT(dx=ILsmnGhcjznISC7{)|=L_i7MYweg>YHf9>ACmI_;7X9eysYP$oQph zCCShf=l1r8M~utQ*WwI^;{Az{ZB>(qyf5$MNJZ{k0;A_&h&|JRYD{< z>%Da5J!zc(O(~XpkqPg$W_dKoLfNUqN=kY9mrsLJ)5^z=1k?ev$a(iQw+BI-0uWeX zMb;$mFVKT7y<<5^NRC7C>nekxBez#HIi))q4OS$dEci!KUXZ@o8SVL5$qqz0Vmd#d z0io0Ho93*0TswkDc10{)Ihn)`=7#&^Ec7l(l^W6`%}{$3lB6Fd3t5H2*G1~uxHR)J zlU9X~o-5-)Q_sfA%FHsPw)*>N>LgaQEU|o;1_WFjh01%>_VWDuOhrouNEbw8d}BKQ zwx&NG<4p(hIxBC?onBSUFU+s7$U#WR z*9338HK@z3lvj!E41E$G;0qKg=rGRQe)_bI?ba@0Cr;I`hpiY9_*z{$2Ec-5E4`l! z?CVp@Hiz9GBt+X+?y5|XJybP&$>#}=jfhjNRn}c*I0VbA8 z+}1ndL%WW%@sHWTee15Zz$uztn*pxI#h6Ee(~kQzmy$?pp@ht90k_#+J~`|YUGK+7 zCRAag&q>7D3S}a4{k|#-E?@t-?BUOOn`^gkZf>!*I%Tk1n{nPZLVqfCitF(6Sxp^W zS3t-;lrxTy2_-l-mj!?|X;>*Wk8k4E93AB#l-dxP7DpMMR|_Q4JbDyM9s|}CjFFZy zPeFY|{i{C!Ux_yf%TCO^qMy<($i04d1+U8fHJXW$6y zP;E&4>C)hO`s`WE!_=dB_44Q0!nAbvFE4{ElZM6aN;W&&GE+Z+&u;j>NNqGXomwjt zf3LU?Pa!4EEN;3s+wZm8sv>yeeN}KwS2^o7TH^_eDYebo(O?E=+C?ZPA&HQIJosG% z6qg7>T!HeZC9w)3L*x;j1bQlF{cEc>Vkf)ySeR98B%$BBR-|^zjdJ;5#>EV^03<1P zU;X`mnli@?f-QC-jtgE|B1``g-wuCdkq?NNr%(6up9q_3kA~dijdF?uQm%S>Vozi1euAu)hu#uMCQ-f0R=c2sK{xsAiTN9NgtMA2bro zQqA2>48-;^0xV>a52qKQ0^9rNM_1Zfacis4feo6V*U3=Ftu=a^aoeKeX)zyNrT8ZU z>M{n5c0>%b^~SQNwgnJ8WQCGZ2M10ie&CT4B^!hM1IT@p`M&D1mUDp+!<(Ybot!+5 zmqmr@49GWYjY2=)GWOev`)IZw#HK_%mVi+px*TU#OxM&nn&v?C^6M69K1rk!$v4H3 z8*OgR(WW(i=oVZjSpvZLH^CybOeEnNcY}Bf3N}7`;f+vK77?-6^Es-#m~UJkR%H)} zNbxVK0ZT?my8Ujt@;oeg){y42*|v=QBfr-m!QOBDu&{4pyToxeVcyEA+AxY`S}wv2 z0zNp`IEI5TjX2;S%@R?eO2wU4T)H21f{>tr`X${2Yh;LF6*%e#DLWw$mtUGnjz7kl z;}@ajmir644aFV7=cjvB?^<5>isZaKGjIO*H-Zvh&CiTWq27i@AV9Wp?r4SGrBiaB ze5rpMUSov=Zwcv*IxnOdj(hsgFgYqKkMtSbyT!zwG*8PqxY6@TPBGBZIMm5DaLQ9W z)Pj16pbonZU}_$kx%dW{N5_*7uk#_C6H0H@VS(~$BWb>Nz2!#ieBv&s5*bYjnEKt5 zy$?SprE{N1HSd(q9ex#F@LY|cE28mRUocwswBJXB3#a*5!1MznQj@@Y`s9Ke4k_jk#L3JKu&Ulpa2N zjyIR}IXhF@U3p4Fl%k{Z9G5%-KH^?ZG zRW@*v1u}1|TK$#l=I}u7Z_l+62fq|p1=GUvufkc~%a#-szXq3b)gPi@GjLiYMZ?nD zV3pAVUq_NIzHks(v6gso{Nxx!==2>jeYpFP#l} z$UT0AJBa=GVcE#V<$wSXTbP@v`5Ozs(EEP|w($^fx#17+unAt*@lGpkCDe&Xr_gXo zft?&PkiL!)Go9rTyM@18I)}j36_thm+R6VG(4>PNyIXsv4}OSLm%Lj7JBw_KK<9Z| z^-qs9&UuN|xzDQA9&?{wpPsx?1x|AsMZXVUIVz(|Q5Or~zeRSQss@x{SgtOS5?Kj6 zt>lI=y%`A-CBBGYm^s_Hnex;U(P}w8P{}pf+1&Ogz;q%`n`4fAW3oYK-Mi7|t(kg` zL>~M4gVJYyjy8$qAKKiA*i+J6{7Sy>J&1_`L;%N6XZCy}+Q;s9ogQPS_=Vp{K^Q~gnv}S+>Tm! zMkMmh{+|Um8#CYY=LoH+c-U`A7GOqE+~s<05dYpRr*_WGImwe)r|H*nFj?JB9#XmC zPVhY0-HFL@!X_+v*Y)YGK0@@CpT|M?0mT-pQU=SoLqW>btT=mP^6+XxW=(_AHmjlZ z57F_w$tWHM0v2K&`w!=3&f-6MC>!P1e{~O#COua(A7(Y;C|JtQL+2B5>sI7FN5Hq9 zv<01A0XjiJBJYBV3p+ zOBkN)m6Qm)1rDrJn9^=^+7A%pNoJd#%hCw?&uLv-&8ar z^+!VeVsyS)f$-UgH%-1_d|%69JqNq1D{1J$rBftsxxerGE^NeS89rjn@(*G8ZTv@j=pqDM{vSdq_(2B|8ht|rfcMq@$0I;%#s{qm z9SxNjVqilFw_CMSCiS+POlMp*l(Tch#?GXjy)O!e*#BNh__M7A!BMAKc%b4CQ-HgD z5Qo|Nvv`&UFSlL&7zaT%ERUS>t1pVoK!dtQm(?f;DMbbSSNetntb^BAx~0CJAzJpr zk|((xJ7ww~(xt@Q2gV&5x*a}PkWE`0$l-o%eZ;gSh>|oOkdvM5X;o++-)4sGUUD>0 zX^ZJtO$5(*#zBrO-I=kAtHt*{2Gha`y)n=zqKkqqqJ!PVAp8 za~i$pcNH8Rv})+x0!UW}4C0ymPk^lS|@HBp|&eRaGST!)E0~xO$ zB8R6xuT=DU-D4o~jJ(n%W@cHd?%iT=51shKJm&$i-Oyr-g`*!9I?B`^{`^XV-)j{- ziAGLnJ^UPwUB{bNHpdGehffsbshwozqmk&c<@Zy*4Rsgw^WZ*h)r&4Hq(IoiS;MD> zM(zT|fnt-n0Q@PpE|_448{FdjplL83!$JV5a1wzQhdis}SL-Xg>36x0`syrdJ51d{ zl@x1{s_nbG?3%3~|J{Ttkg^QZ&Ybv&h{#?9C9Td%c{pJ^=vXb{<3NLj2B{0ka3eJ| zYdE>9R?rm0cye`ix66BfW~A5`ewCtm@s)aniNDPTFP$-?`S{-Los#1$UcG|P3jbln zZ_&|}QA7IA6SCAiGMC}3@9UVrOt zy4jT!Y{U)@wSk*>5j#Q1_0r}}!H%s`fSJisiMFBUZ2qf^u@=Uk`&|QKPm<6*!wJAx zMChSuS-s^xQN&8y@m1<$OEl7zty{#@v807YaZnMp_+H+CJK5kk|DkrYxcSUErPBS@om#n&rJs^O4#8NniHU|1S;6jP)c^uqNj4(2>g zvOBR~q{sbpJbT4;!qDTgU$rLX1gi>ESo&NF5`w~#cU+8WoZMSkRmpY`&IYvj?vBmyulsMSUP{fJSkRZ3t!qr)Mj~+p%tPBWmH3ZNTl!{|L6q zVPxV`&L>fNdsp%3=@hcNsCMZ5G?ZJ0--#GcDF>p4UYGxoa9=?eq*t$x_lZ&dxV|jf zy1Ht{UcxCT*RnPqV==U3UXok`Y#KK*bLlWs)cI4!2t&C4W0B;UqI_L#b9hIDl98|W zirj|XCp9wU{iNxZeDkheA%d;=@mi7a2`vaD>uZzl0O5ETJ())c# zVo1*Cm68%`gk)1Hlv3C_u1+a`8?nl)a-Wb%z&)>M#JGy?a(Z_b>A_0Izx*!IF{7v? z!(-r2#pPFB*ZJS31fMLz++^@wK>6+eWEur9zRDquK8{GDt=Lx~5Sjet^!Htu)N4`u z9Vc!?7D$~u*SX;e#$P=-xVLTL{&Vg+gpgM7>)QhiFQz%d?M#N=9~r#eo0eSy zumpy`FC}%GYVvX3j8L(;Q?dIQRhQ!WlcxZZm2cC>E(PDOa4k?Wh53jby-X^LJbx7u zm$H0#&{=C~+~Np@Xo2I(--&X5TOo*8r#sF1Fz7}`t4DSJuEhLy&7lsAAm8HjwJz-l zse<7b9XY(~=zo62K9h%)yLk(0y4i`@oYa?8+|aOAI)J#NxQ+A1PNW>u@GBmuwt9oh zW|`oY5C+y{Hr0DmI#eOscr>_@WVQ zzv7nAYvbQye%(tY7FoH@;RN!Njshm-->mNoX_4+EsvF=khSE0jN#m>cz8srUH#EiO zji$b|>m12wvVPT6^yRYS7IXZ90S!Ju_HW7GK-}(3=e6y95Xxi>=00yOgHJHQxmt#A z^r=_2uq$x@j@X^5tDD_hSbfe<*}f}$I0RCWfG6x&{q{y-4Bju-k^I37IPABc{;}Qy zYOL;?m4ieNi_)+uGvdPb)eTYmm?@9o0n7;FR)=u8LJTRGI&G9$QtQKke_yBd6)mUM zpzfW1#sd=eEDOI{H|)0wamLalK;L%-XE9(D2Th?5Po{zZ;J&j2H$IXOM=yThk=rkH zyv|Y%mtHK?enx(;gFTn$euoIP+?UVR#6(KZeX1HW7UTI<=_9zbGU)&2{`|8}#B%F) z^xKWojvq$H`Z9ZGpRY8tpt2A^-0FKsIQX*dV*M+F@Vi%o5nS;NrGyCe(=7RTvRsB4 zhbu&Xy7!?C4?1ikC0FQ9f?D@Ik>&oFB+d``gt{lTvQd15r!G10iq0p{u-d2aPY+Zy z0di4-X{N|WX25ecSvj0XgFW$JAk#=3V)4jql|Sz8d(|&pOC@+zHPoM6Q9}!6WC(gR zI!YRs3&?J){F!&lU0ZH?k~e_Pzw|70BKtS+1%uSaD7QI*|Fh{=D5o%U+vhgB;wUy( zLGfC5&dn**9Xtl};J-F)1!!@Tl`+YQvK(mPK}DG;4g&uQcYGk|(aMD= z#y<_x?ev`Eet{A#yWAa)>jqQOGQT%d$55=7bQ_;9p z(}zjTiDg2*CWj8Td^;8vJ#gIG`0!O}XvNh#En z1<*kI*0j5oTD`YK+A|fwhX)jI;zuI<&#fIkxQ=LN#4}ZNp^DoB^QtCgS~@ycKMWps zri;8Af9j9k28!YYNn6y~QzQ+U z16O|U8aNl%S%-n!;}sQ5EZ+nEXZ>{mQG&J;AnLFtCmba>5*DF(#HHYWU48t0wChMv zRor^qh7x`)Lkfa`;lRxFu{hM;xbqQIi_Z+7Q@4j9ZG zHuM+kEtah$YpHAym2Pj2Q#=c{Za+-#ny-5kyvsswD-?>!OU8$`Pvb+9VC*IOZH9gtxd_ct@POM>|m0u%9uX%(|Zzwj@qGiJCxTnI)4tYk_YFjE8t7Lc9-Wa zDsNO~v~{jm>iB8EwBt=49n(9;U;2AgRNm~Xwe$Gx1WmlWVByC3uI1q+!K>nyyh$fB zLA9F&TXD9LGWv+Nl}7?##GCsdAT&bBCsDyWV1(;xrzK7fc;H_X`c1%rqY-FT5EDzTnh!}~5hzsSeyDW?Jjivi;jq%3w(=s2! zZSg^>@TJ(VFAQDnKNvTnPsvb|+sLPm{>|p*BDO-_4xfLFo2WGt${7Ev=??)AfjOMU zSV8&*;cJeT5(|^Ia-(UQg3oD9K~G9Tq^@hvbNsyRt1jLv?xo^E_u3)0;@N{eiz0Ej zhta&UA~Y#3wBu+^e#fouBuT_!tX@y*Z`%naw>_JtPiihO4`#cu^WeYI*=5!; zsKJ1q$hf&_R^F6d@18xW4SWI<*4CJbIa#|{(5uNeT=axMP^Eb}RxxH0-V5oS>Aqpe zJb@QxG&7Yt1DAGl=n6f~TY8hcg;|=?2=0K^dlBoF@Txbzs()K57Z$|JEKQ~?_!buC zr&n3_qen_MHs3kdTJoVhLUJq-hEB2&L5RcGm~jQ;;C}m456L%v$f-obVF&+7XRrCZVY`W zYqx$>+zZessb6}8*9F}4|C<+p_^y$cfrgJuOQG2yT35C33+KH-{U)Zh67l|*I-A#U zzspj2cds8;1tz@nt3PbEelEaI>sS}K(RSaCh@Q!*8DkIrs#YWs@?=|cHPuX)RuyF} z8NI}q(Uwr;R4(Gr%pVHX#@}ZrAG?y1dYmK*wdQIl)l2v|81CQY&>r3?FV+>%ivzTn z7>hh(s;jOwA?AicAf=8PAAHf6?uY%!@zIIVR(m6&b<7L6Y}CZ68xLND`R_)3lryJ8 z9zDpWgVR$$jE@OL1UMfhUL7K`qAnG{B+W zJ4Wfynw(xM=X*%YimBt5I$>#gu0)sI1}?h^k5#FqGXo*-?c4_TbiS{>1RX1=tXA0< zOI-Rl=}ds<8eJl)9Ak_p^{rrMlm!iqIYFirxqX$EyIS#mTgT^GDsSHm_V)C|GEt-= zLd! `Notifications from ${host} are on.`, notificationsOff: 'Notifications are off for this phone.', notificationsDenied: 'Notifications are turned off for DeepSeekBot in the phone settings.', @@ -2122,7 +2123,7 @@ const zh: typeof en = { connected: '已连接', notifications: '通知', notificationsChannel: 'Bot 的请求', - notificationsHint: 'Bot 需要你或回复你时通知你。私密输入不会显示。', + notificationsHint: '通知类型适用于此手机配对的所有远程。私密输入不会显示。', notificationsOn: (host: string) => `已开启来自 ${host} 的通知。`, notificationsOff: '这台手机的通知已关闭。', notificationsDenied: '手机设置里关闭了 DeepSeekBot 的通知。', diff --git a/apps/mobile/test/device-notification-preferences.test.ts b/apps/mobile/test/device-notification-preferences.test.ts index 9f83853f5e..64b3db3eb6 100644 --- a/apps/mobile/test/device-notification-preferences.test.ts +++ b/apps/mobile/test/device-notification-preferences.test.ts @@ -101,4 +101,17 @@ describe('phone-owned notification preferences', () => { await f.prefs.retry(); expect(f.disk()?.approval).toBe(false); }); + it('copies the latest choice before token registration and refuses replaced credentials', async () => { + const f = fixture(DEFAULT_NOTIFICATION_PREFERENCES); + await f.prefs.sync([host('a')]); + await Promise.all([f.prefs.set({ reply: false }), f.prefs.prepare(host('a'))]); + expect(f.options.push.setPreferences).toHaveBeenLastCalledWith( + host('a'), + expect.objectContaining({ reply: false }), + ); + await f.prefs.sync([host('a', 'new')]); + await expect(f.prefs.prepare(host('a'))).rejects.toThrow( + 'notification-preferences-unavailable', + ); + }); }); diff --git a/apps/mobile/test/host-session-unpair.test.ts b/apps/mobile/test/host-session-unpair.test.ts index b769073cbf..9367566d29 100644 --- a/apps/mobile/test/host-session-unpair.test.ts +++ b/apps/mobile/test/host-session-unpair.test.ts @@ -64,6 +64,13 @@ vi.mock('../src/host/native-notifications', () => ({ vi.mock('../src/host/native-notification-avatars', () => ({ notificationAvatars: { sync: async () => true }, })); +vi.mock('../src/host/native-notification-preferences', () => ({ + notificationPreferences: { + sync: async () => undefined, + retry: async () => undefined, + prepare: async () => undefined, + }, +})); vi.mock('../src/telemetry/native', () => ({ mobileTelemetry: { foreground: () => undefined, send: () => undefined }, })); diff --git a/apps/mobile/test/notification-settings.test.tsx b/apps/mobile/test/notification-settings.test.tsx index ad7bafa887..a44c24d1c3 100644 --- a/apps/mobile/test/notification-settings.test.tsx +++ b/apps/mobile/test/notification-settings.test.tsx @@ -60,6 +60,32 @@ const notice = (container: HTMLElement, tone: string) => [...container.querySelectorAll(`[data-testid="notice-${tone}"]`)].map((node) => node.textContent); describe('Notification settings', () => { + it('keeps device choices editable without system permission or a push token', async () => { + const settings = port({ + load: async () => { + throw new Error('offline'); + }, + set: vi.fn(async () => undefined), + }); + const view = await render( + {}} + onOpenSettings={() => {}} + />, + ); + const reply = view.querySelector(`[aria-label="${t.pushKind.reply}"]`)!; + expect(reply.checked).toBe(true); + expect(reply.disabled).toBe(false); + await act(async () => reply.click()); + expect(settings.set).toHaveBeenCalledWith({ reply: false }); + expect(view.textContent).toContain(t.notificationPreferencePending); + expect(settings.test).not.toHaveBeenCalled(); + }); + it('offers to turn notifications on before the phone allows them', async () => { const onEnable = vi.fn(); const view = await render( diff --git a/packages/client/src/client/host-tabs.tsx b/packages/client/src/client/host-tabs.tsx index 9b7afdd594..c0ce7cc55e 100644 --- a/packages/client/src/client/host-tabs.tsx +++ b/packages/client/src/client/host-tabs.tsx @@ -351,7 +351,8 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null if (id === 'settings' && menuRemote !== undefined) setSettings({ hostId: menuRemote.host.hostId, name: menuRemote.host.hostName }); if (id === 'remove' && menuRemote !== undefined) setRemoving(menuRemote); - if (id === 'notifications' && menuRemote !== undefined) setNotificationHost(menuRemote); + if (id === 'notifications' && menuRemote !== undefined) + setNotificationHost(menuRemote); }} onClose={() => setMenu(undefined)} /> From df3d159accb8c54c0adb2b2651a3849840034239 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 01:12:00 +0900 Subject: [PATCH 03/18] test: capture remote notification settings in both themes Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .github/pr-screenshots/n4/a-d.png | Bin 0 -> 76105 bytes .github/pr-screenshots/n4/a-l.png | Bin 0 -> 80014 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-screenshots/n4/a-d.png create mode 100644 .github/pr-screenshots/n4/a-l.png diff --git a/.github/pr-screenshots/n4/a-d.png b/.github/pr-screenshots/n4/a-d.png new file mode 100644 index 0000000000000000000000000000000000000000..a0bc9619b24fdc8428891f1a38687935adecdd18 GIT binary patch literal 76105 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࿌OJE&r>V3qpPIE@S-J&+es&$m4jS!m%jSJwmW%x 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 Nff{I5Jb3*2{{ZuMX6*m~ literal 0 HcmV?d00001 From 7f6f6399858566e7f02c1486520db18e05b054aa Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 02:04:30 +0900 Subject: [PATCH 04/18] fix: discover Host notification attention and retry device copies Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../features/phone-notifications.md | 4 +- apps/mobile/scripts/tob-mobile-driver.mjs | 88 +-------- .../host/device-notification-preferences.ts | 25 +++ apps/mobile/src/host/host-session.ts | 2 + apps/mobile/src/host/inbox.ts | 33 +++- apps/mobile/src/host/wire.ts | 1 + .../device-notification-preferences.test.ts | 47 +++++ apps/mobile/test/host-session-unpair.test.ts | 1 + .../test/notification-discovery.test.ts | 85 ++++++++ docs/architecture/botharness-architecture.md | 2 +- docs/bot-cli.md | 4 + docs/bot-cli.zh.md | 4 + packages/client/src/client/remote-hosts.ts | 2 +- .../client/remote-notification-settings.tsx | 39 ++-- .../client/src/client/remote-notifications.ts | 31 ++- packages/client/test/client-observer.test.ts | 26 +++ .../client/test/remote-notifications.test.ts | 65 ++++++- packages/core/package.json | 3 +- .../core/src/bots/cli-device-notifications.ts | 7 + packages/core/src/bots/cli-inbox.ts | 33 ++++ packages/core/src/bots/cli-live.ts | 2 + .../core/src/bots/cli-phone-notifications.ts | 95 +++++++++ .../core/src/bots/development-inspector.d.mts | 13 ++ .../core/src/bots/development-inspector.mjs | 90 +++++++++ packages/core/src/bridge/methods.ts | 21 ++ packages/core/src/bridge/rpc.ts | 2 + .../src/diagnostics/client-diagnostics.ts | 2 + .../core/src/diagnostics/client-observer.ts | 8 + packages/core/src/runtime/human-attention.ts | 13 +- .../src/runtime/notification-attention.ts | 65 +++++++ packages/core/test/cli-inbox.test.ts | 20 ++ .../core/test/cli-phone-notifications.test.ts | 181 ++++++++++++++++++ .../core/test/human-attention-query.test.ts | 74 +++++++ pnpm-lock.yaml | 3 + scripts/cli-ui-inventory.json | 10 +- 35 files changed, 985 insertions(+), 116 deletions(-) create mode 100644 apps/mobile/test/notification-discovery.test.ts create mode 100644 packages/core/src/bots/cli-phone-notifications.ts create mode 100644 packages/core/src/bots/development-inspector.d.mts create mode 100644 packages/core/src/bots/development-inspector.mjs create mode 100644 packages/core/src/runtime/notification-attention.ts create mode 100644 packages/core/test/cli-phone-notifications.test.ts diff --git a/.agents/skills/verify-deepseekbot/features/phone-notifications.md b/.agents/skills/verify-deepseekbot/features/phone-notifications.md index 47333e4224..5b33711332 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-notifications.md +++ b/.agents/skills/verify-deepseekbot/features/phone-notifications.md @@ -9,7 +9,9 @@ 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`: 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 ` and keep pairing files private. Native default/intermediate/maximum text × light/dark on both platforms remains required. +- `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 ` and keep pairing files private. Native default/intermediate/maximum text × light/dark on both platforms remains required. - `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. diff --git a/apps/mobile/scripts/tob-mobile-driver.mjs b/apps/mobile/scripts/tob-mobile-driver.mjs index a1727ca174..341015b231 100644 --- a/apps/mobile/scripts/tob-mobile-driver.mjs +++ b/apps/mobile/scripts/tob-mobile-driver.mjs @@ -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'; @@ -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( diff --git a/apps/mobile/src/host/device-notification-preferences.ts b/apps/mobile/src/host/device-notification-preferences.ts index 63819b1d2e..2d821f4e44 100644 --- a/apps/mobile/src/host/device-notification-preferences.ts +++ b/apps/mobile/src/host/device-notification-preferences.ts @@ -27,6 +27,25 @@ export function createDeviceNotificationPreferences(options: { }) { let snapshot: DevicePreferenceSnapshot = { preferences: undefined, syncing: false, failed: [] }; let hosts: readonly SavedHost[] = []; + let foreground = false; + let retryTimer: ReturnType | 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) => { snapshot = { ...snapshot, ...patch }; @@ -53,6 +72,7 @@ export function createDeviceNotificationPreferences(options: { publish({ preferences }); }; const sync = async () => { + clearRetry(); publish({ syncing: true }); try { if (snapshot.preferences === undefined) { @@ -78,6 +98,7 @@ export function createDeviceNotificationPreferences(options: { publish({ failed: results.filter((id): id is string => id !== undefined) }); } finally { publish({ syncing: false }); + scheduleRetry(); } }; return { @@ -96,6 +117,10 @@ export function createDeviceNotificationPreferences(options: { retry() { return enqueue(sync); }, + setForeground(active: boolean) { + foreground = active; + scheduleRetry(); + }, prepare(host: SavedHost) { return enqueue(async () => { const preferences = snapshot.preferences; diff --git a/apps/mobile/src/host/host-session.ts b/apps/mobile/src/host/host-session.ts index 98f5110d55..2e1e4b64d9 100644 --- a/apps/mobile/src/host/host-session.ts +++ b/apps/mobile/src/host/host-session.ts @@ -66,6 +66,7 @@ function reflectInbox(hostId: string, inbox: Inbox): void { function adopt(next: SavedHosts, trigger: ConnectionTrigger = 'open'): void { mobileTelemetry.foreground(AppState.currentState === 'active', next.hosts.length); + notificationPreferences.setForeground(AppState.currentState === 'active'); const previous = state.saved; stored = next; void notificationPreferences.sync(next.hosts).catch(() => undefined); @@ -142,6 +143,7 @@ void loadSavedHosts().then( AppState.addEventListener('change', (next) => { mobileTelemetry.foreground(next === 'active', state.hosts.length); + notificationPreferences.setForeground(next === 'active'); if (next !== 'active') return; void notificationPreferences.retry().catch(() => undefined); pushSession.reflectAgain(); diff --git a/apps/mobile/src/host/inbox.ts b/apps/mobile/src/host/inbox.ts index 77e1e94c19..89cd652f36 100644 --- a/apps/mobile/src/host/inbox.ts +++ b/apps/mobile/src/host/inbox.ts @@ -24,6 +24,7 @@ export interface InboxQuery { sort?: 'default' | 'newest' | 'oldest'; section?: InboxSection | InboxSetAside | 'all'; activity?: boolean; + notifications?: boolean; } export type InboxSnapshot = @@ -35,6 +36,7 @@ export type InboxSnapshot = cursors?: Partial>; loadingMore?: InboxSection[]; sections: Record; + notificationItems?: HumanAttentionItem[] | undefined; unread?: Readonly>; stale?: string | undefined; } @@ -60,6 +62,7 @@ export function createInbox( let readAgain = false; let stopped = false; let revision = 0; + let connectionRevision = 0; const loading = new Set(); const setAside = options.section === 'cleared' || options.section === 'snoozed' ? options.section : undefined; @@ -96,6 +99,7 @@ export function createInbox( }; const readOnce = async (): Promise => { + const connectionAtStart = connectionRevision; revision += 1; loading.clear(); const retained = @@ -115,7 +119,29 @@ export function createInbox( } return { ...page, items: [...items.values()] }; }; - const [pages, counts] = await Promise.all([ + const readNotifications = async (): Promise => { + if (!options.notifications) return undefined; + const items = new Map(); + const cursors = new Set(); + let cursor: string | undefined; + do { + if (stopped || client.getSnapshot().connection.status !== 'connected') return undefined; + const page = await client.call('humanAttention', { + notifications: true, + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }); + if (page.notificationVersion !== 1) return undefined; + for (const item of page.items) items.set(item.id, item); + cursor = page.nextCursor; + if (cursor !== undefined) { + if (cursors.has(cursor)) throw new Error('notification-cursor-repeated'); + cursors.add(cursor); + } + } while (cursor !== undefined); + return [...items.values()]; + }; + const [pages, counts, notificationItems] = await Promise.all([ Promise.all( INBOX_SECTIONS.map((category) => options.activity && @@ -129,11 +155,12 @@ export function createInbox( ), ), client.call('channelUnreadCounts').catch(() => ({ channels: [] })), + readNotifications().catch(() => undefined), ]); const unread = Object.fromEntries( counts.channels.map((entry) => [entry.channelId, entry.unreadCount]), ); - if (stopped) return; + if (stopped || readAgain || connectionAtStart !== connectionRevision) return; const [action, replies, otherUnread, handled] = pages.map((page) => page.items) as [ HumanAttentionItem[], HumanAttentionItem[], @@ -150,6 +177,7 @@ export function createInbox( ), loadingMore: [], sections: { action, replies, unread: otherUnread, handled }, + notificationItems, unread, }); }; @@ -188,6 +216,7 @@ export function createInbox( let wasConnected = false; const offClient = client.subscribe(() => { const connected = client.getSnapshot().connection.status === 'connected'; + if (connected !== wasConnected) connectionRevision += 1; if (connected && !wasConnected) background(); wasConnected = connected; }); diff --git a/apps/mobile/src/host/wire.ts b/apps/mobile/src/host/wire.ts index 7137474536..2a306c018b 100644 --- a/apps/mobile/src/host/wire.ts +++ b/apps/mobile/src/host/wire.ts @@ -407,6 +407,7 @@ export interface HumanAssignmentContext { export interface HumanAttentionPage { queryVersion?: number; + notificationVersion?: number; items: HumanAttentionItem[]; nextCursor?: string; } diff --git a/apps/mobile/test/device-notification-preferences.test.ts b/apps/mobile/test/device-notification-preferences.test.ts index 64b3db3eb6..b0cc222eb7 100644 --- a/apps/mobile/test/device-notification-preferences.test.ts +++ b/apps/mobile/test/device-notification-preferences.test.ts @@ -54,6 +54,53 @@ function fixture(saved?: PushPreferences) { } describe('phone-owned notification preferences', () => { + it('drops removed Hosts from retry and resets backoff after success', async () => { + vi.useFakeTimers(); + const f = fixture(DEFAULT_NOTIFICATION_PREFERENCES); + try { + f.prefs.setForeground(true); + f.offline.add('b'); + await f.prefs.sync([host('a'), host('b')]); + await vi.advanceTimersByTimeAsync(1000); + await f.prefs.sync([host('a')]); + const calls = f.options.push.setPreferences.mock.calls.length; + await vi.advanceTimersByTimeAsync(60_000); + expect(f.options.push.setPreferences).toHaveBeenCalledTimes(calls); + expect(vi.getTimerCount()).toBe(0); + await f.prefs.sync([host('a'), host('b')]); + f.offline.clear(); + await vi.advanceTimersByTimeAsync(1000); + expect(f.prefs.getSnapshot().failed).toEqual([]); + } finally { + f.prefs.setForeground(false); + vi.useRealTimers(); + } + }); + it('retries a noncurrent Host while A stays connected and pauses in the background', async () => { + vi.useFakeTimers(); + const f = fixture(DEFAULT_NOTIFICATION_PREFERENCES); + try { + f.prefs.setForeground(true); + f.offline.add('b'); + await f.prefs.sync([host('a'), host('b')]); + await f.prefs.set({ reply: false }); + await vi.advanceTimersByTimeAsync(1000); + expect(f.prefs.getSnapshot().failed).toEqual(['b']); + f.prefs.setForeground(false); + const calls = f.options.push.setPreferences.mock.calls.length; + f.offline.clear(); + await vi.advanceTimersByTimeAsync(60_000); + expect(f.options.push.setPreferences).toHaveBeenCalledTimes(calls); + f.prefs.setForeground(true); + await vi.advanceTimersByTimeAsync(1000); + expect(f.copies.get('b')?.reply).toBe(false); + expect(f.prefs.getSnapshot().failed).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + } finally { + f.prefs.setForeground(false); + vi.useRealTimers(); + } + }); it('seeds once from existing switches and copies every change to every pairing', async () => { const f = fixture(); await f.prefs.sync([host('a'), host('b')]); diff --git a/apps/mobile/test/host-session-unpair.test.ts b/apps/mobile/test/host-session-unpair.test.ts index 9367566d29..ef5ebc8ca1 100644 --- a/apps/mobile/test/host-session-unpair.test.ts +++ b/apps/mobile/test/host-session-unpair.test.ts @@ -66,6 +66,7 @@ vi.mock('../src/host/native-notification-avatars', () => ({ })); vi.mock('../src/host/native-notification-preferences', () => ({ notificationPreferences: { + setForeground: () => undefined, sync: async () => undefined, retry: async () => undefined, prepare: async () => undefined, diff --git a/apps/mobile/test/notification-discovery.test.ts b/apps/mobile/test/notification-discovery.test.ts new file mode 100644 index 0000000000..cf59ec5e3f --- /dev/null +++ b/apps/mobile/test/notification-discovery.test.ts @@ -0,0 +1,85 @@ +import { expect, it, vi } from 'vitest'; +import { createInbox } from '../src/host/inbox'; +import type { HostClient } from '../src/host/client'; +import type { HumanAttentionItem } from '../src/host/wire'; + +const item = (id: string): HumanAttentionItem => ({ + id, + kind: 'channel-reply', + category: 'replies', + botSlug: 'ada', + summary: id, + createdAt: '2026-10-01T00:00:00Z', +}); + +it('publishes only complete notification discovery and restarts a scan invalidated between pages', async () => { + let live = () => {}; + let fail = false; + let mutate = false; + let changed = false; + const call = vi.fn(async (method: string, args?: Record) => { + if (method === 'channelUnreadCounts') return { channels: [] }; + if (!args?.notifications) return { queryVersion: 1, items: [] }; + if (!args.cursor) + return { + notificationVersion: 1, + items: [item(changed ? 'inserted' : 'withdrawn')], + nextCursor: 'same-time-page', + }; + if (fail) throw Error('page-unavailable'); + if (mutate && !changed) { + changed = true; + live(); + } + return { notificationVersion: 1, items: [item('dm')] }; + }); + const unavailable = () => { + throw Error('unused client operation'); + }; + const client: HostClient = { + start: unavailable, + stop: unavailable, + retryNow: unavailable, + refresh: unavailable, + upload: unavailable, + api: unavailable, + resource: unavailable, + follow: unavailable, + getSnapshot: () => ({ connection: { status: 'connected', route: '' } }), + subscribe: () => () => {}, + onLive: (listener) => { + live = () => listener('activity'); + return () => {}; + }, + call: call as HostClient['call'], + }; + const inbox = createInbox(client, { activity: true, notifications: true }); + const published: string[][] = []; + inbox.subscribe(() => { + const snapshot = inbox.getSnapshot(); + if (snapshot.status === 'ready' && snapshot.notificationItems) + published.push(snapshot.notificationItems.map((item) => item.id)); + }); + try { + await inbox.refresh(); + expect(published.at(-1)).toEqual(['withdrawn', 'dm']); + published.length = 0; + mutate = true; + await inbox.refresh(); + expect(published).toEqual([['inserted', 'dm']]); + fail = true; + await inbox.refresh(); + expect(inbox.getSnapshot()).toMatchObject({ status: 'ready', notificationItems: undefined }); + expect(published).toHaveLength(1); + fail = false; + await inbox.refresh(); + expect(published.at(-1)).toEqual(['inserted', 'dm']); + expect( + call.mock.calls + .filter(([, args]) => args?.notifications) + .every(([, args]) => args?.sort === undefined && args?.category === undefined), + ).toBe(true); + } finally { + inbox.stop(); + } +}); diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 26b457ee4a..71d54b9472 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -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 has no closed-page notification path. +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 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. `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. diff --git a/docs/bot-cli.md b/docs/bot-cli.md index 941cc7283c..f3aec51388 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -8,6 +8,10 @@ Bot summaries from `list` also include `lastActivityAt` (epoch milliseconds) whe `deepseekbot browser-notification-clients` lists online Client targets. `deepseekbot browser-notifications --client-id --remote-host-id ` reads that browser's local choice and revision. To write, add `--expected-revision --patch-stdin` and pipe `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`. These commands use the serving Host's usual online authentication and existing Client control relay; the Host persists no browser choice or Remote Host credential. Writes refuse stale revisions and unknown Hosts. Enabling requires permission already granted in the browser (`notification-permission-required` otherwise); CLI never opens the OS permission prompt. Closing the page ends both its control target and alerts. +`deepseekbot phone-notifications --metro-port --target-id --device-name ` reads the existing phone preference owner in an assigned Expo development client. Add `--preferences-stdin` with exactly the four boolean kinds to persist through that same owner and copy to every paired Host. It requires the shared-device batch/lease, a running loopback Metro inspector, an exact unique target, the development app ID, `__DEV__`, and the uniquely initialized owner. It never writes storage directly, creates another owner, enables OS permission, or treats Host copies as the phone choice. One JSON result distinguishes `saved` from `syncStatus` (`complete`/`pending`), `syncing`, and `failedHostIds`. A disconnected/timed-out write has an unknown outcome: read again before repeating it. Missing/ambiguous targets, release clients, unready owners and transport failures return coded refusals. **This development-only AX path is unavailable in release clients; it is not release CLI parity or completed native QA.** Native real runs remain in the external combined acceptance batch. + +`deepseekbot inbox --notifications [--limit 1..100] [--cursor ]` uses the Host's existing `humanAttention` query with notification discovery enabled. Follow every returned cursor to obtain Host-decided actions, unread group mentions/replies and ordinary unread DM replies. This is independent of Activity's mentions-first page; filters cannot be combined with this mode. `notificationVersion: 1` is required, with a coded refusal on older Hosts. It opens no additional feed connection and does not change attention or unread state. + `deepseekbot device-notifications --device-file ` reads the admitted device's send-preference copy from a live Host. Add `--preferences-stdin` and pipe `{"approval":true,"question":true,"private-input":true,"reply":false}` to replace its four kinds, even before a push token is registered. The private file has the saved pairing shape (`hostId`, `hostName`, `routes`, `deviceId`, `credential`); never put its credential in arguments or publish the file. Output is one JSON document without credentials; revoked pairings return `device-revoked`. This edits that Host's delivery copy only; the phone's persisted preference is authoritative and overwrites it on the next sync. Browser notification choices remain local to that browser and do not change Host attention. ```bash diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 4f96b13810..f6d8ce0c9a 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -8,6 +8,10 @@ `deepseekbot browser-notification-clients` 列出在线 Client。`deepseekbot browser-notifications --client-id --remote-host-id ` 读取该浏览器的本地选择和 revision。写入时加 `--expected-revision --patch-stdin`,从标准输入传入 `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`。命令使用提供页面的 Host 的现有认证及 Client 定向控制通道;Host 不保存浏览器选择或远程凭据。版本过期或远程不存在时明确拒绝。开启前须已在浏览器授予通知权限,否则返回 `notification-permission-required`;CLI 不弹出系统授权。页面关闭后,控制目标和提醒都会结束。 +`deepseekbot phone-notifications --metro-port <端口> --target-id <精确ID> --device-name <精确名称>` 在已分配的 Expo development client 中读取现有手机偏好 owner。加 `--preferences-stdin`,仅传入四类布尔值,由同一 owner 持久化并复制到全部已配对 Host。前提是共享设备 batch/lease、运行中的 loopback Metro inspector、精确且唯一的目标、开发版 app ID、`__DEV__` 和唯一已初始化的 owner。命令不直接写存储、不新建 owner、不弹系统授权、不把 Host 副本当手机选择。单个 JSON 分别返回 `saved`、`syncStatus`(`complete`/`pending`)、`syncing` 和 `failedHostIds`。断开/超时后的写入结果未知,应先重新读取再决定是否重试。目标缺失/歧义、release、owner 未就绪及传输失败均明确拒绝。**该 AX 路径仅限 development client,release 不可用,也不代表 release CLI parity 或原生 QA 已通过。** 原生真实运行仍交外部统一批次验收。 + +`deepseekbot inbox --notifications [--limit 1..100] [--cursor <游标>]` 使用 Host 现有 `humanAttention` 的通知发现模式。遍历全部返回游标,取得 Host 判定的待办、未读群提及/回复和普通未读 DM 回复。它独立于 Activity 的 mentions-first 分页窗口,不可混用筛选条件。响应须有 `notificationVersion: 1`,旧 Host 明确拒绝;不增加 Feed 连接,不修改 attention 或已读状态。 + `deepseekbot device-notifications --device-file <私有配对.json>` 从运行中的 Host 读取当前配对设备的通知发送副本。加上 `--preferences-stdin`,从标准输入传入 `{"approval":true,"question":true,"private-input":true,"reply":false}`,即可替换四种通知选择,无需先注册推送 token。私有文件使用已保存配对的结构(`hostId`、`hostName`、`routes`、`deviceId`、`credential`);凭据不能放入参数或公开文件。标准输出仅有一个不含凭据的 JSON,配对撤销后返回 `device-revoked`。命令只修改该 Host 的发送副本;手机持久化选择仍是权威,下次同步会覆盖副本。浏览器通知选择只保存在该浏览器,不影响 Host attention。 ```bash diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index 9b0ed84368..c6df57d516 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -223,7 +223,7 @@ export function createRemoteHosts(options: { const start = (entry: HostEntry): void => { entry.client.start(); if (entry.inbox !== undefined) return; - const feed = createInbox(entry.client, { activity: true }, entry.retained); + const feed = createInbox(entry.client, { activity: true, notifications: true }, entry.retained); entry.inbox = { feed, release: feed.subscribe(publish) }; }; diff --git a/packages/client/src/client/remote-notification-settings.tsx b/packages/client/src/client/remote-notification-settings.tsx index befc294aa3..56975aac02 100644 --- a/packages/client/src/client/remote-notification-settings.tsx +++ b/packages/client/src/client/remote-notification-settings.tsx @@ -3,7 +3,8 @@ import { Switch } from '@deepseek-ai/dsh-client-ui-primitives'; import { PUSH_KINDS, type PushKind } from '../../../../apps/mobile/src/host/push.js'; import type { RemoteNotifications } from './remote-notifications.js'; import type { BotHarnessTranslate, BotHarnessKey } from './locale.js'; -import { FormDialog } from './ds/form-dialog.js'; +import { SettingsDialog } from './ds/settings-dialog.js'; +import { Note } from './ds/note.js'; import { SettingsRow, SettingsSection } from './ds/settings-row.js'; const labels: Record = { @@ -39,24 +40,26 @@ export function RemoteNotificationSettings({ .finally(() => setBusy(false)); }; return ( - section }} + sections={[{ id: 'notifications', label: t('remoteHosts.notifications.title', { name }) }]} + activeId="notifications" + onSelect={() => {}} + onClose={onClose} > + {(error || snapshot.permission === 'denied' || snapshot.permission === 'unsupported') && ( + + {error + ? t('remoteHosts.notifications.failed') + : snapshot.permission === 'denied' + ? t('remoteHosts.notifications.denied') + : snapshot.permission === 'unsupported' + ? t('remoteHosts.notifications.unsupported') + : undefined} + + )} ))} - + ); } diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts index abdca143d3..ab8d9abc32 100644 --- a/packages/client/src/client/remote-notifications.ts +++ b/packages/client/src/client/remote-notifications.ts @@ -19,6 +19,13 @@ type Snapshot = { permission: DesktopNotificationPermission; }; const off: Choice = { enabled: false, preferences: DEFAULT_NOTIFICATION_PREFERENCES }; +type NotificationDiagnostic = + | 'remote-notification-show-refused' + | 'remote-notification-show-failed'; +function reportDiagnostic(code: NotificationDiagnostic): void { + if (typeof window !== 'undefined') + window.dispatchEvent(new CustomEvent('botharness:notification-diagnostic', { detail: code })); +} const kinds: Record = { 'tool-approval': 'approval', 'workspace-grant-request': 'approval', @@ -37,6 +44,7 @@ export function createRemoteNotifications(options: { api?: DesktopNotificationApi | undefined; open(target: RemoteConversationTarget): void; privateBody(): string; + diagnostic?(code: NotificationDiagnostic): void; }) { const { api, storage } = options; let hosts: Record = {}; @@ -52,6 +60,7 @@ export function createRemoteNotifications(options: { } catch {} let snapshot: Snapshot = { hosts, permission: api?.permission() ?? 'unsupported' }; const listeners = new Set<() => void>(); + const reported = new Set(); const state = new Map< string, { @@ -169,9 +178,15 @@ export function createRemoteNotifications(options: { close(id); continue; } + if (remote.inbox.notificationItems === undefined) continue; if (entry.inbox === remote.inbox) continue; + const previousIds = new Set( + entry.inbox.status === 'ready' + ? entry.inbox.notificationItems?.map((item) => item.id) + : [], + ); entry.inbox = remote.inbox; - const items = [...remote.inbox.sections.action, ...remote.inbox.sections.replies]; + const items = remote.inbox.notificationItems; const active = new Set( items.filter((item) => item.isUnread !== false).map((item) => `${id}:${item.id}`), ); @@ -184,7 +199,7 @@ export function createRemoteNotifications(options: { const pref = choice(id); for (const item of items) { const key = `${id}:${item.id}`; - const known = entry.seen.has(item.id); + const known = entry.seen.has(item.id) || previousIds.has(item.id); entry.seen.add(item.id); const kind = kinds[item.kind]; if ( @@ -213,7 +228,17 @@ export function createRemoteNotifications(options: { shown.delete(key); options.open(target(remote, item)); }; - } catch {} + } catch (error) { + const code = + error instanceof Error && + (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); + } + } } entry.high = items.reduce( (high, item) => (item.createdAt > high ? item.createdAt : high), diff --git a/packages/client/test/client-observer.test.ts b/packages/client/test/client-observer.test.ts index ca77ca609e..c7307bc754 100644 --- a/packages/client/test/client-observer.test.ts +++ b/packages/client/test/client-observer.test.ts @@ -14,6 +14,32 @@ 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('retains a late shell observation when its first report follows a warning flood', async () => { vi.useFakeTimers(); const diagnostics = createClientDiagnostics(); diff --git a/packages/client/test/remote-notifications.test.ts b/packages/client/test/remote-notifications.test.ts index 084b1503a9..2b781450f5 100644 --- a/packages/client/test/remote-notifications.test.ts +++ b/packages/client/test/remote-notifications.test.ts @@ -16,7 +16,11 @@ function remote(id: string, items: HumanAttentionItem[]): RemoteHost { return { host: { hostId: id, hostName: id, deviceId: id, credential: id, routes: [] }, snapshot: { connection: { status: 'connected', route: '' } }, - inbox: { status: 'ready', sections: { action: items, replies: [], unread: [], handled: [] } }, + inbox: { + status: 'ready', + notificationItems: items, + sections: { action: items, replies: [], unread: [], handled: [] }, + }, }; } function fixture() { @@ -58,6 +62,65 @@ function fixture() { } describe('Remote Host browser alerts', () => { + it('retains dedupe and the complete baseline when a later notification page fails', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + f.notices.update([remote('a', [item('old', 'channel-reply', 'same-time')])]); + const incomplete = remote('a', []); + if (incomplete.inbox.status === 'ready') incomplete.inbox.notificationItems = undefined; + f.notices.update([incomplete]); + f.notices.update([ + remote('a', [ + item('old', 'channel-reply', 'same-time'), + item('new', 'channel-reply', 'same-time'), + ]), + ]); + expect(f.show).toHaveBeenCalledTimes(1); + f.notices.update([ + remote('a', [ + item('old', 'channel-reply', 'same-time'), + item('new', 'channel-reply', 'same-time'), + ]), + ]); + expect(f.show).toHaveBeenCalledTimes(1); + }); + it('reports bounded redacted show failures without replaying the failed item', async () => { + const f = fixture(); + const diagnostic = vi.fn(); + const notices = createRemoteNotifications({ ...f.options, diagnostic }); + await notices.set('a', { enabled: true }); + notices.update([remote('a', [])]); + f.show.mockImplementation(() => { + throw new Error('private payload and credentials'); + }); + notices.update([remote('a', [item('1')])]); + notices.update([remote('a', [item('1'), item('2')])]); + notices.update([remote('a', [item('1'), item('2')])]); + expect(diagnostic).toHaveBeenCalledExactlyOnceWith('remote-notification-show-failed'); + expect(f.show).toHaveBeenCalledTimes(2); + }); + it('covers all Host-decided kinds including DM beyond the Activity window without replay', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + const old = Array.from({ length: 35 }, (_, index) => + item(`old-${index}`, 'channel-mention', '2026-01-01'), + ); + f.notices.update([remote('a', old)]); + const fresh = [ + 'tool-approval', + 'user-question', + 'private-input', + 'channel-mention', + 'channel-reply', + 'channel-reply', + ].map((kind, index) => item(`new-${index}`, kind, '2026-01-02')); + const next = remote('a', [...old, ...fresh]); + if (next.inbox.status === 'ready') + next.inbox.sections = { action: [], replies: old.slice(0, 30), unread: [], handled: [] }; + f.notices.update([next]); + f.notices.update([remote('a', [...old, ...fresh])]); + expect(f.show).toHaveBeenCalledTimes(6); + }); it('CLI uses the same choice with revision checks and never prompts for permission', async () => { const f = fixture(); const before = await f.notices.control({ hostId: 'a' }, undefined); diff --git a/packages/core/package.json b/packages/core/package.json index a9d4274ab9..4caa79b3d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,7 +21,8 @@ "css-tree": "3.2.1", "saxes": "6.0.0", "silk-wasm": "3.7.1", - "uqr": "0.1.3" + "uqr": "0.1.3", + "ws": "8.21.3" }, "devDependencies": { "@deepseek-ai/cordis": "4.0.5", diff --git a/packages/core/src/bots/cli-device-notifications.ts b/packages/core/src/bots/cli-device-notifications.ts index eab341441b..ccf1b222ca 100644 --- a/packages/core/src/bots/cli-device-notifications.ts +++ b/packages/core/src/bots/cli-device-notifications.ts @@ -5,6 +5,10 @@ import { createHostPush, PushRequestFailed } from '../../../../apps/mobile/src/h import { CliLiveError } from './cli-live-error.js'; export const DEVICE_NOTIFICATION_COMMANDS = [ + { + command: 'phone-notifications', + description: 'read or change the phone owner in an assigned Metro development client', + }, { command: 'device-notifications', description: @@ -12,6 +16,9 @@ export const DEVICE_NOTIFICATION_COMMANDS = [ }, ] as const; export const DEVICE_NOTIFICATION_OPTIONS = { + 'metro-port': { type: 'string' }, + 'target-id': { type: 'string' }, + 'device-name': { type: 'string' }, 'device-file': { type: 'string' }, 'preferences-stdin': { type: 'boolean' }, } as const; diff --git a/packages/core/src/bots/cli-inbox.ts b/packages/core/src/bots/cli-inbox.ts index 2e50aae6cd..a4d5366161 100644 --- a/packages/core/src/bots/cli-inbox.ts +++ b/packages/core/src/bots/cli-inbox.ts @@ -2,6 +2,7 @@ import { watchInbox } from './cli-inbox-watch.js'; import { CliLiveError } from './cli-live-error.js'; export const INBOX_OPTIONS = { + notifications: { type: 'boolean' }, category: { type: 'string' }, 'watch-seconds': { type: 'string' }, channel: { type: 'string' }, @@ -76,6 +77,7 @@ export const INBOX_COMMANDS = [ export const INBOX_HOST_COMMANDS = ['grant-revoke', 'grant-write-set'] as const; export interface InboxValues { + notifications?: boolean; category?: string; 'watch-seconds'?: string; kind?: string; @@ -157,6 +159,8 @@ export async function runInboxCommand( values: InboxValues, connect: () => Promise<{ rpc: Rpc; stream?: (path: string) => Promise }>, ): Promise { + if (command === 'inbox-watch' && values.notifications) + usage('Use inbox --notifications with its returned cursor.'); const call = prepare(command === 'inbox-watch' ? 'inbox' : command, rest, values); if (command === 'inbox-watch') { const seconds = Number(values['watch-seconds'] ?? 10); @@ -184,6 +188,11 @@ export async function runInboxCommand( } const { rpc } = await connect(); const result = await rpc>(call.method, call.args); + if (call.args['notifications'] === true && result['notificationVersion'] !== 1) + throw new CliLiveError( + 'host-query-unsupported', + 'Update the Host to discover notification attention.', + ); if (call.args['dismissed'] === true && result['dismissed'] !== true) throw new CliLiveError('host-query-unsupported', 'Update the Host to list dismissed items.'); if (call.args['queryVersion'] === 1 && result['queryVersion'] !== 1) @@ -232,6 +241,30 @@ function prepare( const limit = values.limit === undefined ? undefined : Number(values.limit); if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)) usage('--limit must be 1–100.'); + if (values.notifications) { + if ( + [ + values.category, + values.bot, + values.channel, + values.sort, + values.search, + values.kind, + ].some((value) => value !== undefined) || + values.unread || + values.snoozed || + values.dismissed + ) + usage('--notifications only accepts --limit and --cursor.'); + return { + method: 'humanAttention', + args: { + notifications: true, + ...(limit === undefined ? {} : { limit }), + ...(values.cursor === undefined ? {} : { cursor: values.cursor }), + }, + }; + } return { method: 'humanAttention', args: { diff --git a/packages/core/src/bots/cli-live.ts b/packages/core/src/bots/cli-live.ts index 980c04d9d8..5a92762b8c 100644 --- a/packages/core/src/bots/cli-live.ts +++ b/packages/core/src/bots/cli-live.ts @@ -1,3 +1,4 @@ +import { runPhoneNotifications } from './cli-phone-notifications.js'; import { WINDOW_ZOOM_COMMANDS, WINDOW_ZOOM_OPTIONS, @@ -746,6 +747,7 @@ export async function runLiveCli( io: BotCreateCliIo, ): Promise { if (command === 'device-notifications') return runDeviceNotifications(rest, values, io); + if (command === 'phone-notifications') return runPhoneNotifications(rest, values, io); if (BROWSER_NOTIFICATION_COMMANDS.some((entry) => entry.command === command)) return runBrowserNotifications(command, rest, values, io, () => connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), diff --git a/packages/core/src/bots/cli-phone-notifications.ts b/packages/core/src/bots/cli-phone-notifications.ts new file mode 100644 index 0000000000..9746e4398f --- /dev/null +++ b/packages/core/src/bots/cli-phone-notifications.ts @@ -0,0 +1,95 @@ +import { connect } from './development-inspector.mjs'; +import { parseNotificationPreferences } from '../../../../apps/mobile/src/host/device-notification-preferences.js'; +import type { PushPreferences } from '../../../../apps/mobile/src/host/push.js'; +import { CliLiveError } from './cli-live-error.js'; + +export function phoneNotificationExpression(preferences?: PushPreferences): string { + return `(async () => { + if (typeof __DEV__ === 'undefined' || !__DEV__) throw Error('DEBUG_ONLY'); + if (typeof globalThis.__r?.getModules !== 'function') throw Error('NOTIFICATION_OWNER_UNAVAILABLE'); + const matches = [...globalThis.__r.getModules().values()].filter(m => m.isInitialized && m.publicModule?.exports?.notificationPreferences); + if (matches.length !== 1) throw Error('NOTIFICATION_OWNER_UNAVAILABLE'); + const owner = matches[0].publicModule.exports.notificationPreferences; + if (!owner.ready || typeof owner.getSnapshot !== 'function' || typeof owner.set !== 'function') throw Error('NOTIFICATION_OWNER_UNAVAILABLE'); + try { await owner.ready; } catch { throw Error('NOTIFICATION_OWNER_NOT_READY'); } + const preferences = ${JSON.stringify(preferences ?? null)}; + if (preferences !== null) { + try { await owner.set(preferences); } catch { throw Error('NOTIFICATION_SAVE_FAILED'); } + } + const current = owner.getSnapshot(); + if (current.preferences === undefined) throw Error('NOTIFICATION_OWNER_NOT_READY'); + return { + owner: 'phone', + developmentClient: true, + saved: preferences !== null, + preferences: current.preferences, + syncing: current.syncing, + failedHostIds: current.failed, + syncStatus: current.syncing || current.failed.length ? 'pending' : 'complete' + }; + })()`; +} + +export async function runPhoneNotifications( + rest: string[], + values: { + 'metro-port'?: string; + 'target-id'?: string; + 'device-name'?: string; + 'preferences-stdin'?: boolean; + }, + io: { readStdin?: () => Promise }, +): Promise { + const port = Number(values['metro-port']); + if ( + rest.length || + !Number.isInteger(port) || + port < 1024 || + port > 65535 || + !values['target-id'] || + !values['device-name'] + ) + throw new CliLiveError( + 'usage', + 'Provide --metro-port, --target-id and --device-name for the assigned development client.', + ); + let preferences: PushPreferences | undefined; + if (values['preferences-stdin']) { + try { + const value: unknown = JSON.parse((await io.readStdin?.()) ?? ''); + preferences = parseNotificationPreferences(value); + if ( + preferences && + Object.keys(value as object).some((key) => !Object.hasOwn(preferences!, key)) + ) + preferences = undefined; + } catch {} + if (!preferences) + throw new CliLiveError( + 'invalid-input', + 'Provide all four boolean notification kinds on stdin.', + ); + } + let client: Awaited> | undefined; + try { + client = await connect(port, values['target-id'], values['device-name']); + const result = await client.send('Runtime.evaluate', { + expression: phoneNotificationExpression(preferences), + awaitPromise: true, + returnByValue: true, + }); + if (result.result?.value === undefined) throw new Error('INSPECTOR_RESULT_REQUIRED'); + return result.result.value; + } catch (error) { + const code = + error instanceof Error && /^[A-Z_]+$/.test(error.message) + ? error.message.toLowerCase().replaceAll('_', '-') + : 'inspector-unreachable'; + throw new CliLiveError( + code, + 'Phone notification operation refused; check the assigned development client.', + ); + } finally { + client?.close(); + } +} diff --git a/packages/core/src/bots/development-inspector.d.mts b/packages/core/src/bots/development-inspector.d.mts new file mode 100644 index 0000000000..3e5af9e572 --- /dev/null +++ b/packages/core/src/bots/development-inspector.d.mts @@ -0,0 +1,13 @@ +export function targetSocket( + target: { deviceName: string; appId: string; webSocketDebuggerUrl: string } | undefined, + port: number, + deviceName: string, +): URL; +export function connect( + port: number, + targetId: string, + deviceName: string, +): Promise<{ + send(method: string, params: Record): Promise<{ result?: { value?: unknown } }>; + close(): void; +}>; diff --git a/packages/core/src/bots/development-inspector.mjs b/packages/core/src/bots/development-inspector.mjs new file mode 100644 index 0000000000..e0b39b62b3 --- /dev/null +++ b/packages/core/src/bots/development-inspector.mjs @@ -0,0 +1,90 @@ +import WebSocket from 'ws'; + +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 matches = targets.filter((t) => t.id === targetId); + if (matches.length !== 1) throw Error('EXPLICIT_UNIQUE_TARGET_REQUIRED'); + const target = matches[0]; + const url = targetSocket(target, port, simulatorName); + const ws = new WebSocket(url, { origin: `http://127.0.0.1:${port}` }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.terminate(); + reject(Error('INSPECTOR_TIMEOUT')); + }, 5000); + ws.once('open', () => { + clearTimeout(timer); + resolve(); + }); + ws.once('error', () => { + clearTimeout(timer); + reject(Error('INSPECTOR_CONNECTION_FAILED')); + }); + }); + 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', + 'NOTIFICATION_OWNER_UNAVAILABLE', + 'NOTIFICATION_OWNER_NOT_READY', + 'NOTIFICATION_SAVE_FAILED', + '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(), + }; +} diff --git a/packages/core/src/bridge/methods.ts b/packages/core/src/bridge/methods.ts index 5e06986aa7..697fc95eaa 100644 --- a/packages/core/src/bridge/methods.ts +++ b/packages/core/src/bridge/methods.ts @@ -2,6 +2,7 @@ import { approveGrantRequest, type GrantRequestApprovalResult, } from '../workspaces/grant-requests.js'; +import { notificationAttentionPage } from '../runtime/notification-attention.js'; import { MemoryImageError } from '../memory/image.js'; import { MIN_IDLE_MS, @@ -5163,6 +5164,8 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { }, humanAttention(payload) { const source = asObject(payload); + if (source['notifications'] !== undefined && typeof source['notifications'] !== 'boolean') + return invalidInput('notifications must be a boolean'); if (source['queryVersion'] !== undefined && source['queryVersion'] !== 1) return invalidInput('unsupported Activity query version'); if (source['queryVersion'] === 1 && deps.humanAttention === undefined) @@ -5224,6 +5227,24 @@ export function createBridgeMethods(deps: BridgeMethodsDeps): BridgeMethods { ) return invalidInput('invalid cursor'); try { + if (source['notifications'] === true) { + if (deps.humanAttention === undefined) + return invalidInput('notification queries are unavailable'); + if ( + Object.keys(source).some( + (key) => + !['notifications', 'limit', 'cursor'].includes(key) && source[key] !== undefined, + ) + ) + return invalidInput('notification discovery does not accept Activity filters'); + return { + ok: true, + value: notificationAttentionPage(deps.humanAttention, { + ...(limit === undefined ? {} : { limit: limit as number }), + ...(cursor === undefined ? {} : { cursor: cursor as string }), + }), + }; + } return { ok: true, value: { diff --git a/packages/core/src/bridge/rpc.ts b/packages/core/src/bridge/rpc.ts index 44a411ddfe..08359ca7c8 100644 --- a/packages/core/src/bridge/rpc.ts +++ b/packages/core/src/bridge/rpc.ts @@ -1380,6 +1380,7 @@ export class BotharnessBridgeService extends TypertRemoteService { unreadOnly?: boolean, queryVersion?: number, dismissed?: boolean, + notifications?: boolean, ): HumanAttentionPage { return unwrap( this.methods.humanAttention({ @@ -1395,6 +1396,7 @@ export class BotharnessBridgeService extends TypertRemoteService { unreadOnly, queryVersion, dismissed, + notifications, }), ); } diff --git a/packages/core/src/diagnostics/client-diagnostics.ts b/packages/core/src/diagnostics/client-diagnostics.ts index 4533af99b1..99002d548e 100644 --- a/packages/core/src/diagnostics/client-diagnostics.ts +++ b/packages/core/src/diagnostics/client-diagnostics.ts @@ -5,6 +5,8 @@ import { openLogDatabase, type LogDatabase, type LogEntryInput } from '../logs/l export const CLIENT_DIAGNOSTICS_PATH = '/api/botharness/client-diagnostics'; const CODES = new Set([ + 'remote-notification-show-refused', + 'remote-notification-show-failed', 'observation-deferred', 'resource-load-failed', 'observer-installed', diff --git a/packages/core/src/diagnostics/client-observer.ts b/packages/core/src/diagnostics/client-observer.ts index 196930b13a..36398b2920 100644 --- a/packages/core/src/diagnostics/client-observer.ts +++ b/packages/core/src/diagnostics/client-observer.ts @@ -141,6 +141,13 @@ export function installClientObserver(win: Window & typeof globalThis): void { const onRejection = (event: PromiseRejectionEvent): void => { record(classify([event.reason]), 'unhandledrejection', true); }; + const onNotificationDiagnostic = (event: Event): void => { + if (!(event instanceof win.CustomEvent)) return; + const code: unknown = event.detail; + if (code === 'remote-notification-show-refused' || code === 'remote-notification-show-failed') + record(code, 'lifecycle'); + }; + win.addEventListener('botharness:notification-diagnostic', onNotificationDiagnostic); win.addEventListener('error', onError, true); win.addEventListener('unhandledrejection', onRejection); const shellPresent = (): boolean => { @@ -207,6 +214,7 @@ export function installClientObserver(win: Window & typeof globalThis): void { win.cancelAnimationFrame(frame); win.removeEventListener('error', onError, true); win.removeEventListener('unhandledrejection', onRejection); + win.removeEventListener('botharness:notification-diagnostic', onNotificationDiagnostic); win.removeEventListener('pagehide', onHide); if (win.console.error === errorConsole) win.console.error = originalError; if (win.console.warn === warnConsole) win.console.warn = originalWarn; diff --git a/packages/core/src/runtime/human-attention.ts b/packages/core/src/runtime/human-attention.ts index 8d933f6725..71bf53379f 100644 --- a/packages/core/src/runtime/human-attention.ts +++ b/packages/core/src/runtime/human-attention.ts @@ -53,6 +53,7 @@ export interface HumanAttentionItem { export interface HumanAttentionPage { queryVersion?: 1; + notificationVersion?: 1; items: HumanAttentionItem[]; nextCursor?: string; } @@ -87,7 +88,10 @@ export interface HumanAttentionQuery { }): HumanAttentionPage; status(messageCounts?: boolean): HumanAttentionStatus; unreadByChannel(): Array<{ channelId: string; unreadCount: number }>; - dmReplies(limit: number): HumanAttentionItem[]; + dmReplies( + limit: number, + before?: { createdAt: string; sourceEventId: string }, + ): HumanAttentionItem[]; durableAttention(): Array<{ botSlug: string; waitingHumanCount: number; @@ -1045,7 +1049,7 @@ SELECT 'handled:' || request.source_event_id AS id, : {}), }; }, - dmReplies(limit) { + dmReplies(limit, before) { const rows = database.read( (db) => db @@ -1058,6 +1062,7 @@ SELECT 'handled:' || request.source_event_id AS id, AND json_extract(e.payload_json, '$.author.kind') = 'bot' AND ${DM_CARD_FIELDS.map((field) => `json_extract(e.payload_json, '$.${field}') IS NULL`).join(' AND ')} AND NOT EXISTS (SELECT 1 FROM human_inbox_dismissals d WHERE d.human_id = '${LOCAL_HUMAN_ID}' AND d.item_id = 'unread:' || v.channel_id AND v.revision <= d.through_revision) + AND (? IS NULL OR v.created_at < ? OR (v.created_at = ? AND v.source_event_id < ?)) ORDER BY v.created_at DESC, v.source_event_id DESC LIMIT ? `) @@ -1065,6 +1070,10 @@ SELECT 'handled:' || request.source_event_id AS id, LOCAL_HUMAN_ID, LOCAL_HUMAN_ID, JSON.stringify(mutedChannelIds()), + before?.createdAt ?? null, + before?.createdAt ?? null, + before?.createdAt ?? null, + before?.sourceEventId ?? null, limit, ) as Array<{ channel_id: string; diff --git a/packages/core/src/runtime/notification-attention.ts b/packages/core/src/runtime/notification-attention.ts new file mode 100644 index 0000000000..a6cd49a4d9 --- /dev/null +++ b/packages/core/src/runtime/notification-attention.ts @@ -0,0 +1,65 @@ +import type { HumanAttentionPage, HumanAttentionQuery } from './human-attention.js'; + +type Cursor = { + section: number; + page?: string; + before?: { createdAt: string; sourceEventId: string }; +}; + +export function notificationAttentionPage( + attention: Pick, + input: { limit?: number; cursor?: string }, +): HumanAttentionPage { + const limit = input.limit ?? 100; + let cursor: Cursor = { section: 0 }; + if (input.cursor !== undefined) { + const value: unknown = JSON.parse(Buffer.from(input.cursor, 'base64url').toString()); + if (typeof value !== 'object' || value === null) throw new Error('invalid notification cursor'); + const parsed = value as Cursor & { version?: number }; + if ( + parsed.version !== 1 || + ![0, 1, 2].includes(parsed.section) || + (parsed.page !== undefined && typeof parsed.page !== 'string') || + (parsed.before !== undefined && + (typeof parsed.before?.createdAt !== 'string' || + typeof parsed.before?.sourceEventId !== 'string')) + ) + throw new Error('invalid notification cursor'); + cursor = parsed; + } + const encode = (next: Cursor) => + Buffer.from(JSON.stringify({ version: 1, ...next })).toString('base64url'); + if (cursor.section < 2) { + const page = attention.list({ + category: cursor.section === 0 ? 'action' : 'replies', + sort: 'newest', + unreadOnly: true, + limit, + ...(cursor.page === undefined ? {} : { cursor: cursor.page }), + }); + return { + notificationVersion: 1, + items: page.items, + nextCursor: encode( + page.nextCursor + ? { section: cursor.section, page: page.nextCursor } + : { section: cursor.section + 1 }, + ), + }; + } + const rows = attention.dmReplies(limit + 1, cursor.before); + const items = rows.slice(0, limit); + const last = items.at(-1); + return { + notificationVersion: 1, + items, + ...(rows.length > limit && last?.sourceEventId !== undefined + ? { + nextCursor: encode({ + section: 2, + before: { createdAt: last.createdAt, sourceEventId: last.sourceEventId }, + }), + } + : {}), + }; +} diff --git a/packages/core/test/cli-inbox.test.ts b/packages/core/test/cli-inbox.test.ts index 5c74dfced2..4c48ce67c7 100644 --- a/packages/core/test/cli-inbox.test.ts +++ b/packages/core/test/cli-inbox.test.ts @@ -423,3 +423,23 @@ describe('Human Inbox, assignment, Session and approval-rule commands', () => { expect(live.calls).toHaveLength(0); }); }); + +it('CLI requests Host notification discovery and refuses unsupported Hosts', async () => { + const { runInboxCommand } = await import('../src/bots/cli-inbox.js'); + const rpc = vi.fn(async () => ({ notificationVersion: 1, items: [], nextCursor: 'next' })); + const connect = async () => ({ + rpc: rpc as (method: string, args: Record) => Promise, + }); + await expect( + runInboxCommand('inbox', [], { notifications: true, limit: '20', cursor: 'page' }, connect), + ).resolves.toMatchObject({ nextCursor: 'next' }); + expect(rpc).toHaveBeenCalledExactlyOnceWith('humanAttention', { + notifications: true, + limit: 20, + cursor: 'page', + }); + rpc.mockResolvedValue({ items: [] } as any); + await expect(runInboxCommand('inbox', [], { notifications: true }, connect)).rejects.toThrow( + 'Update the Host', + ); +}); diff --git a/packages/core/test/cli-phone-notifications.test.ts b/packages/core/test/cli-phone-notifications.test.ts new file mode 100644 index 0000000000..f482bcd467 --- /dev/null +++ b/packages/core/test/cli-phone-notifications.test.ts @@ -0,0 +1,181 @@ +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { runInNewContext } from 'node:vm'; +import { expect, it, vi } from 'vitest'; +import { runBotCreateCli } from '../src/bots/bot-create-cli.js'; +import { phoneNotificationExpression } from '../src/bots/cli-phone-notifications.js'; +import { + createDeviceNotificationPreferences, + DEFAULT_NOTIFICATION_PREFERENCES, +} from '../../../apps/mobile/src/host/device-notification-preferences.js'; +import type { PushPreferences } from '../../../apps/mobile/src/host/push.js'; + +const { WebSocketServer } = createRequire(import.meta.url)('ws'); + +function context(owner: unknown, dev = true, copies = 1) { + return { + __DEV__: dev, + __r: { + getModules: () => + new Map( + Array.from({ length: copies }, (_, i) => [ + i, + { isInitialized: true, publicModule: { exports: { notificationPreferences: owner } } }, + ]), + ), + }, + }; +} + +it('the development CLI and UI use one persistent owner and distinguish pending Host copies', async () => { + let disk: PushPreferences = DEFAULT_NOTIFICATION_PREFERENCES; + const copies = new Map(); + const offline = new Set(['b']); + const options = { + storage: { + read: async () => disk, + write: (value: PushPreferences) => { + disk = value; + }, + }, + push: { + settings: vi.fn(), + setPreferences: vi.fn(async (host, preferences) => { + if (offline.has(host.hostId)) throw Error('offline'); + copies.set(host.hostId, preferences); + return { registered: false, enabled: true, preferences, quietChannelIds: [] }; + }), + }, + }; + const owner = createDeviceNotificationPreferences(options); + const hosts = ['a', 'b'].map((id) => ({ + hostId: id, + hostName: id, + deviceId: id, + credential: 'private-secret', + routes: [], + })); + await owner.sync(hosts); + const ui = vi.fn(); + owner.subscribe(ui); + const result = await runInNewContext( + phoneNotificationExpression({ ...disk, reply: false }), + context(owner), + ); + expect(result).toMatchObject({ + owner: 'phone', + saved: true, + syncStatus: 'pending', + failedHostIds: ['b'], + preferences: { reply: false }, + }); + expect(ui).toHaveBeenCalled(); + expect(JSON.stringify(result)).not.toContain('private-secret'); + expect(owner.getSnapshot().preferences?.reply).toBe(false); + offline.clear(); + const reloaded = createDeviceNotificationPreferences(options); + await reloaded.sync(hosts); + expect(copies.get('b')?.reply).toBe(false); + expect(await runInNewContext(phoneNotificationExpression(), context(reloaded))).toMatchObject({ + saved: false, + syncStatus: 'complete', + preferences: { reply: false }, + }); + for (const runtime of [context(owner, false), context(owner, true, 0), context(owner, true, 2)]) + await expect(runInNewContext(phoneNotificationExpression(), runtime)).rejects.toThrow(); + await expect( + runInNewContext( + phoneNotificationExpression(), + context({ ...owner, ready: Promise.reject(Error('private error')) }), + ), + ).rejects.toThrow('NOTIFICATION_OWNER_NOT_READY'); +}); + +it('requires an exact unique development target and reports release, disconnect and timeout refusals', async () => { + let duplicates = false; + let deviceName = 'Assigned phone'; + let release = false; + let disconnect = false; + let stall = false; + const server = createServer((_request, response) => { + const target = { + id: 'target', + deviceName, + appId: release ? 'ai.botharness.deepseekbot' : 'ai.botharness.deepseekbot.dev', + webSocketDebuggerUrl: `ws://127.0.0.1:${port}/inspector`, + }; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify(duplicates ? [target, target] : [target])); + }); + const sockets = new WebSocketServer({ server }); + sockets.on('connection', (socket: any) => { + socket.on('message', (raw: Buffer) => { + if (disconnect) { + socket.close(); + return; + } + if (stall) return; + const request = JSON.parse(String(raw)); + socket.send( + JSON.stringify({ + id: request.id, + result: { result: { value: { owner: 'phone', saved: false } } }, + }), + ); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw Error('address required'); + const port = address.port; + const run = async (target = 'target') => { + const output: string[] = []; + await runBotCreateCli( + [ + 'phone-notifications', + '--metro-port', + String(port), + '--target-id', + target, + '--device-name', + 'Assigned phone', + ], + { env: {}, stdout: (text) => output.push(text), stderr: () => {} }, + ); + return JSON.parse(output[0]!); + }; + try { + expect(await run()).toMatchObject({ owner: 'phone' }); + expect(await run('missing')).toMatchObject({ + error: { code: 'explicit-unique-target-required' }, + }); + duplicates = true; + expect(await run()).toMatchObject({ error: { code: 'explicit-unique-target-required' } }); + duplicates = false; + deviceName = 'Wrong phone'; + expect(await run()).toMatchObject({ + error: { code: 'explicit-simulator-app-target-required' }, + }); + deviceName = 'Assigned phone'; + release = true; + expect(await run()).toMatchObject({ + error: { code: 'explicit-simulator-app-target-required' }, + }); + release = false; + disconnect = true; + expect(await run()).toMatchObject({ error: { code: 'inspector-closed' } }); + disconnect = false; + stall = true; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const pending = run(); + await vi.waitUntil(() => sockets.clients.size === 1); + await vi.advanceTimersByTimeAsync(20_100); + expect(await pending).toMatchObject({ error: { code: 'inspector-timeout' } }); + } finally { + vi.useRealTimers(); + for (const socket of sockets.clients) socket.terminate(); + sockets.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/packages/core/test/human-attention-query.test.ts b/packages/core/test/human-attention-query.test.ts index 4565b6495b..8bbeadef45 100644 --- a/packages/core/test/human-attention-query.test.ts +++ b/packages/core/test/human-attention-query.test.ts @@ -18,6 +18,80 @@ function adapter(): BotAgentAdapter { } describe('Human attention projection', () => { + it('discovers notifications past 30 old mentions and includes paginated ordinary DM replies', async () => { + const core = createCore({ + dshHome: createTempRoot('notification-discovery-'), + agents: adapter(), + }); + try { + core.registry.create({ slug: 'ada', displayName: 'Ada' }); + const group = core.channels.createGroup({ name: 'Team', members: ['ada'] }); + const dm = core.channels.getOrCreateDm('ada', 'Ada')!; + await core.channels.appendMessage(group.id, { + id: 'human', + at: '2026-10-01T00:00:00Z', + author: { kind: 'human' }, + body: 'Review', + }); + for (let i = 0; i < 35; i++) + await core.channels.appendMessage(group.id, { + id: `mention-${i}`, + at: '2026-10-01T00:01:00Z', + author: { kind: 'bot', slug: 'ada' }, + body: '@Human review', + botCausation: { rootSourceEventId: 'root', parentSourceEventId: 'parent', hop: 1 }, + humanMentions: [{ humanId: 'local-human', label: 'Human', start: 0, end: 6 }], + replyTo: 'human', + }); + await core.channels.appendMessage(group.id, { + id: 'new-reply', + at: '2026-10-01T00:02:00Z', + author: { kind: 'bot', slug: 'ada' }, + body: 'Ready', + replyTo: 'human', + }); + for (let i = 0; i < 4; i++) + await core.channels.appendMessage(dm.id, { + id: `dm-${i}`, + at: '2026-10-01T00:03:00Z', + author: { kind: 'bot', slug: 'ada' }, + body: 'Ordinary DM', + }); + expect( + core.humanAttention + .list({ category: 'replies', sort: 'mentions-first', limit: 30 }) + .items.some((item) => item.messageId === 'new-reply'), + ).toBe(false); + const methods = createBridgeMethods({ ...core }); + const found: string[] = []; + let cursor: string | undefined; + do { + const page = await methods.humanAttention({ + notifications: true, + limit: 3, + ...(cursor ? { cursor } : {}), + }); + if (!page.ok) throw new Error('notification-query-refused'); + expect(page.value.notificationVersion).toBe(1); + found.push(...page.value.items.map((item) => item.messageId!)); + cursor = page.value.nextCursor; + } while (cursor); + expect(found).toHaveLength(40); + expect(new Set(found).size).toBe(40); + expect(found).toContain('new-reply'); + expect(found).toContain('dm-0'); + expect(found).toContain('dm-3'); + expect( + await methods.humanAttention({ notifications: true, cursor: 'invalid' }), + ).toMatchObject({ ok: false }); + expect( + await methods.humanAttention({ notifications: true, sort: 'mentions-first' }), + ).toMatchObject({ ok: false }); + } finally { + await core.runtime.close(); + core.operationalDatabase.close(); + } + }); it('pages mention priority across replies and applies unread-only before pagination', async () => { const core = createCore({ dshHome: createTempRoot('activity-sort-'), agents: adapter() }); try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b27c8c6b3..da71a3e5a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -808,6 +808,9 @@ importers: uqr: specifier: 0.1.3 version: 0.1.3 + ws: + specifier: 8.21.3 + version: 8.21.3 devDependencies: '@deepseek-ai/cordis': specifier: 4.0.5 diff --git a/scripts/cli-ui-inventory.json b/scripts/cli-ui-inventory.json index f242162b21..5761a97ca6 100644 --- a/scripts/cli-ui-inventory.json +++ b/scripts/cli-ui-inventory.json @@ -785,14 +785,20 @@ }, { "id": "remote-host-notifications", - "description": "Browser-owned Remote Host notification choices on an explicit online Client; phone send copies use device authentication.", + "description": "Browser-owned Remote Host choices; inbox --notifications pages Host-decided attention. Phone owner AX is development-client-only (phone-notifications); release parity and native real runs remain unverified. device-notifications edits Host copies only.", "scope": "Client / Paired Device", "surfaces": [ "storage:packages/client/src/client/remote-notifications.ts:setItem(REMOTE_NOTIFICATION_KEY)", "setting:packages/client/src/client/remote-notification-settings.tsx:{t('remoteHosts.notifications.enable')}", "setting:packages/client/src/client/remote-notification-settings.tsx:{t(labels[kind])}" ], - "commands": ["browser-notification-clients", "browser-notifications", "device-notifications"] + "commands": [ + "browser-notification-clients", + "browser-notifications", + "device-notifications", + "phone-notifications", + "inbox" + ] }, { "id": "deepseekbot-local-appearance", From e8cedf8d66f08ba523f4b6eab05593ae427964d1 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 02:19:14 +0900 Subject: [PATCH 05/18] build: regenerate portable client bundles and document review checks Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../features/phone-notifications.md | 2 + .../features/remote-hosts.md | 2 + changes/2500-device-notifications.md | 2 + packages/browser/lib/client.js | 156 +- packages/computer/lib/client.js | 82 +- packages/core/src/bots/cli-live.ts | 3660 ++++++++--------- 6 files changed, 1955 insertions(+), 1949 deletions(-) diff --git a/.agents/skills/verify-deepseekbot/features/phone-notifications.md b/.agents/skills/verify-deepseekbot/features/phone-notifications.md index 5b33711332..0446e954fa 100644 --- a/.agents/skills/verify-deepseekbot/features/phone-notifications.md +++ b/.agents/skills/verify-deepseekbot/features/phone-notifications.md @@ -13,6 +13,8 @@ The phone app keeps every paired computer and shows one. A push notification is 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 ` 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 --target-id --device-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. ## How to get to it (user POV) diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index ece14e1dd8..f5e9492a7f 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -18,6 +18,8 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu - `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) Enter Bot mode, then Messages. Open the channel header's add menu and choose Add remote. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host icon, then a Bot or group row. Right-click the Host icon and choose Remove to remove it. A remote Bot DM with Browser or Computer access shows the corresponding header button. Open its viewer, choose Pause Bot and take control, interact with the screen or send text, then Resume Bot or Close viewer. Groups have no viewer entry. diff --git a/changes/2500-device-notifications.md b/changes/2500-device-notifications.md index 87203ad4d7..4f897d7df0 100644 --- a/changes/2500-device-notifications.md +++ b/changes/2500-device-notifications.md @@ -5,7 +5,9 @@ section: Changed ## en - Notification kinds now stay on the phone and sync to every paired Host, including after pairing again; each Remote Host in the browser has its own notification choices for alerts while the page is open ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Browser discovery includes ordinary DM replies and replies beyond the Activity page. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query. ## zh - 通知类型现在保存在手机,并同步到所有已配对 Host,重新配对后也会恢复;浏览器可为每个远程单独选择通知,仅在页面打开时提醒([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复;手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询。 diff --git a/packages/browser/lib/client.js b/packages/browser/lib/client.js index 4f75ca5880..07f4046738 100644 --- a/packages/browser/lib/client.js +++ b/packages/browser/lib/client.js @@ -823,18 +823,18 @@ window.__ModuleLoader__.load({ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { role: "status", "data-panel-empty": "", - className: "bhx78zum5 bhxdt5ytf bhx6s0dn4 bhxl56j7k bhx1iyjqo2 bhx16qymgn bhx14opcb4 bhx9f619 bhx2b8uid bhx1hablki", + className: "bhx78zum5 bhxdt5ytf bhx6s0dn4 bhxl56j7k bhx1iyjqo2 bhx1mckxr8 bhx1tsjn1a bhx9f619 bhx2b8uid bhxr3raxm", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "aria-hidden": "true", - className: "bhx1iur23p bhx1hfu2dq bhx2lah0s bhx14dlum0", + className: "bhx1i1gie9 bhxdkzj7d bhx2lah0s bhxwq55zj", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChannelSidebarIcon, { name: icon, size: "100%" }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { - className: "bhx1ghz6dp bhxxc7z9f bhx1hablki bhx17cqwc0 bhx15n174q", + className: "bhx1ghz6dp bhxxc7z9f bhxr3raxm bhxgowt09 bhx12cu2lo", children }), action === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { @@ -2038,34 +2038,34 @@ window.__ModuleLoader__.load({ const styles$1 = { note: { kogj98: "bhx1ghz6dp", - kGuDYH: "bhx17cqwc0", - kLWn49: "bhx15n174q", + kGuDYH: "bhxgowt09", + kLWn49: "bhx12cu2lo", kHjlTd: "bhxj0a0fe", $$css: true }, inset: { - k8WAf4: "bhx1leccfj", - kg3NbH: "bhx792b8t", + k8WAf4: "bhx89kiln", + kg3NbH: "bhx1w8vjyx", $$css: true }, muted: { - kMwMTN: "bhx1hablki", + kMwMTN: "bhxr3raxm", $$css: true }, subtle: { - kMwMTN: "bhx14dlum0", + kMwMTN: "bhxwq55zj", $$css: true }, danger: { - kMwMTN: "bhx1sspbqh", + kMwMTN: "bhx8ay0r6", $$css: true }, success: { - kMwMTN: "bhx108ve1s", + kMwMTN: "bhx1pideu2", $$css: true }, warning: { - kMwMTN: "bhx1c0x75y", + kMwMTN: "bhxz9v26x", $$css: true } }; @@ -2081,9 +2081,9 @@ window.__ModuleLoader__.load({ function ProgressLine({ percent, label }) { return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { role: "status", - className: "bhxrvj5dj bhx1fykdyo bhxh8yej3 bhx1hablki bhx17cqwc0 bhx15n174q", + className: "bhxrvj5dj bhx5v8n67 bhxh8yej3 bhxr3raxm bhxgowt09 bhx12cu2lo", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("progress", { - className: "bhxh8yej3 bhxols6we bhx1rpn2j5", + className: "bhxh8yej3 bhxols6we bhx1g31g6d", max: 100, value: Math.max(0, Math.min(100, percent)) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })] @@ -2095,7 +2095,7 @@ window.__ModuleLoader__.load({ navigator.clipboard?.writeText(command).then(() => setCopied(true)); }; return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhxrvj5dj bhxcksle6 bhx619ttb bhxh8yej3", + className: "bhxrvj5dj bhx2zm2ee bhx619ttb bhxh8yej3", children: [ installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { tone: "warning", @@ -2105,11 +2105,11 @@ window.__ModuleLoader__.load({ label: installing.label }), access === "none" && installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { children: labels.manual }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", { - className: "bhx1ghz6dp bhxh8yej3 bhx9f619 bhx1leccfj bhx792b8t bhxjpou06 bhxblcitj bhx1gs2zk7 bhxmz2490 bhx17cqwc0 bhx15n174q bhx126k92a bhxj0a0fe", + className: "bhx1ghz6dp bhxh8yej3 bhx9f619 bhx89kiln bhx1w8vjyx bhx4g09nt bhx1xojs80 bhx24dycr bhxmz2490 bhxgowt09 bhx12cu2lo bhx126k92a bhxj0a0fe", children: command })] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhx1nry0o3 bhx1a02dak", + className: "bhx78zum5 bhx12e6dxb bhx1a02dak", children: [ access !== "none" && installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { size: "sm", @@ -2592,7 +2592,7 @@ window.__ModuleLoader__.load({ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { ref: mount, "data-pointer-menu": "", - className: "bhxixxii4 bhxu96u03 bhx13vifvy bhx1uif995 bhxjgjsw3 bhxp6lfbs bhx47corl", + className: "bhxixxii4 bhxu96u03 bhx13vifvy bhx51uzqw bhx1kkor1i bhxp6lfbs bhx47corl", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Menu, { ...props, open: true, @@ -2606,21 +2606,21 @@ window.__ModuleLoader__.load({ const pillStyles = { pill: { k1xSpc: "bhx3nfvp2", kGNEyG: "bhx6s0dn4", - kOIVth: "bhx1nry0o3", + kOIVth: "bhx12e6dxb", ks0D6T: "bhx193iq5w", - kZKoxP: "bhx1n56ao0", - kg3NbH: "bhxa30tib", + kZKoxP: "bhxxevliy", + kg3NbH: "bhx1vid4s1", kMzoRj: "bhxc342km", - kaIpWk: "bhxjpou06", - kWkggS: "bhxfo3ty bhxrplx1s", - kMwMTN: "bhx1gs2zk7", + kaIpWk: "bhx4g09nt", + kWkggS: "bhxw43b1w bhx1lc0nsx", + kMwMTN: "bhx24dycr", kMv6JI: "bhxjb2p0i", - kGuDYH: "bhxer0amy", - kLWn49: "bhx1jatdxk", + kGuDYH: "bhx5g6txa", + kLWn49: "bhx3350y7", khDVqt: "bhxuxw1ft", kkrTdU: "bhx1ypdohk bhxtgyt42", kSiTet: "bhx1hc1fzr bhxijokvz", - kI3sdo: "bhx1a2a7pz bhx5dwnpn", + kI3sdo: "bhx1a2a7pz bhx1m95a6t", kInvED: "bhx1y3gkto", $$css: true } }; @@ -2695,7 +2695,7 @@ window.__ModuleLoader__.load({ className: "bhxeuugli bhxb3r6kr bhxlyipyv bhxuxw1ft", children: current?.label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { - className: "bhx3nfvp2 bhx2lah0s bhx1hablki", + className: "bhx3nfvp2 bhx2lah0s bhxr3raxm", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 12 }) })] }), active && point ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointerMenu, { @@ -2736,24 +2736,24 @@ window.__ModuleLoader__.load({ "aria-labelledby": titleId, "aria-describedby": description === void 0 ? void 0 : descriptionId, ...data, - className: "bhx78zum5 bhx6s0dn4 bhx4bgz6 bhx15bqsqq bhx1q0g3np bhxcxmu7o bhx1uptv2i bhx16qymgn bhx19fcs9c bhx1cg4dk7 bhxeuugli bhxz617pa bhx1gtm3y5 bhx1q0q8m5 bhxqlejh0 bhx1bta2m4", + className: "bhx78zum5 bhx6s0dn4 bhx4bgz6 bhx15bqsqq bhx1q0g3np bhxcxmu7o bhx1uptv2i bhx1mckxr8 bhx1kwkzks bhxv0lzym bhxeuugli bhxe74ayl bhxrvphhj bhx1q0q8m5 bhxnoyelg bhx1bta2m4", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhxdt5ytf bhx1fykdyo bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli", + className: "bhx78zum5 bhxdt5ytf bhx5v8n67 bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { id: titleId, - className: "bhx1gs2zk7 bhx2dqotr bhx11nzxlp bhxs1wz3g bhxj0a0fe", + className: "bhx24dycr bhxskcadq bhx1gy5ux2 bhx1nj67n bhxj0a0fe", children: title }), description === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { id: descriptionId, - className: "bhx1hablki bhx17cqwc0 bhx15n174q bhxj0a0fe", + className: "bhxr3raxm bhxgowt09 bhx12cu2lo bhxj0a0fe", children: description }), children ] }), control === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { - className: "bhx78zum5 bhx1a02dak bhx6s0dn4 bhx13a6bvl bhx403fy9 bhx3xz8fp bhxcksle6 bhx2lah0s bhxeuugli bhxh9uqaf bhx1r6q2vj bhx1cnm762", + className: "bhx78zum5 bhx1a02dak bhx6s0dn4 bhx13a6bvl bhx403fy9 bhx3xz8fp bhx2zm2ee bhx2lah0s bhxeuugli bhxh9uqaf bhx1r6q2vj bhx1cnm762", children: control })] }); @@ -3228,42 +3228,42 @@ window.__ModuleLoader__.load({ .bhx-level-0{} .bhx-level-1:not(#\\#){} .bhx1ghz6dp:not(#\\#){margin:0} -.bhx14opcb4:not(#\\#){padding:16px} +.bhx1tsjn1a:not(#\\#){padding:16px} .bhx-level-2:not(#\\#):not(#\\#){} -.bhx118alp2:not(#\\#):not(#\\#){border-color:var(--dsw-alias-border-l2)} -.bhx1w7u4cj:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-md, 12px)} -.bhxjpou06:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-sm, 8px)} +.bhx7vmdhn:not(#\\#):not(#\\#){border-color:var(--dsw-alias-border-l2)} +.bhx10e1no3:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-md, 12px)} +.bhx4g09nt:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-sm, 8px)} .bhxbsl7fq:not(#\\#):not(#\\#){border-style:dashed} .bhxc342km:not(#\\#):not(#\\#){border-width:0} -.bhxc2pr59:not(#\\#):not(#\\#){border-width:1px} -.bhx16qymgn:not(#\\#):not(#\\#){gap:12px} -.bhx1fykdyo:not(#\\#):not(#\\#){gap:2px} -.bhx1nry0o3:not(#\\#):not(#\\#){gap:4px} -.bhxcksle6:not(#\\#):not(#\\#){gap:8px} -.bhxx7iei1:not(#\\#):not(#\\#){margin-block:12px} +.bhx1sfpsak:not(#\\#):not(#\\#){border-width:1px} +.bhx1mckxr8:not(#\\#):not(#\\#){gap:12px} +.bhx5v8n67:not(#\\#):not(#\\#){gap:2px} +.bhx12e6dxb:not(#\\#):not(#\\#){gap:4px} +.bhx2zm2ee:not(#\\#):not(#\\#){gap:8px} +.bhx1d3k6p2:not(#\\#):not(#\\#){margin-block:12px} .bhx1a2a7pz:not(#\\#):not(#\\#){outline:none} .bhxb3r6kr:not(#\\#):not(#\\#){overflow:hidden} -.bhxz617pa:not(#\\#):not(#\\#){padding-block:12px} -.bhxxx7nam:not(#\\#):not(#\\#){padding-block:24px} -.bhx1leccfj:not(#\\#):not(#\\#){padding-block:4px} -.bhxa30tib:not(#\\#):not(#\\#){padding-inline:12px 8px} -.bhxwajc3y:not(#\\#):not(#\\#){padding-inline:16px} -.bhx792b8t:not(#\\#):not(#\\#){padding-inline:4px} -.bhx5dwnpn:focus-visible:not(#\\#):not(#\\#){outline:var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary)} -@media (max-width: 648px){.bhx1cg4dk7.bhx1cg4dk7:not(#\\#):not(#\\#){gap:8px}} -@container bh-settings (max-width: 480px){.bhx19fcs9c.bhx19fcs9c:not(#\\#):not(#\\#){gap:8px}} +.bhxe74ayl:not(#\\#):not(#\\#){padding-block:12px} +.bhx1tv32aw:not(#\\#):not(#\\#){padding-block:24px} +.bhx89kiln:not(#\\#):not(#\\#){padding-block:4px} +.bhx1vid4s1:not(#\\#):not(#\\#){padding-inline:12px 8px} +.bhx1xa34d1:not(#\\#):not(#\\#){padding-inline:16px} +.bhx1w8vjyx:not(#\\#):not(#\\#){padding-inline:4px} +.bhx1m95a6t:focus-visible:not(#\\#):not(#\\#){outline:var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary)} +@media (max-width: 648px){.bhxv0lzym.bhxv0lzym:not(#\\#):not(#\\#){gap:8px}} +@container bh-settings (max-width: 480px){.bhx1kwkzks.bhx1kwkzks:not(#\\#):not(#\\#){gap:8px}} .bhx-level-3:not(#\\#):not(#\\#):not(#\\#){} -.bhx1rpn2j5:not(#\\#):not(#\\#):not(#\\#){accent-color:var(--dsw-alias-state-business-primary)} +.bhx1g31g6d:not(#\\#):not(#\\#):not(#\\#){accent-color:var(--dsw-alias-state-business-primary)} .bhx6s0dn4:not(#\\#):not(#\\#):not(#\\#){align-items:center} -.bhxfo3ty:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-bg-module-platform)} -.bhxblcitj:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)} +.bhxw43b1w:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-bg-module-platform)} +.bhx1xojs80:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)} .bhx9f619:not(#\\#):not(#\\#):not(#\\#){box-sizing:border-box} -.bhx1gs2zk7:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-primary)} -.bhx1hablki:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-secondary)} -.bhx14dlum0:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-tertiary)} -.bhx1sspbqh:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-error-primary)} -.bhx108ve1s:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-success-primary)} -.bhx1c0x75y:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-warn-primary)} +.bhx24dycr:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-primary)} +.bhxr3raxm:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-secondary)} +.bhxwq55zj:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-tertiary)} +.bhx8ay0r6:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-error-primary)} +.bhx1pideu2:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-success-primary)} +.bhxz9v26x:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-warn-primary)} .bhx1ypdohk:not(#\\#):not(#\\#):not(#\\#){cursor:pointer} .bhx78zum5:not(#\\#):not(#\\#):not(#\\#){display:flex} .bhxrvj5dj:not(#\\#):not(#\\#):not(#\\#){display:grid} @@ -3277,16 +3277,16 @@ window.__ModuleLoader__.load({ .bhx1a02dak:not(#\\#):not(#\\#):not(#\\#){flex-wrap:wrap} .bhxjb2p0i:not(#\\#):not(#\\#):not(#\\#){font-family:inherit} .bhxmz2490:not(#\\#):not(#\\#):not(#\\#){font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} -.bhx17cqwc0:not(#\\#):not(#\\#):not(#\\#){font-size:12px} -.bhxer0amy:not(#\\#):not(#\\#):not(#\\#){font-size:13px} -.bhx2dqotr:not(#\\#):not(#\\#):not(#\\#){font-size:14px} -.bhxs1wz3g:not(#\\#):not(#\\#):not(#\\#){font-weight:500} +.bhxgowt09:not(#\\#):not(#\\#):not(#\\#){font-size:12px} +.bhx5g6txa:not(#\\#):not(#\\#):not(#\\#){font-size:13px} +.bhxskcadq:not(#\\#):not(#\\#):not(#\\#){font-size:14px} +.bhx1nj67n:not(#\\#):not(#\\#):not(#\\#){font-weight:500} .bhxl56j7k:not(#\\#):not(#\\#):not(#\\#){justify-content:center} .bhx13a6bvl:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-end} .bhx619ttb:not(#\\#):not(#\\#):not(#\\#){justify-items:start} -.bhx15n174q:not(#\\#):not(#\\#):not(#\\#){line-height:16px} -.bhx11nzxlp:not(#\\#):not(#\\#):not(#\\#){line-height:20px} -.bhx1jatdxk:not(#\\#):not(#\\#):not(#\\#){line-height:20px} +.bhx12cu2lo:not(#\\#):not(#\\#):not(#\\#){line-height:16px} +.bhx1gy5ux2:not(#\\#):not(#\\#):not(#\\#){line-height:20px} +.bhx3350y7:not(#\\#):not(#\\#):not(#\\#){line-height:20px} .bhx1hc1fzr:not(#\\#):not(#\\#):not(#\\#){opacity:1} .bhx1y3gkto:not(#\\#):not(#\\#):not(#\\#){outline-offset:1px} .bhxj0a0fe:not(#\\#):not(#\\#):not(#\\#){overflow-wrap:anywhere} @@ -3301,7 +3301,7 @@ window.__ModuleLoader__.load({ .bhxp6lfbs:not(#\\#):not(#\\#):not(#\\#){z-index:1100} .bhxtgyt42:disabled:not(#\\#):not(#\\#):not(#\\#){cursor:default} .bhxijokvz:disabled:not(#\\#):not(#\\#):not(#\\#){opacity:.5} -.bhxrplx1s:hover:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)} +.bhx1lc0nsx:hover:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)} @media (max-width: 648px){.bhx15bqsqq.bhx15bqsqq:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}} @media (max-width: 648px){.bhx1uptv2i.bhx1uptv2i:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}} @media (max-width: 648px){.bhx3xz8fp.bhx3xz8fp:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}} @@ -3309,24 +3309,24 @@ window.__ModuleLoader__.load({ @container bh-settings (max-width: 480px){.bhxcxmu7o.bhxcxmu7o:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}} @container bh-settings (max-width: 480px){.bhx403fy9.bhx403fy9:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}} .bhx-level-4:not(#\\#):not(#\\#):not(#\\#):not(#\\#){} -.bhxqlejh0:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:var(--dsw-alias-border-l1)} +.bhxnoyelg:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:var(--dsw-alias-border-l1)} .bhx1q0q8m5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-style:solid} -.bhx1gtm3y5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-width:1px} -.bhxjgjsw3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:1px} -.bhx1n56ao0:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:28px} -.bhx1hfu2dq:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:40px} +.bhxrvphhj:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-width:1px} +.bhx1kkor1i:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:1px} +.bhxxevliy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:28px} +.bhxdkzj7d:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:40px} .bhxols6we:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:6px} .bhxu96u03:not(#\\#):not(#\\#):not(#\\#):not(#\\#){left:0} .bhx193iq5w:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%} .bhxxc7z9f:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:360px} .bhxh9uqaf:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:60%} .bhxeuugli:not(#\\#):not(#\\#):not(#\\#):not(#\\#){min-width:0} -.bhxugyd1d:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-bottom:4px} -.bhx2o8g9q:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:16px} +.bhx1e1oj2y:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-bottom:4px} +.bhxb590mu:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:16px} .bhx13vifvy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){top:0} .bhxh8yej3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:100%} -.bhx1uif995:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:1px} -.bhx1iur23p:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:40px} +.bhx51uzqw:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:1px} +.bhx1i1gie9:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:40px} .bhx10zg81t:first-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:0} .bhx1bta2m4:last-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:transparent} @media (max-width: 648px){.bhx1cnm762.bhx1cnm762:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}} diff --git a/packages/computer/lib/client.js b/packages/computer/lib/client.js index 9a23d0c6a5..7bfcabe5c4 100644 --- a/packages/computer/lib/client.js +++ b/packages/computer/lib/client.js @@ -823,18 +823,18 @@ window.__ModuleLoader__.load({ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { role: "status", "data-panel-empty": "", - className: "bhx78zum5 bhxdt5ytf bhx6s0dn4 bhxl56j7k bhx1iyjqo2 bhx16qymgn bhx14opcb4 bhx9f619 bhx2b8uid bhx1hablki", + className: "bhx78zum5 bhxdt5ytf bhx6s0dn4 bhxl56j7k bhx1iyjqo2 bhx1mckxr8 bhx1tsjn1a bhx9f619 bhx2b8uid bhxr3raxm", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "aria-hidden": "true", - className: "bhx1iur23p bhx1hfu2dq bhx2lah0s bhx14dlum0", + className: "bhx1i1gie9 bhxdkzj7d bhx2lah0s bhxwq55zj", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChannelSidebarIcon, { name: icon, size: "100%" }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { - className: "bhx1ghz6dp bhxxc7z9f bhx1hablki bhx17cqwc0 bhx15n174q", + className: "bhx1ghz6dp bhxxc7z9f bhxr3raxm bhxgowt09 bhx12cu2lo", children }), action === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { @@ -2040,34 +2040,34 @@ window.__ModuleLoader__.load({ const styles = { note: { kogj98: "bhx1ghz6dp", - kGuDYH: "bhx17cqwc0", - kLWn49: "bhx15n174q", + kGuDYH: "bhxgowt09", + kLWn49: "bhx12cu2lo", kHjlTd: "bhxj0a0fe", $$css: true }, inset: { - k8WAf4: "bhx1leccfj", - kg3NbH: "bhx792b8t", + k8WAf4: "bhx89kiln", + kg3NbH: "bhx1w8vjyx", $$css: true }, muted: { - kMwMTN: "bhx1hablki", + kMwMTN: "bhxr3raxm", $$css: true }, subtle: { - kMwMTN: "bhx14dlum0", + kMwMTN: "bhxwq55zj", $$css: true }, danger: { - kMwMTN: "bhx1sspbqh", + kMwMTN: "bhx8ay0r6", $$css: true }, success: { - kMwMTN: "bhx108ve1s", + kMwMTN: "bhx1pideu2", $$css: true }, warning: { - kMwMTN: "bhx1c0x75y", + kMwMTN: "bhxz9v26x", $$css: true } }; @@ -2083,21 +2083,21 @@ window.__ModuleLoader__.load({ const pillStyles = { pill: { k1xSpc: "bhx3nfvp2", kGNEyG: "bhx6s0dn4", - kOIVth: "bhx1nry0o3", + kOIVth: "bhx12e6dxb", ks0D6T: "bhx193iq5w", - kZKoxP: "bhx1n56ao0", - kg3NbH: "bhxa30tib", + kZKoxP: "bhxxevliy", + kg3NbH: "bhx1vid4s1", kMzoRj: "bhxc342km", - kaIpWk: "bhxjpou06", - kWkggS: "bhxfo3ty bhxrplx1s", - kMwMTN: "bhx1gs2zk7", + kaIpWk: "bhx4g09nt", + kWkggS: "bhxw43b1w bhx1lc0nsx", + kMwMTN: "bhx24dycr", kMv6JI: "bhxjb2p0i", - kGuDYH: "bhxer0amy", - kLWn49: "bhx1jatdxk", + kGuDYH: "bhx5g6txa", + kLWn49: "bhx3350y7", khDVqt: "bhxuxw1ft", kkrTdU: "bhx1ypdohk bhxtgyt42", kSiTet: "bhx1hc1fzr bhxijokvz", - kI3sdo: "bhx1a2a7pz bhx5dwnpn", + kI3sdo: "bhx1a2a7pz bhx1m95a6t", kInvED: "bhx1y3gkto", $$css: true } }; @@ -2105,13 +2105,13 @@ window.__ModuleLoader__.load({ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", { type: "button", ...rest, - className: "bhx3nfvp2 bhx6s0dn4 bhx1nry0o3 bhx193iq5w bhx1n56ao0 bhxa30tib bhxc342km bhxjpou06 bhxfo3ty bhxrplx1s bhx1gs2zk7 bhxjb2p0i bhxer0amy bhx1jatdxk bhxuxw1ft bhx1ypdohk bhxtgyt42 bhx1hc1fzr bhxijokvz bhx1a2a7pz bhx5dwnpn bhx1y3gkto", + className: "bhx3nfvp2 bhx6s0dn4 bhx12e6dxb bhx193iq5w bhxxevliy bhx1vid4s1 bhxc342km bhx4g09nt bhxw43b1w bhx1lc0nsx bhx24dycr bhxjb2p0i bhx5g6txa bhx3350y7 bhxuxw1ft bhx1ypdohk bhxtgyt42 bhx1hc1fzr bhxijokvz bhx1a2a7pz bhx1m95a6t bhx1y3gkto", children }); } function PillFileButton({ children, ...input }) { return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { - className: "bhx3nfvp2 bhx6s0dn4 bhx1nry0o3 bhx193iq5w bhx1n56ao0 bhxa30tib bhxc342km bhxjpou06 bhxfo3ty bhxrplx1s bhx1gs2zk7 bhxjb2p0i bhxer0amy bhx1jatdxk bhxuxw1ft bhx1ypdohk bhxtgyt42 bhx1hc1fzr bhxijokvz bhx1a2a7pz bhx5dwnpn bhx1y3gkto", + className: "bhx3nfvp2 bhx6s0dn4 bhx12e6dxb bhx193iq5w bhxxevliy bhx1vid4s1 bhxc342km bhx4g09nt bhxw43b1w bhx1lc0nsx bhx24dycr bhxjb2p0i bhx5g6txa bhx3350y7 bhxuxw1ft bhx1ypdohk bhxtgyt42 bhx1hc1fzr bhxijokvz bhx1a2a7pz bhx1m95a6t bhx1y3gkto", children: [children, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", { ...input, type: "file", @@ -2191,7 +2191,7 @@ window.__ModuleLoader__.load({ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { ref: mount, "data-pointer-menu": "", - className: "bhxixxii4 bhxu96u03 bhx13vifvy bhx1uif995 bhxjgjsw3 bhxp6lfbs bhx47corl", + className: "bhxixxii4 bhxu96u03 bhx13vifvy bhx51uzqw bhx1kkor1i bhxp6lfbs bhx47corl", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Menu$1, { ...props, open: true, @@ -2271,7 +2271,7 @@ window.__ModuleLoader__.load({ className: "bhxeuugli bhxb3r6kr bhxlyipyv bhxuxw1ft", children: current?.label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { - className: "bhx3nfvp2 bhx2lah0s bhx1hablki", + className: "bhx3nfvp2 bhx2lah0s bhxr3raxm", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 12 }) })] }), active && point ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointerMenu, { @@ -2312,24 +2312,24 @@ window.__ModuleLoader__.load({ "aria-labelledby": titleId, "aria-describedby": description === void 0 ? void 0 : descriptionId, ...data, - className: "bhx78zum5 bhx6s0dn4 bhx4bgz6 bhx15bqsqq bhx1q0g3np bhxcxmu7o bhx1uptv2i bhx16qymgn bhx19fcs9c bhx1cg4dk7 bhxeuugli bhxz617pa bhx1gtm3y5 bhx1q0q8m5 bhxqlejh0 bhx1bta2m4", + className: "bhx78zum5 bhx6s0dn4 bhx4bgz6 bhx15bqsqq bhx1q0g3np bhxcxmu7o bhx1uptv2i bhx1mckxr8 bhx1kwkzks bhxv0lzym bhxeuugli bhxe74ayl bhxrvphhj bhx1q0q8m5 bhxnoyelg bhx1bta2m4", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhxdt5ytf bhx1fykdyo bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli", + className: "bhx78zum5 bhxdt5ytf bhx5v8n67 bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli", children: [ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { id: titleId, - className: "bhx1gs2zk7 bhx2dqotr bhx11nzxlp bhxs1wz3g bhxj0a0fe", + className: "bhx24dycr bhxskcadq bhx1gy5ux2 bhx1nj67n bhxj0a0fe", children: title }), description === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { id: descriptionId, - className: "bhx1hablki bhx17cqwc0 bhx15n174q bhxj0a0fe", + className: "bhxr3raxm bhxgowt09 bhx12cu2lo bhxj0a0fe", children: description }), children ] }), control === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { - className: "bhx78zum5 bhx1a02dak bhx6s0dn4 bhx13a6bvl bhx403fy9 bhx3xz8fp bhxcksle6 bhx2lah0s bhxeuugli bhxh9uqaf bhx1r6q2vj bhx1cnm762", + className: "bhx78zum5 bhx1a02dak bhx6s0dn4 bhx13a6bvl bhx403fy9 bhx3xz8fp bhx2zm2ee bhx2lah0s bhxeuugli bhxh9uqaf bhx1r6q2vj bhx1cnm762", children: control })] }); @@ -2342,9 +2342,9 @@ window.__ModuleLoader__.load({ function ProgressLine({ percent, label }) { return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { role: "status", - className: "bhxrvj5dj bhx1fykdyo bhxh8yej3 bhx1hablki bhx17cqwc0 bhx15n174q", + className: "bhxrvj5dj bhx5v8n67 bhxh8yej3 bhxr3raxm bhxgowt09 bhx12cu2lo", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("progress", { - className: "bhxh8yej3 bhxols6we bhx1rpn2j5", + className: "bhxh8yej3 bhxols6we bhx1g31g6d", max: 100, value: Math.max(0, Math.min(100, percent)) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label })] @@ -2356,7 +2356,7 @@ window.__ModuleLoader__.load({ navigator.clipboard?.writeText(command).then(() => setCopied(true)); }; return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhxrvj5dj bhxcksle6 bhx619ttb bhxh8yej3", + className: "bhxrvj5dj bhx2zm2ee bhx619ttb bhxh8yej3", children: [ installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { tone: "warning", @@ -2366,11 +2366,11 @@ window.__ModuleLoader__.load({ label: installing.label }), access === "none" && installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { children: labels.manual }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", { - className: "bhx1ghz6dp bhxh8yej3 bhx9f619 bhx1leccfj bhx792b8t bhxjpou06 bhxblcitj bhx1gs2zk7 bhxmz2490 bhx17cqwc0 bhx15n174q bhx126k92a bhxj0a0fe", + className: "bhx1ghz6dp bhxh8yej3 bhx9f619 bhx89kiln bhx1w8vjyx bhx4g09nt bhx1xojs80 bhx24dycr bhxmz2490 bhxgowt09 bhx12cu2lo bhx126k92a bhxj0a0fe", children: command })] }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhx1nry0o3 bhx1a02dak", + className: "bhx78zum5 bhx12e6dxb bhx1a02dak", children: [ access !== "none" && installing === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, { size: "sm", @@ -2516,7 +2516,7 @@ window.__ModuleLoader__.load({ }), meta: t(granted ? "local.granted" : "local.description"), detail: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhxrvj5dj bhxcksle6 bhx619ttb", + className: "bhxrvj5dj bhx2zm2ee bhx619ttb", children: [failure === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { tone: "danger", children: failure @@ -2729,7 +2729,7 @@ window.__ModuleLoader__.load({ disabled: disabled === true, onClick: onToggle, children: [label, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { - className: "bhx3nfvp2 bhx2lah0s bhx1hablki", + className: "bhx3nfvp2 bhx2lah0s bhxr3raxm", children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 12 }) })] }); @@ -2948,7 +2948,7 @@ window.__ModuleLoader__.load({ ref: busyResource }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { - className: "bhx1ghz6dp bhx2o8g9q bhxugyd1d bhx1hablki bhx17cqwc0 bhx15n174q", + className: "bhx1ghz6dp bhxb590mu bhx1e1oj2y bhxr3raxm bhxgowt09 bhx12cu2lo", children: t("section.description") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingsRow, { @@ -3011,7 +3011,7 @@ window.__ModuleLoader__.load({ ] }) }), manualOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { - className: "bhx78zum5 bhx6s0dn4 bhx16qymgn bhxeuugli bhxz617pa bhx1gtm3y5 bhx1q0q8m5 bhxqlejh0", + className: "bhx78zum5 bhx6s0dn4 bhx1mckxr8 bhxeuugli bhxe74ayl bhxrvphhj bhx1q0q8m5 bhxnoyelg", children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, { className: { className: "bhx1iyjqo2 bhxs83m0k bhx1r8uery bhxeuugli" }.className ?? "", value: manualPath, @@ -3144,7 +3144,7 @@ window.__ModuleLoader__.load({ ] }) }), selectedFile === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { - className: "bhx1ghz6dp bhxb3r6kr bhxlyipyv bhxuxw1ft bhx1hablki bhx17cqwc0 bhx15n174q", + className: "bhx1ghz6dp bhxb3r6kr bhxlyipyv bhxuxw1ft bhxr3raxm bhxgowt09 bhx12cu2lo", children: selectedFile }), busy !== void 0 && phaseKey !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Note, { children: `${t(phaseKey)} · ${t("entry.elapsed", { seconds: liveElapsed })}` }) : null @@ -3171,7 +3171,7 @@ window.__ModuleLoader__.load({ } //#endregion //#region packages/client/src/client/ds/stylex-css.ts - const STYLEX_CSS = "\n.bhx-level-0{}\n.bhx-level-1:not(#\\#){}\n.bhx1ghz6dp:not(#\\#){margin:0}\n.bhx14opcb4:not(#\\#){padding:16px}\n.bhx-level-2:not(#\\#):not(#\\#){}\n.bhx118alp2:not(#\\#):not(#\\#){border-color:var(--dsw-alias-border-l2)}\n.bhx1w7u4cj:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-md, 12px)}\n.bhxjpou06:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-sm, 8px)}\n.bhxbsl7fq:not(#\\#):not(#\\#){border-style:dashed}\n.bhxc342km:not(#\\#):not(#\\#){border-width:0}\n.bhxc2pr59:not(#\\#):not(#\\#){border-width:1px}\n.bhx16qymgn:not(#\\#):not(#\\#){gap:12px}\n.bhx1fykdyo:not(#\\#):not(#\\#){gap:2px}\n.bhx1nry0o3:not(#\\#):not(#\\#){gap:4px}\n.bhxcksle6:not(#\\#):not(#\\#){gap:8px}\n.bhxx7iei1:not(#\\#):not(#\\#){margin-block:12px}\n.bhx1a2a7pz:not(#\\#):not(#\\#){outline:none}\n.bhxb3r6kr:not(#\\#):not(#\\#){overflow:hidden}\n.bhxz617pa:not(#\\#):not(#\\#){padding-block:12px}\n.bhxxx7nam:not(#\\#):not(#\\#){padding-block:24px}\n.bhx1leccfj:not(#\\#):not(#\\#){padding-block:4px}\n.bhxa30tib:not(#\\#):not(#\\#){padding-inline:12px 8px}\n.bhxwajc3y:not(#\\#):not(#\\#){padding-inline:16px}\n.bhx792b8t:not(#\\#):not(#\\#){padding-inline:4px}\n.bhx5dwnpn:focus-visible:not(#\\#):not(#\\#){outline:var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary)}\n@media (max-width: 648px){.bhx1cg4dk7.bhx1cg4dk7:not(#\\#):not(#\\#){gap:8px}}\n@container bh-settings (max-width: 480px){.bhx19fcs9c.bhx19fcs9c:not(#\\#):not(#\\#){gap:8px}}\n.bhx-level-3:not(#\\#):not(#\\#):not(#\\#){}\n.bhx1rpn2j5:not(#\\#):not(#\\#):not(#\\#){accent-color:var(--dsw-alias-state-business-primary)}\n.bhx6s0dn4:not(#\\#):not(#\\#):not(#\\#){align-items:center}\n.bhxfo3ty:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-bg-module-platform)}\n.bhxblcitj:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)}\n.bhx9f619:not(#\\#):not(#\\#):not(#\\#){box-sizing:border-box}\n.bhx1gs2zk7:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-primary)}\n.bhx1hablki:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-secondary)}\n.bhx14dlum0:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-tertiary)}\n.bhx1sspbqh:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-error-primary)}\n.bhx108ve1s:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-success-primary)}\n.bhx1c0x75y:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-warn-primary)}\n.bhx1ypdohk:not(#\\#):not(#\\#):not(#\\#){cursor:pointer}\n.bhx78zum5:not(#\\#):not(#\\#):not(#\\#){display:flex}\n.bhxrvj5dj:not(#\\#):not(#\\#):not(#\\#){display:grid}\n.bhx3nfvp2:not(#\\#):not(#\\#):not(#\\#){display:inline-flex}\n.bhx1r8uery:not(#\\#):not(#\\#):not(#\\#){flex-basis:0}\n.bhxdt5ytf:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}\n.bhx1q0g3np:not(#\\#):not(#\\#):not(#\\#){flex-direction:row}\n.bhx1iyjqo2:not(#\\#):not(#\\#):not(#\\#){flex-grow:1}\n.bhx2lah0s:not(#\\#):not(#\\#):not(#\\#){flex-shrink:0}\n.bhxs83m0k:not(#\\#):not(#\\#):not(#\\#){flex-shrink:1}\n.bhx1a02dak:not(#\\#):not(#\\#):not(#\\#){flex-wrap:wrap}\n.bhxjb2p0i:not(#\\#):not(#\\#):not(#\\#){font-family:inherit}\n.bhxmz2490:not(#\\#):not(#\\#):not(#\\#){font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}\n.bhx17cqwc0:not(#\\#):not(#\\#):not(#\\#){font-size:12px}\n.bhxer0amy:not(#\\#):not(#\\#):not(#\\#){font-size:13px}\n.bhx2dqotr:not(#\\#):not(#\\#):not(#\\#){font-size:14px}\n.bhxs1wz3g:not(#\\#):not(#\\#):not(#\\#){font-weight:500}\n.bhxl56j7k:not(#\\#):not(#\\#):not(#\\#){justify-content:center}\n.bhx13a6bvl:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-end}\n.bhx619ttb:not(#\\#):not(#\\#):not(#\\#){justify-items:start}\n.bhx15n174q:not(#\\#):not(#\\#):not(#\\#){line-height:16px}\n.bhx11nzxlp:not(#\\#):not(#\\#):not(#\\#){line-height:20px}\n.bhx1jatdxk:not(#\\#):not(#\\#):not(#\\#){line-height:20px}\n.bhx1hc1fzr:not(#\\#):not(#\\#):not(#\\#){opacity:1}\n.bhx1y3gkto:not(#\\#):not(#\\#):not(#\\#){outline-offset:1px}\n.bhxj0a0fe:not(#\\#):not(#\\#):not(#\\#){overflow-wrap:anywhere}\n.bhx67bb7w:not(#\\#):not(#\\#):not(#\\#){pointer-events:auto}\n.bhx47corl:not(#\\#):not(#\\#):not(#\\#){pointer-events:none}\n.bhxixxii4:not(#\\#):not(#\\#):not(#\\#){position:fixed}\n.bhx2b8uid:not(#\\#):not(#\\#):not(#\\#){text-align:center}\n.bhxp4054r:not(#\\#):not(#\\#):not(#\\#){text-align:end}\n.bhxlyipyv:not(#\\#):not(#\\#):not(#\\#){text-overflow:ellipsis}\n.bhxuxw1ft:not(#\\#):not(#\\#):not(#\\#){white-space:nowrap}\n.bhx126k92a:not(#\\#):not(#\\#):not(#\\#){white-space:pre-wrap}\n.bhxp6lfbs:not(#\\#):not(#\\#):not(#\\#){z-index:1100}\n.bhxtgyt42:disabled:not(#\\#):not(#\\#):not(#\\#){cursor:default}\n.bhxijokvz:disabled:not(#\\#):not(#\\#):not(#\\#){opacity:.5}\n.bhxrplx1s:hover:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)}\n@media (max-width: 648px){.bhx15bqsqq.bhx15bqsqq:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}}\n@media (max-width: 648px){.bhx1uptv2i.bhx1uptv2i:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}}\n@media (max-width: 648px){.bhx3xz8fp.bhx3xz8fp:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}}\n@container bh-settings (max-width: 480px){.bhx4bgz6.bhx4bgz6:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}}\n@container bh-settings (max-width: 480px){.bhxcxmu7o.bhxcxmu7o:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}}\n@container bh-settings (max-width: 480px){.bhx403fy9.bhx403fy9:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}}\n.bhx-level-4:not(#\\#):not(#\\#):not(#\\#):not(#\\#){}\n.bhxqlejh0:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:var(--dsw-alias-border-l1)}\n.bhx1q0q8m5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-style:solid}\n.bhx1gtm3y5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-width:1px}\n.bhxjgjsw3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:1px}\n.bhx1n56ao0:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:28px}\n.bhx1hfu2dq:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:40px}\n.bhxols6we:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:6px}\n.bhxu96u03:not(#\\#):not(#\\#):not(#\\#):not(#\\#){left:0}\n.bhx193iq5w:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}\n.bhxxc7z9f:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:360px}\n.bhxh9uqaf:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:60%}\n.bhxeuugli:not(#\\#):not(#\\#):not(#\\#):not(#\\#){min-width:0}\n.bhxugyd1d:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-bottom:4px}\n.bhx2o8g9q:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:16px}\n.bhx13vifvy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){top:0}\n.bhxh8yej3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:100%}\n.bhx1uif995:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:1px}\n.bhx1iur23p:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:40px}\n.bhx10zg81t:first-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:0}\n.bhx1bta2m4:last-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:transparent}\n@media (max-width: 648px){.bhx1cnm762.bhx1cnm762:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}}\n@container bh-settings (max-width: 480px){.bhx1r6q2vj.bhx1r6q2vj:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}}\n"; + const STYLEX_CSS = "\n.bhx-level-0{}\n.bhx-level-1:not(#\\#){}\n.bhx1ghz6dp:not(#\\#){margin:0}\n.bhx1tsjn1a:not(#\\#){padding:16px}\n.bhx-level-2:not(#\\#):not(#\\#){}\n.bhx7vmdhn:not(#\\#):not(#\\#){border-color:var(--dsw-alias-border-l2)}\n.bhx10e1no3:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-md, 12px)}\n.bhx4g09nt:not(#\\#):not(#\\#){border-radius:var(--dsw-radius-sm, 8px)}\n.bhxbsl7fq:not(#\\#):not(#\\#){border-style:dashed}\n.bhxc342km:not(#\\#):not(#\\#){border-width:0}\n.bhx1sfpsak:not(#\\#):not(#\\#){border-width:1px}\n.bhx1mckxr8:not(#\\#):not(#\\#){gap:12px}\n.bhx5v8n67:not(#\\#):not(#\\#){gap:2px}\n.bhx12e6dxb:not(#\\#):not(#\\#){gap:4px}\n.bhx2zm2ee:not(#\\#):not(#\\#){gap:8px}\n.bhx1d3k6p2:not(#\\#):not(#\\#){margin-block:12px}\n.bhx1a2a7pz:not(#\\#):not(#\\#){outline:none}\n.bhxb3r6kr:not(#\\#):not(#\\#){overflow:hidden}\n.bhxe74ayl:not(#\\#):not(#\\#){padding-block:12px}\n.bhx1tv32aw:not(#\\#):not(#\\#){padding-block:24px}\n.bhx89kiln:not(#\\#):not(#\\#){padding-block:4px}\n.bhx1vid4s1:not(#\\#):not(#\\#){padding-inline:12px 8px}\n.bhx1xa34d1:not(#\\#):not(#\\#){padding-inline:16px}\n.bhx1w8vjyx:not(#\\#):not(#\\#){padding-inline:4px}\n.bhx1m95a6t:focus-visible:not(#\\#):not(#\\#){outline:var(--dsw-focus-ring-width, 2px) solid var(--dsw-alias-state-business-primary)}\n@media (max-width: 648px){.bhxv0lzym.bhxv0lzym:not(#\\#):not(#\\#){gap:8px}}\n@container bh-settings (max-width: 480px){.bhx1kwkzks.bhx1kwkzks:not(#\\#):not(#\\#){gap:8px}}\n.bhx-level-3:not(#\\#):not(#\\#):not(#\\#){}\n.bhx1g31g6d:not(#\\#):not(#\\#):not(#\\#){accent-color:var(--dsw-alias-state-business-primary)}\n.bhx6s0dn4:not(#\\#):not(#\\#):not(#\\#){align-items:center}\n.bhxw43b1w:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-bg-module-platform)}\n.bhx1xojs80:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)}\n.bhx9f619:not(#\\#):not(#\\#):not(#\\#){box-sizing:border-box}\n.bhx24dycr:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-primary)}\n.bhxr3raxm:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-secondary)}\n.bhxwq55zj:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-label-tertiary)}\n.bhx8ay0r6:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-error-primary)}\n.bhx1pideu2:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-success-primary)}\n.bhxz9v26x:not(#\\#):not(#\\#):not(#\\#){color:var(--dsw-alias-state-warn-primary)}\n.bhx1ypdohk:not(#\\#):not(#\\#):not(#\\#){cursor:pointer}\n.bhx78zum5:not(#\\#):not(#\\#):not(#\\#){display:flex}\n.bhxrvj5dj:not(#\\#):not(#\\#):not(#\\#){display:grid}\n.bhx3nfvp2:not(#\\#):not(#\\#):not(#\\#){display:inline-flex}\n.bhx1r8uery:not(#\\#):not(#\\#):not(#\\#){flex-basis:0}\n.bhxdt5ytf:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}\n.bhx1q0g3np:not(#\\#):not(#\\#):not(#\\#){flex-direction:row}\n.bhx1iyjqo2:not(#\\#):not(#\\#):not(#\\#){flex-grow:1}\n.bhx2lah0s:not(#\\#):not(#\\#):not(#\\#){flex-shrink:0}\n.bhxs83m0k:not(#\\#):not(#\\#):not(#\\#){flex-shrink:1}\n.bhx1a02dak:not(#\\#):not(#\\#):not(#\\#){flex-wrap:wrap}\n.bhxjb2p0i:not(#\\#):not(#\\#):not(#\\#){font-family:inherit}\n.bhxmz2490:not(#\\#):not(#\\#):not(#\\#){font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}\n.bhxgowt09:not(#\\#):not(#\\#):not(#\\#){font-size:12px}\n.bhx5g6txa:not(#\\#):not(#\\#):not(#\\#){font-size:13px}\n.bhxskcadq:not(#\\#):not(#\\#):not(#\\#){font-size:14px}\n.bhx1nj67n:not(#\\#):not(#\\#):not(#\\#){font-weight:500}\n.bhxl56j7k:not(#\\#):not(#\\#):not(#\\#){justify-content:center}\n.bhx13a6bvl:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-end}\n.bhx619ttb:not(#\\#):not(#\\#):not(#\\#){justify-items:start}\n.bhx12cu2lo:not(#\\#):not(#\\#):not(#\\#){line-height:16px}\n.bhx1gy5ux2:not(#\\#):not(#\\#):not(#\\#){line-height:20px}\n.bhx3350y7:not(#\\#):not(#\\#):not(#\\#){line-height:20px}\n.bhx1hc1fzr:not(#\\#):not(#\\#):not(#\\#){opacity:1}\n.bhx1y3gkto:not(#\\#):not(#\\#):not(#\\#){outline-offset:1px}\n.bhxj0a0fe:not(#\\#):not(#\\#):not(#\\#){overflow-wrap:anywhere}\n.bhx67bb7w:not(#\\#):not(#\\#):not(#\\#){pointer-events:auto}\n.bhx47corl:not(#\\#):not(#\\#):not(#\\#){pointer-events:none}\n.bhxixxii4:not(#\\#):not(#\\#):not(#\\#){position:fixed}\n.bhx2b8uid:not(#\\#):not(#\\#):not(#\\#){text-align:center}\n.bhxp4054r:not(#\\#):not(#\\#):not(#\\#){text-align:end}\n.bhxlyipyv:not(#\\#):not(#\\#):not(#\\#){text-overflow:ellipsis}\n.bhxuxw1ft:not(#\\#):not(#\\#):not(#\\#){white-space:nowrap}\n.bhx126k92a:not(#\\#):not(#\\#):not(#\\#){white-space:pre-wrap}\n.bhxp6lfbs:not(#\\#):not(#\\#):not(#\\#){z-index:1100}\n.bhxtgyt42:disabled:not(#\\#):not(#\\#):not(#\\#){cursor:default}\n.bhxijokvz:disabled:not(#\\#):not(#\\#):not(#\\#){opacity:.5}\n.bhx1lc0nsx:hover:not(#\\#):not(#\\#):not(#\\#){background-color:var(--dsw-alias-interactive-bg-hover)}\n@media (max-width: 648px){.bhx15bqsqq.bhx15bqsqq:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}}\n@media (max-width: 648px){.bhx1uptv2i.bhx1uptv2i:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}}\n@media (max-width: 648px){.bhx3xz8fp.bhx3xz8fp:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}}\n@container bh-settings (max-width: 480px){.bhx4bgz6.bhx4bgz6:not(#\\#):not(#\\#):not(#\\#){align-items:stretch}}\n@container bh-settings (max-width: 480px){.bhxcxmu7o.bhxcxmu7o:not(#\\#):not(#\\#):not(#\\#){flex-direction:column}}\n@container bh-settings (max-width: 480px){.bhx403fy9.bhx403fy9:not(#\\#):not(#\\#):not(#\\#){justify-content:flex-start}}\n.bhx-level-4:not(#\\#):not(#\\#):not(#\\#):not(#\\#){}\n.bhxnoyelg:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:var(--dsw-alias-border-l1)}\n.bhx1q0q8m5:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-style:solid}\n.bhxrvphhj:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-width:1px}\n.bhx1kkor1i:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:1px}\n.bhxxevliy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:28px}\n.bhxdkzj7d:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:40px}\n.bhxols6we:not(#\\#):not(#\\#):not(#\\#):not(#\\#){height:6px}\n.bhxu96u03:not(#\\#):not(#\\#):not(#\\#):not(#\\#){left:0}\n.bhx193iq5w:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}\n.bhxxc7z9f:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:360px}\n.bhxh9uqaf:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:60%}\n.bhxeuugli:not(#\\#):not(#\\#):not(#\\#):not(#\\#){min-width:0}\n.bhx1e1oj2y:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-bottom:4px}\n.bhxb590mu:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:16px}\n.bhx13vifvy:not(#\\#):not(#\\#):not(#\\#):not(#\\#){top:0}\n.bhxh8yej3:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:100%}\n.bhx51uzqw:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:1px}\n.bhx1i1gie9:not(#\\#):not(#\\#):not(#\\#):not(#\\#){width:40px}\n.bhx10zg81t:first-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){padding-top:0}\n.bhx1bta2m4:last-child:not(#\\#):not(#\\#):not(#\\#):not(#\\#){border-bottom-color:transparent}\n@media (max-width: 648px){.bhx1cnm762.bhx1cnm762:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}}\n@container bh-settings (max-width: 480px){.bhx1r6q2vj.bhx1r6q2vj:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-width:100%}}\n"; //#endregion //#region packages/computer/src/client/index.tsx const ACCESS_STYLE = ` diff --git a/packages/core/src/bots/cli-live.ts b/packages/core/src/bots/cli-live.ts index 5a92762b8c..7f06fbb1eb 100644 --- a/packages/core/src/bots/cli-live.ts +++ b/packages/core/src/bots/cli-live.ts @@ -1,1830 +1,1830 @@ -import { runPhoneNotifications } from './cli-phone-notifications.js'; -import { - WINDOW_ZOOM_COMMANDS, - WINDOW_ZOOM_OPTIONS, - runWindowZoomCommand, -} from './cli-window-zoom.js'; -import { PHONE_ROUTE_COMMANDS, runPhoneRouteCommand } from './cli-phone-route.js'; -import { DEVICES_COMMANDS, isDevicesCommand, runDevicesCommand } from './cli-devices.js'; -import { - DEVICE_NOTIFICATION_COMMANDS, - DEVICE_NOTIFICATION_OPTIONS, - runDeviceNotifications, -} from './cli-device-notifications.js'; -import { - BROWSER_NOTIFICATION_COMMANDS, - runBrowserNotifications, -} from './cli-browser-notifications.js'; -import { APPEARANCE_COMMANDS, APPEARANCE_OPTIONS, runAppearanceCommand } from './cli-appearance.js'; -import { prepareCloud, prepareCloudWork } from './cli-cloud.js'; -import { randomUUID } from 'node:crypto'; -import { isDeepStrictEqual } from 'node:util'; -import { readFileSync, writeFileSync } from 'node:fs'; -import { isAbsolute } from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; -import { dmChannelId } from '../channels/channel.js'; -import type { BotCreateCliIo } from './bot-create-cli.js'; -import { CliLiveError } from './cli-live-error.js'; -import { BOT_TOOL_COMMANDS, runBotToolCommand } from './cli-bot-tool.js'; -import { - channelOperationKey, - CHANNEL_COMMANDS, - CHANNEL_OPTIONS, - isChannelCommand, - runChannelCommand, -} from './cli-channels.js'; -import { - BROWSER_COMPUTER_COMMANDS, - BROWSER_COMPUTER_OPTIONS, - isBrowserComputerCommand, - runBrowserComputerCommand, - type BrowserComputerValues, -} from './cli-browser-computer.js'; -import { - IM_COMMANDS, - IM_OPTIONS, - prepareImAuthorization, - runImAuthorization, - type ImValues, -} from './cli-im-authorization.js'; -import { - MESSAGING_COMMANDS, - MESSAGING_OPTIONS, - prepareMessaging, - runMessaging, - type MessagingCommand, - type MessagingValues, -} from './cli-messaging.js'; -import { - isModelsUsageCommand, - MODELS_USAGE_COMMANDS, - MODELS_USAGE_OPTIONS, - runModelsUsage, - type ModelsUsageValues, -} from './cli-models-usage.js'; -import { - HOST_ADMIN_COMMANDS, - HOST_ADMIN_OPTIONS, - isHostAdminCommand, - runHostAdminCommand, - type HostAdminValues, -} from './cli-host-admin.js'; -import { - INBOX_COMMANDS, - INBOX_OPTIONS, - isInboxCommand, - runInboxCommand, - type InboxValues, -} from './cli-inbox.js'; -import { - SCHEDULE_OPTIONS, - isScheduleCommand, - prepareScheduleCommand, - runScheduleCommand, - type ScheduleValues, -} from './cli-schedules.js'; -import { - PROFILE_COMMANDS, - PROFILE_OPTIONS, - isProfileCommand, - runProfileCommand, - type ProfileValues, -} from './cli-profile.js'; -import { - TODO_COMMANDS, - TODO_OPTIONS, - isTodoCommand, - runTodoCommand, - type TodoValues, -} from './cli-todos.js'; -import { - APPROVAL_TEMPLATE_COMMANDS, - APPROVAL_TEMPLATE_OPTIONS, - isApprovalTemplateCommand, - runApprovalTemplateCommand, - type ApprovalTemplateValues, -} from './cli-approval-templates.js'; -import { - APPROVAL_REVIEWER_COMMANDS, - APPROVAL_REVIEWER_OPTIONS, - isApprovalReviewerCommand, - runApprovalReviewerCommand, - type ApprovalReviewerValues, -} from './cli-approval-reviewer.js'; -import { - isStorageCommand, - runStorageCommand, - STORAGE_COMMANDS, - STORAGE_OPTIONS, - type StorageValues, -} from './cli-storage.js'; -import { - IDLE_COMPACTION_COMMANDS, - isIdleCompactionCommand, - runIdleCompactionCommand, -} from './cli-idle-compaction.js'; -import { - CONNECT_COMMANDS, - CONNECT_OPTIONS, - isConnectCommand, - runConnectCommand, -} from './cli-connect.js'; -export { CliLiveError } from './cli-live-error.js'; - -export const LIVE_OPTIONS = { - 'remote-host-id': { type: 'string' }, - ...DEVICE_NOTIFICATION_OPTIONS, - ...WINDOW_ZOOM_OPTIONS, - ...CONNECT_OPTIONS, - ...APPEARANCE_OPTIONS, - ...IM_OPTIONS, - ...MESSAGING_OPTIONS, - ...MODELS_USAGE_OPTIONS, - ...CHANNEL_OPTIONS, - ...HOST_ADMIN_OPTIONS, - ...INBOX_OPTIONS, - ...SCHEDULE_OPTIONS, - ...BROWSER_COMPUTER_OPTIONS, - ...PROFILE_OPTIONS, - ...TODO_OPTIONS, - ...APPROVAL_TEMPLATE_OPTIONS, - ...APPROVAL_REVIEWER_OPTIONS, - ...STORAGE_OPTIONS, - host: { type: 'string' }, - 'token-file': { type: 'string' }, - 'image-source': { type: 'string' }, - 'image-revision': { type: 'string' }, - 'image-reference': { type: 'string' }, - timeout: { type: 'string' }, - 'message-id': { type: 'string' }, - outcome: { type: 'string' }, - 'answer-stdin': { type: 'boolean' }, - 'values-stdin': { type: 'boolean' }, - since: { type: 'string' }, - workspace: { type: 'string' }, - mode: { type: 'string' }, - scope: { type: 'string' }, - 'acknowledge-risk': { type: 'boolean' }, - 'patch-stdin': { type: 'boolean' }, - 'previous-value': { type: 'string' }, - member: { type: 'string', multiple: true }, - app: { type: 'string' }, - bot: { type: 'string' }, - conversation: { type: 'string' }, - out: { type: 'string' }, - 'args-stdin': { type: 'boolean' }, - session: { type: 'string' }, - turns: { type: 'string' }, - offset: { type: 'string' }, - kind: { type: 'string' }, - checkpoint: { type: 'string' }, - 'expected-current': { type: 'string' }, - 'restore-id': { type: 'string' }, - 'repair-id': { type: 'string' }, - 'default-location': { type: 'boolean' }, - tag: { type: 'string' }, - target: { type: 'string' }, -} as const; - -const DEVICE_COMMANDS = [ - 'browser-open', - 'browser-frame', - 'browser-takeover', - 'browser-input', - 'browser-stop', - 'computer-start', - 'computer-status', - 'computer-stop', - 'browser-tools', - 'browser-tool', - 'computer-tools', - 'computer-tool', - 'computer-frame', - 'computer-takeover', - 'computer-input', -] as const; - -const IM_CONTROL_COMMANDS = [ - 'im-app-add', - 'im-app-status', - 'im-app-remove', - 'im-bind', - 'im-unbind', - 'im-status', - 'channel-connect', - 'channel-ingests', -] as const; - -const TOKEN_IM_PLATFORMS = new Set(['discord', 'slack', 'qq', 'dingtalk', 'wecom', 'matrix']); - -const CONTROL_COMMANDS = [ - 'browser-access-set', - 'computer-access-set', - 'access', - 'access-set', - 'settings', - 'settings-set', - 'workspace-create', - 'group-create', -] as const; - -export const LIVE_COMMANDS = [ - ...BROWSER_NOTIFICATION_COMMANDS, - ...DEVICE_NOTIFICATION_COMMANDS, - ...WINDOW_ZOOM_COMMANDS, - ...PHONE_ROUTE_COMMANDS, - ...DEVICES_COMMANDS, - ...CONNECT_COMMANDS, - ...IM_COMMANDS, - ...MESSAGING_COMMANDS, - { - command: 'cloud', - description: 'manage an explicit cloud Agent connection (private JSON through stdin)', - }, - { - command: 'cloud-work', - description: 'run the cloud_work Tool directly for a Bot using JSON from stdin', - }, - ...MODELS_USAGE_COMMANDS, - ...CHANNEL_COMMANDS, - ...HOST_ADMIN_COMMANDS, - ...APPEARANCE_COMMANDS, - ...INBOX_COMMANDS, - ...BROWSER_COMPUTER_COMMANDS, - ...PROFILE_COMMANDS, - ...BOT_TOOL_COMMANDS, - ...TODO_COMMANDS, - ...APPROVAL_TEMPLATE_COMMANDS, - ...APPROVAL_REVIEWER_COMMANDS, - ...IDLE_COMPACTION_COMMANDS, - ...STORAGE_COMMANDS, - { - command: 'send', - description: 'send a Human DM and collect its committed Bot reply from a live Host', - }, - { - command: 'send-status', - description: 'inspect a Human DM receipt and its exact Bot replies without resending', - }, - { - command: 'tool-approval-status', - description: 'inspect a pending tool approval on a live Host', - }, - { - command: 'tool-approval-decide', - description: 'allow once, always allow a secret_cli kind, or reject an owned tool approval', - }, - { command: 'user-question-status', description: 'inspect a formal Human question' }, - { command: 'ask-secret-status', description: 'inspect a private input request without values' }, - { - command: 'ask-secret-submit', - description: 'submit confidential field JSON from stdin through the authenticated Owner', - }, - { command: 'ask-secret-cancel', description: 'cancel a private input request as Owner' }, - { command: 'ask-secret-account-status', description: 'inspect current private account access' }, - { command: 'ask-secret-replace', description: 'open a blank replacement private input card' }, - { command: 'ask-secret-revoke', description: 'revoke local private account access as Owner' }, - { - command: 'ask-secret-accounts', - description: "list a Bot's private accounts as Owner, without values", - }, - { - command: 'user-question-answer', - description: 'answer a formal Human question with JSON from stdin', - }, - { command: 'release-info', description: 'read the running Host release lifecycle' }, - { command: 'workspace-options', description: 'list live Host workspaces for grants' }, - { - command: 'folder-browse', - description: 'list folder names on the Host computer, as the phone folder picker does', - }, - { command: 'grant-create', description: 'authorize a Bot workspace through the live Host' }, - { - command: 'browser-access-set', - description: "turn a Bot's Browser Access on or off through the live Host", - }, - { - command: 'computer-access-set', - description: "turn a Bot's Computer Access on or off through the live Host", - }, - { command: 'access', description: "read a Bot's access preset (workspace write or full access)" }, - { - command: 'access-set', - description: "set a Bot's access preset; full access needs --acknowledge-risk", - }, - { command: 'settings', description: 'read one live Host settings namespace' }, - { - command: 'settings-set', - description: 'patch one live Host settings namespace with JSON from stdin', - }, - { command: 'workspace-create', description: 'register an absolute folder as a Host workspace' }, - { command: 'group-create', description: 'create a group Channel with optional initial Bots' }, - { - command: 'im-app-add', - description: 'add a token-based IM app (Discord, Slack, …) with credentials JSON from stdin', - }, - { command: 'im-app-status', description: "read a token IM platform's apps and connection state" }, - { command: 'im-app-remove', description: 'remove a token IM app from the Provider' }, - { command: 'im-bind', description: 'bind a connected IM app to a Bot' }, - { command: 'im-unbind', description: 'unbind an IM app from a Bot' }, - { command: 'im-status', description: "read a Bot's IM apps and bindings with reception state" }, - { - command: 'channel-connect', - description: "connect an external group conversation to a Channel through a Bot's binding", - }, - { command: 'channel-ingests', description: "list a Channel's connected external conversations" }, - { command: 'browser-open', description: "start a Bot's Browser and open its tab for viewing" }, - { - command: 'browser-frame', - description: "save the current picture of a Bot's Browser to a file", - }, - { - command: 'browser-takeover', - description: "pause a Bot's Browser for Human input (--enabled) or hand it back (--disabled)", - }, - { - command: 'browser-input', - description: - "click, scroll, type or press a key in a paused Bot's Browser (input JSON from stdin)", - }, - { command: 'browser-stop', description: "stop a Bot's Browser" }, - { - command: 'computer-start', - description: 'start the profile Computer with explicit authorization', - }, - { command: 'computer-status', description: 'read the profile Computer target and state' }, - { - command: 'computer-frame', - description: 'save the current picture of the profile Computer desktop to a file', - }, - { - command: 'computer-takeover', - description: - 'pause Bot use of the profile Computer for Human input (--enabled) or hand it back (--disabled)', - }, - { - command: 'computer-input', - description: - 'click, scroll, type or press a key on a paused profile Computer desktop (input JSON from stdin)', - }, - { command: 'computer-stop', description: 'stop the profile Computer' }, - { - command: 'browser-tools', - description: 'list the Browser tools a Bot can call, with input schemas', - }, - { - command: 'browser-tool', - description: - 'run one Browser tool directly for a Bot, without the model (args JSON from stdin)', - }, - { - command: 'memory-image', - description: - 'export version-bound Memory image bytes (--path --image-source --image-revision [--image-reference] --out)', - }, - { command: 'memory-file-target', description: 'resolve a memory file path for a Bot' }, - { command: 'memory-graph', description: "read a Bot's memory commit graph" }, - { command: 'memory-commit-diff', description: 'read the full diff of one memory commit' }, - { command: 'memory-changes', description: 'list uncommitted memory changes' }, - { command: 'memory-working-diff', description: 'read the diff of one uncommitted memory file' }, - { command: 'memory-checkpoints', description: 'list memory recovery checkpoints' }, - { command: 'memory-restore', description: 'restore memory to a recovery checkpoint' }, - { command: 'memory-restore-commit', description: 'restore memory to an earlier commit' }, - { command: 'memory-versions', description: 'list named memory versions' }, - { command: 'memory-version-name', description: 'name a memory commit as a version' }, - { command: 'memory-version-unname', description: 'remove a memory version name' }, - { command: 'memory-repair', description: 'repair a memory repository against its expected head' }, - { - command: 'memory-move', - description: "move a Bot's Memory repository to another folder, or back to the default", - }, - { - command: 'bot-trace', - description: 'show which tools a Bot called in its recent turns, with outcomes', - }, - { - command: 'computer-tools', - description: 'list the Computer tools a Bot can call, with input schemas', - }, - { - command: 'computer-tool', - description: - 'run one Computer tool directly for a Bot, without the model (args JSON from stdin)', - }, -] as const; - -interface LiveValues - extends - ImValues, - MessagingValues, - ModelsUsageValues, - HostAdminValues, - InboxValues, - ScheduleValues, - BrowserComputerValues, - ProfileValues, - TodoValues, - ApprovalTemplateValues, - ApprovalReviewerValues { - 'client-id'?: string; - 'device-file'?: string; - 'remote-host-id'?: string; - 'preferences-stdin'?: boolean; - host?: string; - 'token-file'?: string; - 'image-source'?: string; - 'image-revision'?: string; - 'image-reference'?: string; - timeout?: string; - 'message-id'?: string; - body?: string; - 'body-stdin'?: boolean; - 'answer-stdin'?: boolean; - 'values-stdin'?: boolean; - outcome?: string; - since?: string; - workspace?: string; - limit?: string; - before?: string; - enabled?: boolean; - disabled?: boolean; - mode?: string; - scope?: string; - 'acknowledge-risk'?: boolean; - 'patch-stdin'?: boolean; - 'previous-value'?: string; - member?: string[]; - 'operation-key'?: string; - app?: string; - bot?: string; - conversation?: string; - out?: string; - 'args-stdin'?: boolean; - session?: string; - turns?: string; - offset?: string; - kind?: string; - checkpoint?: string; - 'expected-current'?: string; - 'restore-id'?: string; - 'repair-id'?: string; - 'memory-location'?: string; - 'default-location'?: boolean; - tag?: string; - path?: string; - sha?: string; - name?: string; - 'expected-head'?: string; - 'edit-id'?: string; - target?: string; -} - -function usage(message: string): never { - throw new CliLiveError('usage', message); -} - -function isLoopbackName(name: string): boolean { - return name === 'localhost' || name === '[::1]' || /^127\.\d+\.\d+\.\d+$/u.test(name); -} - -function hostUrl(value: string | undefined): URL { - if (!value) usage('Live commands require --host or DEEPSEEKBOT_HOST.'); - let url: URL; - try { - url = new URL(value); - } catch { - usage('--host must be an HTTP(S) origin.'); - } - const name = url.hostname; - const loopback = isLoopbackName(name); - const tailnet = - name.endsWith('.ts.net') || /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/u.test(name); - if ( - url.username || - url.password || - url.search || - url.hash || - url.pathname !== '/' || - !( - (loopback && (url.protocol === 'http:' || url.protocol === 'https:')) || - (tailnet && url.protocol === 'https:') - ) - ) { - usage( - '--host must be a loopback origin or a tailnet HTTPS origin, without credentials, query or path.', - ); - } - return url; -} - -function timeoutMs(value: string | undefined): number { - const seconds = value === undefined ? 120 : Number(value); - if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 600) - usage('--timeout must be between 0 and 600 seconds.'); - return Math.ceil(seconds * 1000); -} - -function record(value: unknown): Record { - return typeof value === 'object' && value !== null ? (value as Record) : {}; -} - -export async function connectLiveHost(values: LiveValues, io: BotCreateCliIo, signal: AbortSignal) { - const base = hostUrl(values.host ?? io.env['DEEPSEEKBOT_HOST']); - let token = io.env['DEEPSEEKBOT_HOST_TOKEN']; - if (values['token-file'] !== undefined) { - try { - token = readFileSync(values['token-file'], 'utf8').trim(); - } catch { - throw new CliLiveError('host-unauthorized', 'Cannot read the Host token file.'); - } - } - if (!token?.trim()) - throw new CliLiveError( - 'host-unauthorized', - 'Set DEEPSEEKBOT_HOST_TOKEN or provide --token-file.', - ); - const secret = token.trim(); - const login = new URL(base); - login.searchParams.set('token', secret); - let cookie = ''; - const refused = (status: number): CliLiveError => - status === 403 && !isLoopbackName(base.hostname) - ? new CliLiveError( - 'host-untrusted', - `The Host does not accept requests addressed to ${base.hostname}; restart it with --trusted-host ${base.hostname}.`, - ) - : new CliLiveError( - 'host-unauthorized', - 'The Host refused this authority cookie; refresh its launch token.', - ); - const request = async (url: URL, init: RequestInit = {}): Promise => { - try { - return await fetch(url, { ...init, signal, redirect: 'manual' }); - } catch { - throw new CliLiveError( - 'host-unreachable', - 'The Host could not be reached before the command deadline.', - ); - } - }; - for (let attempt = 0; attempt < 2; attempt += 1) { - const response = await request(login, { headers: cookie ? { cookie } : {} }); - const cookies = response.headers.getSetCookie(); - if (cookies.length > 0) cookie = cookies.map((entry) => entry.split(';')[0]).join('; '); - await response.body?.cancel(); - if (response.status === 401 || response.status === 403) - throw new CliLiveError( - 'host-unauthorized', - 'Host login was refused; refresh its launch token.', - ); - if (cookie) break; - } - if (!cookie) - throw new CliLiveError('host-unauthorized', 'Host login did not issue an authority cookie.'); - const readResponse = async (response: Response): Promise> => { - if (response.status === 401 || response.status === 403) { - await response.body?.cancel(); - throw refused(response.status); - } - try { - const body = await response.text(); - try { - return record(JSON.parse(body)); - } catch { - throw new CliLiveError('host-protocol-error', 'The Host did not return a JSON response.'); - } - } catch (error) { - if (error instanceof CliLiveError) throw error; - throw new CliLiveError( - 'host-unreachable', - 'The Host response was interrupted before the command deadline.', - ); - } - }; - const scrub = (value: string) => - value.replaceAll(secret, '[redacted]').replaceAll(encodeURIComponent(secret), '[redacted]'); - const rpc = async (method: string, args: Record): Promise => { - const name = method.includes('/') ? method : `botharness/${method}`; - const response = await request(new URL(`/api/${name}`, base), { - method: 'POST', - headers: { 'content-type': 'application/json', cookie }, - body: JSON.stringify({ - type: 'client-request', - rpcId: randomUUID(), - method: name, - payload: method.startsWith('dsh-im/') ? args : { args }, - }), - }); - const payload = await readResponse(response); - if (!response.ok || payload['type'] !== 'server-response') - throw new CliLiveError('host-protocol-error', 'The Host did not return a DSH RPC response.'); - const result = record(payload['result']); - if (result['ok'] !== true) { - const error = record(result['error']); - const code = typeof error['code'] === 'string' ? error['code'] : 'host-protocol-error'; - const message = - typeof error['message'] === 'string' ? error['message'] : 'The Host refused the command.'; - throw new CliLiveError(code, scrub(message)); - } - return result['value'] as T; - }; - const importBotZip = async ( - archive: Uint8Array, - metadata: { name?: string; displayName?: string; roles?: string[]; description?: string }, - ): Promise => { - const url = new URL('/api/botharness/bot-zip/import', base); - for (const key of ['name', 'displayName', 'description'] as const) { - if (metadata[key] !== undefined) url.searchParams.set(key, metadata[key]); - } - if (metadata.roles !== undefined) url.searchParams.set('roles', JSON.stringify(metadata.roles)); - const response = await request(url, { - method: 'POST', - headers: { cookie, 'content-type': 'application/zip' }, - body: new Uint8Array(archive), - }); - const payload = await readResponse(response); - if (!response.ok) { - const error = record(payload['error']); - const bot = record(payload['bot']); - throw new CliLiveError( - typeof error['code'] === 'string' ? scrub(error['code']) : 'host-protocol-error', - scrub( - typeof error['message'] === 'string' - ? error['message'] - : 'The Host refused this Bot Zip import.', - ), - undefined, - undefined, - typeof bot['id'] === 'string' && typeof bot['name'] === 'string' - ? { - bot: { id: bot['id'], name: bot['name'] }, - steps: [{ name: 'create-bot', status: 'ok' }], - outcome: 'created', - } - : undefined, - ); - } - return payload as T; - }; - const refuse = (path: string, status: number, bytes: Uint8Array): never => { - let detail: Record = {}; - try { - detail = record(JSON.parse(new TextDecoder().decode(bytes))); - } catch { - detail = { error: new TextDecoder().decode(bytes).slice(0, 200) }; - } - const nested = record(detail['error']); - const code = - typeof detail['code'] === 'string' - ? detail['code'] - : typeof nested['code'] === 'string' - ? nested['code'] - : undefined; - if ((status === 401 || status === 403) && code === undefined) throw refused(status); - const packages = Array.isArray(detail['packages']) - ? detail['packages'].filter((name): name is string => typeof name === 'string') - : []; - throw new CliLiveError( - code === undefined ? 'host-refused' : scrub(code), - scrub( - (typeof detail['error'] === 'string' && detail['error'] !== '' - ? detail['error'] - : typeof nested['message'] === 'string' - ? nested['message'] - : `The Host refused ${path} with HTTP ${String(status)}.`) + - (packages.length === 0 ? '' : ` Packages: ${packages.join(' ')}.`), - ), - ); - }; - const http = async ( - path: string, - body?: Record, - ): Promise<{ response: Response; bytes: Uint8Array }> => { - const response = await request(new URL(path, base), { - method: body === undefined ? 'GET' : 'POST', - headers: { - cookie, - ...(body === undefined ? {} : { 'content-type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - let bytes: Uint8Array; - try { - bytes = new Uint8Array(await response.arrayBuffer()); - } catch { - throw new CliLiveError( - 'host-unreachable', - 'The Host response was interrupted before the command deadline.', - ); - } - if (!response.ok) refuse(path, response.status, bytes); - return { response, bytes }; - }; - const stream = async (path: string, init: RequestInit = {}): Promise => { - const response = await request(new URL(path, base), { - ...init, - headers: { ...(init.headers as Record | undefined), cookie }, - }); - if (!response.ok) refuse(path, response.status, new Uint8Array(await response.arrayBuffer())); - return response; - }; - const socket = (path: string): WebSocket => { - const url = new URL(path, base); - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - const Socket = WebSocket as unknown as new ( - url: string, - init: { headers: Record }, - ) => WebSocket; - return new Socket(url.href, { headers: { cookie } }); - }; - return { rpc, importBotZip, http, stream, socket }; -} - -export async function runLiveCli( - command: string, - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - if (command === 'device-notifications') return runDeviceNotifications(rest, values, io); - if (command === 'phone-notifications') return runPhoneNotifications(rest, values, io); - if (BROWSER_NOTIFICATION_COMMANDS.some((entry) => entry.command === command)) - return runBrowserNotifications(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (IM_COMMANDS.some((entry) => entry.command === command)) { - const operation = await prepareImAuthorization(command, rest, values, io); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - try { - return await runImAuthorization( - operation, - (await connectLiveHost(values, io, signal)).rpc, - signal, - ); - } catch (error) { - if ( - error instanceof CliLiveError && - !error.authorization && - command !== 'im-apps' && - command !== 'im-authorize' - ) - throw new CliLiveError(error.code, error.message, undefined, { - attemptId: operation.target, - }); - throw error; - } - } - if (MEMORY_COMMANDS.has(command)) return runMemoryCommand(command, rest, values, io); - if (isModelsUsageCommand(command)) - return runModelsUsage(command, rest, values, io, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isConnectCommand(command)) - return runConnectCommand(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (WINDOW_ZOOM_COMMANDS.some((entry) => entry.command === command)) - return runWindowZoomCommand(command, rest, values, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (PHONE_ROUTE_COMMANDS.some((entry) => entry.command === command)) - return runPhoneRouteCommand(command, rest, values, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (isDevicesCommand(command)) - return runDevicesCommand(command, rest, values, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (command === 'cloud-work') { - const input = await prepareCloudWork(rest, values, io); - const host = await connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))); - return host.rpc('cloudWork', input); - } - if (command === 'cloud') { - const input = await prepareCloud(rest, values, io); - const host = await connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))); - return host.rpc('cloudConnections', { input }); - } - if (MESSAGING_COMMANDS.some((entry) => entry.command === command)) { - const operation = await prepareMessaging(command as MessagingCommand, rest, values, io); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return runMessaging(operation, (await connectLiveHost(values, io, signal)).rpc); - } - if (isInboxCommand(command)) - return runInboxCommand(command, rest, values, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (isScheduleCommand(command)) { - const prepared = prepareScheduleCommand(command, rest, values); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return runScheduleCommand(prepared, (await connectLiveHost(values, io, signal)).rpc); - } - if (isBrowserComputerCommand(command)) - return runBrowserComputerCommand(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (APPEARANCE_COMMANDS.some((entry) => entry.command === command)) - return runAppearanceCommand(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (isProfileCommand(command)) - return runProfileCommand(command, rest, values, io, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isApprovalReviewerCommand(command)) - return runApprovalReviewerCommand(command, rest, values, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isStorageCommand(command)) - return runStorageCommand(command, rest, values as StorageValues, io, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isIdleCompactionCommand(command)) - return runIdleCompactionCommand(command, rest, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isApprovalTemplateCommand(command)) - return runApprovalTemplateCommand(command, rest, values, io, async () => { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc; - }); - if (isTodoCommand(command)) - return runTodoCommand(command, rest, values, io, () => - connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (BOT_TOOL_COMMANDS.some((entry) => entry.command === command)) - return runBotToolCommand( - command as (typeof BOT_TOOL_COMMANDS)[number]['command'], - rest, - values, - io, - () => connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), - ); - if (command === 'bot-trace') { - if (rest.length !== 1 || !rest[0]?.trim()) usage('bot-trace needs exactly one bot id.'); - const turns = values.turns === undefined ? undefined : Number(values.turns); - if (turns !== undefined && (!Number.isInteger(turns) || turns < 1 || turns > 20)) - usage('--turns must be an integer from 1 to 20.'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc('botToolTrace', { - slug: rest[0].trim(), - ...(values.session === undefined ? {} : { sessionId: values.session }), - ...(turns === undefined ? {} : { turns }), - }); - } - if (isChannelCommand(command)) - return runChannelCommand(command, rest, values, io, connectLiveHost); - if (isHostAdminCommand(command)) { - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return runHostAdminCommand(command, rest, values, io, { - connect: async () => (await connectLiveHost(values, io, signal)).rpc, - signal, - }); - } - if (DEVICE_COMMANDS.some((entry) => entry === command)) - return runDeviceCommand(command as (typeof DEVICE_COMMANDS)[number], rest, values, io); - if (command === 'folder-browse') { - if (rest.length > 1) usage('folder-browse takes at most one folder path.'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - const path = rest[0]?.trim(); - return (await connectLiveHost(values, io, signal)).rpc('folderBrowse', path ? { path } : {}); - } - if (command === 'grants') { - if (rest.length !== 1 || !rest[0]?.trim()) usage('grants needs exactly one bot id.'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc('grants', { slug: rest[0].trim() }); - } - if (IM_CONTROL_COMMANDS.some((entry) => entry === command)) - return runImControlCommand(command as (typeof IM_CONTROL_COMMANDS)[number], rest, values, io); - if (CONTROL_COMMANDS.some((entry) => entry === command)) - return runControlCommand(command as (typeof CONTROL_COMMANDS)[number], rest, values, io); - const noTarget = command === 'release-info' || command === 'workspace-options'; - if (rest.length !== (noTarget ? 0 : 1)) - usage( - `${command} ${noTarget ? 'takes no positional arguments' : 'needs exactly one target id'}.`, - ); - const target = rest[0]?.trim() ?? ''; - if (!noTarget && !target) usage(`${command} requires a nonempty target id.`); - const messageId = values['message-id']; - if ( - [ - 'send-status', - 'tool-approval-status', - 'tool-approval-decide', - 'user-question-status', - 'user-question-answer', - 'ask-secret-status', - 'ask-secret-submit', - 'ask-secret-cancel', - 'ask-secret-account-status', - 'ask-secret-replace', - 'ask-secret-revoke', - ].includes(command) && - !messageId?.trim() - ) - usage('--message-id is required.'); - let body: string | undefined; - let answer: unknown; - let confidential: Record | undefined; - if (command === 'ask-secret-submit') { - if (!values['values-stdin']) usage('ask-secret-submit requires --values-stdin.'); - try { - const input = await (io.readStdin?.() ?? Promise.resolve('')); - if (input.length > 140_000) throw new Error('Private input exceeds the limit'); - const parsed: unknown = JSON.parse(input); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error('Invalid private input'); - const entries = Object.entries(parsed); - if ( - entries.length < 1 || - entries.length > 8 || - entries.some( - ([key, value]) => - !/^[a-z][a-z0-9_]{0,47}$/.test(key) || - typeof value !== 'string' || - value.length > 16_384, - ) - ) - throw new Error('Invalid private fields'); - confidential = Object.fromEntries(entries); - } catch { - usage('Private input stdin must be a JSON object with 1–8 declared string fields.'); - } - } - if (command === 'send') { - if ((values.body !== undefined) === (values['body-stdin'] === true)) - usage('send requires exactly one of --body or --body-stdin.'); - body = values['body-stdin'] ? await (io.readStdin?.() ?? Promise.resolve('')) : values.body; - if (!body?.trim()) usage('send requires a nonempty message.'); - if ( - messageId !== undefined && - !/^human-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(messageId) - ) - usage('--message-id must be human- followed by a UUID.'); - } - if ( - command === 'tool-approval-decide' && - values.outcome !== 'allowed-once' && - values.outcome !== 'allowed-always-tool' && - values.outcome !== 'allowed-always-kind' && - values.outcome !== 'rejected' - ) - usage('--outcome must be allowed-once, allowed-always-tool, allowed-always-kind or rejected.'); - if (command === 'user-question-answer') { - if (!values['answer-stdin']) usage('user-question-answer requires --answer-stdin.'); - try { - answer = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); - } catch { - usage('Question answers must be valid JSON on stdin.'); - } - const answers = record(answer)['answers']; - if ( - !Array.isArray(answers) || - !answers.every((entry) => { - const item = record(entry); - return ( - typeof item['id'] === 'string' && - Array.isArray(item['selected']) && - item['selected'].every((choice) => typeof choice === 'string') && - (item['custom'] === undefined || typeof item['custom'] === 'string') - ); - }) - ) - usage( - 'Answer JSON requires answers with id, selected string array and optional custom text.', - ); - } - if (command === 'grant-create' && !values.workspace?.trim()) usage('--workspace is required.'); - const duration = timeoutMs(values.timeout); - const deadline = Date.now() + duration; - const signal = AbortSignal.timeout(duration); - const { rpc } = await connectLiveHost(values, io, signal); - if (command.startsWith('ask-secret-')) { - try { - if (command === 'ask-secret-accounts') { - const result = await rpc<{ - status?: unknown; - accounts?: unknown; - replaceable?: unknown; - }>('privateAccounts', { - slug: target, - }); - if ( - (result.status !== 'ready' && result.status !== 'unavailable') || - !Array.isArray(result.accounts) - ) - throw new Error('Invalid private accounts'); - const optional = (value: unknown) => (typeof value === 'string' ? value : undefined); - const accounts = result.accounts.map((entry: unknown) => { - const row = record(entry); - const status = row['status']; - if ( - typeof row['account'] !== 'string' || - (status !== 'configured' && status !== 'replacing' && status !== 'unavailable') || - !Array.isArray(row['fields']) - ) - throw new Error('Invalid private account'); - return { - account: row['account'], - purpose: optional(row['purpose']), - fields: row['fields'].map((value: unknown) => { - const field = record(value); - if ( - typeof field['id'] !== 'string' || - typeof field['label'] !== 'string' || - typeof field['required'] !== 'boolean' - ) - throw new Error('Invalid private field'); - return { id: field['id'], label: field['label'], required: field['required'] }; - }), - status, - configuredAt: optional(row['configuredAt']), - channelId: optional(row['channelId']), - messageId: optional(row['messageId']), - replacementMessageId: optional(row['replacementMessageId']), - }; - }); - return { - status: result.status, - accounts, - ...(typeof result.replaceable === 'boolean' ? { replaceable: result.replaceable } : {}), - }; - } - if (command === 'ask-secret-status' || command === 'ask-secret-account-status') { - const result = await rpc<{ status?: unknown }>( - command === 'ask-secret-status' ? 'privateInputStatus' : 'privateAccountStatus', - { - channelId: target, - messageId, - }, - ); - const statuses = - command === 'ask-secret-status' - ? ['pending', 'configured', 'cancelled', 'expired'] - : ['configured', 'revoked', 'superseded', 'unavailable']; - if (typeof result.status !== 'string' || !statuses.includes(result.status)) - throw new Error('Invalid private status'); - return { status: result.status }; - } - if (command === 'ask-secret-replace') { - const result = await rpc<{ messageId?: unknown }>('privateInputReplace', { - channelId: target, - messageId, - }); - if ( - typeof result.messageId !== 'string' || - !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(result.messageId) - ) - throw new Error('Invalid replacement card'); - return { messageId: result.messageId }; - } - if (command === 'ask-secret-revoke') { - const result = await rpc<{ - accepted?: unknown; - stopped?: unknown; - completed?: unknown; - uncertain?: unknown; - }>('privateAccountRevoke', { channelId: target, messageId }); - if ( - result.accepted !== true || - [result.stopped, result.completed, result.uncertain].some( - (count) => typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0, - ) - ) - throw new Error('Invalid revocation result'); - return { - accepted: true, - stopped: result.stopped, - completed: result.completed, - uncertain: result.uncertain, - }; - } - const method = command === 'ask-secret-submit' ? 'privateInputSubmit' : 'privateInputCancel'; - const result = await rpc<{ accepted?: unknown }>(method, { - channelId: target, - messageId, - ...(confidential === undefined ? {} : { values: confidential }), - }); - if (result.accepted !== true) throw new Error('Private request was not accepted'); - return { accepted: true }; - } catch { - throw new CliLiveError( - 'private-input-not-accepted', - 'Private input operation was not accepted. Check Owner authentication and request status before retrying.', - ); - } finally { - if (confidential !== undefined) - for (const key of Object.keys(confidential)) delete confidential[key]; - } - } - if (command === 'send') { - const channelId = dmChannelId(target); - const receipt = { channelId, messageId: messageId ?? `human-${randomUUID()}` }; - let sent: unknown; - try { - const bot = await rpc<{ bot: { displayName?: string } }>('get', { slug: target }); - await rpc('channelDm', { slug: target, displayName: bot.bot.displayName }); - sent = await rpc('channelSend', { channelId, body, messageId: receipt.messageId }); - while (Date.now() < deadline) { - const status = await rpc<{ state: string; replies: unknown[] }>( - 'channelSendStatus', - receipt, - ); - if (!status || !Array.isArray(status.replies) || typeof status.state !== 'string') - throw new CliLiveError( - 'host-protocol-error', - 'The Host returned an invalid send status.', - ); - if (status.state === 'handled' && status.replies.length > 0) - return { receipt, ...record(sent), ...status }; - if (status.state === 'handled' || status.state === 'needs-repair') - throw new CliLiveError( - status.state === 'handled' ? 'reply-not-produced' : 'send-needs-repair', - 'The Host has no committed Bot reply for this request.', - receipt, - ); - await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal }); - } - } catch (error) { - if (error instanceof CliLiveError) throw new CliLiveError(error.code, error.message, receipt); - throw new CliLiveError( - 'reply-timeout', - 'The command deadline elapsed; inspect send-status with this receipt before retrying.', - receipt, - ); - } - throw new CliLiveError( - 'reply-timeout', - 'No reply before the deadline; inspect send-status with this receipt before retrying.', - receipt, - ); - } - if (command === 'send-status') - return rpc('channelSendStatus', { channelId: dmChannelId(target), messageId }); - if (command === 'channel-messages') { - const limit = values.limit === undefined ? undefined : Number(values.limit); - if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 200)) - usage('--limit must be 1–200.'); - return rpc('channelMessages', { - channelId: target, - ...(limit === undefined ? {} : { limit }), - ...(values.before === undefined ? {} : { before: values.before }), - }); - } - if (command === 'tool-approval-status') - return rpc('toolApprovalStatus', { channelId: target, messageId }); - if (command === 'tool-approval-decide') - return rpc('toolApprovalDecide', { channelId: target, messageId, outcome: values.outcome }); - if (command === 'user-question-status') - return rpc('userQuestionStatus', { channelId: target, messageId }); - if (command === 'user-question-answer') - return rpc('userQuestionAnswer', { channelId: target, messageId, answer }); - if (command === 'workspace-options') return rpc('workspaceOptions', {}); - if (command === 'grant-create') - return rpc('grantCreate', { slug: target, workspaceId: values.workspace }); - return rpc('releaseInfo', values.since === undefined ? {} : { since: values.since }); -} - -async function runControlCommand( - command: (typeof CONTROL_COMMANDS)[number], - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one target.`); - const target = rest[0].trim(); - let patch: Record | undefined; - let reviewed: ReviewedSettings | undefined; - if (command === 'browser-access-set' || command === 'computer-access-set') { - if (values.enabled === values.disabled) - usage(`${command} needs exactly one of --enabled or --disabled.`); - } - if (command === 'access-set') { - if (values.mode !== 'workspace-write' && values.mode !== 'danger-full-access') - usage('--mode must be workspace-write or danger-full-access.'); - if (values.scope !== undefined && values.scope !== 'chat' && values.scope !== 'assignments') - usage('--scope must be chat or assignments.'); - if (values.mode === 'danger-full-access' && values['acknowledge-risk'] !== true) - usage( - 'Full access lets the Bot run any command and use the Browser and Computer without asking. Pass --acknowledge-risk to confirm.', - ); - } - if (command === 'settings-set') { - if (values['patch-stdin'] !== true) usage('settings-set requires --patch-stdin.'); - try { - const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error('not an object'); - patch = parsed as Record; - } catch { - usage('--patch-stdin must be a JSON object of settings fields.'); - } - reviewed = parseReviewedSettings(values, patch); - } - if (command === 'workspace-create' && !target.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(target)) - usage('workspace-create needs an absolute folder path.'); - const members = (values.member ?? []).map((member) => member.trim()).filter(Boolean); - const operationKey = - command === 'group-create' && values['operation-key'] !== undefined - ? channelOperationKey(values['operation-key']) - : undefined; - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - const { rpc } = await connectLiveHost(values, io, signal); - if (command === 'browser-access-set' || command === 'computer-access-set') - return rpc(command === 'browser-access-set' ? 'browserAccessSet' : 'computerAccessSet', { - slug: target, - enabled: values.enabled === true, - }); - if (command === 'access') return rpc('assignmentAccessGet', { slug: target }); - if (command === 'access-set') - return rpc('assignmentAccessSet', { - slug: target, - mode: values.mode, - ...(values.scope === undefined ? {} : { scope: values.scope }), - ...(values.mode === 'danger-full-access' ? { acknowledgeRisk: true } : {}), - }); - if (command === 'workspace-create') return rpc('workspace/create', { request: { path: target } }); - if (command === 'group-create') - return rpc('channelCreate', { - name: target, - members, - ...(operationKey === undefined ? {} : { operationKey }), - }); - const described = record(await rpc('settings/describe', {})); - const namespaces = Array.isArray(described['namespaces']) ? described['namespaces'] : []; - const found = namespaces.map(record).find((entry) => entry['ns'] === target); - if (found === undefined) - throw new CliLiveError('unknown-settings', `The Host has no settings namespace ${target}.`); - if (command === 'settings') - return { ns: target, revision: found['revision'], value: found['value'] }; - if (reviewed !== undefined) { - const current = record(found['value']); - if ( - found['revision'] !== reviewed.expectedRevision || - !Object.entries(reviewed.previousValue).every(([key, value]) => - isDeepStrictEqual(current[key], value), - ) - ) - throw new CliLiveError( - 'stale-setting', - 'The reviewed settings changed; read and review the current value before retrying.', - ); - } - const updated = record( - await rpc('settings/update', { - ns: target, - patch, - expectedRevision: reviewed?.expectedRevision ?? found['revision'], - }), - ); - return { ns: target, revision: updated['revision'], value: updated['value'] }; -} - -function appSummary(value: unknown) { - const app = record(value); - const bot = record(app['bot']); - const error = app['error'] === null || app['error'] === undefined ? null : record(app['error']); - return { - id: app['botId'], - name: bot['name'] ?? app['name'], - state: app['state'], - connected: app['connected'] === true, - error: error === null ? null : { code: error['code'], message: error['message'] }, - ...(app['stats'] === undefined ? {} : { stats: app['stats'] }), - }; -} - -function connectionSummary(platform: string, value: unknown) { - const status = record(value); - return { - platform, - state: status['state'], - apps: (Array.isArray(status['bots']) ? status['bots'] : []).map(appSummary), - }; -} - -async function runImControlCommand( - command: (typeof IM_CONTROL_COMMANDS)[number], - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one target.`); - const target = rest[0].trim(); - const app = values.app?.trim() ?? ''; - const platformCommand = - command === 'im-app-add' || command === 'im-app-status' || command === 'im-app-remove'; - if (platformCommand && !TOKEN_IM_PLATFORMS.has(target)) - usage( - `${command} supports ${[...TOKEN_IM_PLATFORMS].join(', ')}; use im-authorize for feishu or weixin.`, - ); - let credentials: Record | undefined; - if (command === 'im-app-add') { - if (values['credentials-stdin'] !== true) usage('im-app-add requires --credentials-stdin.'); - try { - const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error('not an object'); - credentials = parsed as Record; - } catch { - usage('--credentials-stdin must be a JSON object, for example {"token":"…"}.'); - } - } - if ((command === 'im-app-remove' || command === 'im-bind' || command === 'im-unbind') && !app) - usage(`${command} requires --app .`); - if (command === 'channel-connect' && (!values.bot?.trim() || !values.conversation?.trim())) - usage('channel-connect requires --bot and --conversation .'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - const { rpc } = await connectLiveHost(values, io, signal); - const provider = (method: string, payload: Record) => - rpc(`dsh-im/${target}`, { method, payload }); - if (command === 'im-app-add') - return connectionSummary(target, await provider('bot.bind-credentials', credentials!)); - if (command === 'im-app-status') - return connectionSummary(target, await provider('connection.status', {})); - if (command === 'im-app-remove') - return connectionSummary(target, await provider('bot.delete', { botId: app, confirm: true })); - if (command === 'channel-ingests') return rpc('channelIngests', { channelId: target }); - if (command === 'im-bind') { - const apps = record(await rpc('messagingApps', {})); - const found = (Array.isArray(apps['apps']) ? apps['apps'] : []) - .map(record) - .find((entry) => entry['ref'] === app); - if (found === undefined) - throw new CliLiveError('unknown-app', `The Host has no connected IM app ${app}.`); - return rpc('messagingIdentity', { - slug: target, - input: { - kind: 'bind', - providerId: found['providerId'], - accountRef: found['ref'], - fingerprint: found['fingerprint'], - }, - }); - } - const slug = command === 'channel-connect' ? values.bot!.trim() : target; - const snapshot = record(await rpc('messagingSnapshot', { slug })); - const identities = (Array.isArray(snapshot['identities']) ? snapshot['identities'] : []).map( - record, - ); - if (command === 'im-status') - return { - apps: (Array.isArray(snapshot['accounts']) ? snapshot['accounts'] : []) - .map(record) - .map((entry) => ({ - ref: entry['ref'], - platform: entry['platform'], - name: entry['name'], - connected: entry['connected'] === true, - })), - bindings: identities.map((entry) => ({ - id: entry['id'], - app: entry['accountRef'], - platform: entry['platform'], - name: entry['name'], - enabled: entry['enabled'], - availability: entry['availability'], - reception: entry['reception'], - conversations: entry['grantCount'], - })), - }; - const binding = identities.find((entry) => - command === 'channel-connect' - ? app === '' || entry['accountRef'] === app - : entry['accountRef'] === app, - ); - if (binding === undefined) - throw new CliLiveError( - 'unknown-binding', - command === 'channel-connect' - ? `Bot ${slug} has no IM app binding to connect through.` - : `Bot ${slug} is not bound to IM app ${app}.`, - ); - if (command === 'im-unbind') - return rpc('messagingIdentity', { - slug, - input: { kind: 'unbind', id: binding['id'], expectedRevision: binding['revision'] }, - }); - return rpc('channelIngest', { - channelId: target, - input: { - kind: 'add', - bindingId: binding['id'], - conversation: { kind: 'group', id: values.conversation!.trim() }, - }, - }); -} - -async function runDeviceCommand( - command: (typeof DEVICE_COMMANDS)[number], - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - const direct = command === 'browser-tool' || command === 'computer-tool'; - const computer = (command.startsWith('computer-') && !direct) || command === 'browser-tools'; - const positionals = direct ? 2 : computer ? 0 : 1; - if (rest.length !== positionals || rest.some((value) => !value.trim())) - usage( - direct - ? `${command} needs a bot id and a tool name.` - : computer - ? `${command} takes no positional arguments.` - : `${command} needs exactly one bot id.`, - ); - let toolArgs: Record = {}; - const input = command === 'browser-input' || command === 'computer-input'; - if (input && values['args-stdin'] !== true) - usage( - `${command} needs --args-stdin with a JSON input such as {"kind":"click","x":10,"y":20}.`, - ); - if ((direct || input) && values['args-stdin'] === true) { - try { - const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) - throw new Error('not an object'); - toolArgs = parsed as Record; - } catch { - usage('--args-stdin must be a JSON object of tool arguments.'); - } - } - if ( - direct && - values.out !== undefined && - !values.out.startsWith('/') && - !/^[A-Za-z]:[\\/]/u.test(values.out) - ) - usage('--out must be an absolute file path.'); - const slug = rest[0]?.trim() ?? ''; - if (command === 'browser-frame' || command === 'computer-frame') { - const out = values.out ?? ''; - if (!out.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(out)) - usage(`${command} requires --out .`); - } - if ( - (command === 'browser-takeover' || command === 'computer-takeover') && - values.enabled === values.disabled - ) - usage(`${command} needs exactly one of --enabled or --disabled.`); - if ( - command === 'computer-start' && - values.target !== undefined && - values.target !== 'local' && - values.target !== 'container' - ) - usage('--target must be local or container.'); - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - const { http } = await connectLiveHost(values, io, signal); - const json = async (path: string, body?: Record) => { - const { bytes } = await http(path, body); - try { - return JSON.parse(new TextDecoder().decode(bytes)) as unknown; - } catch { - throw new CliLiveError('host-protocol-error', `The Host returned no JSON for ${path}.`); - } - }; - let saved = false; - const savedImages = (items: unknown[]) => - items.map((value) => { - const item = record(value); - if (item['type'] !== 'image' || typeof item['data'] !== 'string') return item; - const bytes = Buffer.from(item['data'], 'base64'); - if (values.out !== undefined && !saved) { - writeFileSync(values.out, bytes, { mode: 0o600 }); - saved = true; - return { - type: 'image', - mimeType: item['mimeType'], - path: values.out, - bytes: bytes.byteLength, - }; - } - return { type: 'image', mimeType: item['mimeType'], bytes: bytes.byteLength }; - }); - if (command === 'browser-tools') return json('/api/browser/tools'); - if (command === 'computer-tools') return json('/api/computer/tools'); - if (command === 'computer-tool') { - const result = record( - await json('/api/computer/tool', { slug, tool: rest[1]!.trim(), args: toolArgs }), - ); - const inner = record(result['result']); - return Array.isArray(inner['content']) - ? { ...result, result: { ...inner, content: savedImages(inner['content']) } } - : result; - } - if (command === 'browser-tool') { - const result = record( - await json('/api/browser/tool', { slug, tool: rest[1]!.trim(), args: toolArgs }), - ); - return { - ...result, - content: savedImages(Array.isArray(result['content']) ? result['content'] : []), - }; - } - if (command === 'browser-open') return json('/api/browser/open', { slug }); - if (command === 'browser-takeover') - return json('/api/browser/takeover', { slug, active: values.enabled === true }); - if (command === 'browser-input') return json('/api/browser/input', { ...toolArgs, slug }); - if (command === 'browser-stop') return json('/api/browser/stop', { slug }); - if (command === 'computer-start') - return json('/api/computer/start', { - authorize: true, - ...(values.target === undefined ? {} : { target: values.target }), - }); - if (command === 'computer-status') return json('/api/computer/status'); - if (command === 'computer-stop') return json('/api/computer/stop', { authorize: true }); - if (command === 'computer-takeover') - return json('/api/computer/takeover', { active: values.enabled === true }); - if (command === 'computer-input') return json('/api/computer/input', toolArgs); - if (command === 'computer-frame') { - const { response, bytes: answer } = await http('/api/computer/frame'); - if (response.status === 204) - throw new CliLiveError('no-frame', 'The Computer has not produced a frame yet; try again.'); - let desktop: Record; - try { - desktop = record(JSON.parse(new TextDecoder().decode(answer))); - } catch { - throw new CliLiveError( - 'host-protocol-error', - 'The Host returned no JSON for /api/computer/frame.', - ); - } - if (typeof desktop['data'] !== 'string') - throw new CliLiveError('host-protocol-error', 'The Host returned no Computer frame.'); - const bytes = Buffer.from(desktop['data'], 'base64'); - writeFileSync(values.out!, bytes, { mode: 0o600 }); - return { - path: values.out, - bytes: bytes.byteLength, - mimeType: desktop['mimeType'], - width: desktop['width'], - height: desktop['height'], - seq: desktop['seq'], - target: desktop['target'], - }; - } - const frame = record(await json(`/api/browser/frame?slug=${encodeURIComponent(slug)}`)); - if (typeof frame['data'] !== 'string') - throw new CliLiveError('host-protocol-error', 'The Host returned no Browser frame.'); - const bytes = Buffer.from(frame['data'], 'base64'); - writeFileSync(values.out!, bytes, { mode: 0o600 }); - return { - path: values.out, - bytes: bytes.byteLength, - mimeType: frame['mimeType'], - ...(frame['viewport'] === undefined ? {} : { viewport: frame['viewport'] }), - }; -} - -const MEMORY_COMMANDS = new Set([ - 'memory-snapshot', - 'memory-file', - 'memory-image', - 'memory-history', - 'memory-diff', - 'memory-save', - 'memory-file-target', - 'memory-graph', - 'memory-commit-diff', - 'memory-changes', - 'memory-working-diff', - 'memory-checkpoints', - 'memory-restore', - 'memory-restore-commit', - 'memory-versions', - 'memory-version-name', - 'memory-version-unname', - 'memory-repair', - 'memory-move', -]); - -const SHA = /^[0-9a-f]{40}$/u; - -async function runMemoryCommand( - command: string, - rest: string[], - values: LiveValues, - io: BotCreateCliIo, -): Promise { - if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one bot id.`); - const slug = rest[0].trim(); - const channelId = dmChannelId(slug); - const required = (name: keyof LiveValues, flag: string): string => { - const value = values[name]; - if (typeof value !== 'string' || !value.trim()) usage(`${command} requires --${flag}.`); - return value.trim(); - }; - const sha = (): string => { - const value = required('sha', 'sha '); - if (!SHA.test(value)) usage('--sha must be a 40-character lowercase commit sha.'); - return value; - }; - const expectedHead = (): string => { - const value = required('expected-head', 'expected-head '); - if (!SHA.test(value)) usage('--expected-head must be a 40-character lowercase commit sha.'); - return value; - }; - let method: string; - let args: Record; - switch (command) { - case 'memory-snapshot': - [method, args] = ['memorySnapshot', { channelId }]; - break; - case 'memory-image': { - const path = required('path', 'path '); - const source = required('image-source', 'image-source worktree|index|'); - const revision = required('image-revision', 'image-revision '); - const out = required('out', 'out '); - if (!isAbsolute(out)) usage('--out must be an absolute path.'); - const host = await connectLiveHost( - values, - io, - AbortSignal.timeout(timeoutMs(values.timeout)), - ); - const reference = values['image-reference']; - const { bytes, response } = await host.http( - '/api/botharness/memory/image?' + - new URLSearchParams({ - slug, - path, - source, - revision, - ...(reference === undefined ? {} : { reference }), - }), - ); - const mime = response.headers.get('content-type'); - if ( - !['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'].includes( - mime ?? '', - ) - ) - throw new CliLiveError('host-protocol-error', 'Expected a supported Memory image'); - writeFileSync(out, bytes, { flag: 'wx' }); - return { - path, - source, - revision, - ...(reference === undefined ? {} : { reference }), - mime, - bytes: bytes.length, - out, - }; - } - case 'memory-file': - [method, args] = ['memoryFile', { channelId, path: required('path', 'path ') }]; - break; - case 'memory-history': - [method, args] = ['memoryHistory', { channelId }]; - break; - case 'memory-diff': - [method, args] = ['memoryDiff', { channelId, sha: sha() }]; - break; - case 'memory-file-target': - [method, args] = ['memoryFileTarget', { slug, path: required('path', 'path ') }]; - break; - case 'memory-graph': { - const offset = values.offset === undefined ? 0 : Number(values.offset); - if (!Number.isInteger(offset) || offset < 0 || offset > 10_000) - usage('--offset must be an integer from 0 to 10000.'); - [method, args] = ['memoryGitGraph', { channelId, offset }]; - break; - } - case 'memory-commit-diff': - [method, args] = ['memoryGitCommitDiff', { channelId, sha: sha() }]; - break; - case 'memory-changes': - [method, args] = ['memoryWorkingChanges', { channelId }]; - break; - case 'memory-working-diff': { - const kind = required('kind', 'kind staged|unstaged|untracked|current'); - if (!['staged', 'unstaged', 'untracked', 'current'].includes(kind)) - usage('--kind must be staged, unstaged, untracked or current.'); - [method, args] = [ - 'memoryWorkingDiff', - { channelId, path: required('path', 'path '), kind }, - ]; - break; - } - case 'memory-checkpoints': - [method, args] = ['memoryRecoveryHistory', { channelId }]; - break; - case 'memory-restore': - [method, args] = [ - 'memoryRestore', - { - channelId, - checkpointId: required('checkpoint', 'checkpoint '), - expectedCurrentId: required('expected-current', 'expected-current '), - }, - ]; - break; - case 'memory-restore-commit': - [method, args] = [ - 'memoryRestoreCommit', - { - channelId, - sha: sha(), - expectedHead: expectedHead(), - restoreId: values['restore-id']?.trim() || randomUUID(), - ...(values.name === undefined ? {} : { name: values.name }), - }, - ]; - break; - case 'memory-versions': - [method, args] = ['memoryVersions', { channelId }]; - break; - case 'memory-version-name': - [method, args] = [ - 'memoryNameVersion', - { channelId, sha: sha(), name: required('name', 'name ') }, - ]; - break; - case 'memory-version-unname': - [method, args] = [ - 'memoryRemoveVersionName', - { channelId, tag: required('tag', 'tag ') }, - ]; - break; - case 'memory-move': { - const location = values['memory-location']?.trim(); - if ((location === undefined || location === '') === (values['default-location'] !== true)) - usage( - 'memory-move needs --memory-location or --default-location, not both.', - ); - if (location !== undefined && location !== '' && !isAbsolute(location)) - usage('--memory-location must be an absolute folder path.'); - [method, args] = [ - 'memoryMove', - { slug, location: location === undefined || location === '' ? null : location }, - ]; - break; - } - case 'memory-repair': - [method, args] = [ - 'memoryRepair', - { - channelId, - expectedHead: expectedHead(), - repairId: values['repair-id']?.trim() || randomUUID(), - }, - ]; - break; - default: { - const path = required('path', 'path '); - const head = expectedHead(); - let body = values.body; - if (values['body-stdin'] === true) body = await (io.readStdin?.() ?? Promise.resolve('')); - if (body === undefined) usage('memory-save needs --body or --body-stdin.'); - [method, args] = [ - 'memorySave', - { - channelId, - path, - body, - expectedHead: head, - editId: values['edit-id']?.trim() || randomUUID(), - }, - ]; - } - } - const signal = AbortSignal.timeout(timeoutMs(values.timeout)); - return (await connectLiveHost(values, io, signal)).rpc(method, args); -} - -export interface ReviewedSettings { - expectedRevision: number; - previousValue: Record; -} - -export function parseReviewedSettings( - values: { 'expected-revision'?: string; 'previous-value'?: string }, - patch: Record, -): ReviewedSettings | undefined { - const revision = values['expected-revision']; - const previous = values['previous-value']; - if (revision === undefined && previous === undefined) return undefined; - if (revision === undefined || previous === undefined) - usage('--expected-revision and --previous-value must be supplied together.'); - if (!/^(0|[1-9][0-9]*)$/u.test(revision) || !Number.isSafeInteger(Number(revision))) - usage('--expected-revision must be a nonnegative safe integer.'); - let previousValue: unknown; - try { - previousValue = JSON.parse(previous); - } catch { - usage('--previous-value must be a JSON object of the reviewed settings fields.'); - } - if (typeof previousValue !== 'object' || previousValue === null || Array.isArray(previousValue)) - usage('--previous-value must be a JSON object of the reviewed settings fields.'); - const keys = Object.keys(patch).sort(); - if (keys.length === 0 || !isDeepStrictEqual(keys, Object.keys(previousValue).sort())) - usage('--previous-value must contain exactly the fields in the nonempty patch.'); - return { - expectedRevision: Number(revision), - previousValue: previousValue as Record, - }; -} +import { runPhoneNotifications } from './cli-phone-notifications.js'; +import { + WINDOW_ZOOM_COMMANDS, + WINDOW_ZOOM_OPTIONS, + runWindowZoomCommand, +} from './cli-window-zoom.js'; +import { PHONE_ROUTE_COMMANDS, runPhoneRouteCommand } from './cli-phone-route.js'; +import { DEVICES_COMMANDS, isDevicesCommand, runDevicesCommand } from './cli-devices.js'; +import { + DEVICE_NOTIFICATION_COMMANDS, + DEVICE_NOTIFICATION_OPTIONS, + runDeviceNotifications, +} from './cli-device-notifications.js'; +import { + BROWSER_NOTIFICATION_COMMANDS, + runBrowserNotifications, +} from './cli-browser-notifications.js'; +import { APPEARANCE_COMMANDS, APPEARANCE_OPTIONS, runAppearanceCommand } from './cli-appearance.js'; +import { prepareCloud, prepareCloudWork } from './cli-cloud.js'; +import { randomUUID } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { dmChannelId } from '../channels/channel.js'; +import type { BotCreateCliIo } from './bot-create-cli.js'; +import { CliLiveError } from './cli-live-error.js'; +import { BOT_TOOL_COMMANDS, runBotToolCommand } from './cli-bot-tool.js'; +import { + channelOperationKey, + CHANNEL_COMMANDS, + CHANNEL_OPTIONS, + isChannelCommand, + runChannelCommand, +} from './cli-channels.js'; +import { + BROWSER_COMPUTER_COMMANDS, + BROWSER_COMPUTER_OPTIONS, + isBrowserComputerCommand, + runBrowserComputerCommand, + type BrowserComputerValues, +} from './cli-browser-computer.js'; +import { + IM_COMMANDS, + IM_OPTIONS, + prepareImAuthorization, + runImAuthorization, + type ImValues, +} from './cli-im-authorization.js'; +import { + MESSAGING_COMMANDS, + MESSAGING_OPTIONS, + prepareMessaging, + runMessaging, + type MessagingCommand, + type MessagingValues, +} from './cli-messaging.js'; +import { + isModelsUsageCommand, + MODELS_USAGE_COMMANDS, + MODELS_USAGE_OPTIONS, + runModelsUsage, + type ModelsUsageValues, +} from './cli-models-usage.js'; +import { + HOST_ADMIN_COMMANDS, + HOST_ADMIN_OPTIONS, + isHostAdminCommand, + runHostAdminCommand, + type HostAdminValues, +} from './cli-host-admin.js'; +import { + INBOX_COMMANDS, + INBOX_OPTIONS, + isInboxCommand, + runInboxCommand, + type InboxValues, +} from './cli-inbox.js'; +import { + SCHEDULE_OPTIONS, + isScheduleCommand, + prepareScheduleCommand, + runScheduleCommand, + type ScheduleValues, +} from './cli-schedules.js'; +import { + PROFILE_COMMANDS, + PROFILE_OPTIONS, + isProfileCommand, + runProfileCommand, + type ProfileValues, +} from './cli-profile.js'; +import { + TODO_COMMANDS, + TODO_OPTIONS, + isTodoCommand, + runTodoCommand, + type TodoValues, +} from './cli-todos.js'; +import { + APPROVAL_TEMPLATE_COMMANDS, + APPROVAL_TEMPLATE_OPTIONS, + isApprovalTemplateCommand, + runApprovalTemplateCommand, + type ApprovalTemplateValues, +} from './cli-approval-templates.js'; +import { + APPROVAL_REVIEWER_COMMANDS, + APPROVAL_REVIEWER_OPTIONS, + isApprovalReviewerCommand, + runApprovalReviewerCommand, + type ApprovalReviewerValues, +} from './cli-approval-reviewer.js'; +import { + isStorageCommand, + runStorageCommand, + STORAGE_COMMANDS, + STORAGE_OPTIONS, + type StorageValues, +} from './cli-storage.js'; +import { + IDLE_COMPACTION_COMMANDS, + isIdleCompactionCommand, + runIdleCompactionCommand, +} from './cli-idle-compaction.js'; +import { + CONNECT_COMMANDS, + CONNECT_OPTIONS, + isConnectCommand, + runConnectCommand, +} from './cli-connect.js'; +export { CliLiveError } from './cli-live-error.js'; + +export const LIVE_OPTIONS = { + 'remote-host-id': { type: 'string' }, + ...DEVICE_NOTIFICATION_OPTIONS, + ...WINDOW_ZOOM_OPTIONS, + ...CONNECT_OPTIONS, + ...APPEARANCE_OPTIONS, + ...IM_OPTIONS, + ...MESSAGING_OPTIONS, + ...MODELS_USAGE_OPTIONS, + ...CHANNEL_OPTIONS, + ...HOST_ADMIN_OPTIONS, + ...INBOX_OPTIONS, + ...SCHEDULE_OPTIONS, + ...BROWSER_COMPUTER_OPTIONS, + ...PROFILE_OPTIONS, + ...TODO_OPTIONS, + ...APPROVAL_TEMPLATE_OPTIONS, + ...APPROVAL_REVIEWER_OPTIONS, + ...STORAGE_OPTIONS, + host: { type: 'string' }, + 'token-file': { type: 'string' }, + 'image-source': { type: 'string' }, + 'image-revision': { type: 'string' }, + 'image-reference': { type: 'string' }, + timeout: { type: 'string' }, + 'message-id': { type: 'string' }, + outcome: { type: 'string' }, + 'answer-stdin': { type: 'boolean' }, + 'values-stdin': { type: 'boolean' }, + since: { type: 'string' }, + workspace: { type: 'string' }, + mode: { type: 'string' }, + scope: { type: 'string' }, + 'acknowledge-risk': { type: 'boolean' }, + 'patch-stdin': { type: 'boolean' }, + 'previous-value': { type: 'string' }, + member: { type: 'string', multiple: true }, + app: { type: 'string' }, + bot: { type: 'string' }, + conversation: { type: 'string' }, + out: { type: 'string' }, + 'args-stdin': { type: 'boolean' }, + session: { type: 'string' }, + turns: { type: 'string' }, + offset: { type: 'string' }, + kind: { type: 'string' }, + checkpoint: { type: 'string' }, + 'expected-current': { type: 'string' }, + 'restore-id': { type: 'string' }, + 'repair-id': { type: 'string' }, + 'default-location': { type: 'boolean' }, + tag: { type: 'string' }, + target: { type: 'string' }, +} as const; + +const DEVICE_COMMANDS = [ + 'browser-open', + 'browser-frame', + 'browser-takeover', + 'browser-input', + 'browser-stop', + 'computer-start', + 'computer-status', + 'computer-stop', + 'browser-tools', + 'browser-tool', + 'computer-tools', + 'computer-tool', + 'computer-frame', + 'computer-takeover', + 'computer-input', +] as const; + +const IM_CONTROL_COMMANDS = [ + 'im-app-add', + 'im-app-status', + 'im-app-remove', + 'im-bind', + 'im-unbind', + 'im-status', + 'channel-connect', + 'channel-ingests', +] as const; + +const TOKEN_IM_PLATFORMS = new Set(['discord', 'slack', 'qq', 'dingtalk', 'wecom', 'matrix']); + +const CONTROL_COMMANDS = [ + 'browser-access-set', + 'computer-access-set', + 'access', + 'access-set', + 'settings', + 'settings-set', + 'workspace-create', + 'group-create', +] as const; + +export const LIVE_COMMANDS = [ + ...BROWSER_NOTIFICATION_COMMANDS, + ...DEVICE_NOTIFICATION_COMMANDS, + ...WINDOW_ZOOM_COMMANDS, + ...PHONE_ROUTE_COMMANDS, + ...DEVICES_COMMANDS, + ...CONNECT_COMMANDS, + ...IM_COMMANDS, + ...MESSAGING_COMMANDS, + { + command: 'cloud', + description: 'manage an explicit cloud Agent connection (private JSON through stdin)', + }, + { + command: 'cloud-work', + description: 'run the cloud_work Tool directly for a Bot using JSON from stdin', + }, + ...MODELS_USAGE_COMMANDS, + ...CHANNEL_COMMANDS, + ...HOST_ADMIN_COMMANDS, + ...APPEARANCE_COMMANDS, + ...INBOX_COMMANDS, + ...BROWSER_COMPUTER_COMMANDS, + ...PROFILE_COMMANDS, + ...BOT_TOOL_COMMANDS, + ...TODO_COMMANDS, + ...APPROVAL_TEMPLATE_COMMANDS, + ...APPROVAL_REVIEWER_COMMANDS, + ...IDLE_COMPACTION_COMMANDS, + ...STORAGE_COMMANDS, + { + command: 'send', + description: 'send a Human DM and collect its committed Bot reply from a live Host', + }, + { + command: 'send-status', + description: 'inspect a Human DM receipt and its exact Bot replies without resending', + }, + { + command: 'tool-approval-status', + description: 'inspect a pending tool approval on a live Host', + }, + { + command: 'tool-approval-decide', + description: 'allow once, always allow a secret_cli kind, or reject an owned tool approval', + }, + { command: 'user-question-status', description: 'inspect a formal Human question' }, + { command: 'ask-secret-status', description: 'inspect a private input request without values' }, + { + command: 'ask-secret-submit', + description: 'submit confidential field JSON from stdin through the authenticated Owner', + }, + { command: 'ask-secret-cancel', description: 'cancel a private input request as Owner' }, + { command: 'ask-secret-account-status', description: 'inspect current private account access' }, + { command: 'ask-secret-replace', description: 'open a blank replacement private input card' }, + { command: 'ask-secret-revoke', description: 'revoke local private account access as Owner' }, + { + command: 'ask-secret-accounts', + description: "list a Bot's private accounts as Owner, without values", + }, + { + command: 'user-question-answer', + description: 'answer a formal Human question with JSON from stdin', + }, + { command: 'release-info', description: 'read the running Host release lifecycle' }, + { command: 'workspace-options', description: 'list live Host workspaces for grants' }, + { + command: 'folder-browse', + description: 'list folder names on the Host computer, as the phone folder picker does', + }, + { command: 'grant-create', description: 'authorize a Bot workspace through the live Host' }, + { + command: 'browser-access-set', + description: "turn a Bot's Browser Access on or off through the live Host", + }, + { + command: 'computer-access-set', + description: "turn a Bot's Computer Access on or off through the live Host", + }, + { command: 'access', description: "read a Bot's access preset (workspace write or full access)" }, + { + command: 'access-set', + description: "set a Bot's access preset; full access needs --acknowledge-risk", + }, + { command: 'settings', description: 'read one live Host settings namespace' }, + { + command: 'settings-set', + description: 'patch one live Host settings namespace with JSON from stdin', + }, + { command: 'workspace-create', description: 'register an absolute folder as a Host workspace' }, + { command: 'group-create', description: 'create a group Channel with optional initial Bots' }, + { + command: 'im-app-add', + description: 'add a token-based IM app (Discord, Slack, …) with credentials JSON from stdin', + }, + { command: 'im-app-status', description: "read a token IM platform's apps and connection state" }, + { command: 'im-app-remove', description: 'remove a token IM app from the Provider' }, + { command: 'im-bind', description: 'bind a connected IM app to a Bot' }, + { command: 'im-unbind', description: 'unbind an IM app from a Bot' }, + { command: 'im-status', description: "read a Bot's IM apps and bindings with reception state" }, + { + command: 'channel-connect', + description: "connect an external group conversation to a Channel through a Bot's binding", + }, + { command: 'channel-ingests', description: "list a Channel's connected external conversations" }, + { command: 'browser-open', description: "start a Bot's Browser and open its tab for viewing" }, + { + command: 'browser-frame', + description: "save the current picture of a Bot's Browser to a file", + }, + { + command: 'browser-takeover', + description: "pause a Bot's Browser for Human input (--enabled) or hand it back (--disabled)", + }, + { + command: 'browser-input', + description: + "click, scroll, type or press a key in a paused Bot's Browser (input JSON from stdin)", + }, + { command: 'browser-stop', description: "stop a Bot's Browser" }, + { + command: 'computer-start', + description: 'start the profile Computer with explicit authorization', + }, + { command: 'computer-status', description: 'read the profile Computer target and state' }, + { + command: 'computer-frame', + description: 'save the current picture of the profile Computer desktop to a file', + }, + { + command: 'computer-takeover', + description: + 'pause Bot use of the profile Computer for Human input (--enabled) or hand it back (--disabled)', + }, + { + command: 'computer-input', + description: + 'click, scroll, type or press a key on a paused profile Computer desktop (input JSON from stdin)', + }, + { command: 'computer-stop', description: 'stop the profile Computer' }, + { + command: 'browser-tools', + description: 'list the Browser tools a Bot can call, with input schemas', + }, + { + command: 'browser-tool', + description: + 'run one Browser tool directly for a Bot, without the model (args JSON from stdin)', + }, + { + command: 'memory-image', + description: + 'export version-bound Memory image bytes (--path --image-source --image-revision [--image-reference] --out)', + }, + { command: 'memory-file-target', description: 'resolve a memory file path for a Bot' }, + { command: 'memory-graph', description: "read a Bot's memory commit graph" }, + { command: 'memory-commit-diff', description: 'read the full diff of one memory commit' }, + { command: 'memory-changes', description: 'list uncommitted memory changes' }, + { command: 'memory-working-diff', description: 'read the diff of one uncommitted memory file' }, + { command: 'memory-checkpoints', description: 'list memory recovery checkpoints' }, + { command: 'memory-restore', description: 'restore memory to a recovery checkpoint' }, + { command: 'memory-restore-commit', description: 'restore memory to an earlier commit' }, + { command: 'memory-versions', description: 'list named memory versions' }, + { command: 'memory-version-name', description: 'name a memory commit as a version' }, + { command: 'memory-version-unname', description: 'remove a memory version name' }, + { command: 'memory-repair', description: 'repair a memory repository against its expected head' }, + { + command: 'memory-move', + description: "move a Bot's Memory repository to another folder, or back to the default", + }, + { + command: 'bot-trace', + description: 'show which tools a Bot called in its recent turns, with outcomes', + }, + { + command: 'computer-tools', + description: 'list the Computer tools a Bot can call, with input schemas', + }, + { + command: 'computer-tool', + description: + 'run one Computer tool directly for a Bot, without the model (args JSON from stdin)', + }, +] as const; + +interface LiveValues + extends + ImValues, + MessagingValues, + ModelsUsageValues, + HostAdminValues, + InboxValues, + ScheduleValues, + BrowserComputerValues, + ProfileValues, + TodoValues, + ApprovalTemplateValues, + ApprovalReviewerValues { + 'client-id'?: string; + 'device-file'?: string; + 'remote-host-id'?: string; + 'preferences-stdin'?: boolean; + host?: string; + 'token-file'?: string; + 'image-source'?: string; + 'image-revision'?: string; + 'image-reference'?: string; + timeout?: string; + 'message-id'?: string; + body?: string; + 'body-stdin'?: boolean; + 'answer-stdin'?: boolean; + 'values-stdin'?: boolean; + outcome?: string; + since?: string; + workspace?: string; + limit?: string; + before?: string; + enabled?: boolean; + disabled?: boolean; + mode?: string; + scope?: string; + 'acknowledge-risk'?: boolean; + 'patch-stdin'?: boolean; + 'previous-value'?: string; + member?: string[]; + 'operation-key'?: string; + app?: string; + bot?: string; + conversation?: string; + out?: string; + 'args-stdin'?: boolean; + session?: string; + turns?: string; + offset?: string; + kind?: string; + checkpoint?: string; + 'expected-current'?: string; + 'restore-id'?: string; + 'repair-id'?: string; + 'memory-location'?: string; + 'default-location'?: boolean; + tag?: string; + path?: string; + sha?: string; + name?: string; + 'expected-head'?: string; + 'edit-id'?: string; + target?: string; +} + +function usage(message: string): never { + throw new CliLiveError('usage', message); +} + +function isLoopbackName(name: string): boolean { + return name === 'localhost' || name === '[::1]' || /^127\.\d+\.\d+\.\d+$/u.test(name); +} + +function hostUrl(value: string | undefined): URL { + if (!value) usage('Live commands require --host or DEEPSEEKBOT_HOST.'); + let url: URL; + try { + url = new URL(value); + } catch { + usage('--host must be an HTTP(S) origin.'); + } + const name = url.hostname; + const loopback = isLoopbackName(name); + const tailnet = + name.endsWith('.ts.net') || /^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d+\.\d+$/u.test(name); + if ( + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/' || + !( + (loopback && (url.protocol === 'http:' || url.protocol === 'https:')) || + (tailnet && url.protocol === 'https:') + ) + ) { + usage( + '--host must be a loopback origin or a tailnet HTTPS origin, without credentials, query or path.', + ); + } + return url; +} + +function timeoutMs(value: string | undefined): number { + const seconds = value === undefined ? 120 : Number(value); + if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 600) + usage('--timeout must be between 0 and 600 seconds.'); + return Math.ceil(seconds * 1000); +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +export async function connectLiveHost(values: LiveValues, io: BotCreateCliIo, signal: AbortSignal) { + const base = hostUrl(values.host ?? io.env['DEEPSEEKBOT_HOST']); + let token = io.env['DEEPSEEKBOT_HOST_TOKEN']; + if (values['token-file'] !== undefined) { + try { + token = readFileSync(values['token-file'], 'utf8').trim(); + } catch { + throw new CliLiveError('host-unauthorized', 'Cannot read the Host token file.'); + } + } + if (!token?.trim()) + throw new CliLiveError( + 'host-unauthorized', + 'Set DEEPSEEKBOT_HOST_TOKEN or provide --token-file.', + ); + const secret = token.trim(); + const login = new URL(base); + login.searchParams.set('token', secret); + let cookie = ''; + const refused = (status: number): CliLiveError => + status === 403 && !isLoopbackName(base.hostname) + ? new CliLiveError( + 'host-untrusted', + `The Host does not accept requests addressed to ${base.hostname}; restart it with --trusted-host ${base.hostname}.`, + ) + : new CliLiveError( + 'host-unauthorized', + 'The Host refused this authority cookie; refresh its launch token.', + ); + const request = async (url: URL, init: RequestInit = {}): Promise => { + try { + return await fetch(url, { ...init, signal, redirect: 'manual' }); + } catch { + throw new CliLiveError( + 'host-unreachable', + 'The Host could not be reached before the command deadline.', + ); + } + }; + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await request(login, { headers: cookie ? { cookie } : {} }); + const cookies = response.headers.getSetCookie(); + if (cookies.length > 0) cookie = cookies.map((entry) => entry.split(';')[0]).join('; '); + await response.body?.cancel(); + if (response.status === 401 || response.status === 403) + throw new CliLiveError( + 'host-unauthorized', + 'Host login was refused; refresh its launch token.', + ); + if (cookie) break; + } + if (!cookie) + throw new CliLiveError('host-unauthorized', 'Host login did not issue an authority cookie.'); + const readResponse = async (response: Response): Promise> => { + if (response.status === 401 || response.status === 403) { + await response.body?.cancel(); + throw refused(response.status); + } + try { + const body = await response.text(); + try { + return record(JSON.parse(body)); + } catch { + throw new CliLiveError('host-protocol-error', 'The Host did not return a JSON response.'); + } + } catch (error) { + if (error instanceof CliLiveError) throw error; + throw new CliLiveError( + 'host-unreachable', + 'The Host response was interrupted before the command deadline.', + ); + } + }; + const scrub = (value: string) => + value.replaceAll(secret, '[redacted]').replaceAll(encodeURIComponent(secret), '[redacted]'); + const rpc = async (method: string, args: Record): Promise => { + const name = method.includes('/') ? method : `botharness/${method}`; + const response = await request(new URL(`/api/${name}`, base), { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ + type: 'client-request', + rpcId: randomUUID(), + method: name, + payload: method.startsWith('dsh-im/') ? args : { args }, + }), + }); + const payload = await readResponse(response); + if (!response.ok || payload['type'] !== 'server-response') + throw new CliLiveError('host-protocol-error', 'The Host did not return a DSH RPC response.'); + const result = record(payload['result']); + if (result['ok'] !== true) { + const error = record(result['error']); + const code = typeof error['code'] === 'string' ? error['code'] : 'host-protocol-error'; + const message = + typeof error['message'] === 'string' ? error['message'] : 'The Host refused the command.'; + throw new CliLiveError(code, scrub(message)); + } + return result['value'] as T; + }; + const importBotZip = async ( + archive: Uint8Array, + metadata: { name?: string; displayName?: string; roles?: string[]; description?: string }, + ): Promise => { + const url = new URL('/api/botharness/bot-zip/import', base); + for (const key of ['name', 'displayName', 'description'] as const) { + if (metadata[key] !== undefined) url.searchParams.set(key, metadata[key]); + } + if (metadata.roles !== undefined) url.searchParams.set('roles', JSON.stringify(metadata.roles)); + const response = await request(url, { + method: 'POST', + headers: { cookie, 'content-type': 'application/zip' }, + body: new Uint8Array(archive), + }); + const payload = await readResponse(response); + if (!response.ok) { + const error = record(payload['error']); + const bot = record(payload['bot']); + throw new CliLiveError( + typeof error['code'] === 'string' ? scrub(error['code']) : 'host-protocol-error', + scrub( + typeof error['message'] === 'string' + ? error['message'] + : 'The Host refused this Bot Zip import.', + ), + undefined, + undefined, + typeof bot['id'] === 'string' && typeof bot['name'] === 'string' + ? { + bot: { id: bot['id'], name: bot['name'] }, + steps: [{ name: 'create-bot', status: 'ok' }], + outcome: 'created', + } + : undefined, + ); + } + return payload as T; + }; + const refuse = (path: string, status: number, bytes: Uint8Array): never => { + let detail: Record = {}; + try { + detail = record(JSON.parse(new TextDecoder().decode(bytes))); + } catch { + detail = { error: new TextDecoder().decode(bytes).slice(0, 200) }; + } + const nested = record(detail['error']); + const code = + typeof detail['code'] === 'string' + ? detail['code'] + : typeof nested['code'] === 'string' + ? nested['code'] + : undefined; + if ((status === 401 || status === 403) && code === undefined) throw refused(status); + const packages = Array.isArray(detail['packages']) + ? detail['packages'].filter((name): name is string => typeof name === 'string') + : []; + throw new CliLiveError( + code === undefined ? 'host-refused' : scrub(code), + scrub( + (typeof detail['error'] === 'string' && detail['error'] !== '' + ? detail['error'] + : typeof nested['message'] === 'string' + ? nested['message'] + : `The Host refused ${path} with HTTP ${String(status)}.`) + + (packages.length === 0 ? '' : ` Packages: ${packages.join(' ')}.`), + ), + ); + }; + const http = async ( + path: string, + body?: Record, + ): Promise<{ response: Response; bytes: Uint8Array }> => { + const response = await request(new URL(path, base), { + method: body === undefined ? 'GET' : 'POST', + headers: { + cookie, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await response.arrayBuffer()); + } catch { + throw new CliLiveError( + 'host-unreachable', + 'The Host response was interrupted before the command deadline.', + ); + } + if (!response.ok) refuse(path, response.status, bytes); + return { response, bytes }; + }; + const stream = async (path: string, init: RequestInit = {}): Promise => { + const response = await request(new URL(path, base), { + ...init, + headers: { ...(init.headers as Record | undefined), cookie }, + }); + if (!response.ok) refuse(path, response.status, new Uint8Array(await response.arrayBuffer())); + return response; + }; + const socket = (path: string): WebSocket => { + const url = new URL(path, base); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const Socket = WebSocket as unknown as new ( + url: string, + init: { headers: Record }, + ) => WebSocket; + return new Socket(url.href, { headers: { cookie } }); + }; + return { rpc, importBotZip, http, stream, socket }; +} + +export async function runLiveCli( + command: string, + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + if (command === 'device-notifications') return runDeviceNotifications(rest, values, io); + if (command === 'phone-notifications') return runPhoneNotifications(rest, values, io); + if (BROWSER_NOTIFICATION_COMMANDS.some((entry) => entry.command === command)) + return runBrowserNotifications(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (IM_COMMANDS.some((entry) => entry.command === command)) { + const operation = await prepareImAuthorization(command, rest, values, io); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + try { + return await runImAuthorization( + operation, + (await connectLiveHost(values, io, signal)).rpc, + signal, + ); + } catch (error) { + if ( + error instanceof CliLiveError && + !error.authorization && + command !== 'im-apps' && + command !== 'im-authorize' + ) + throw new CliLiveError(error.code, error.message, undefined, { + attemptId: operation.target, + }); + throw error; + } + } + if (MEMORY_COMMANDS.has(command)) return runMemoryCommand(command, rest, values, io); + if (isModelsUsageCommand(command)) + return runModelsUsage(command, rest, values, io, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isConnectCommand(command)) + return runConnectCommand(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (WINDOW_ZOOM_COMMANDS.some((entry) => entry.command === command)) + return runWindowZoomCommand(command, rest, values, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (PHONE_ROUTE_COMMANDS.some((entry) => entry.command === command)) + return runPhoneRouteCommand(command, rest, values, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (isDevicesCommand(command)) + return runDevicesCommand(command, rest, values, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (command === 'cloud-work') { + const input = await prepareCloudWork(rest, values, io); + const host = await connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))); + return host.rpc('cloudWork', input); + } + if (command === 'cloud') { + const input = await prepareCloud(rest, values, io); + const host = await connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))); + return host.rpc('cloudConnections', { input }); + } + if (MESSAGING_COMMANDS.some((entry) => entry.command === command)) { + const operation = await prepareMessaging(command as MessagingCommand, rest, values, io); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return runMessaging(operation, (await connectLiveHost(values, io, signal)).rpc); + } + if (isInboxCommand(command)) + return runInboxCommand(command, rest, values, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (isScheduleCommand(command)) { + const prepared = prepareScheduleCommand(command, rest, values); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return runScheduleCommand(prepared, (await connectLiveHost(values, io, signal)).rpc); + } + if (isBrowserComputerCommand(command)) + return runBrowserComputerCommand(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (APPEARANCE_COMMANDS.some((entry) => entry.command === command)) + return runAppearanceCommand(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (isProfileCommand(command)) + return runProfileCommand(command, rest, values, io, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isApprovalReviewerCommand(command)) + return runApprovalReviewerCommand(command, rest, values, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isStorageCommand(command)) + return runStorageCommand(command, rest, values as StorageValues, io, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isIdleCompactionCommand(command)) + return runIdleCompactionCommand(command, rest, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isApprovalTemplateCommand(command)) + return runApprovalTemplateCommand(command, rest, values, io, async () => { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc; + }); + if (isTodoCommand(command)) + return runTodoCommand(command, rest, values, io, () => + connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (BOT_TOOL_COMMANDS.some((entry) => entry.command === command)) + return runBotToolCommand( + command as (typeof BOT_TOOL_COMMANDS)[number]['command'], + rest, + values, + io, + () => connectLiveHost(values, io, AbortSignal.timeout(timeoutMs(values.timeout))), + ); + if (command === 'bot-trace') { + if (rest.length !== 1 || !rest[0]?.trim()) usage('bot-trace needs exactly one bot id.'); + const turns = values.turns === undefined ? undefined : Number(values.turns); + if (turns !== undefined && (!Number.isInteger(turns) || turns < 1 || turns > 20)) + usage('--turns must be an integer from 1 to 20.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc('botToolTrace', { + slug: rest[0].trim(), + ...(values.session === undefined ? {} : { sessionId: values.session }), + ...(turns === undefined ? {} : { turns }), + }); + } + if (isChannelCommand(command)) + return runChannelCommand(command, rest, values, io, connectLiveHost); + if (isHostAdminCommand(command)) { + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return runHostAdminCommand(command, rest, values, io, { + connect: async () => (await connectLiveHost(values, io, signal)).rpc, + signal, + }); + } + if (DEVICE_COMMANDS.some((entry) => entry === command)) + return runDeviceCommand(command as (typeof DEVICE_COMMANDS)[number], rest, values, io); + if (command === 'folder-browse') { + if (rest.length > 1) usage('folder-browse takes at most one folder path.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const path = rest[0]?.trim(); + return (await connectLiveHost(values, io, signal)).rpc('folderBrowse', path ? { path } : {}); + } + if (command === 'grants') { + if (rest.length !== 1 || !rest[0]?.trim()) usage('grants needs exactly one bot id.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc('grants', { slug: rest[0].trim() }); + } + if (IM_CONTROL_COMMANDS.some((entry) => entry === command)) + return runImControlCommand(command as (typeof IM_CONTROL_COMMANDS)[number], rest, values, io); + if (CONTROL_COMMANDS.some((entry) => entry === command)) + return runControlCommand(command as (typeof CONTROL_COMMANDS)[number], rest, values, io); + const noTarget = command === 'release-info' || command === 'workspace-options'; + if (rest.length !== (noTarget ? 0 : 1)) + usage( + `${command} ${noTarget ? 'takes no positional arguments' : 'needs exactly one target id'}.`, + ); + const target = rest[0]?.trim() ?? ''; + if (!noTarget && !target) usage(`${command} requires a nonempty target id.`); + const messageId = values['message-id']; + if ( + [ + 'send-status', + 'tool-approval-status', + 'tool-approval-decide', + 'user-question-status', + 'user-question-answer', + 'ask-secret-status', + 'ask-secret-submit', + 'ask-secret-cancel', + 'ask-secret-account-status', + 'ask-secret-replace', + 'ask-secret-revoke', + ].includes(command) && + !messageId?.trim() + ) + usage('--message-id is required.'); + let body: string | undefined; + let answer: unknown; + let confidential: Record | undefined; + if (command === 'ask-secret-submit') { + if (!values['values-stdin']) usage('ask-secret-submit requires --values-stdin.'); + try { + const input = await (io.readStdin?.() ?? Promise.resolve('')); + if (input.length > 140_000) throw new Error('Private input exceeds the limit'); + const parsed: unknown = JSON.parse(input); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error('Invalid private input'); + const entries = Object.entries(parsed); + if ( + entries.length < 1 || + entries.length > 8 || + entries.some( + ([key, value]) => + !/^[a-z][a-z0-9_]{0,47}$/.test(key) || + typeof value !== 'string' || + value.length > 16_384, + ) + ) + throw new Error('Invalid private fields'); + confidential = Object.fromEntries(entries); + } catch { + usage('Private input stdin must be a JSON object with 1–8 declared string fields.'); + } + } + if (command === 'send') { + if ((values.body !== undefined) === (values['body-stdin'] === true)) + usage('send requires exactly one of --body or --body-stdin.'); + body = values['body-stdin'] ? await (io.readStdin?.() ?? Promise.resolve('')) : values.body; + if (!body?.trim()) usage('send requires a nonempty message.'); + if ( + messageId !== undefined && + !/^human-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(messageId) + ) + usage('--message-id must be human- followed by a UUID.'); + } + if ( + command === 'tool-approval-decide' && + values.outcome !== 'allowed-once' && + values.outcome !== 'allowed-always-tool' && + values.outcome !== 'allowed-always-kind' && + values.outcome !== 'rejected' + ) + usage('--outcome must be allowed-once, allowed-always-tool, allowed-always-kind or rejected.'); + if (command === 'user-question-answer') { + if (!values['answer-stdin']) usage('user-question-answer requires --answer-stdin.'); + try { + answer = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); + } catch { + usage('Question answers must be valid JSON on stdin.'); + } + const answers = record(answer)['answers']; + if ( + !Array.isArray(answers) || + !answers.every((entry) => { + const item = record(entry); + return ( + typeof item['id'] === 'string' && + Array.isArray(item['selected']) && + item['selected'].every((choice) => typeof choice === 'string') && + (item['custom'] === undefined || typeof item['custom'] === 'string') + ); + }) + ) + usage( + 'Answer JSON requires answers with id, selected string array and optional custom text.', + ); + } + if (command === 'grant-create' && !values.workspace?.trim()) usage('--workspace is required.'); + const duration = timeoutMs(values.timeout); + const deadline = Date.now() + duration; + const signal = AbortSignal.timeout(duration); + const { rpc } = await connectLiveHost(values, io, signal); + if (command.startsWith('ask-secret-')) { + try { + if (command === 'ask-secret-accounts') { + const result = await rpc<{ + status?: unknown; + accounts?: unknown; + replaceable?: unknown; + }>('privateAccounts', { + slug: target, + }); + if ( + (result.status !== 'ready' && result.status !== 'unavailable') || + !Array.isArray(result.accounts) + ) + throw new Error('Invalid private accounts'); + const optional = (value: unknown) => (typeof value === 'string' ? value : undefined); + const accounts = result.accounts.map((entry: unknown) => { + const row = record(entry); + const status = row['status']; + if ( + typeof row['account'] !== 'string' || + (status !== 'configured' && status !== 'replacing' && status !== 'unavailable') || + !Array.isArray(row['fields']) + ) + throw new Error('Invalid private account'); + return { + account: row['account'], + purpose: optional(row['purpose']), + fields: row['fields'].map((value: unknown) => { + const field = record(value); + if ( + typeof field['id'] !== 'string' || + typeof field['label'] !== 'string' || + typeof field['required'] !== 'boolean' + ) + throw new Error('Invalid private field'); + return { id: field['id'], label: field['label'], required: field['required'] }; + }), + status, + configuredAt: optional(row['configuredAt']), + channelId: optional(row['channelId']), + messageId: optional(row['messageId']), + replacementMessageId: optional(row['replacementMessageId']), + }; + }); + return { + status: result.status, + accounts, + ...(typeof result.replaceable === 'boolean' ? { replaceable: result.replaceable } : {}), + }; + } + if (command === 'ask-secret-status' || command === 'ask-secret-account-status') { + const result = await rpc<{ status?: unknown }>( + command === 'ask-secret-status' ? 'privateInputStatus' : 'privateAccountStatus', + { + channelId: target, + messageId, + }, + ); + const statuses = + command === 'ask-secret-status' + ? ['pending', 'configured', 'cancelled', 'expired'] + : ['configured', 'revoked', 'superseded', 'unavailable']; + if (typeof result.status !== 'string' || !statuses.includes(result.status)) + throw new Error('Invalid private status'); + return { status: result.status }; + } + if (command === 'ask-secret-replace') { + const result = await rpc<{ messageId?: unknown }>('privateInputReplace', { + channelId: target, + messageId, + }); + if ( + typeof result.messageId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(result.messageId) + ) + throw new Error('Invalid replacement card'); + return { messageId: result.messageId }; + } + if (command === 'ask-secret-revoke') { + const result = await rpc<{ + accepted?: unknown; + stopped?: unknown; + completed?: unknown; + uncertain?: unknown; + }>('privateAccountRevoke', { channelId: target, messageId }); + if ( + result.accepted !== true || + [result.stopped, result.completed, result.uncertain].some( + (count) => typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0, + ) + ) + throw new Error('Invalid revocation result'); + return { + accepted: true, + stopped: result.stopped, + completed: result.completed, + uncertain: result.uncertain, + }; + } + const method = command === 'ask-secret-submit' ? 'privateInputSubmit' : 'privateInputCancel'; + const result = await rpc<{ accepted?: unknown }>(method, { + channelId: target, + messageId, + ...(confidential === undefined ? {} : { values: confidential }), + }); + if (result.accepted !== true) throw new Error('Private request was not accepted'); + return { accepted: true }; + } catch { + throw new CliLiveError( + 'private-input-not-accepted', + 'Private input operation was not accepted. Check Owner authentication and request status before retrying.', + ); + } finally { + if (confidential !== undefined) + for (const key of Object.keys(confidential)) delete confidential[key]; + } + } + if (command === 'send') { + const channelId = dmChannelId(target); + const receipt = { channelId, messageId: messageId ?? `human-${randomUUID()}` }; + let sent: unknown; + try { + const bot = await rpc<{ bot: { displayName?: string } }>('get', { slug: target }); + await rpc('channelDm', { slug: target, displayName: bot.bot.displayName }); + sent = await rpc('channelSend', { channelId, body, messageId: receipt.messageId }); + while (Date.now() < deadline) { + const status = await rpc<{ state: string; replies: unknown[] }>( + 'channelSendStatus', + receipt, + ); + if (!status || !Array.isArray(status.replies) || typeof status.state !== 'string') + throw new CliLiveError( + 'host-protocol-error', + 'The Host returned an invalid send status.', + ); + if (status.state === 'handled' && status.replies.length > 0) + return { receipt, ...record(sent), ...status }; + if (status.state === 'handled' || status.state === 'needs-repair') + throw new CliLiveError( + status.state === 'handled' ? 'reply-not-produced' : 'send-needs-repair', + 'The Host has no committed Bot reply for this request.', + receipt, + ); + await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal }); + } + } catch (error) { + if (error instanceof CliLiveError) throw new CliLiveError(error.code, error.message, receipt); + throw new CliLiveError( + 'reply-timeout', + 'The command deadline elapsed; inspect send-status with this receipt before retrying.', + receipt, + ); + } + throw new CliLiveError( + 'reply-timeout', + 'No reply before the deadline; inspect send-status with this receipt before retrying.', + receipt, + ); + } + if (command === 'send-status') + return rpc('channelSendStatus', { channelId: dmChannelId(target), messageId }); + if (command === 'channel-messages') { + const limit = values.limit === undefined ? undefined : Number(values.limit); + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 200)) + usage('--limit must be 1–200.'); + return rpc('channelMessages', { + channelId: target, + ...(limit === undefined ? {} : { limit }), + ...(values.before === undefined ? {} : { before: values.before }), + }); + } + if (command === 'tool-approval-status') + return rpc('toolApprovalStatus', { channelId: target, messageId }); + if (command === 'tool-approval-decide') + return rpc('toolApprovalDecide', { channelId: target, messageId, outcome: values.outcome }); + if (command === 'user-question-status') + return rpc('userQuestionStatus', { channelId: target, messageId }); + if (command === 'user-question-answer') + return rpc('userQuestionAnswer', { channelId: target, messageId, answer }); + if (command === 'workspace-options') return rpc('workspaceOptions', {}); + if (command === 'grant-create') + return rpc('grantCreate', { slug: target, workspaceId: values.workspace }); + return rpc('releaseInfo', values.since === undefined ? {} : { since: values.since }); +} + +async function runControlCommand( + command: (typeof CONTROL_COMMANDS)[number], + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one target.`); + const target = rest[0].trim(); + let patch: Record | undefined; + let reviewed: ReviewedSettings | undefined; + if (command === 'browser-access-set' || command === 'computer-access-set') { + if (values.enabled === values.disabled) + usage(`${command} needs exactly one of --enabled or --disabled.`); + } + if (command === 'access-set') { + if (values.mode !== 'workspace-write' && values.mode !== 'danger-full-access') + usage('--mode must be workspace-write or danger-full-access.'); + if (values.scope !== undefined && values.scope !== 'chat' && values.scope !== 'assignments') + usage('--scope must be chat or assignments.'); + if (values.mode === 'danger-full-access' && values['acknowledge-risk'] !== true) + usage( + 'Full access lets the Bot run any command and use the Browser and Computer without asking. Pass --acknowledge-risk to confirm.', + ); + } + if (command === 'settings-set') { + if (values['patch-stdin'] !== true) usage('settings-set requires --patch-stdin.'); + try { + const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error('not an object'); + patch = parsed as Record; + } catch { + usage('--patch-stdin must be a JSON object of settings fields.'); + } + reviewed = parseReviewedSettings(values, patch); + } + if (command === 'workspace-create' && !target.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(target)) + usage('workspace-create needs an absolute folder path.'); + const members = (values.member ?? []).map((member) => member.trim()).filter(Boolean); + const operationKey = + command === 'group-create' && values['operation-key'] !== undefined + ? channelOperationKey(values['operation-key']) + : undefined; + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const { rpc } = await connectLiveHost(values, io, signal); + if (command === 'browser-access-set' || command === 'computer-access-set') + return rpc(command === 'browser-access-set' ? 'browserAccessSet' : 'computerAccessSet', { + slug: target, + enabled: values.enabled === true, + }); + if (command === 'access') return rpc('assignmentAccessGet', { slug: target }); + if (command === 'access-set') + return rpc('assignmentAccessSet', { + slug: target, + mode: values.mode, + ...(values.scope === undefined ? {} : { scope: values.scope }), + ...(values.mode === 'danger-full-access' ? { acknowledgeRisk: true } : {}), + }); + if (command === 'workspace-create') return rpc('workspace/create', { request: { path: target } }); + if (command === 'group-create') + return rpc('channelCreate', { + name: target, + members, + ...(operationKey === undefined ? {} : { operationKey }), + }); + const described = record(await rpc('settings/describe', {})); + const namespaces = Array.isArray(described['namespaces']) ? described['namespaces'] : []; + const found = namespaces.map(record).find((entry) => entry['ns'] === target); + if (found === undefined) + throw new CliLiveError('unknown-settings', `The Host has no settings namespace ${target}.`); + if (command === 'settings') + return { ns: target, revision: found['revision'], value: found['value'] }; + if (reviewed !== undefined) { + const current = record(found['value']); + if ( + found['revision'] !== reviewed.expectedRevision || + !Object.entries(reviewed.previousValue).every(([key, value]) => + isDeepStrictEqual(current[key], value), + ) + ) + throw new CliLiveError( + 'stale-setting', + 'The reviewed settings changed; read and review the current value before retrying.', + ); + } + const updated = record( + await rpc('settings/update', { + ns: target, + patch, + expectedRevision: reviewed?.expectedRevision ?? found['revision'], + }), + ); + return { ns: target, revision: updated['revision'], value: updated['value'] }; +} + +function appSummary(value: unknown) { + const app = record(value); + const bot = record(app['bot']); + const error = app['error'] === null || app['error'] === undefined ? null : record(app['error']); + return { + id: app['botId'], + name: bot['name'] ?? app['name'], + state: app['state'], + connected: app['connected'] === true, + error: error === null ? null : { code: error['code'], message: error['message'] }, + ...(app['stats'] === undefined ? {} : { stats: app['stats'] }), + }; +} + +function connectionSummary(platform: string, value: unknown) { + const status = record(value); + return { + platform, + state: status['state'], + apps: (Array.isArray(status['bots']) ? status['bots'] : []).map(appSummary), + }; +} + +async function runImControlCommand( + command: (typeof IM_CONTROL_COMMANDS)[number], + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one target.`); + const target = rest[0].trim(); + const app = values.app?.trim() ?? ''; + const platformCommand = + command === 'im-app-add' || command === 'im-app-status' || command === 'im-app-remove'; + if (platformCommand && !TOKEN_IM_PLATFORMS.has(target)) + usage( + `${command} supports ${[...TOKEN_IM_PLATFORMS].join(', ')}; use im-authorize for feishu or weixin.`, + ); + let credentials: Record | undefined; + if (command === 'im-app-add') { + if (values['credentials-stdin'] !== true) usage('im-app-add requires --credentials-stdin.'); + try { + const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error('not an object'); + credentials = parsed as Record; + } catch { + usage('--credentials-stdin must be a JSON object, for example {"token":"…"}.'); + } + } + if ((command === 'im-app-remove' || command === 'im-bind' || command === 'im-unbind') && !app) + usage(`${command} requires --app .`); + if (command === 'channel-connect' && (!values.bot?.trim() || !values.conversation?.trim())) + usage('channel-connect requires --bot and --conversation .'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const { rpc } = await connectLiveHost(values, io, signal); + const provider = (method: string, payload: Record) => + rpc(`dsh-im/${target}`, { method, payload }); + if (command === 'im-app-add') + return connectionSummary(target, await provider('bot.bind-credentials', credentials!)); + if (command === 'im-app-status') + return connectionSummary(target, await provider('connection.status', {})); + if (command === 'im-app-remove') + return connectionSummary(target, await provider('bot.delete', { botId: app, confirm: true })); + if (command === 'channel-ingests') return rpc('channelIngests', { channelId: target }); + if (command === 'im-bind') { + const apps = record(await rpc('messagingApps', {})); + const found = (Array.isArray(apps['apps']) ? apps['apps'] : []) + .map(record) + .find((entry) => entry['ref'] === app); + if (found === undefined) + throw new CliLiveError('unknown-app', `The Host has no connected IM app ${app}.`); + return rpc('messagingIdentity', { + slug: target, + input: { + kind: 'bind', + providerId: found['providerId'], + accountRef: found['ref'], + fingerprint: found['fingerprint'], + }, + }); + } + const slug = command === 'channel-connect' ? values.bot!.trim() : target; + const snapshot = record(await rpc('messagingSnapshot', { slug })); + const identities = (Array.isArray(snapshot['identities']) ? snapshot['identities'] : []).map( + record, + ); + if (command === 'im-status') + return { + apps: (Array.isArray(snapshot['accounts']) ? snapshot['accounts'] : []) + .map(record) + .map((entry) => ({ + ref: entry['ref'], + platform: entry['platform'], + name: entry['name'], + connected: entry['connected'] === true, + })), + bindings: identities.map((entry) => ({ + id: entry['id'], + app: entry['accountRef'], + platform: entry['platform'], + name: entry['name'], + enabled: entry['enabled'], + availability: entry['availability'], + reception: entry['reception'], + conversations: entry['grantCount'], + })), + }; + const binding = identities.find((entry) => + command === 'channel-connect' + ? app === '' || entry['accountRef'] === app + : entry['accountRef'] === app, + ); + if (binding === undefined) + throw new CliLiveError( + 'unknown-binding', + command === 'channel-connect' + ? `Bot ${slug} has no IM app binding to connect through.` + : `Bot ${slug} is not bound to IM app ${app}.`, + ); + if (command === 'im-unbind') + return rpc('messagingIdentity', { + slug, + input: { kind: 'unbind', id: binding['id'], expectedRevision: binding['revision'] }, + }); + return rpc('channelIngest', { + channelId: target, + input: { + kind: 'add', + bindingId: binding['id'], + conversation: { kind: 'group', id: values.conversation!.trim() }, + }, + }); +} + +async function runDeviceCommand( + command: (typeof DEVICE_COMMANDS)[number], + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + const direct = command === 'browser-tool' || command === 'computer-tool'; + const computer = (command.startsWith('computer-') && !direct) || command === 'browser-tools'; + const positionals = direct ? 2 : computer ? 0 : 1; + if (rest.length !== positionals || rest.some((value) => !value.trim())) + usage( + direct + ? `${command} needs a bot id and a tool name.` + : computer + ? `${command} takes no positional arguments.` + : `${command} needs exactly one bot id.`, + ); + let toolArgs: Record = {}; + const input = command === 'browser-input' || command === 'computer-input'; + if (input && values['args-stdin'] !== true) + usage( + `${command} needs --args-stdin with a JSON input such as {"kind":"click","x":10,"y":20}.`, + ); + if ((direct || input) && values['args-stdin'] === true) { + try { + const parsed: unknown = JSON.parse(await (io.readStdin?.() ?? Promise.resolve(''))); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) + throw new Error('not an object'); + toolArgs = parsed as Record; + } catch { + usage('--args-stdin must be a JSON object of tool arguments.'); + } + } + if ( + direct && + values.out !== undefined && + !values.out.startsWith('/') && + !/^[A-Za-z]:[\\/]/u.test(values.out) + ) + usage('--out must be an absolute file path.'); + const slug = rest[0]?.trim() ?? ''; + if (command === 'browser-frame' || command === 'computer-frame') { + const out = values.out ?? ''; + if (!out.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(out)) + usage(`${command} requires --out .`); + } + if ( + (command === 'browser-takeover' || command === 'computer-takeover') && + values.enabled === values.disabled + ) + usage(`${command} needs exactly one of --enabled or --disabled.`); + if ( + command === 'computer-start' && + values.target !== undefined && + values.target !== 'local' && + values.target !== 'container' + ) + usage('--target must be local or container.'); + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + const { http } = await connectLiveHost(values, io, signal); + const json = async (path: string, body?: Record) => { + const { bytes } = await http(path, body); + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + throw new CliLiveError('host-protocol-error', `The Host returned no JSON for ${path}.`); + } + }; + let saved = false; + const savedImages = (items: unknown[]) => + items.map((value) => { + const item = record(value); + if (item['type'] !== 'image' || typeof item['data'] !== 'string') return item; + const bytes = Buffer.from(item['data'], 'base64'); + if (values.out !== undefined && !saved) { + writeFileSync(values.out, bytes, { mode: 0o600 }); + saved = true; + return { + type: 'image', + mimeType: item['mimeType'], + path: values.out, + bytes: bytes.byteLength, + }; + } + return { type: 'image', mimeType: item['mimeType'], bytes: bytes.byteLength }; + }); + if (command === 'browser-tools') return json('/api/browser/tools'); + if (command === 'computer-tools') return json('/api/computer/tools'); + if (command === 'computer-tool') { + const result = record( + await json('/api/computer/tool', { slug, tool: rest[1]!.trim(), args: toolArgs }), + ); + const inner = record(result['result']); + return Array.isArray(inner['content']) + ? { ...result, result: { ...inner, content: savedImages(inner['content']) } } + : result; + } + if (command === 'browser-tool') { + const result = record( + await json('/api/browser/tool', { slug, tool: rest[1]!.trim(), args: toolArgs }), + ); + return { + ...result, + content: savedImages(Array.isArray(result['content']) ? result['content'] : []), + }; + } + if (command === 'browser-open') return json('/api/browser/open', { slug }); + if (command === 'browser-takeover') + return json('/api/browser/takeover', { slug, active: values.enabled === true }); + if (command === 'browser-input') return json('/api/browser/input', { ...toolArgs, slug }); + if (command === 'browser-stop') return json('/api/browser/stop', { slug }); + if (command === 'computer-start') + return json('/api/computer/start', { + authorize: true, + ...(values.target === undefined ? {} : { target: values.target }), + }); + if (command === 'computer-status') return json('/api/computer/status'); + if (command === 'computer-stop') return json('/api/computer/stop', { authorize: true }); + if (command === 'computer-takeover') + return json('/api/computer/takeover', { active: values.enabled === true }); + if (command === 'computer-input') return json('/api/computer/input', toolArgs); + if (command === 'computer-frame') { + const { response, bytes: answer } = await http('/api/computer/frame'); + if (response.status === 204) + throw new CliLiveError('no-frame', 'The Computer has not produced a frame yet; try again.'); + let desktop: Record; + try { + desktop = record(JSON.parse(new TextDecoder().decode(answer))); + } catch { + throw new CliLiveError( + 'host-protocol-error', + 'The Host returned no JSON for /api/computer/frame.', + ); + } + if (typeof desktop['data'] !== 'string') + throw new CliLiveError('host-protocol-error', 'The Host returned no Computer frame.'); + const bytes = Buffer.from(desktop['data'], 'base64'); + writeFileSync(values.out!, bytes, { mode: 0o600 }); + return { + path: values.out, + bytes: bytes.byteLength, + mimeType: desktop['mimeType'], + width: desktop['width'], + height: desktop['height'], + seq: desktop['seq'], + target: desktop['target'], + }; + } + const frame = record(await json(`/api/browser/frame?slug=${encodeURIComponent(slug)}`)); + if (typeof frame['data'] !== 'string') + throw new CliLiveError('host-protocol-error', 'The Host returned no Browser frame.'); + const bytes = Buffer.from(frame['data'], 'base64'); + writeFileSync(values.out!, bytes, { mode: 0o600 }); + return { + path: values.out, + bytes: bytes.byteLength, + mimeType: frame['mimeType'], + ...(frame['viewport'] === undefined ? {} : { viewport: frame['viewport'] }), + }; +} + +const MEMORY_COMMANDS = new Set([ + 'memory-snapshot', + 'memory-file', + 'memory-image', + 'memory-history', + 'memory-diff', + 'memory-save', + 'memory-file-target', + 'memory-graph', + 'memory-commit-diff', + 'memory-changes', + 'memory-working-diff', + 'memory-checkpoints', + 'memory-restore', + 'memory-restore-commit', + 'memory-versions', + 'memory-version-name', + 'memory-version-unname', + 'memory-repair', + 'memory-move', +]); + +const SHA = /^[0-9a-f]{40}$/u; + +async function runMemoryCommand( + command: string, + rest: string[], + values: LiveValues, + io: BotCreateCliIo, +): Promise { + if (rest.length !== 1 || !rest[0]?.trim()) usage(`${command} needs exactly one bot id.`); + const slug = rest[0].trim(); + const channelId = dmChannelId(slug); + const required = (name: keyof LiveValues, flag: string): string => { + const value = values[name]; + if (typeof value !== 'string' || !value.trim()) usage(`${command} requires --${flag}.`); + return value.trim(); + }; + const sha = (): string => { + const value = required('sha', 'sha '); + if (!SHA.test(value)) usage('--sha must be a 40-character lowercase commit sha.'); + return value; + }; + const expectedHead = (): string => { + const value = required('expected-head', 'expected-head '); + if (!SHA.test(value)) usage('--expected-head must be a 40-character lowercase commit sha.'); + return value; + }; + let method: string; + let args: Record; + switch (command) { + case 'memory-snapshot': + [method, args] = ['memorySnapshot', { channelId }]; + break; + case 'memory-image': { + const path = required('path', 'path '); + const source = required('image-source', 'image-source worktree|index|'); + const revision = required('image-revision', 'image-revision '); + const out = required('out', 'out '); + if (!isAbsolute(out)) usage('--out must be an absolute path.'); + const host = await connectLiveHost( + values, + io, + AbortSignal.timeout(timeoutMs(values.timeout)), + ); + const reference = values['image-reference']; + const { bytes, response } = await host.http( + '/api/botharness/memory/image?' + + new URLSearchParams({ + slug, + path, + source, + revision, + ...(reference === undefined ? {} : { reference }), + }), + ); + const mime = response.headers.get('content-type'); + if ( + !['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'].includes( + mime ?? '', + ) + ) + throw new CliLiveError('host-protocol-error', 'Expected a supported Memory image'); + writeFileSync(out, bytes, { flag: 'wx' }); + return { + path, + source, + revision, + ...(reference === undefined ? {} : { reference }), + mime, + bytes: bytes.length, + out, + }; + } + case 'memory-file': + [method, args] = ['memoryFile', { channelId, path: required('path', 'path ') }]; + break; + case 'memory-history': + [method, args] = ['memoryHistory', { channelId }]; + break; + case 'memory-diff': + [method, args] = ['memoryDiff', { channelId, sha: sha() }]; + break; + case 'memory-file-target': + [method, args] = ['memoryFileTarget', { slug, path: required('path', 'path ') }]; + break; + case 'memory-graph': { + const offset = values.offset === undefined ? 0 : Number(values.offset); + if (!Number.isInteger(offset) || offset < 0 || offset > 10_000) + usage('--offset must be an integer from 0 to 10000.'); + [method, args] = ['memoryGitGraph', { channelId, offset }]; + break; + } + case 'memory-commit-diff': + [method, args] = ['memoryGitCommitDiff', { channelId, sha: sha() }]; + break; + case 'memory-changes': + [method, args] = ['memoryWorkingChanges', { channelId }]; + break; + case 'memory-working-diff': { + const kind = required('kind', 'kind staged|unstaged|untracked|current'); + if (!['staged', 'unstaged', 'untracked', 'current'].includes(kind)) + usage('--kind must be staged, unstaged, untracked or current.'); + [method, args] = [ + 'memoryWorkingDiff', + { channelId, path: required('path', 'path '), kind }, + ]; + break; + } + case 'memory-checkpoints': + [method, args] = ['memoryRecoveryHistory', { channelId }]; + break; + case 'memory-restore': + [method, args] = [ + 'memoryRestore', + { + channelId, + checkpointId: required('checkpoint', 'checkpoint '), + expectedCurrentId: required('expected-current', 'expected-current '), + }, + ]; + break; + case 'memory-restore-commit': + [method, args] = [ + 'memoryRestoreCommit', + { + channelId, + sha: sha(), + expectedHead: expectedHead(), + restoreId: values['restore-id']?.trim() || randomUUID(), + ...(values.name === undefined ? {} : { name: values.name }), + }, + ]; + break; + case 'memory-versions': + [method, args] = ['memoryVersions', { channelId }]; + break; + case 'memory-version-name': + [method, args] = [ + 'memoryNameVersion', + { channelId, sha: sha(), name: required('name', 'name ') }, + ]; + break; + case 'memory-version-unname': + [method, args] = [ + 'memoryRemoveVersionName', + { channelId, tag: required('tag', 'tag ') }, + ]; + break; + case 'memory-move': { + const location = values['memory-location']?.trim(); + if ((location === undefined || location === '') === (values['default-location'] !== true)) + usage( + 'memory-move needs --memory-location or --default-location, not both.', + ); + if (location !== undefined && location !== '' && !isAbsolute(location)) + usage('--memory-location must be an absolute folder path.'); + [method, args] = [ + 'memoryMove', + { slug, location: location === undefined || location === '' ? null : location }, + ]; + break; + } + case 'memory-repair': + [method, args] = [ + 'memoryRepair', + { + channelId, + expectedHead: expectedHead(), + repairId: values['repair-id']?.trim() || randomUUID(), + }, + ]; + break; + default: { + const path = required('path', 'path '); + const head = expectedHead(); + let body = values.body; + if (values['body-stdin'] === true) body = await (io.readStdin?.() ?? Promise.resolve('')); + if (body === undefined) usage('memory-save needs --body or --body-stdin.'); + [method, args] = [ + 'memorySave', + { + channelId, + path, + body, + expectedHead: head, + editId: values['edit-id']?.trim() || randomUUID(), + }, + ]; + } + } + const signal = AbortSignal.timeout(timeoutMs(values.timeout)); + return (await connectLiveHost(values, io, signal)).rpc(method, args); +} + +export interface ReviewedSettings { + expectedRevision: number; + previousValue: Record; +} + +export function parseReviewedSettings( + values: { 'expected-revision'?: string; 'previous-value'?: string }, + patch: Record, +): ReviewedSettings | undefined { + const revision = values['expected-revision']; + const previous = values['previous-value']; + if (revision === undefined && previous === undefined) return undefined; + if (revision === undefined || previous === undefined) + usage('--expected-revision and --previous-value must be supplied together.'); + if (!/^(0|[1-9][0-9]*)$/u.test(revision) || !Number.isSafeInteger(Number(revision))) + usage('--expected-revision must be a nonnegative safe integer.'); + let previousValue: unknown; + try { + previousValue = JSON.parse(previous); + } catch { + usage('--previous-value must be a JSON object of the reviewed settings fields.'); + } + if (typeof previousValue !== 'object' || previousValue === null || Array.isArray(previousValue)) + usage('--previous-value must be a JSON object of the reviewed settings fields.'); + const keys = Object.keys(patch).sort(); + if (keys.length === 0 || !isDeepStrictEqual(keys, Object.keys(previousValue).sort())) + usage('--previous-value must contain exactly the fields in the nonempty patch.'); + return { + expectedRevision: Number(revision), + previousValue: previousValue as Record, + }; +} From 4d9203336d927744245956d9d91ed2a9ccf8c53b Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 02:30:03 +0900 Subject: [PATCH 06/18] docs: attribute notification follow-up changes to issue Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- changes/2500-device-notifications.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changes/2500-device-notifications.md b/changes/2500-device-notifications.md index 4f897d7df0..a82be3d12e 100644 --- a/changes/2500-device-notifications.md +++ b/changes/2500-device-notifications.md @@ -5,9 +5,9 @@ section: Changed ## en - Notification kinds now stay on the phone and sync to every paired Host, including after pairing again; each Remote Host in the browser has its own notification choices for alerts while the page is open ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). -- Browser discovery includes ordinary DM replies and replies beyond the Activity page. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query. +- Browser discovery includes ordinary DM replies and replies beyond the Activity page. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). ## zh - 通知类型现在保存在手机,并同步到所有已配对 Host,重新配对后也会恢复;浏览器可为每个远程单独选择通知,仅在页面打开时提醒([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 -- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复;手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询。 +- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复;手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 From 030a018915c320b224d3d589686fbd1524c68cc6 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 02:53:08 +0900 Subject: [PATCH 07/18] fix: establish notification baseline before enabling alerts Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../features/remote-hosts.md | 2 + apps/mobile/src/host/inbox.ts | 18 +++ .../test/notification-discovery.test.ts | 8 +- changes/2500-device-notifications.md | 4 +- docs/architecture/botharness-architecture.md | 2 +- docs/bot-cli.md | 2 +- docs/bot-cli.zh.md | 2 +- packages/client/src/client/remote-hosts.ts | 15 ++ .../client/remote-notification-settings.tsx | 5 +- .../client/src/client/remote-notifications.ts | 97 ++++++++++--- packages/client/test/remote-hosts.test.ts | 129 +++++++++++++++++- .../client/test/remote-notifications.test.ts | 90 +++++++++++- 12 files changed, 343 insertions(+), 31 deletions(-) diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index f5e9492a7f..28bb11446e 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -54,3 +54,5 @@ 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. diff --git a/apps/mobile/src/host/inbox.ts b/apps/mobile/src/host/inbox.ts index 89cd652f36..c2f09c4923 100644 --- a/apps/mobile/src/host/inbox.ts +++ b/apps/mobile/src/host/inbox.ts @@ -280,6 +280,24 @@ export function actionCount(snapshot: InboxSnapshot): number { return snapshot.status === 'ready' ? snapshot.sections.action.length : 0; } +export async function prepareNotificationBaseline( + inbox: Inbox, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const before = inbox.getSnapshot(); + await inbox.refresh(); + signal.throwIfAborted(); + const snapshot = inbox.getSnapshot(); + if ( + snapshot === before || + snapshot.status !== 'ready' || + snapshot.stale || + snapshot.notificationItems === undefined + ) + throw new Error('notification-baseline-unavailable'); +} + type InboxObservation = { getSnapshot(): InboxSnapshot; getInbox(): Inbox | undefined; diff --git a/apps/mobile/test/notification-discovery.test.ts b/apps/mobile/test/notification-discovery.test.ts index cf59ec5e3f..e21f8d0a34 100644 --- a/apps/mobile/test/notification-discovery.test.ts +++ b/apps/mobile/test/notification-discovery.test.ts @@ -1,5 +1,5 @@ import { expect, it, vi } from 'vitest'; -import { createInbox } from '../src/host/inbox'; +import { createInbox, prepareNotificationBaseline } from '../src/host/inbox'; import type { HostClient } from '../src/host/client'; import type { HumanAttentionItem } from '../src/host/wire'; @@ -68,11 +68,13 @@ it('publishes only complete notification discovery and restarts a scan invalidat await inbox.refresh(); expect(published).toEqual([['inserted', 'dm']]); fail = true; - await inbox.refresh(); + await expect(prepareNotificationBaseline(inbox, new AbortController().signal)).rejects.toThrow( + 'notification-baseline-unavailable', + ); expect(inbox.getSnapshot()).toMatchObject({ status: 'ready', notificationItems: undefined }); expect(published).toHaveLength(1); fail = false; - await inbox.refresh(); + await prepareNotificationBaseline(inbox, new AbortController().signal); expect(published.at(-1)).toEqual(['inserted', 'dm']); expect( call.mock.calls diff --git a/changes/2500-device-notifications.md b/changes/2500-device-notifications.md index a82be3d12e..657730bd18 100644 --- a/changes/2500-device-notifications.md +++ b/changes/2500-device-notifications.md @@ -5,9 +5,9 @@ section: Changed ## en - Notification kinds now stay on the phone and sync to every paired Host, including after pairing again; each Remote Host in the browser has its own notification choices for alerts while the page is open ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). -- Browser discovery includes ordinary DM replies and replies beyond the Activity page. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Browser discovery includes ordinary DM replies and replies beyond the Activity page; enabling waits for a complete baseline so pending old items stay silent. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). ## zh - 通知类型现在保存在手机,并同步到所有已配对 Host,重新配对后也会恢复;浏览器可为每个远程单独选择通知,仅在页面打开时提醒([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 -- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复;手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复,启用前等待完整基线,避免补发扫描中的旧条目;手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 71d54b9472..82eb6e1ba0 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -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 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. `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 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. diff --git a/docs/bot-cli.md b/docs/bot-cli.md index f3aec51388..e89995cd2e 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -6,7 +6,7 @@ Bot summaries from `list` also include `lastActivityAt` (epoch milliseconds) whe ## Usage -`deepseekbot browser-notification-clients` lists online Client targets. `deepseekbot browser-notifications --client-id --remote-host-id ` reads that browser's local choice and revision. To write, add `--expected-revision --patch-stdin` and pipe `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`. These commands use the serving Host's usual online authentication and existing Client control relay; the Host persists no browser choice or Remote Host credential. Writes refuse stale revisions and unknown Hosts. Enabling requires permission already granted in the browser (`notification-permission-required` otherwise); CLI never opens the OS permission prompt. Closing the page ends both its control target and alerts. +`deepseekbot browser-notification-clients` lists online Client targets. `deepseekbot browser-notifications --client-id --remote-host-id ` reads that browser's local choice and revision. To write, add `--expected-revision --patch-stdin` and pipe `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`. These commands use the serving Host's usual online authentication and existing Client control relay; the Host persists no browser choice or Remote Host credential. Writes refuse stale revisions and unknown Hosts. Enabling requires permission already granted in the browser (`notification-permission-required` otherwise); CLI never opens the OS permission prompt. Closing the page ends both its control target and alerts. Enabling a previously inactive kind first refreshes the shared Inbox through every notification page, keeping the old choice until that complete baseline is ready. The successful write is the enable boundary: earlier items stay silent and later group/DM replies can alert. A failed page returns `notification-baseline-unavailable`; a ten-second deadline returns `notification-baseline-timeout`. Switching Host, closing settings, removing the pairing, losing permission or releasing the page cancels pending activation (`notification-activation-cancelled`); it cannot persist later. Retry explicitly after a refusal. Normal shared Host Feed observation continues while the page remains subscribed. `deepseekbot phone-notifications --metro-port --target-id --device-name ` reads the existing phone preference owner in an assigned Expo development client. Add `--preferences-stdin` with exactly the four boolean kinds to persist through that same owner and copy to every paired Host. It requires the shared-device batch/lease, a running loopback Metro inspector, an exact unique target, the development app ID, `__DEV__`, and the uniquely initialized owner. It never writes storage directly, creates another owner, enables OS permission, or treats Host copies as the phone choice. One JSON result distinguishes `saved` from `syncStatus` (`complete`/`pending`), `syncing`, and `failedHostIds`. A disconnected/timed-out write has an unknown outcome: read again before repeating it. Missing/ambiguous targets, release clients, unready owners and transport failures return coded refusals. **This development-only AX path is unavailable in release clients; it is not release CLI parity or completed native QA.** Native real runs remain in the external combined acceptance batch. diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index f6d8ce0c9a..6125b7e9b2 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -6,7 +6,7 @@ ## 用法 -`deepseekbot browser-notification-clients` 列出在线 Client。`deepseekbot browser-notifications --client-id --remote-host-id ` 读取该浏览器的本地选择和 revision。写入时加 `--expected-revision --patch-stdin`,从标准输入传入 `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`。命令使用提供页面的 Host 的现有认证及 Client 定向控制通道;Host 不保存浏览器选择或远程凭据。版本过期或远程不存在时明确拒绝。开启前须已在浏览器授予通知权限,否则返回 `notification-permission-required`;CLI 不弹出系统授权。页面关闭后,控制目标和提醒都会结束。 +`deepseekbot browser-notification-clients` 列出在线 Client。`deepseekbot browser-notifications --client-id --remote-host-id ` 读取该浏览器的本地选择和 revision。写入时加 `--expected-revision --patch-stdin`,从标准输入传入 `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`。命令使用提供页面的 Host 的现有认证及 Client 定向控制通道;Host 不保存浏览器选择或远程凭据。版本过期或远程不存在时明确拒绝。开启前须已在浏览器授予通知权限,否则返回 `notification-permission-required`;CLI 不弹出系统授权。页面关闭后,控制目标和提醒都会结束。 开启原先未启用的类型时,先通过共享 Inbox 读完全部通知分页,完整基线就绪前保持旧选择;写入成功才是启用边界,此前条目静默,此后的群回复和 DM 可提醒。部分页失败返回 `notification-baseline-unavailable`,十秒期限返回 `notification-baseline-timeout`。切换 Host、关闭设置、移除配对、权限丢失或释放页面会取消待完成的启用(`notification-activation-cancelled`),迟到结果不能落盘。拒绝后显式重试;页面仍订阅时,正常共享 Host Feed 观察继续。 `deepseekbot phone-notifications --metro-port <端口> --target-id <精确ID> --device-name <精确名称>` 在已分配的 Expo development client 中读取现有手机偏好 owner。加 `--preferences-stdin`,仅传入四类布尔值,由同一 owner 持久化并复制到全部已配对 Host。前提是共享设备 batch/lease、运行中的 loopback Metro inspector、精确且唯一的目标、开发版 app ID、`__DEV__` 和唯一已初始化的 owner。命令不直接写存储、不新建 owner、不弹系统授权、不把 Host 副本当手机选择。单个 JSON 分别返回 `saved`、`syncStatus`(`complete`/`pending`)、`syncing` 和 `failedHostIds`。断开/超时后的写入结果未知,应先重新读取再决定是否重试。目标缺失/歧义、release、owner 未就绪及传输失败均明确拒绝。**该 AX 路径仅限 development client,release 不可用,也不代表 release CLI parity 或原生 QA 已通过。** 原生真实运行仍交外部统一批次验收。 diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index c6df57d516..c0c6bca3de 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -11,6 +11,7 @@ import { import { parseSavedHost, type SavedHost } from '../../../../apps/mobile/src/host/hosts.js'; import { createInbox, + prepareNotificationBaseline, type Inbox, type InboxSnapshot, observeInbox, @@ -150,6 +151,19 @@ export function createRemoteHosts(options: { const notifications = createRemoteNotifications({ storage, api: browserNotificationApi(), + async prepareEnable(hostId, signal) { + const entry = clients.get(hostId); + const inbox = entry?.inbox?.feed; + if (entry === undefined || inbox === undefined) + throw new Error('notification-baseline-unavailable'); + await prepareNotificationBaseline(inbox, signal); + if ( + clients.get(hostId) !== entry || + entry.inbox?.feed !== inbox || + entry.client.getSnapshot().connection.status !== 'connected' + ) + throw new Error('notification-activation-cancelled'); + }, privateBody: () => document.documentElement.lang.startsWith('zh') ? '需要你提供私密输入' @@ -339,6 +353,7 @@ export function createRemoteHosts(options: { await revokePairing({ host: entry.host, carrier }); }, select(next) { + if (tab !== next) notifications.cancel(); tab = next; try { storage?.setItem(HOST_TAB_STORAGE_KEY, next); diff --git a/packages/client/src/client/remote-notification-settings.tsx b/packages/client/src/client/remote-notification-settings.tsx index 56975aac02..2e9f226f41 100644 --- a/packages/client/src/client/remote-notification-settings.tsx +++ b/packages/client/src/client/remote-notification-settings.tsx @@ -47,7 +47,10 @@ export function RemoteNotificationSettings({ sections={[{ id: 'notifications', label: t('remoteHosts.notifications.title', { name }) }]} activeId="notifications" onSelect={() => {}} - onClose={onClose} + onClose={() => { + notifications.cancel(hostId); + onClose(); + }} > {(error || snapshot.permission === 'denied' || snapshot.permission === 'unsupported') && ( diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts index ab8d9abc32..d01e3fcc79 100644 --- a/packages/client/src/client/remote-notifications.ts +++ b/packages/client/src/client/remote-notifications.ts @@ -2,7 +2,11 @@ import { DEFAULT_NOTIFICATION_PREFERENCES, parseNotificationPreferences, } from '../../../../apps/mobile/src/host/device-notification-preferences.js'; -import type { PushKind, PushPreferences } from '../../../../apps/mobile/src/host/push.js'; +import { + PUSH_KINDS, + type PushKind, + type PushPreferences, +} from '../../../../apps/mobile/src/host/push.js'; import type { HumanAttentionItem } from '../../../../apps/mobile/src/host/wire.js'; import type { RemoteHost, HostStorage, RemoteConversationTarget } from './remote-hosts.js'; import { @@ -44,6 +48,7 @@ export function createRemoteNotifications(options: { api?: DesktopNotificationApi | undefined; open(target: RemoteConversationTarget): void; privateBody(): string; + prepareEnable(hostId: string, signal: AbortSignal): Promise; diagnostic?(code: NotificationDiagnostic): void; }) { const { api, storage } = options; @@ -73,6 +78,24 @@ export function createRemoteNotifications(options: { >(); const shown = new Map(); const revisions = new Map(); + const pending = new Map(); + const cancel = (hostId?: string) => { + for (const [id, controller] of pending) { + if (hostId !== undefined && id !== hostId) continue; + controller.abort(new Error('notification-activation-cancelled')); + pending.delete(id); + } + }; + const wait = (work: Promise, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + const abort = () => { + signal.removeEventListener('abort', abort); + reject(signal.reason); + }; + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); const publish = () => { snapshot = { hosts, permission: api?.permission() ?? 'unsupported' }; for (const listener of listeners) listener(); @@ -89,6 +112,7 @@ export function createRemoteNotifications(options: { const owner = { getSnapshot: () => snapshot, choice, + cancel, subscribe(listener: () => void) { listeners.add(listener); return () => { @@ -99,27 +123,53 @@ export function createRemoteNotifications(options: { hostId: string, patch: { enabled?: boolean; preferences?: Partial }, ) { + cancel(hostId); + const controller = new AbortController(); + pending.set(hostId, controller); const revision = (revisions.get(hostId) ?? 0) + 1; revisions.set(hostId, revision); - if (patch.enabled === true && api?.permission() === 'default') { - try { - await api.requestPermission(); - } catch {} + try { + if (patch.enabled === true && api?.permission() === 'default') { + try { + await wait(api.requestPermission(), controller.signal); + } catch {} + } + controller.signal.throwIfAborted(); + if (revisions.get(hostId) !== revision) return; + const current = choice(hostId); + const next = { + enabled: + patch.enabled === undefined + ? current.enabled + : patch.enabled && api?.permission() === 'granted', + preferences: { ...current.preferences, ...patch.preferences }, + }; + if ( + next.enabled && + PUSH_KINDS.some( + (kind) => next.preferences[kind] && (!current.enabled || !current.preferences[kind]), + ) + ) { + const timeout = setTimeout( + () => controller.abort(new Error('notification-baseline-timeout')), + 10_000, + ); + try { + await wait(options.prepareEnable(hostId, controller.signal), controller.signal); + } finally { + clearTimeout(timeout); + } + controller.signal.throwIfAborted(); + if (api?.permission() !== 'granted') throw new Error('notification-permission-required'); + } + const updated = { ...hosts, [hostId]: next }; + storage?.setItem(REMOTE_NOTIFICATION_KEY, JSON.stringify(updated)); + hosts = updated; + close(hostId); + publish(); + } finally { + if (pending.get(hostId) === controller) pending.delete(hostId); } - if (revisions.get(hostId) !== revision) return; - const current = choice(hostId); - const next = { - enabled: - patch.enabled === undefined - ? current.enabled - : patch.enabled && api?.permission() === 'granted', - preferences: { ...current.preferences, ...patch.preferences }, - }; - const updated = { ...hosts, [hostId]: next }; - storage?.setItem(REMOTE_NOTIFICATION_KEY, JSON.stringify(updated)); - hosts = updated; - close(hostId); - publish(); }, async control(input: Record, expectedRevision: unknown) { const hostId = input.hostId as string; @@ -144,6 +194,7 @@ export function createRemoteNotifications(options: { }, update(remotes: readonly RemoteHost[]) { const present = new Set(remotes.map((remote) => remote.host.hostId)); + for (const id of pending.keys()) if (!present.has(id)) cancel(id); for (const id of state.keys()) if (!present.has(id)) { state.delete(id); @@ -152,12 +203,16 @@ export function createRemoteNotifications(options: { const permission = api?.permission() ?? 'unsupported'; if (permission !== snapshot.permission) { publish(); - if (permission !== 'granted') close(); + if (permission !== 'granted') { + cancel(); + close(); + } } for (const remote of remotes) { const id = remote.host.hostId; let entry = state.get(id); if (entry === undefined || entry.credential !== remote.host.credential) { + if (entry !== undefined) cancel(id); close(id); entry = { credential: remote.host.credential, @@ -173,6 +228,7 @@ export function createRemoteNotifications(options: { remote.inbox.status !== 'ready' || remote.inbox.stale ) { + if (remote.snapshot.connection.status !== 'connected') cancel(id); entry.inbox = remote.inbox; entry.baseline = false; close(id); @@ -249,6 +305,7 @@ export function createRemoteNotifications(options: { } }, stop() { + cancel(); state.clear(); close(); }, diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index 882d84b7d7..03c949ba67 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -169,11 +169,14 @@ describe('remote hosts', () => { const releases: Array<() => void> = []; afterEach(() => { for (const release of releases.splice(0)) release(); + vi.unstubAllGlobals(); }); function liveHost() { const host = new FakeHost(); - host.methods.set('humanAttention', () => ({ items: [], queryVersion: 1 })); + host.methods.set('humanAttention', (args) => + args.notifications ? { items: [], notificationVersion: 1 } : { items: [], queryVersion: 1 }, + ); host.methods.set('channelUnreadCounts', () => ({ channels: [{ channelId: 'dm-ada', unreadCount: 2 }], })); @@ -241,6 +244,129 @@ describe('Host Feed lifecycle', () => { expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ unread: { 'dm-ada': 2 } }); }); + it('establishes an enable boundary through the shared paginated Inbox despite concurrent live frames and page failure', async () => { + const show = vi.fn(); + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + onclick = null; + close() {} + constructor(title: string, options: unknown) { + show(title, options); + } + }, + }); + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { enabled: true, preferences: { reply: false } }); + const items = Array.from({ length: 35 }, (_, index) => ({ + id: `old-${index}`, + kind: 'channel-mention', + category: 'replies', + botSlug: 'ada', + createdAt: 'same-time', + summary: `old-${index}`, + })); + let finish!: () => void; + let gate: Promise | undefined = new Promise((resolve) => { + finish = resolve; + }); + let tails = 0; + let fail = false; + host.methods.set('humanAttention', async (args) => { + if (!args.notifications) return { queryVersion: 1, items: [] }; + if (!args.cursor) + return { notificationVersion: 1, items: items.slice(0, 30), nextCursor: 'tail' }; + tails++; + await gate; + if (fail) throw new Error('page unavailable'); + return { notificationVersion: 1, items: items.slice(30) }; + }); + const live = () => + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + live(); + await vi.waitFor(() => expect(tails).toBe(1)); + const enabled = sources.notifications.set(host.hostId, { preferences: { reply: true } }); + live(); + expect(sources.notifications.choice(host.hostId).preferences.reply).toBe(false); + finish(); + gate = undefined; + await enabled; + expect(tails).toBeGreaterThan(1); + expect(show).not.toHaveBeenCalled(); + items.push( + ...['group', 'dm'].map((id) => ({ + id, + kind: 'channel-reply', + category: 'replies', + botSlug: 'ada', + createdAt: 'same-time', + summary: id, + })), + ); + live(); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(2)); + await sources.notifications.set(host.hostId, { preferences: { reply: false } }); + fail = true; + await expect( + sources.notifications.set(host.hostId, { preferences: { reply: true } }), + ).rejects.toThrow('notification-baseline-unavailable'); + expect(sources.notifications.choice(host.hostId).preferences.reply).toBe(false); + fail = false; + await sources.notifications.set(host.hostId, { preferences: { reply: true } }); + expect(show).toHaveBeenCalledTimes(2); + items.push({ + id: 'later', + kind: 'channel-reply', + category: 'replies', + botSlug: 'ada', + createdAt: 'same-time', + summary: 'later', + }); + live(); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(3)); + }); + + it.each(['switch', 'remove', 'dispose'] as const)( + 'cancels pending activation on Host %s without applying a late scan', + async (operation) => { + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + }, + }); + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + const release = sources.subscribe(() => {}); + releases.push(release); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + let entered = false; + host.methods.set('humanAttention', async (args) => { + if (!args.notifications) return { queryVersion: 1, items: [] }; + entered = true; + await gate; + return { notificationVersion: 1, items: [] }; + }); + const enabled = sources.notifications.set(host.hostId, { enabled: true }); + const refused = expect(enabled).rejects.toThrow('notification-activation-cancelled'); + await vi.waitFor(() => expect(entered).toBe(true)); + if (operation === 'switch') sources.select('local'); + else if (operation === 'remove') await sources.remove(host.hostId); + else release(); + await refused; + finish(); + await Promise.resolve(); + expect(sources.notifications.choice(host.hostId).enabled).toBe(false); + expect(storage.values.has('botharness.remoteNotifications.v1')).toBe(false); + }, + ); + it('retains legacy pairings when browser storage refuses the migration write', () => { const { host, saved } = liveHost(); const storage = memoryStorage({ @@ -291,6 +417,7 @@ describe('Host Feed lifecycle', () => { { category: 'action', limit: 100, sort: 'newest', queryVersion: 1 }, { category: 'replies', limit: 30, sort: 'mentions-first', queryVersion: 1 }, { category: 'unread', limit: 30, queryVersion: 1 }, + { notifications: true, limit: 100 }, ]); first(); host.methods.set('channelUnreadCounts', () => ({ diff --git a/packages/client/test/remote-notifications.test.ts b/packages/client/test/remote-notifications.test.ts index 2b781450f5..7568b52ca6 100644 --- a/packages/client/test/remote-notifications.test.ts +++ b/packages/client/test/remote-notifications.test.ts @@ -47,6 +47,7 @@ function fixture() { api: { permission: () => permission, requestPermission: request, show }, open, privateBody: () => 'Private input needed', + prepareEnable: async () => {}, }; return { notices: createRemoteNotifications(options), @@ -62,6 +63,92 @@ function fixture() { } describe('Remote Host browser alerts', () => { + it('refuses a timed-out enable without changing the persisted choice and allows a fresh retry', async () => { + vi.useFakeTimers(); + const f = fixture(); + f.permission('granted'); + let finish!: () => void; + const prepareEnable = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const notices = createRemoteNotifications({ ...f.options, prepareEnable }); + try { + const enabling = notices.set('a', { enabled: true }); + const refused = expect(enabling).rejects.toThrow('notification-baseline-timeout'); + await vi.advanceTimersByTimeAsync(10_000); + await refused; + finish(); + expect(notices.choice('a').enabled).toBe(false); + prepareEnable.mockResolvedValue(undefined); + await notices.set('a', { enabled: true }); + notices.update([remote('a', [])]); + notices.update([remote('a', [item('new')])]); + expect(f.show).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('cancels activation when permission is revoked, and never applies a late completion after disable', async () => { + const f = fixture(); + f.permission('granted'); + let finish!: () => void; + const prepareEnable = () => + new Promise((resolve) => { + finish = resolve; + }); + const notices = createRemoteNotifications({ ...f.options, prepareEnable }); + notices.update([remote('a', [])]); + const enabling = notices.set('a', { enabled: true }); + const refused = expect(enabling).rejects.toThrow('notification-activation-cancelled'); + f.permission('denied'); + notices.update([remote('a', [])]); + await refused; + finish(); + expect(notices.choice('a').enabled).toBe(false); + f.permission('granted'); + const again = notices.set('a', { enabled: true }); + const disabled = expect(again).rejects.toThrow('notification-activation-cancelled'); + await notices.set('a', { enabled: false }); + finish(); + await disabled; + expect(notices.choice('a').enabled).toBe(false); + }); + + it('does not replay a pending 35-mention scan when enabling replies, then alerts new group and DM replies', async () => { + const f = fixture(); + let finish!: () => void; + const prepareEnable = vi.fn(async () => {}); + const notices = createRemoteNotifications({ ...f.options, prepareEnable }); + f.permission('granted'); + await notices.set('a', { enabled: true, preferences: { reply: false } }); + notices.update([remote('a', [])]); + prepareEnable.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const enabling = notices.set('a', { preferences: { reply: true } }); + const old = Array.from({ length: 35 }, (_, index) => + item(`old-${index}`, 'channel-mention', 'same-time'), + ); + notices.update([remote('a', old)]); + expect(f.show).not.toHaveBeenCalled(); + finish(); + await enabling; + const fresh = [ + item('group', 'channel-reply', 'same-time'), + item('dm', 'channel-reply', 'same-time'), + ]; + notices.update([remote('a', [...old, ...fresh])]); + expect(f.show).toHaveBeenCalledTimes(2); + notices.update([remote('a', [...old, ...fresh])]); + expect(f.show).toHaveBeenCalledTimes(2); + }); it('retains dedupe and the complete baseline when a later notification page fails', async () => { const f = fixture(); await f.notices.set('a', { enabled: true }); @@ -192,9 +279,10 @@ describe('Remote Host browser alerts', () => { }), ); const enable = f.notices.set('a', { enabled: true }); + const refused = expect(enable).rejects.toThrow('notification-activation-cancelled'); await f.notices.set('a', { enabled: false }); resolve(); - await enable; + await refused; expect(f.notices.choice('a').enabled).toBe(false); }); }); From 86b2024133670d17b10a420639cda0e5eb3e0c87 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 03:06:52 +0900 Subject: [PATCH 08/18] fix: respect notification control deadlines and locale Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../features/remote-hosts.md | 118 +++++++++--------- docs/bot-cli.md | 2 +- docs/bot-cli.zh.md | 2 +- .../client/src/client/appearance-control.ts | 12 +- packages/client/src/client/index.ts | 2 + packages/client/src/client/locale.ts | 2 + packages/client/src/client/remote-hosts.ts | 12 +- .../client/src/client/remote-notifications.ts | 32 ++++- .../client/test/notification-control.test.ts | 106 ++++++++++++++++ packages/client/test/remote-hosts.test.ts | 44 +++++++ .../client/test/remote-notifications.test.ts | 48 +++++-- 11 files changed, 305 insertions(+), 75 deletions(-) create mode 100644 packages/client/test/notification-control.test.ts diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 28bb11446e..4509b1d1fd 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -1,58 +1,60 @@ -# Browser Remote Hosts - -The browser pairs a Remote Host as a Paired Device and reads its Host Feed through the shared mobile Host client. This Host remains the management context. - -## Sub-features - -- `pair`: Add remote redeems the other Host's pairing link. -- `tabs`: the icon strip filters All, This host and each Remote Host; tooltips name the Host, and Chinese uses 全部、本机、远程. -- `open`: a remote row opens a Host-scoped conversation. -- `avatars`: remote Bot portraits and group images/member stacks appear in rows and conversation headers; Bot message authors and live drafts use the same remote portrait. -- `activity-unread`: remote Bot rows display received activity; DM/group unread counts come from their Host Inbox. -- `read`: a visible remote conversation at its latest message marks that message read on its owning Host and refreshes the shared Inbox. -- `message-body`: remote images, files, rich text and request cards use the existing message body, with authenticated resources and decisions on the source Host. -- `upgrade`: existing browser pairings and the selected tab survive the storage-key rename. -- `remove`: removing a Host forgets its rows, stops its Inbox and releases cached avatar object URLs. -- `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) - -Enter Bot mode, then Messages. Open the channel header's add menu and choose Add remote. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host icon, then a Bot or group row. Right-click the Host icon and choose Remove to remove it. A remote Bot DM with Browser or Computer access shows the corresponding header button. Open its viewer, choose Pause Bot and take control, interact with the screen or send text, then Resume Bot or Close viewer. Groups have no viewer entry. - -## Driving it with verify-app - -Launch two independently named instances with `node scripts/verify-app.mjs launch --name `. Run `doctor` for each. Open both local login URLs. Pair from Host A's browser using the link created on Host B. Verify the new tab, All rows, remote conversation header and canonical device record on Host B. Capture tabs and the Add remote dialog in English and Chinese, light and dark. For the rename baseline, run the pre-change Client with the same browser storage, upgrade, and verify the pairing and selected Host remain without another offer. - -Read the browser console and run `node scripts/verify-app.mjs observe --name ` after startup and relevant actions. For a reply test select `deepseek-v4-pro`, send a uniquely marked message and compare the visible response to Host B's canonical timeline. Inspect the remote timeline in reverse DOM order: newest is first. - -For avatars, give Host B one uploaded Bot image, one seeded or composed Bot, a group with those members and a group with an uploaded image. Open each from All and the Host icon. Compare the row, header and Bot message author with Host B's canonical roster and timeline. A direct `bot-tool channel_send` needs an existing trusted Source Event in that Channel; seed a Human group message with the members' wake policies set to silent before the direct call. Inspect the browser's device-authorized avatar GET and CORS response, the loaded object-URL image dimensions and both themes. Equal slugs on Host A and Host B must retain Host B's portrait, including group member stacks. Change Host B's avatar and verify the visible portrait updates; remove the Remote Host and verify its rows and portraits disappear. Streaming draft authors also use the portrait; mark the live-model draft path untested unless driven with the specified model. - -For viewers, start a disposable Browser and Computer on Host B through the existing CLI. Open a harmless fixture in each; capture the actual remote frame in light and dark, take control and verify a click/text change against `browser-observation` / `browser-frame` or `computer-frame`. Confirm Host A is untouched, and inspect the gateway requests: frame/input HTTP uses the device header without cookies; the Host Feed mux uses a one-time stream ticket. Close during takeover, switch between same-slug Bots on different Hosts, stop/restart Host B, and revoke the browser's device credential. Frames and input must stop after disposal/revocation; reconnect must not replay input. Preserve actual missing runtime prerequisites as blockers rather than replacing the frame with a fixture response. The Computer may use an isolated container; this does not qualify the Human's local desktop. - -For control races, delay an input response, queue text, Resume, then take control again before completing the first response: the old queued text must not arrive. Interrupt only frame requests while the Feed remains online; recovered frames must be read-only and require another takeover. Delay a takeover response after the Host accepts it, then Remove the Host: input stops immediately and release must precede unpair. If cleanup cannot finish within five seconds, removal continues; Browser may require the Host operator to resume it. - -Keep the remote screen stationary during frame recovery. The Host must keep its sequence unchanged and answer matching `after` polls with 204 after ten seconds; the viewer recovers by fetching the current frame without that stale cursor, not by requiring a screen change. In both UI languages, inject a frame/input failure: the Web panel must show localized recovery guidance and a reconnect action, while the viewer snapshot retains the original diagnostic notice rather than rendering it as product copy. - -For Remote settings, use two disposable Hosts with different home basenames and distinguishable model presets and chat data. Open Host B's icon menu in Host A's browser. Capture the menu and all three settings sections in light and dark. Change B's default model or a preset, then read both Hosts with `model-catalog` and `model-presets`; A must be unchanged. Compare Usage and Memory with B's `overview-usage` and `overview-memory`. In Storage, select a disposable chat, preview media or whole messages, verify Clear/Delete is disabled before acknowledgement, then confirm. Read B's storage and timeline and verify A's data is unchanged. Exercise both cleanup targets; use their matching CLI preview/confirm commands against B as well. Disconnect B and revoke its browser pairing in separate runs: settings must show an unavailable state or refusal, with no local data fallback or replayed write. Never clean up real conversations for this journey. - -The default-model proof must change the model value on a Profile whose native settings owner can save it; a same-value no-op is insufficient. Keep a page's observed revision, change the default from another client, and verify the old page refuses without overwriting it. Explicit refresh must permit a subsequent save. Omit the catalog revision at the test transport boundary to verify the shared client refuses before any write; label that case as fault injection. For existing presets, verify Enter plus blur increments the revision once, a later blur saves independently, composition Enter does not submit, and a delayed write remains on its original Host/preset after closing and switching Hosts. Preserve the draft/error on a rejected save. New presets retain an explicit create action. Reject a remote storage preview token on the other Host, and verify neither cleanup command proceeds without its confirmation flag. - -For message bodies, upload an image and a text file to Host B through its attachment API and send them in a disposable Channel. Open that Channel from Host A: the image may load immediately, but files and audio must not fetch until Download or Play is selected. Repeated pending clicks must share one request; downloading audio must not start playback. Verify that GET requests use Host B's device gateway. Switch source or close during a delayed response and verify that it cannot trigger a download or playback afterward. Refuse or remove source access and confirm the conversation retains its title and usable Close control without a timeline, composer or viewer. Request a real tool approval with `deepseek-v4-pro`, decide its card from Host A, and compare Host B's decision record with the unchanged Host A history. Capture the same conversation in light and dark before and after the renderer change. Switch between Hosts and Channels to verify draft/card isolation and media cleanup. A revoked device must never load an attachment through Host A instead. - -## Gotchas - -- The workspace hides `data-panel-empty-action` descendants. Viewer reconnect must stay in its own controls; verify the button is visible and clickable inside the real workspace, not only in a standalone panel render. -- Remote avatars consume the authenticated image cache; This Host retains its current read adapter. -- With unread messages on both Hosts, compare each row and tab with `channelUnreadCounts`. Open the remote DM/group, verify its count clears on that Host while the other Host stays unread. Keep an active Bot running to inspect its activity indicator. Stop only the task-owned remote Host and verify cached rows/counts remain with an offline tab. -- Activity timestamps are receipt times; they are not authoritative tool start times. -- The remote message renderer replaces the earlier text-and-attachment-names-only view. Qualify images and cards through the two-Host path above; test results remain separate from Human acceptance. -- 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. +# Browser Remote Hosts + +The browser pairs a Remote Host as a Paired Device and reads its Host Feed through the shared mobile Host client. This Host remains the management context. + +## Sub-features + +- `pair`: Add remote redeems the other Host's pairing link. +- `tabs`: the icon strip filters All, This host and each Remote Host; tooltips name the Host, and Chinese uses 全部、本机、远程. +- `open`: a remote row opens a Host-scoped conversation. +- `avatars`: remote Bot portraits and group images/member stacks appear in rows and conversation headers; Bot message authors and live drafts use the same remote portrait. +- `activity-unread`: remote Bot rows display received activity; DM/group unread counts come from their Host Inbox. +- `read`: a visible remote conversation at its latest message marks that message read on its owning Host and refreshes the shared Inbox. +- `message-body`: remote images, files, rich text and request cards use the existing message body, with authenticated resources and decisions on the source Host. +- `upgrade`: existing browser pairings and the selected tab survive the storage-key rename. +- `remove`: removing a Host forgets its rows, stops its Inbox and releases cached avatar object URLs. +- `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) + +Enter Bot mode, then Messages. Open the channel header's add menu and choose Add remote. Paste a link from the other Host's Settings → Devices → Pair a phone. Choose All or the Remote Host icon, then a Bot or group row. Right-click the Host icon and choose Remove to remove it. A remote Bot DM with Browser or Computer access shows the corresponding header button. Open its viewer, choose Pause Bot and take control, interact with the screen or send text, then Resume Bot or Close viewer. Groups have no viewer entry. + +## Driving it with verify-app + +Launch two independently named instances with `node scripts/verify-app.mjs launch --name `. Run `doctor` for each. Open both local login URLs. Pair from Host A's browser using the link created on Host B. Verify the new tab, All rows, remote conversation header and canonical device record on Host B. Capture tabs and the Add remote dialog in English and Chinese, light and dark. For the rename baseline, run the pre-change Client with the same browser storage, upgrade, and verify the pairing and selected Host remain without another offer. + +Read the browser console and run `node scripts/verify-app.mjs observe --name ` after startup and relevant actions. For a reply test select `deepseek-v4-pro`, send a uniquely marked message and compare the visible response to Host B's canonical timeline. Inspect the remote timeline in reverse DOM order: newest is first. + +For avatars, give Host B one uploaded Bot image, one seeded or composed Bot, a group with those members and a group with an uploaded image. Open each from All and the Host icon. Compare the row, header and Bot message author with Host B's canonical roster and timeline. A direct `bot-tool channel_send` needs an existing trusted Source Event in that Channel; seed a Human group message with the members' wake policies set to silent before the direct call. Inspect the browser's device-authorized avatar GET and CORS response, the loaded object-URL image dimensions and both themes. Equal slugs on Host A and Host B must retain Host B's portrait, including group member stacks. Change Host B's avatar and verify the visible portrait updates; remove the Remote Host and verify its rows and portraits disappear. Streaming draft authors also use the portrait; mark the live-model draft path untested unless driven with the specified model. + +For viewers, start a disposable Browser and Computer on Host B through the existing CLI. Open a harmless fixture in each; capture the actual remote frame in light and dark, take control and verify a click/text change against `browser-observation` / `browser-frame` or `computer-frame`. Confirm Host A is untouched, and inspect the gateway requests: frame/input HTTP uses the device header without cookies; the Host Feed mux uses a one-time stream ticket. Close during takeover, switch between same-slug Bots on different Hosts, stop/restart Host B, and revoke the browser's device credential. Frames and input must stop after disposal/revocation; reconnect must not replay input. Preserve actual missing runtime prerequisites as blockers rather than replacing the frame with a fixture response. The Computer may use an isolated container; this does not qualify the Human's local desktop. + +For control races, delay an input response, queue text, Resume, then take control again before completing the first response: the old queued text must not arrive. Interrupt only frame requests while the Feed remains online; recovered frames must be read-only and require another takeover. Delay a takeover response after the Host accepts it, then Remove the Host: input stops immediately and release must precede unpair. If cleanup cannot finish within five seconds, removal continues; Browser may require the Host operator to resume it. + +Keep the remote screen stationary during frame recovery. The Host must keep its sequence unchanged and answer matching `after` polls with 204 after ten seconds; the viewer recovers by fetching the current frame without that stale cursor, not by requiring a screen change. In both UI languages, inject a frame/input failure: the Web panel must show localized recovery guidance and a reconnect action, while the viewer snapshot retains the original diagnostic notice rather than rendering it as product copy. + +For Remote settings, use two disposable Hosts with different home basenames and distinguishable model presets and chat data. Open Host B's icon menu in Host A's browser. Capture the menu and all three settings sections in light and dark. Change B's default model or a preset, then read both Hosts with `model-catalog` and `model-presets`; A must be unchanged. Compare Usage and Memory with B's `overview-usage` and `overview-memory`. In Storage, select a disposable chat, preview media or whole messages, verify Clear/Delete is disabled before acknowledgement, then confirm. Read B's storage and timeline and verify A's data is unchanged. Exercise both cleanup targets; use their matching CLI preview/confirm commands against B as well. Disconnect B and revoke its browser pairing in separate runs: settings must show an unavailable state or refusal, with no local data fallback or replayed write. Never clean up real conversations for this journey. + +The default-model proof must change the model value on a Profile whose native settings owner can save it; a same-value no-op is insufficient. Keep a page's observed revision, change the default from another client, and verify the old page refuses without overwriting it. Explicit refresh must permit a subsequent save. Omit the catalog revision at the test transport boundary to verify the shared client refuses before any write; label that case as fault injection. For existing presets, verify Enter plus blur increments the revision once, a later blur saves independently, composition Enter does not submit, and a delayed write remains on its original Host/preset after closing and switching Hosts. Preserve the draft/error on a rejected save. New presets retain an explicit create action. Reject a remote storage preview token on the other Host, and verify neither cleanup command proceeds without its confirmation flag. + +For message bodies, upload an image and a text file to Host B through its attachment API and send them in a disposable Channel. Open that Channel from Host A: the image may load immediately, but files and audio must not fetch until Download or Play is selected. Repeated pending clicks must share one request; downloading audio must not start playback. Verify that GET requests use Host B's device gateway. Switch source or close during a delayed response and verify that it cannot trigger a download or playback afterward. Refuse or remove source access and confirm the conversation retains its title and usable Close control without a timeline, composer or viewer. Request a real tool approval with `deepseek-v4-pro`, decide its card from Host A, and compare Host B's decision record with the unchanged Host A history. Capture the same conversation in light and dark before and after the renderer change. Switch between Hosts and Channels to verify draft/card isolation and media cleanup. A revoked device must never load an attachment through Host A instead. + +## Gotchas + +- The workspace hides `data-panel-empty-action` descendants. Viewer reconnect must stay in its own controls; verify the button is visible and clickable inside the real workspace, not only in a standalone panel render. +- Remote avatars consume the authenticated image cache; This Host retains its current read adapter. +- With unread messages on both Hosts, compare each row and tab with `channelUnreadCounts`. Open the remote DM/group, verify its count clears on that Host while the other Host stays unread. Keep an active Bot running to inspect its activity indicator. Stop only the task-owned remote Host and verify cached rows/counts remain with an offline tab. +- Activity timestamps are receipt times; they are not authoritative tool start times. +- The remote message renderer replaces the earlier text-and-attachment-names-only view. Qualify images and cards through the two-Host path above; test results remain separate from Human acceptance. +- 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. diff --git a/docs/bot-cli.md b/docs/bot-cli.md index e89995cd2e..204cf2a2f9 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -6,7 +6,7 @@ Bot summaries from `list` also include `lastActivityAt` (epoch milliseconds) whe ## Usage -`deepseekbot browser-notification-clients` lists online Client targets. `deepseekbot browser-notifications --client-id --remote-host-id ` reads that browser's local choice and revision. To write, add `--expected-revision --patch-stdin` and pipe `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`. These commands use the serving Host's usual online authentication and existing Client control relay; the Host persists no browser choice or Remote Host credential. Writes refuse stale revisions and unknown Hosts. Enabling requires permission already granted in the browser (`notification-permission-required` otherwise); CLI never opens the OS permission prompt. Closing the page ends both its control target and alerts. Enabling a previously inactive kind first refreshes the shared Inbox through every notification page, keeping the old choice until that complete baseline is ready. The successful write is the enable boundary: earlier items stay silent and later group/DM replies can alert. A failed page returns `notification-baseline-unavailable`; a ten-second deadline returns `notification-baseline-timeout`. Switching Host, closing settings, removing the pairing, losing permission or releasing the page cancels pending activation (`notification-activation-cancelled`); it cannot persist later. Retry explicitly after a refusal. Normal shared Host Feed observation continues while the page remains subscribed. +`deepseekbot browser-notification-clients` lists online Client targets. `deepseekbot browser-notifications --client-id --remote-host-id ` reads that browser's local choice and revision. To write, add `--expected-revision --patch-stdin` and pipe `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`. These commands use the serving Host's usual online authentication and existing Client control relay; the Host persists no browser choice or Remote Host credential. Writes refuse stale revisions and unknown Hosts. Enabling requires permission already granted in the browser (`notification-permission-required` otherwise); CLI never opens the OS permission prompt. Closing the page ends both its control target and alerts. Enabling a previously inactive kind first refreshes the shared Inbox through every notification page, keeping the old choice until that complete baseline is ready. The successful write is the enable boundary: earlier items stay silent and later group/DM replies can alert. A failed page returns `notification-baseline-unavailable`; a ten-second UI baseline deadline returns `notification-baseline-timeout`. CLI writes also carry the existing appearance relay deadline (currently eight seconds) and its cancellation signal to the same owner. The owner checks both immediately before persistence; a relay timeout or unmount cannot be followed by a late write, and an expired notification command sends no late acknowledgement. Switching Host, closing settings, removing the pairing, losing permission or releasing the page cancels pending activation (`notification-activation-cancelled`); it cannot persist later. Retry explicitly after a refusal. Normal shared Host Feed observation continues while the page remains subscribed. `deepseekbot phone-notifications --metro-port --target-id --device-name ` reads the existing phone preference owner in an assigned Expo development client. Add `--preferences-stdin` with exactly the four boolean kinds to persist through that same owner and copy to every paired Host. It requires the shared-device batch/lease, a running loopback Metro inspector, an exact unique target, the development app ID, `__DEV__`, and the uniquely initialized owner. It never writes storage directly, creates another owner, enables OS permission, or treats Host copies as the phone choice. One JSON result distinguishes `saved` from `syncStatus` (`complete`/`pending`), `syncing`, and `failedHostIds`. A disconnected/timed-out write has an unknown outcome: read again before repeating it. Missing/ambiguous targets, release clients, unready owners and transport failures return coded refusals. **This development-only AX path is unavailable in release clients; it is not release CLI parity or completed native QA.** Native real runs remain in the external combined acceptance batch. diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 6125b7e9b2..03e5ac4373 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -6,7 +6,7 @@ ## 用法 -`deepseekbot browser-notification-clients` 列出在线 Client。`deepseekbot browser-notifications --client-id --remote-host-id ` 读取该浏览器的本地选择和 revision。写入时加 `--expected-revision --patch-stdin`,从标准输入传入 `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`。命令使用提供页面的 Host 的现有认证及 Client 定向控制通道;Host 不保存浏览器选择或远程凭据。版本过期或远程不存在时明确拒绝。开启前须已在浏览器授予通知权限,否则返回 `notification-permission-required`;CLI 不弹出系统授权。页面关闭后,控制目标和提醒都会结束。 开启原先未启用的类型时,先通过共享 Inbox 读完全部通知分页,完整基线就绪前保持旧选择;写入成功才是启用边界,此前条目静默,此后的群回复和 DM 可提醒。部分页失败返回 `notification-baseline-unavailable`,十秒期限返回 `notification-baseline-timeout`。切换 Host、关闭设置、移除配对、权限丢失或释放页面会取消待完成的启用(`notification-activation-cancelled`),迟到结果不能落盘。拒绝后显式重试;页面仍订阅时,正常共享 Host Feed 观察继续。 +`deepseekbot browser-notification-clients` 列出在线 Client。`deepseekbot browser-notifications --client-id --remote-host-id ` 读取该浏览器的本地选择和 revision。写入时加 `--expected-revision --patch-stdin`,从标准输入传入 `{"enabled":true,"preferences":{"approval":true,"question":true,"private-input":true,"reply":false}}`。命令使用提供页面的 Host 的现有认证及 Client 定向控制通道;Host 不保存浏览器选择或远程凭据。版本过期或远程不存在时明确拒绝。开启前须已在浏览器授予通知权限,否则返回 `notification-permission-required`;CLI 不弹出系统授权。页面关闭后,控制目标和提醒都会结束。 开启原先未启用的类型时,先通过共享 Inbox 读完全部通知分页,完整基线就绪前保持旧选择;写入成功才是启用边界,此前条目静默,此后的群回复和 DM 可提醒。部分页失败返回 `notification-baseline-unavailable`,UI 基线的十秒期限返回 `notification-baseline-timeout`。CLI 写入还将既有 appearance relay 的请求期限(当前八秒)和取消信号传到同一 owner,并在落盘前再次检查;relay 超时或卸载后,迟到分页不能写入,已过期的通知命令不发送迟到回执。切换 Host、关闭设置、移除配对、权限丢失或释放页面会取消待完成的启用(`notification-activation-cancelled`),迟到结果不能落盘。拒绝后显式重试;页面仍订阅时,正常共享 Host Feed 观察继续。 `deepseekbot phone-notifications --metro-port <端口> --target-id <精确ID> --device-name <精确名称>` 在已分配的 Expo development client 中读取现有手机偏好 owner。加 `--preferences-stdin`,仅传入四类布尔值,由同一 owner 持久化并复制到全部已配对 Host。前提是共享设备 batch/lease、运行中的 loopback Metro inspector、精确且唯一的目标、开发版 app ID、`__DEV__` 和唯一已初始化的 owner。命令不直接写存储、不新建 owner、不弹系统授权、不把 Host 副本当手机选择。单个 JSON 分别返回 `saved`、`syncStatus`(`complete`/`pending`)、`syncing` 和 `failedHostIds`。断开/超时后的写入结果未知,应先重新读取再决定是否重试。目标缺失/歧义、release、owner 未就绪及传输失败均明确拒绝。**该 AX 路径仅限 development client,release 不可用,也不代表 release CLI parity 或原生 QA 已通过。** 原生真实运行仍交外部统一批次验收。 diff --git a/packages/client/src/client/appearance-control.ts b/packages/client/src/client/appearance-control.ts index 4bcf82f22d..9dfc4b33a4 100644 --- a/packages/client/src/client/appearance-control.ts +++ b/packages/client/src/client/appearance-control.ts @@ -48,7 +48,10 @@ export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper) ) throw new AppearanceError('notification-host-unknown'); try { - result = await sources.notifications.control(input, command.expectedRevision); + result = await sources.notifications.control(input, command.expectedRevision, { + expiresAt: command.expiresAt, + signal: abort.signal, + }); } catch (error) { throw new AppearanceError( error instanceof Error ? error.message : 'notification-client-failed', @@ -93,6 +96,13 @@ export function mountAppearanceControl(owner: Appearance, wallpaper?: Wallpaper) }, }; } + if (abort.signal.aborted) return; + if ( + command.notifications !== undefined && + typeof command.expiresAt === 'number' && + Date.now() >= command.expiresAt + ) + continue; await post({ action: 'reply', ...registration, requestId: command.id, result }); } } catch { diff --git a/packages/client/src/client/index.ts b/packages/client/src/client/index.ts index 2ecb0d7cd9..1eb1d09f5d 100644 --- a/packages/client/src/client/index.ts +++ b/packages/client/src/client/index.ts @@ -10,6 +10,7 @@ import { refreshBotPrimaryColors } from './bot-primary-color.js'; import { Appearance } from './appearance.js'; import { mountAppearance } from './appearance-scope.js'; import { mountAppearanceControl } from './appearance-control.js'; +import { remoteHosts } from './remote-hosts.js'; import { bindBotAppearance } from './bot-color-scheme.js'; import type {} from '@deepseek-ai/dsh-client-ui-theme/client'; import { AppearanceSection } from './appearance-settings.js'; @@ -233,6 +234,7 @@ export function apply(ctx: ClientContext): void { ? undefined : takeHmrView(window as unknown as Record); const t = ctx.locale.bind(LOCALE_NS); + remoteHosts(t); const zoom = mountWindowZoom({ document: typeof document === 'undefined' ? undefined : document, window: typeof window === 'undefined' ? undefined : window, diff --git a/packages/client/src/client/locale.ts b/packages/client/src/client/locale.ts index 9e238b736f..188de7f9eb 100644 --- a/packages/client/src/client/locale.ts +++ b/packages/client/src/client/locale.ts @@ -3954,6 +3954,7 @@ export const zh = { 'remoteHosts.notifications.approval': '工具审批', 'remoteHosts.notifications.question': '问题', 'remoteHosts.notifications.private': '私密输入请求', + 'remoteHosts.notifications.privateBody': '需要你提供私密输入', 'remoteHosts.notifications.reply': '提及和回复', 'remoteHosts.notifications.failed': '无法保存通知设置,请重试。', 'remoteHosts.notifications.denied': '请在浏览器的网站设置中允许通知。', @@ -8339,6 +8340,7 @@ My relationship with you, established lore and topics to avoid: [fill in]. 'remoteHosts.notifications.approval': 'Tool approvals', 'remoteHosts.notifications.question': 'Questions', 'remoteHosts.notifications.private': 'Private input requests', + 'remoteHosts.notifications.privateBody': 'Needs private input from you', 'remoteHosts.notifications.reply': 'Mentions & replies', 'remoteHosts.notifications.failed': 'Could not save notification settings. Try again.', 'remoteHosts.notifications.denied': 'Allow notifications in your browser’s site settings.', diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index c0c6bca3de..6d0db9936e 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -29,6 +29,7 @@ import { createRemoteActivityProjection, type RemoteBotActivity } from './remote import { releaseRemoteHostViewers } from './remote-host-viewer.js'; import { browserNotificationApi } from './desktop-notifications.js'; import { createRemoteNotifications, type RemoteNotifications } from './remote-notifications.js'; +import { en, type BotHarnessTranslate } from './locale.js'; export const REMOTE_HOSTS_STORAGE_KEY = 'botharness.remoteHosts.v1'; export const HOST_TAB_STORAGE_KEY = 'botharness.hostTab.v1'; @@ -136,6 +137,7 @@ export function createRemoteHosts(options: { carrier: HostCarrier; loadImage?: (url: string, headers: Record) => Promise; releaseImage?: (image: string) => void; + translate?: BotHarnessTranslate; }): RemoteHosts { const { storage, carrier, loadImage, releaseImage } = options; const listeners = new Set<() => void>(); @@ -165,9 +167,8 @@ export function createRemoteHosts(options: { throw new Error('notification-activation-cancelled'); }, privateBody: () => - document.documentElement.lang.startsWith('zh') - ? '需要你提供私密输入' - : 'Needs private input from you', + options.translate?.('remoteHosts.notifications.privateBody') ?? + en['remoteHosts.notifications.privateBody'], open(target) { globalThis.window?.focus(); open = target; @@ -473,8 +474,10 @@ async function browserImage( } let shared: RemoteHosts | undefined; +let translate: BotHarnessTranslate = (key) => en[key]; -export function remoteHosts(): RemoteHosts { +export function remoteHosts(translator?: BotHarnessTranslate): RemoteHosts { + if (translator !== undefined) translate = translator; if (shared !== undefined) return shared; shared = createRemoteHosts({ storage: (() => { @@ -487,6 +490,7 @@ export function remoteHosts(): RemoteHosts { carrier: browserCarrier(), loadImage: browserImage, releaseImage: (image) => URL.revokeObjectURL(image), + translate: (key, params) => translate(key, params), }); const sources = shared; let selection = store.getSnapshot().selection; diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts index d01e3fcc79..210a6a2894 100644 --- a/packages/client/src/client/remote-notifications.ts +++ b/packages/client/src/client/remote-notifications.ts @@ -18,6 +18,13 @@ import { export const REMOTE_NOTIFICATION_KEY = 'botharness.remoteNotifications.v1'; type Choice = { enabled: boolean; preferences: PushPreferences }; +type ControlRequest = { expiresAt: number; signal: AbortSignal }; +function checkRequest(request?: ControlRequest): void { + if (request === undefined) return; + if (request.signal.aborted) throw new Error('notification-activation-cancelled'); + if (!Number.isFinite(request.expiresAt) || Date.now() >= request.expiresAt) + throw new Error('appearance-request-stale'); +} type Snapshot = { hosts: Readonly>; permission: DesktopNotificationPermission; @@ -122,9 +129,20 @@ export function createRemoteNotifications(options: { async set( hostId: string, patch: { enabled?: boolean; preferences?: Partial }, + request?: ControlRequest, ) { + checkRequest(request); cancel(hostId); const controller = new AbortController(); + const abortRequest = () => controller.abort(new Error('notification-activation-cancelled')); + request?.signal.addEventListener('abort', abortRequest, { once: true }); + const deadline = + request === undefined + ? undefined + : setTimeout( + () => controller.abort(new Error('appearance-request-stale')), + Math.max(0, request.expiresAt - Date.now()), + ); pending.set(hostId, controller); const revision = (revisions.get(hostId) ?? 0) + 1; revisions.set(hostId, revision); @@ -162,16 +180,25 @@ export function createRemoteNotifications(options: { controller.signal.throwIfAborted(); if (api?.permission() !== 'granted') throw new Error('notification-permission-required'); } + checkRequest(request); + controller.signal.throwIfAborted(); const updated = { ...hosts, [hostId]: next }; storage?.setItem(REMOTE_NOTIFICATION_KEY, JSON.stringify(updated)); hosts = updated; close(hostId); publish(); } finally { + clearTimeout(deadline); + request?.signal.removeEventListener('abort', abortRequest); if (pending.get(hostId) === controller) pending.delete(hostId); } }, - async control(input: Record, expectedRevision: unknown) { + async control( + input: Record, + expectedRevision: unknown, + request: ControlRequest, + ) { + checkRequest(request); const hostId = input.hostId as string; const current = choice(hostId); if (input.patch !== undefined) { @@ -183,8 +210,9 @@ export function createRemoteNotifications(options: { throw new Error('notification-invalid-input'); if (patch.enabled && api?.permission() !== 'granted') throw new Error('notification-permission-required'); - await owner.set(hostId, { enabled: patch.enabled, preferences }); + await owner.set(hostId, { enabled: patch.enabled, preferences }, request); } + checkRequest(request); return { hostId, ...choice(hostId), diff --git a/packages/client/test/notification-control.test.ts b/packages/client/test/notification-control.test.ts new file mode 100644 index 0000000000..00542c21a6 --- /dev/null +++ b/packages/client/test/notification-control.test.ts @@ -0,0 +1,106 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { createAppearanceControl } from '../../core/src/appearance/control.js'; +import { Appearance } from '../src/client/appearance.js'; +import { mountAppearanceControl } from '../src/client/appearance-control.js'; +import { createRemoteNotifications } from '../src/client/remote-notifications.js'; + +const shared = vi.hoisted(() => new Map()); +vi.mock('../src/client/remote-hosts.js', () => ({ remoteHosts: () => shared.get('sources') })); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + shared.clear(); +}); + +it.each(['deadline', 'unmount', 'on-time'] as const)( + 'enforces the relay %s boundary on browser preferences and storage', + async (reason) => { + vi.useFakeTimers(); + const host = createAppearanceControl(); + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + }; + let finish!: () => void; + const prepareEnable = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const notices = createRemoteNotifications({ + storage, + api: { permission: () => 'granted', requestPermission: async () => 'granted', show: vi.fn() }, + open() {}, + privateBody: () => 'private', + prepareEnable, + }); + shared.set('sources', { + notifications: notices, + getSnapshot: () => ({ hosts: [{ host: { hostId: 'remote' } }] }), + }); + const actions: string[] = []; + vi.stubGlobal('location', { protocol: 'http:' }); + vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => { + actions.push(JSON.parse(String(init?.body)).action); + return host.fetch(new Request('http://localhost/api/botharness/appearance-control', init)); + }); + const release = mountAppearanceControl(new Appearance(undefined, () => 'light')); + try { + await vi.waitFor(() => expect(actions).toContain('poll')); + const { clients } = await (await host.fetch(new Request('http://localhost'))).json(); + const before = notices.choice('remote'); + const response = host.fetch( + new Request('http://localhost/api/botharness/appearance-control', { + method: 'POST', + body: JSON.stringify({ + action: 'request', + clientId: clients[0].id, + expectedRevision: JSON.stringify(before), + notifications: { + hostId: 'remote', + patch: { enabled: true, preferences: before.preferences }, + }, + }), + }), + ); + await vi.waitFor(() => expect(prepareEnable).toHaveBeenCalledTimes(1)); + if (reason === 'on-time') { + await vi.advanceTimersByTimeAsync(7000); + finish(); + await vi.advanceTimersByTimeAsync(0); + expect(await (await response).json()).toMatchObject({ enabled: true }); + expect(notices.choice('remote').enabled).toBe(true); + expect( + JSON.parse(data.get('botharness.remoteNotifications.v1') ?? '{}').remote.enabled, + ).toBe(true); + expect(actions.filter((action) => action === 'reply')).toEqual(['reply']); + return; + } + if (reason === 'unmount') release(); + await vi.advanceTimersByTimeAsync(8000); + expect(await (await response).json()).toMatchObject({ + error: { + code: reason === 'deadline' ? 'appearance-client-timeout' : 'appearance-client-stale', + }, + }); + await vi.advanceTimersByTimeAsync(1000); + finish(); + await vi.advanceTimersByTimeAsync(0); + expect(notices.choice('remote')).toEqual(before); + expect(data.size).toBe(0); + expect(actions.filter((action) => action === 'reply')).toEqual([]); + } finally { + release(); + notices.stop(); + host.dispose(); + } + }, +); diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index 03c949ba67..9537a4cf60 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -5,6 +5,7 @@ import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; import { createHostActivityActions } from '../src/client/host-activity-actions.js'; import type { HumanAttentionItem } from '../src/client/store.js'; import { pairingLink } from '../../core/src/devices/http.js'; +import { en, zh, type BotHarnessTranslate } from '../src/client/locale.js'; import { ALL_HOSTS, HOST_TAB_STORAGE_KEY, @@ -244,6 +245,49 @@ describe('Host Feed lifecycle', () => { expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ unread: { 'dm-ada': 2 } }); }); + it('uses the bound translator for private notification bodies after the locale changes', async () => { + const show = vi.fn(); + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + onclick = null; + close() {} + constructor(title: string, options: unknown) { + show(title, options); + } + }, + }); + vi.stubGlobal('document', { documentElement: { lang: 'en' } }); + let dictionary = en; + const translate: BotHarnessTranslate = (key) => dictionary[key]; + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage, translate }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { enabled: true }); + dictionary = zh; + host.methods.set('humanAttention', (args) => + args.notifications + ? { + notificationVersion: 1, + items: [ + { + id: 'private', + kind: 'private-input', + botSlug: 'ada', + createdAt: 'now', + summary: 'must not appear', + }, + ], + } + : { queryVersion: 1, items: [] }, + ); + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(1)); + expect(show.mock.calls[0]?.[1]).toMatchObject({ + body: zh['remoteHosts.notifications.privateBody'], + }); + }); it('establishes an enable boundary through the shared paginated Inbox despite concurrent live frames and page failure', async () => { const show = vi.fn(); vi.stubGlobal('window', { diff --git a/packages/client/test/remote-notifications.test.ts b/packages/client/test/remote-notifications.test.ts index 7568b52ca6..14f4218d5b 100644 --- a/packages/client/test/remote-notifications.test.ts +++ b/packages/client/test/remote-notifications.test.ts @@ -63,6 +63,37 @@ function fixture() { } describe('Remote Host browser alerts', () => { + it('rechecks the relay deadline at commit even when its timer has not run', async () => { + vi.useFakeTimers(); + const f = fixture(); + f.permission('granted'); + const expiresAt = Date.now() + 8000; + const notices = createRemoteNotifications({ + ...f.options, + prepareEnable: async () => { + vi.setSystemTime(expiresAt); + }, + }); + const before = notices.choice('a'); + const saved = f.options.storage.getItem('botharness.remoteNotifications.v1'); + try { + await expect( + notices.control( + { + hostId: 'a', + patch: { enabled: true, preferences: before.preferences }, + }, + JSON.stringify(before), + { expiresAt, signal: new AbortController().signal }, + ), + ).rejects.toThrow('appearance-request-stale'); + expect(notices.choice('a')).toEqual(before); + expect(f.options.storage.getItem('botharness.remoteNotifications.v1')).toBe(saved); + } finally { + vi.useRealTimers(); + } + }); + it('refuses a timed-out enable without changing the persisted choice and allows a fresh retry', async () => { vi.useFakeTimers(); const f = fixture(); @@ -210,18 +241,19 @@ describe('Remote Host browser alerts', () => { }); it('CLI uses the same choice with revision checks and never prompts for permission', async () => { const f = fixture(); - const before = await f.notices.control({ hostId: 'a' }, undefined); + const request = { expiresAt: Date.now() + 8000, signal: new AbortController().signal }; + const before = await f.notices.control({ hostId: 'a' }, undefined, request); const patch = { enabled: true, preferences: before.preferences }; - await expect(f.notices.control({ hostId: 'a', patch }, before.revision)).rejects.toThrow( - 'notification-permission-required', - ); + await expect( + f.notices.control({ hostId: 'a', patch }, before.revision, request), + ).rejects.toThrow('notification-permission-required'); expect(f.request).not.toHaveBeenCalled(); f.permission('granted'); - const after = await f.notices.control({ hostId: 'a', patch }, before.revision); + const after = await f.notices.control({ hostId: 'a', patch }, before.revision, request); expect(after.enabled).toBe(true); - await expect(f.notices.control({ hostId: 'a', patch }, before.revision)).rejects.toThrow( - 'notification-revision-stale', - ); + await expect( + f.notices.control({ hostId: 'a', patch }, before.revision, request), + ).rejects.toThrow('notification-revision-stale'); }); it('requests permission only on enable, skips initial snapshots, dedupes and scopes identical IDs', async () => { const f = fixture(); From 56d7ffc5ed25085fb3206ad288920a60fdac2d18 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 03:24:20 +0900 Subject: [PATCH 09/18] fix: deduplicate remote attention by owner-scoped identity Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../features/remote-hosts.md | 2 + changes/2500-device-notifications.md | 4 +- docs/architecture/botharness-architecture.md | 2440 ++++++++--------- .../client/src/client/remote-notifications.ts | 15 +- packages/client/test/remote-hosts.test.ts | 115 +- .../client/test/remote-notifications.test.ts | 48 +- 6 files changed, 1384 insertions(+), 1240 deletions(-) diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 4509b1d1fd..034236a62f 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -58,3 +58,5 @@ For message bodies, upload an image and a text file to Host B through its attach 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. diff --git a/changes/2500-device-notifications.md b/changes/2500-device-notifications.md index 657730bd18..a8ee53f7b3 100644 --- a/changes/2500-device-notifications.md +++ b/changes/2500-device-notifications.md @@ -5,9 +5,9 @@ section: Changed ## en - Notification kinds now stay on the phone and sync to every paired Host, including after pairing again; each Remote Host in the browser has its own notification choices for alerts while the page is open ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). -- Browser discovery includes ordinary DM replies and replies beyond the Activity page; enabling waits for a complete baseline so pending old items stay silent. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Browser discovery includes ordinary DM replies and replies beyond the Activity page; enabling waits for a complete baseline so pending old items stay silent. Newly admitted attention is deduplicated by identity even when its Source Event is older; previously seen items do not replay after leaving and reentering attention. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). ## zh - 通知类型现在保存在手机,并同步到所有已配对 Host,重新配对后也会恢复;浏览器可为每个远程单独选择通知,仅在页面打开时提醒([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 -- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复,启用前等待完整基线,避免补发扫描中的旧条目;手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复,启用前等待完整基线,避免补发扫描中的旧条目。新进入 attention 的条目按身份去重,不因来源事件较早而漏报;已见条目离开后再进入也不重播。手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 82eb6e1ba0..18e0222e6c 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1,1220 +1,1220 @@ -# BotHarness 架构与数据流 - -#2080 第一条切片让 Web 请求卡与在线 CLI 共用 application-defined Workspace 请求批准命令。Grant owner 在 Channel owner 的现有提交事务内写入读写权限,规范决议与 Inbox Admission 同时提交;成功读回后再沿原运行时处理原任务。新目录与旧只读升级均需 Human 明确确认,不由 Client 串联多个独立成功动作。Orchestrator 与 Assignment 的原生文件写入都检查当前 Grant 写权限;Assignment 仍只使用其原 Grant 和单 cwd。普通目录设置、移动端交互和完整重启恢复矩阵仍按后续切片交付,不能从本次 Web/CLI 路径推定已完成。见 [ADR-0238](../adr/0238-workspace-request-approval-commits-write-authority-with-the-channel-decision.md)。 - -#2053 的本地 Group 发送复核试点由 Channel owner 在正式 Source Event / placement 追加的同一事务内比较实际观察依据。 -#2054 复用该 owner:held 标出实际返回的同伴正式消息;Bot 可引用这些消息显式让话,或提交独立补充。Host 只核对引用是否真实观察且仍有效,不判断语义重复,也不自动结清 Inbox;让话依据随现有草稿 JSON 保存。 -待复核草稿保存在现有 Operational Database,仅正式提交进入 Channel/Inbox;仅 drop 不解决 Inbox;显式 `resolve_inbox_ids` 可在 drop 成功后结清所选事项,过期或重启不自动恢复。 -原始依据与修订草稿归同一 owner,purge 清除派生内容。默认关闭,按群 opt-in;CLI 与模型使用同一契约。 -#2056 的显式恢复复用该记录:`review_action: inspect` 只返回当前授权下的有界元数据;重启后通过 `recover` 携带新观察与修正正文,保留原过期时间与预算。成功提交在同一事务留下消息 ID/意图指纹回执并清空草稿正文;重放只确认规范消息,不新增 Inbox 或恢复旧 Tool Promise。权限、来源与附件检查先于回执读回,详见 [ADR-0232](../adr/0232-group-recovery-retains-authorized-commit-receipts.md);本票真实模型验收仍在进行。 -组合两票时,复核记录保存观察所属 Host,避免跨重启比较进程内序号;新观察仍须覆盖全部必读消息并通过当前内容/附件指纹检查。恢复不重置版本、额度或已用的显式继续机会。显式继续与普通发送共享同一原子提交回执。 -重启重新准入本地群任务前,Host 复查当前成员资格;已撤权且未开始副作用的可重试 Admission 记为 Host 策略忽略,保留原因,不伪造 Bot 观察或完成结论。不确定副作用继续待修复。新的 Human DM 进入独立授权回合,原撤权任务的跨 Channel 发送 fence 保持有效。 -附件 owner 的有界当前内容摘要参与观察指纹,等长文件修改也会触发重新复核;摘要不进入公开消息,不改变附件身份,也不锁住外部文件写入。 -held 工具结果复用 Channel 的有界模型读取投影直接返回新增正文与显式观察凭据;仅完整返回的内容计入观察,省略内容仍需读取,后续提交仍在同一事务重查。其持久边界和保守取舍见 [ADR-0227](../adr/0227-group-send-review-shares-the-channel-commit.md),效果与 Human 验收仍由试点评估决定。 - -忙群最多进行两次复核,耗尽后明确 drop、既有 Inbox snooze 或一次显式继续申请(freshness_override)。显式继续仍原子核对当前消息;出现未读变化则拒绝本次申请,保留责任并延后,不能跳过新纠正。 -Override 绑定 owner、原 Group、当前草稿版本与实际观察;只越过自动复核次数上限,不越过当前消息检查,保留停止、权限、过期与 purge fence。 -耗尽预算时的 `suggestedSnoozeUntil` 是 Host 时间之后 30 秒的延后建议,复用现有 Inbox 调度,不自动安排唤醒;Human 的更晚时间、等待条件与取消约束仍优先。 -选择理由、观察与提交 ID 留在原草稿记录;不新增 wake scheduler 或公开系统消息。见 [ADR-0231](../adr/0231-busy-group-review-has-an-explicit-progress-boundary.md)。 - -Group composer 的 `/workspace` 和工作区 chip 复用 DM 的目录选择器与授权确认组件。默认选中当前未暂停、未删除的全部 Bot;Human 可以取消勾选,只授权指定几个。Group 只是逐 Bot 授权的入口,不持有权限;预览固定确认目标,后来加入的 Bot 不自动继承 Grant。共享目录预览相对于权限是只读的:native Workspace controller 可以登记、解析一个 DSH Workspace,但确认前不会建立任何 Workspace Grant。确认展示每个 Bot、canonical 目录、既有 Assignment mode 和 Orchestrator 写入状态;这些权限设置保持不变。逐 Bot 调用既有 `grantCreate`,可选的 application-defined Group scope 仅约束当前 Channel 成员与确认目录,并在异步 Workspace status 检查后、没有后续 await 的实际写入边界再次校验。SQLite `workspace_grants` 仍是唯一授权权威;无 Channel Workspace Binding、Group Grant 或统一 cwd。成功、有效 Grant 复用、拒绝和未知结果分别展示;失败/未知成员重试前通过同一个 `grants` readback 验证成员、撤销状态与当前目录,读回失败不发起写入。原侧栏与 chip 使用既有逐 Bot 变更通知刷新,正常委派仍通过原 Channel、Orchestrator 与 Assignment seam(#1433)。 - -#1386 已在 Human 授权的隔离 Linux Profile 中复验既有 QA2 PersonaBot:真实 Lark → Owner Web 多字段输入 → 既有审批 → 固定 CLI/MCP 引用认证 → 原私聊回帖;普通容器 CLI/MCP、真实 Spill、跨 Bot 拒绝、轮换/撤销及停止活跃使用、重启与自然到期 Schedule 均保留 native/canonical 证据。模型 wire 正文及运行记录扫描未发现私密 canary;结论限于记录的实际安装版本和两个固定消费者,不推导任意 CLI/MCP。Linux 接收器已停止并恢复原 Windows QA2,正常回帖已核对。当前仍为 opt-in,Linux 工作区文件权限需显式准备。详见[复验记录](https://github.com/BotHarness/DeepSeekBot/blob/main/docs/research/2026-10-12-private-input-linux-qualification.md)与[双语 Linux 指南](https://github.com/BotHarness/DeepSeekBot/blob/main/docs/dev/guides/private-input-linux.md)。 - -#1385 让当前已授权 IM Source Event 发起同一个 Owner Web Chat 私密卡;Host 经既有 Messaging Outbox 向原会话投递仅含 Chat/卡片定位 ID 的 Web 链接,实际 Owner Peer 仍是唯一提交 authority。进程内 source/reply lease 关联真实 Session 当前轮次、Bot 创建身份及 Binding,来源变化过期卡片并停止使用,账号替换/撤销禁止迟到结果投递;最终回复在 native Provider 开始前重新验证。受保护 Profile 仅放行实际注册的四个非私密 Messaging Tool Definitions,不增加第二个 receiver、持久表或 Pairing 策略。独立 Lark QA2 私聊已验证真实 Bot、多字段 Web 输入、现有允许一次、引用 CLI 认证 HTTP 200 与原私聊回帖;其余路径不从该结果推断。详见 [ADR-0172](../adr/0172-im-private-input-keeps-owner-web-authority-and-source-reply-fences.md) 与[双语可运行指南](../dev/guides/private-input-im.md)。 - -#1384 新增 application-defined ProtectedMcp Consumer,复用 native MCP Tool 适配器及官方 SDK stdio Client。模型与既有审批仅携带账号及字段引用;同一个 PrivateInputs owner 核对真实 Bot、当前版本和 Channel configured proof 后,才在私有 MCP tools/call 管道内提供原值。固定 Host 进程只执行一次认证 GET,丢弃正文、头部和错误,Host 在 native 结果持久化前只接受有界状态投影。普通 MCP 保留 native Client Plugin 与隔离 Linux 工作区;任意第三方 MCP 不自动获得凭据。详见 [ADR-0170](../adr/0170-private-mcp-consumption-uses-fixed-server-and-bounded-projection.md)。 - -#1383 复用既有 application-defined Bot Schedule → Source Event → Bot Inbox → Orchestrator 路径。Schedule 只保存引用;重启后仍由 native Credentials 及当前账号绑定核验真实消费,既有执行与 Channel 发送授权分别生效。受保护 Profile 允许 owning Orchestrator 使用四个 `bot_schedule_*` 管理工具。新建私密输入卡还需 Host 当前轮次的真实 Human Source Event;旧 Session 历史、目的 Channel 或模型自述不能提供此证明。无人值守缺少有效配置时立即返回安全 Owner-action 错误,不创建 waiter;详见 [ADR-0169](../adr/0169-private-input-interaction-follows-current-turn-source-events.md)。 - -#1382 在同一个 PrivateInputs owner 内加入 PersonaBot 创建身份与账号范围的 native Credentials 版本记录。Owner 从既有 Web Chat 卡片打开空白替换卡或撤销本地访问;新 grant 激活及 canonical Channel configured resolution 都成功后才返回引用。每次真实消费在账号串行队列内核对当前 UUID,登记 AbortSignal 后释放队列;替换/撤销写入新版本或持久 tombstone,并尝试停止既有使用。宽执行授权不能越过版本检查,停止结果区分已停止、已完成与不确定,401/403 只返回状态及安全修复建议。启动恢复先于 Owner 写入,撤销跨重启保留。详见 [ADR-0167](../adr/0167-private-account-versions-fence-consumption-and-revocation.md);旧程序不理解 tombstone,回滚需恢复完整升级前备份或向前修复。 - -#679 将入口收束为 Bot 模式设置左侧的紧凑图标/未读 Chip;折叠侧栏时则在 Bot 模式下方以相同尺寸对齐。展开时仅显示未读数字 badge,最多为 99+;有未读时入口常显,没有未读时仅在 Bot 模式开启时悬停或键盘聚焦才显示,与设置按钮一致;模式关闭时 hover 不显示,也不进入 Tab 顺序;折叠时入口仅在 Bot 模式开启后显示,图标右上角红点表示有未读或待行动;模式关闭时入口隐藏且不进入 Tab 顺序。无障碍名称保留完整未读数量与待行动提示。活动中心始终直接进入个人 Human Inbox;历史总览导航偏好不再改变入口(#2026)。 Web 页面与 Dock 共用三组可独立折叠的 Activity 投影,浏览器按 Host origin 保存折叠偏好;每组拥有独立分页与错误状态。标题栏搜索、排序和筛选经既有应用自定义 `humanAttention` 查询;查询版本 1 将搜索与未读过滤作用于分页前的合资格内容,并支持提及优先排序。Client 丢弃旧查询结果,首次加载与后台刷新分离,后台读保留已加载的空态或卡片。Host 在规范事务提交或原生请求 owner 变更后合并失效通知,经已有 roster RemoteSnapshotStream(HTTP SSE 兼容入口为 `human-attention/changed`)传递;消息列表与活动中心仍查询同一 Source Event、Channel placement、已读位置和请求 owner,不复制持久通知数据。Client 在订阅后读取基线,重连后重新查询,合并突发通知并补读查询期间的变更;频道列表的 latest 预览按 25ms 窗口合并为一次状态发布,保留各 Channel 最新消息与每个 Bot 的最大活动时间,退出或销毁时清除待发布队列;健康连接不轮询,仅断线时使用 10 秒恢复读取。最后一个展示面关闭后释放订阅和计时器。稍后提醒由 Host 调度最近到期时间,通知后重读规范查询。共享查询实现沿用移动端 #1954 / PR #1966 的实现。 #2231 第一切片沿用 Host Roster 的 Human Channel 免打扰偏好,在 Human Attention 消息查询的搜索、聚合、分页前排除静音 Channel(含已读保留的提及/回复);全局未读同口径,Channel 原始未读位置与明确 Human 请求不变。Web 共用 Activity feed 观察已有 Roster 状态变更,重查可见分组并丢弃旧偏好下的在途结果;全局状态同步重查,不新增连接或轮询。control rail 的独立红点范围由浏览器按 Host origin 单独保存,支持全部、待处理、个人未读、其他未读或关闭,同源窗口通过 storage 事件同步。既有 Human Attention 状态查询可请求完整分类摘要;先按 Source Event 去重再分区,不从面板首屏推算。状态失败或旧 Host 缺少分类时显示未知提示,关闭偏好始终生效;Activity 列表筛选与真实状态不受红点设置影响。 - -BotHarness 是 DSH(DeepSeek Harness)之上的插件层,给 Agent 持久产品身份:**PersonaBot**。PersonaBot 用一个 Orchestrator Session 管理 Inbox,并可同时管理多个独立 Assignment Session;Memory 是 optional capability,Persona 是其中的 optional 内容;两者都不是聊天或执行的前置依赖。DeepSeekBot 是首个应用,提供 roster、Bot Inbox、Assignment Directory、委派和 IM 接入。 - -#2290 修正私密输入的本轮自动来源判定:已接受且仍有效的本地 Human DM/Group Source Event 沿用本地 Owner 交互,混入的 IM 批次或 steering 不再强制选择 IM 来源。显式 IM 选择与账号本轮已记住的来源继续受原有 lease、歧义及撤销检查约束;历史消息不提供交互权限。详见 [ADR-0172](../adr/0172-im-private-input-keeps-owner-web-authority-and-source-reply-fences.md)。 - -#1380 的历史 opt-in 私密输入由 application-defined PrivateInputs owner 关联真实 Orchestrator、PersonaBot 创建身份、账号及多字段 Channel 卡片;Web 与 CLI 经现有 Typert/API Gateway,以真实 Owner Peer 提交原值,Host 原子写入一个 native Credentials Service grant。Channel 与模型只接收声明、状态和引用;既有执行审批先批准引用调用,固定 trusted CLI 才解析并消费原值,只投影 HTTP 状态。Human 已认可卡片交互与真实认证成功,#1380 已合并。原生 `bash` Consumer 由 application-defined LinuxShell Provider 经 native Subprocess argv 启动 digest-pinned 容器,挂载当前授权工作区并保护 `.git`;普通 MCP 保留 native MCP Client Consumer,其 SDK 管理的 stdio 进程也由受控 Docker argv 启动。容器不挂载 Host 凭据/socket、不继承 Host 环境;其他未验证的 Host Capability 仍被整个 Profile 拒绝。该 opt-in 不是默认架构;固定秘密 MCP 消费已由 #1384 交付,实际 Linux 安装验收及限制见 #1386 复验记录。详见 [ADR-0164](../adr/0164-private-input-candidate-separates-native-storage-from-qualified-execution.md) 与[可运行验证路径](../dev/guides/private-input-cli.md)。 自 ADR-0189 起,私密输入在普通 Profile 默认可用:`ask_secret`、固定的 `protected_cli` 与 `secret_http` 对每个 Orchestrator 注册,原值仍只进 native Credentials Service 并绑定发起的 PersonaBot。`secret_http` 由 Host 子进程发出请求,目标是 Owner 在卡片中填写的地址,Bot 只决定其下的路径、方法和 `{{字段}}` 占位符。凭据存储的保护有两层:Host 改写 native Sandbox Provider 返回的命令行,为每条 Shell 命令加上“不可读取该存储”的规则(Seatbelt 规则或 bwrap 遮盖),并在审批之前拒绝针对它的文件工具与点名它的命令。`danger-full-access`、Landlock 与 Windows 下只有后一层。历史 opt-in 的 `privateInputExecution` 曾采用 ADR-0164 的容器边界。上述容器 Shell、容器内 MCP 与 `privateInputExecution` 已由 [ADR-0192](../adr/0192-the-docker-protected-profile-is-removed.md) 删除:密钥输入在所有 Profile 默认可用,使用方为 `protected_cli`、`secret_http` 与 `secret_cli`,凭据存储由沙箱读取拒绝和命令文本拦截保护。 - -#1381 让每张私密卡片持有独立 live authority 与十五分钟 Host deadline;Human Inbox/Bot attention 仅投影已提交且仍有效的请求,并跳转原 Web Chat 卡片。配置、取消与过期共享一个幂等 Channel resolution ID,提交在 canonical SQLite transaction 内重新检查 live authority 后才成为最终 winner。版本二 native grant 必须具备完整且匹配的 configured resolution 才能消费,跨存储失败的孤儿 grant 不可用;启动恢复将丢失 waiter 的卡片持久标为 expired 并清理孤儿 grant,保留已配置引用。私密 resolution 不创建 Inbox Admission,不以恢复或过期唤醒停止的 Bot。Host disposal 排空 pending settlement 与启动恢复;Client 清空输入并拒绝旧 mount/迟到 pending poll 覆盖最终状态。详见 Proposed [ADR-0166](../adr/0166-private-input-settlement-commits-before-continuation.md)。 - -本文描述 #71 确认后的目标架构。M1 registry、M2 Memory 与 #66 roster storage 已实现;#77 已验证 DSH runtime seams,显式 Session ownership、Messaging、Assignment Runtime、统一 operational database 和可移植性按 #79–#81 分阶段落地。更新:2026-10-03。 - -Bot 模式首次体验由 application-defined Onboarding owner 在现有 operational database 保存 Profile receipt。五步 Desktop 引导(#2192,[ADR-0233](../adr/0233-desktop-onboarding-follows-tob-creation-receipts.md))依次为模型配置、通过受保护 Tob 的原 DM 创建 Bot、创建包含该 Bot 的群聊、面板布局教学,以及实际点击 Home 标题关闭首页并进入原 Tob 私聊。沿用 Home 与共享输入框,示例只填写草稿。可信 scoped CLI adapter 在执行前保存 operation 关联,owner 从既有 Bot/Channel 创建回执与实际成员关系恢复进度;拒绝、预留和模型文字均不算创建成功。布局教学完成记录属于当前设备,Host 只保存教程生命周期与必要结果引用。旧完成/跳过状态保持安静,旧未完成状态暂停等待主动重开;升级不重排布局、不修改快捷键或草稿。新 Bot 与 Tob 不再写入默认欢迎消息,既有消息与旧欢迎消息渲染保持兼容。旧 first-reply 证据仍保留作为历史事实,而非新版创建步骤的成功条件。 - -历史欢迎消息保留兼容显示,提供能力介绍、每日晚间问候与十分钟定时测试示例,点击后沿用正常 Human DM 请求与现有执行能力。新闻选项由 Host 在 onboarding 查询中投影当前 native 搜索配置和所选 Bot preset 的 `web_search` 可见性,只返回就绪布尔值,不保存第二份配置或凭据。固定 RC1 的已知 DeepSeek 搜索 Provider 与其独立凭据已配置时提供带真实来源链接的今日 AI 新闻;未配置、无法确认或不支持的搜索组合显示通用工作规划示例。配置就绪不保证网络请求成功,实际搜索失败、空结果和不可用必须如实说明。模型保存和问题发送是两次独立操作:单独选择模型不创建问题;因配置受阻的请求保存模型后进入单独的发送确认,关闭或恢复均不自动发送。引导模型配置复用 native model/credential Services,经 Typert/API Gateway 保存并读回 Profile 默认模型;默认勾选后当前 Bot 继承全局,取消勾选则写独立 Model Plan。新 Bot 无独立 plan 时继承全局;保留 model-plan revision 以校验返回继承的编辑。全局修改只影响之后的继承请求,不覆盖固定计划或运行中的 Assignment。模型失败后的“重试这条消息”沿用原 Human 消息与 Admission,经既有 retryability/side-effect gate 拒绝不安全重放,不再追加 Human 消息。 - -当前 Client UI 由独立 `@botharness/ui` Bundle 挂载,源码仍在 `packages/client`;RC2 的插件图把结尾 `/client` 解释为导出子路径,因此包身份依 [ADR-0066](../adr/0066-rc2-client-bundle-identity.md) 避开该后缀。Client HMR 只暂存当前 Bot/Channel 选择以恢复视图,不复制 Host 中的 PersonaBot、Channel 或消息权威。 - -产品术语以根目录 [`CONTEXT.md`](/zh/dev/design/context) 为唯一词表;[BotHarness Runtime 架构](/zh/dev/design/bot-runtime) 单独展开 PersonaBot、Bot Inbox、Orchestrator、Assignment 与 DSH execution 的关系。DSH/Cordis 本身的术语和 Plugin 开发决策位于 `/zh/dsh`,不在这里重复定义。 - -迁移阶段保持可验证:#79 建立 `botharness.db` owner;#885 的 Schema Generation 56 已将 #66 的 roster arrangement 单向迁入 owning SQLite module。旧 `botharness_roster` Storage Domain 仅作一次性输入,校验失败时 roster 只读且返回 `storage-unavailable`,不会当成空排列;成功后排列与 import marker 同一事务提交,旧源保留供恢复参考,正常运行不再打开或读写该 domain。Session ownership 保持既有数据库权威;#886 的 Schema Generation 72 增加恢复收据、逐 Bot 模型授权/激活记录与 Session 内容可用性。 - -#56 and #137 extend the current roster global slot to `{ pins, hidden?, sectionOrder, topOrder? }`: `pins` canonically orders Channel IDs for both group Channels and PersonaBot DMs, `hidden` omits Channels only from roster navigation while retaining their placement, and `topOrder` mixes section blocks with loose Channels while membership remains owned only by section records. The unary client bridge now has ten arrangement methods, including `topReorder`, `hiddenSet`, and bounded `rosterBatch` (one Host completion notice and one final Client snapshot for multi-select); #885 preserves this order, hidden presentation state, and single-membership invariant in the owning `roster_arrangement` singleton; each command mutates a transient draft and commits the complete arrangement atomically before publication, without dual writes. A missing import marker refuses edits until a valid source is imported; an already populated target without a marker is never overwritten. Sorting remains native DSH Settings, and collapse remains per-Client. 已提交的 roster mutation 会在 Host commit 后发送 `roster/changed` live invalidation;其他窗口只重读权威 roster,不接收也不复制拖拽中的预览状态。新提交的非通知 Channel 消息另以 `roster/latest`(类型化流为 `latest` 帧)推送该 Channel 的最新消息,各窗口只更新对应行的预览与时间,不为每条消息整体重读 roster(#1530)。 - -BOT mode 的折叠 rail 复用这份 Channel 排序读模型:置顶项在分隔线上方,其余 DM 与 group Channel 按 section/未分组的扁平顺序排列。`botharness/channels` 额外投影可选 `latestMessage` 供 hover 摘要使用;该字段从当前 Messaging authority 派生,不成为新的持久化权威。“最近更新”模式下,每段相邻的未分组 Channel 按最新可见消息时间排序,section 位置不变;“手动排序”保留存储的扁平顺序。客户端打开或更新某个 Channel 不会单独改变它的位置(ADR-0031 2026-10-13 更新,#1530)。 - -置顶格也是独立的排序 scope:默认继承全局最近更新/手动模式,可用 `ui-bot-mode.sortModes.pinned` 覆盖;拖动置顶卡片会把当前完整顺序(包括被搜索或隐藏过滤的置顶项)冻结为手动模式,通过 `pinsSet` 持久化,不影响 section 归属或其他 scope 的排序。折叠 rail 使用同一置顶顺序。 - -Hidden Channel 是 application-defined 的可逆 roster presentation state:它会从展开列表、pin grid、搜索与折叠 rail 中消失,但不会改动 Channel、消息、PersonaBot、Memory 或原 placement。破坏性删除仍受 ADR-0037 的 dependency report 与独立确认约束(#138)。 - -当前 Channel Chat 的实时显示遵循 ADR-0054:正式消息先写入当前持久化权威,再以该 Channel 的单调 revision 发出进程内通知。#143 的时间线读取遵循 ADR-0061:Host 的 `channelTimeline` 用不透明游标提供 latest / older / newer / around 窗口,Client 仅保留一段连续的按提交顺序排列的可见消息;SQLite Channel placement 的游标解释留在 Host。首次打开 Channel 取最新页;有上次已读锚点时,重开会在锚点附近分页并定位;向上补页维持视口锚点,定位旧消息可前后补页并高亮,并可继续向 newer 分页直至最新消息;用户离开底部后,新消息只更新未读提示,不强制滚到底,也不把实时消息拼接进尚有后续页的旧窗口。旧 `channelMessages` 端点暂作兼容。已选 Channel 通过经过 DSH 认证的 `/api/botharness/stream` SSE 接收已提交消息;重连可回放、缺口则从 Host 修复。同一连接还传送 Orchestrator 显式 `channel_send` 工具参数产生的进程内草稿预览:`channel/draft` / `channel/draft-settled` / `channel/draft-abandoned` 带 attempt ID 与独立于持久消息的进程内草稿 revision;连接时先发送完整 baseline,断档则重读 committed。草稿不从历史回放,不进入 Inbox,提交后由正式消息替换,放弃时移除;呈现层可消费草稿,任何不可逆操作只认已提交消息(ADR-0054)。Human send 使用 Client 生成的幂等 message ID;网络失败时本地气泡保留失败状态,Human 可把原文和附件恢复到 composer 后再次确认发送,而 Host 对响应丢失后的同 ID 重试只接受一次 durable append。Orchestrator 普通 final 与 Assignment 输出不等于 Channel 消息;当前正式 Channel 消息由 SQLite Source Event 与 placement 事务提交,旧 NDJSON 历史只在首次升级时单向导入。Group 中经候选列表选中的多个 Bot mention 在同一事务形成独立 Inbox Admission;Bot 处理状态经同一 SSE 流独立投影。已入群 Bot 的 channel_send 可提交稳定 ID 列表;Host 从可信 Orchestrator ownership 得到发送者,读取当前成员与名称生成 mention 标签,并在同一事务为不同目标建立独立 Admission。Bot-authored Group 消息保留因果根和跳数;同根同目标只投递一次,过限消息保留在群里但不再唤醒目标。收件 Bot 可以在同一群回复,重启后 pending/retryable Admission 仍可恢复。 Group 之外,Bot-to-Bot DM 使用确定性的双成员 Channel ID;Orchestrator 的 `bot_dm_send` 或该 DM 内的 `channel_send` 以可信 Session ownership 确定发送者。每次发送在同一事务中提交 Bot DM Source Event、placement、收件 Bot Admission(受因果根去重与跳数上限约束),以及只链接原消息的无正文 Channel Notice。Notice 放在引起这次发送的 Source Event 所在 Channel(群、Human DM 或 Bot DM),该 Channel 不可用时回到发送者 Human DM;同一事务为发送者写入一条已处理的 `bot-action` Bot Inbox 记录。若引起发送的正是这个 Bot DM 本身(两个 Bot 来回回复),消息已在原对话中可见,不再写 notice 或记录。Channel Notice 不计入 Human 未读、回执、活动统计与 Channel 预览,也不为其他成员建立 Admission(ADR-0154)。重启时恢复 pending/retryable 收件投递;Bot DM 默认不进入 Human roster,但在隐藏频道管理器中可发现、只读打开,Human 无发送或重命名权限。 -Group Channel 的气泡收件圆环只投影该消息已有的 PersonaBot Inbox Admission,分母是实际收件者而不是群成员总数。入队是「已投递」;运行时 claim 本身仍显示已投递,直到 observed_at 标记消息进入 Orchestrator 回合上下文才显示「处理中」。若 Bot 通过 Channel 查询主动读到消息而没有进入处理该消息的回合,Admission 保持 pending 但 observed_at 非空,投影为独立的「已读」;回合结束是「已处理」,并不意味着发出了回复。明确忽略、可重试失败和需修复失败各有独立状态。每次权威状态变化通过 Channel revision/SSE 通知并重读该投影,不新建第二套已读存储。Bot 发送者不产生自己的 Inbox Admission,也不进入收件人数。Group 的本机 Human 成员以 Host 固定身份、显示名、加入时间及首个可见 revision 保存在 Messaging 权威中;Human 阅读位置按 Channel ID 与 Human ID 共同持久化。既有 Group 从首条消息起可见,旧 Channel 阅读位置迁移到本机 Human。Host 仅对有可见权限的非作者 Human 投影已读/未读,Bot 状态仍只取 Inbox Admission;同一圆饼与明细展示两种语义,不再从浏览器 tab 或作者推断已读(ADR-0078、#347)。Human 已读推进只广播一条携带阅读 revision 的 Channel SSE;重连时从权威已读位置发送 baseline,Client 用消息的 placement revision 更新当前可见窗口,避免阅读大量历史时逐条广播。当前只支持本地一个 Human;跨账号多人 Channel 仍需独立身份与授权设计(#377)。 -Channel 的消息引用(#145)只保存同 Channel 的已提交目标 ID;Human 发送与 PersonaBot 显式 `channel_send` 由同一持久化权威校验,跨 Channel/缺失目标不能产生消息。引用作者和截断摘要在时间线读取时由当前历史投影,不复制为另一份持久内容,也不为每条消息单独读取。目标后来不可见则降级为不可点击提示;点击有效引用复用 `around` 窗口和高亮,不改变相邻消息分组与时间语义。 - -新 Channel 附件(#576,ADR-0100)保存到 profile 管理的独立真实文件,消息持久化 `{fileId,name,mime,size}` 引用。Host 在 append 前校验 profile 归属,查询时从当前文件投影 MIME 与大小,不改写 Source Event envelope;保留的旧 `{hash,name,mime,size}` 通过 generation 39 的 Messaging 绑定解析到独立真实文件,转换前后均校验字节且不改写 envelope;失败保留旧对象可读状态并给出有界修复日志。Composer 的固定上传 key 与消息 ID 分别保障传输和发送重试幂等。认证 Fetch 上传仍走 `/api/botharness/attachment/upload`,新下载以 `channelId + messageId + fileId` 验证归属并用 `no-store` 返回当前字节。Human 文件/图片菜单复用现有 DSH 原生打开能力;Orchestrator `channel_read_image` 使用 `attachment_id`(fileId)或 legacy `hash`,先验证当前成员资格、消息引用、当前 MIME 和大小,再传给 DSH attachment service,模型不接触 Host 路径。引用感知清理保护所有保留 Source Event 的文件身份;不启用自动调度或保留期。详见[双语文件指南](../file-open.zh.md)。 - -拥有该 Session 的 Orchestrator 可复制 `channel_read`(含通过 `message_id` / `content_cursor` 完整读取的内容)或旧版授权读取结果中的最多 10 个可信附件引用,四个字段原样保留:`{fileId,name,mime,size}`。#577 后旧消息投影迁移后的文件身份;过期 `{hash,name,mime,size}` 结果必须先重新读取所属消息再转发,旧 hash 图片读取仍须带所属消息以解析当前字节。Orchestrator 还可通过 `channel_attachment_import` 明确选择并导入已授权的本地结果文件;不能猜测身份与元数据。DSH schema 将 `size` 声明为整数;Host 继续校验 0–9007199254740991 的安全非负字节范围。固定版本的 DSH schema 不支持 `maxItems` 或数值上下界,因此数组上限由描述与运行时校验共同表达。Group 最多接受 20 个提及 ID,每个都必须是其他活跃现成员。`channel_send` 只有在 Messaging 权威接受写入后才返回紧凑的 `{channelId,messageId}` JSON;省略目标时使用入站 Channel。该结果取代原文本确认;附件身份、profile 归属、当前文件语义、回复目标、delivery-key 重试与因果循环边界继续沿用([#570](https://github.com/BotHarness/BotHarness/issues/570))。 - -应用定义的 `group_leave` Tool 只在规范成员移除提交后返回 `{channelId,left:true,outcome:"changed"}`;Bot 当前不是该群成员时返回 `{channelId,left:false,outcome:"unchanged",reason:"not-member"}`。无变更既包括重复退出,也包括从未加入的群;不推断历史成员身份,不返回未加入群的名称或名单。缺失 Channel 抛出 `group_leave: channel-unavailable`,非群聊目标抛出 `group_leave: group-required`,替代旧的含糊 `left:false` 成功结果;Tool 异常保持为失败。既有 `{channelId,left}` 字段及 Core 返回结构保持兼容,调用方须处理新的无效目标失败。ADR-0073 的同一事务仍承担创建者移交 Human、待处理 Admission 撤销、单条持久离群通知及剩余成员注意力策略;提交后的实时通知警告不否定已提交变更。读写权限立即撤销,因此 Bot 应通过仍可访问的 Channel 向 Human 报告([#571](https://github.com/BotHarness/BotHarness/issues/571))。 - -The reply Consumer can compose an explicit `resolve_inbox_ids` selection with a successful send/drop, using the existing Inbox triage owner afterward; completion failures remain separate from delivery. For an exhausted drop without observed peer-yield evidence, the draft owner identifies the original Source Event and Group Message IDs as keep-open work; the Consumer excludes them from composed completion while still resolving selected FYI. A later explicit cancellation/completion decision remains with the Inbox owner. A revoked initiating opted-in Group prevents that run from continuing in another Channel. A later independent DM remains available (ADR-0227). - -Home/Tob(#2082,[ADR-0223](../adr/0223-tob-is-a-protected-local-personabot.md))复用现有 PersonaBot、Memory、Workspace、Channel、审批和 native Settings owners。全局 Home 不保存第二份对话;提交进入 Tob 的同一个 Human DM,消息面板继续展示。空 Profile 的 onboarding 使用 Tob,已有普通 DeepSeek Bot 保持原身份和历史。Generation 96 的本地单例引用决定 Tob 身份,名字、Soul 或导入内容不授予权限。首片的 Assignment 并发上限路径在 #2088 扩展为明确的 CLI allowlist:Host 默认模型、Bot 模型计划、模型预设创建/读取/更新/应用、四个 native runtime 字段、telemetry、Git 可用性/安装,以及带显式确认的发布安装/重启。受限 release-install/release-restart 每次必须由 Human 新作决定,full access、已记住规则或自动审批均不能替代;此规则不为 Git 安装新增确认参数。各操作继续使用其已有 owner;preset 删除尚无 owner,凭据仍经原生私密输入,其余设置不因 CLI 已存在就自动授予 Tob。Tob 通过 DSH-native bash/pwsh Tool → 隔离的 Shell Service → DeepSeekBot CLI 发起操作;application-defined CLI broker 为每次 Shell 执行提供短期 loopback capability,不向模型提供 Human 管理凭据。Host 从真实 ToolExecution 绑定受保护 Tob 的 root Orchestrator 与准确的已准入 Human DM Source Event 集合;不接受 CLI 自报身份。解析后的设置操作沿用 native Settings owner 和现有审批,审批前后及写入时重验来源、旧值和 revision,并读回结果。Shell 完成、取消或 Plugin 释放时撤销 broker;原生 Assignment 工具不变。详见 [ADR-0224](../adr/0224-tob-management-uses-scoped-native-shell-cli.md)。布局 v8 保留 v7 子树与比例,并备份升级前原文;Home 默认快捷键使用独立稳定 ID。 - -#2089 扩展同一受限 CLI seam 到空白 Bot 创建、资料展示和生命周期([ADR-0225](../adr/0225-bot-creation-receipts-and-reviewed-profile-lifecycle.md))。Generation 97 的 `bot_creation_operations` 在原 operational database 保存 operation key、输入指纹与预留身份;Registry/Memory 和 Channel owners 仍分别创建规范 Bot、受管 Memory Repository 与 Human DM。同 key、同 payload 的响应丢失重试核对原身份;不同 payload、已归档/删除身份均拒绝。目录已创建但 Registry 身份缺失时保留现场并返回 `creation-memory-conflict`,不假装文件系统与数据库是原子事务。受限资料写入绑定完整 Registry record 的 `profileFingerprint`,审批后重新检查并读回;元数据 patch 仅允许名称、简介和标签。删除复用原 owner token、依赖和准确路径,默认保留 Memory,显式擦除和重试均不能改变已审核范围;受限删除确认/重试每次需 Human 新作决定。Tob 保护身份不变,导入与跨 Bot 既有 Memory 编辑仍为后续独立能力。 - -#2090 首个切片将同一受限 CLI seam 扩展到群创建和邀请([ADR-0226](../adr/0226-group-management-receipts-preserve-channel-authority.md))。Generation 98 在既有 operational database 保存稳定操作回执,绑定输入、Bot 身份代次与预留 Channel/invitation 身份;Channel 和 invitation owners 仍是群与成员状态的权威。创建时显式指定的初始 Bot 按现有 UI 语义直接入群;后续邀请沿用默认开启的 `autoAcceptGroupInvites=true`:管理操作审批通过后,同一事务记录已接受邀请与成员关系,受邀 Bot 立即入群,无需额外邀请决定。仅当 Human 已关闭自动接受时才保留原有逐邀请决定分支;本片不新增 UI 开关,Tob 报告实际状态。邀请的审核指纹包含频道元数据、成员及邀请策略,不含消息流或未读计数;审批后与写入前重验,拒绝过期审核。同 key、同 payload 重试核对原结果,已完成的受限重放只读回,不再次申请变更审批;删除、身份变化或 payload 冲突不回退到新建。Tob 始终从已准入 Human DM 管理其他 Bot,不加入群,群内容不授予管理权限。#2090 下一切片通过同一 seam 加入群名称/头像、成员普通消息唤醒策略与成员移除,不新增模型 Tool、数据库迁移或 UI。所有受限编辑必须携带已观察的 `expectedChannel`,审批绑定旧状态、准确目标和新值;执行时重验 Human 当前群访问权限、频道快照及目标 Bot 身份。指纹也包含每个成员的有效唤醒策略与继承状态,因此 Bot 默认策略变化会使旧审核过期。移除成员保留 Bot、Memory、其他频道与群历史;移除 owner 同时清除其管理身份,转由 Human 管理。这些编辑不使用操作回执;结果不确定时先读回,仍需修改则依据新快照重新审核,不盲目重试。已认证 Human 命令可选指纹以保持兼容;Tob 不能省略。后续待处理决定切片沿用同一 seam,为 Tob 开放 `group-invite-cancel` 和 `group-join-decide`。两者必须携带已读取指纹,绑定准确的待处理邀请/申请及目标 Bot 身份代次,审批后重验 Human 当前群成员资格、频道快照与记录仍待处理。取消不能撤销默认自动接受的邀请,也不移除现有成员;接受/拒绝申请由原 Channel owner 持久保存状态与适用成员关系,原决定通知只记录一次。受限入口只处理待处理记录;Human 入群决定命令未携带指纹时保留原有同向终态的幂等读回。结果不确定仍先查规范状态,不增加操作回执、数据库迁移或模型 Tool。个人展示设置、删除群与外部成员管理仍为后续范围。 - -## 原生 Human 等待的验证边界 - -[#1036 的有界实验](../research/1036-native-wait-experiment.md) 在固定 DSH -`0.2.0-rc.1` 上证明:独立 Assignment 等待原生工具审批时,Orchestrator -可真实处理并回复无关 Inbox 消息,但没有释放 Assignment 运行许可。 -Orchestrator 自己的原生审批或正式提问仍占有当前 Step;Admission 已接收、 -`steer` 已排入下一 Step 不等于模型已处理,原调用决定并返回后才处理无关输入。 -准确 Session/call 归属及当前 Grant 校验仍由既有 authority 承担,变更/撤销 -范围拒绝原操作。[ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md#native-human-wait-qualification-2026-10-09) -记录本次资格边界,不改变 ADR-0035 的单一 Orchestrator root 约束。 -[#1220](https://github.com/BotHarness/DeepSeekBot/issues/1220) 追踪缺失的受支持原生 -continuation 机制;#1036 保持阻塞,#1037/#1038 生产实现尚未交付。 - -后续[隔离 RC2 实验](../research/1220-native-timed-question-experiment.md) 验证原生 timed -question 可先真实返回 pending,问题保持可回答;同一 Session 可回复无关消息,再通过 -带原 call 身份的 qualified Inbox 输入处理稍后答案。这是 Tool 已结束、问题仍持续的机制, -不是挂起未结束调用。[DM 卡片适配](../research/1220-timed-question-card-e2e.md) 以原 Session/call -关联卡片:只有 `ASK_TIMED_OUT` 结束前台等待,卡片仍查询原生 Projection;稍后答案通过 -原生 `userQuestions.answer` 提交,显示已提交,直到原生接收后才写入 Channel 回答记录。 -稍后提交进入既有 per-Bot Runtime 队列,为同一 live Session 建立新的 application run,保留 -原 Source Event 上下文并重验当前归属、DM 与源内容 fence;run 持续到原生 idle,让模型能使用 -既有 Channel 工具,不追加普通输入或 Inbox Admission。 -进程内映射仅关联已存在的 live Agent/卡片,不是第二个问题权威,也不实现冷恢复。 -生产依赖仍为 RC1,timed 模式仅在显式配置的隔离 RC2 Profile 验证。权限审批、容量释放/重获和同群隐私验收 -继续阻塞,不从这次 DM 能力实验推断完成。 - -## 1 · 系统上下文 - -新附件已遵循 [ADR-0100](../adr/0100-file-open-actions-target-real-host-files.md):独立上传彼此独立,显式复用身份才共享编辑结果。原生打开指向真实目标;后续消息读取、预览和下载使用当前内容,上传源独立。保存不保留附件历史,不生成 Source Revision、Inbox Admission、通知或 Bot wake。目标缺失则报告不可用,不自动重建。旧 CAS 迁移(#577)按 Source Event 附件出现位置预留可重启恢复的身份,仅在保留依赖未转换时继续保护旧对象;其他 CAS 数据与 Memory Git 行为保持各自语义。 - -```mermaid -flowchart LR - Human["Human
DSH Web / IM"] - External["Feishu / Lark
webhook / future providers"] - - subgraph Browser["DSH Web Client"] - UI["DeepSeekBot UI
Roster · Chat · Human Inbox · Assignments · Settings"] - end - - subgraph Host["DSH Host · single profile writer"] - API["Client Bridge RPC"] - Identity["PersonaBot identity"] - Memory["Optional Memory Service
Service Definition · Git Provider"] - Messaging["Messaging
Source Events · Inbox · Outbox"] - Assignments["Assignment Runtime
Orchestrator · Assignment Directory"] - Transfer["Portability
Export · Backup · Restore"] - DB[("botharness.db")] - end - - subgraph DSH["DSH-owned runtime"] - Sessions["Agent / SessionPersistence
Orchestrator · Assignment Sessions · Subagent"] - Credentials["Credentials · profile settings"] - end - - Human --> UI - External <--> Messaging - UI <--> API - API --> Identity - API -.-> Memory - API --> Messaging - API --> Assignments - API --> Transfer - Identity --> DB - Identity -. attachment .-> Memory - Messaging --> DB - Assignments --> DB - Transfer --> DB - Identity <--> Sessions - Messaging --> Assignments - Assignments <--> Sessions - Assignments -. scoped Consumer .-> Memory - Messaging -.-> Credentials - Transfer -.-> Sessions -``` - -浏览器只通过 RPC 访问 Host read models 和 commands。Provider adapter 只负责验证、规范化和执行能力;它不拥有 Inbox,也不能直接唤醒 Agent。DSH 继续拥有 Agent 执行、SessionPersistence、Subagent 与凭据;BotHarness 不复制这些 runtime 权威。 - -Assignment 完成报告通过既有 Human Inbox 最新报告、忽略和来源级隐藏事实派生 `informationalCount`,进入同一有 revision 的 Activity snapshot。仅有信息时显示中性的 `i`,但不会单独让输入框活动行常驻,该行只在执行中或有待处理事项时出现;红色待处理数字不包含信息更新,同时存在时悬浮/聚焦摘要分别说明数量。打开报告本身不代表确认;忽略、隐藏,或主会话已处理这条汇报的 Inbox 投递时清除提示,汇报本身仍列在收件箱中;重启可从权威事实恢复,执行状态不变。 - -## 2 · Deep modules 与所有权 - -```mermaid -flowchart TB - Root["Host composition root
lifecycle · dependency wiring"] - DB["Operational Database Owner
writer lease · schema generation · transaction"] - - subgraph Modules["BotHarness deep modules"] - Bots["PersonaBot
identity · lifecycle · Session ownership"] - Memory["Optional Memory Service
repositories · Git commits · events"] - Msg["Messaging
events · channels · inbox · triggers
grants · outbox"] - Assignments["Assignments
directory · capacity · requests · reports"] - Usage["Usage
retained daily model tokens · read model"] - Portable["Portability
Soul · export · backup · restore"] - Views["Read models
RPC · UI projections"] - end - - Root --> DB - Root --> Bots - Root -. optional Provider .-> Memory - Root --> Msg - Root --> Assignments - Root --> Portable - Root --> Usage - Root --> Views - DB --> Bots - DB --> Memory - DB --> Msg - DB --> Assignments - DB --> Portable - DB --> Usage - Bots -. attachment .-> Memory - Bots --> Assignments - Msg --> Assignments - Bots --> Views - Msg --> Views - Assignments -. scoped Consumer .-> Memory - Memory --> Views - Assignments --> Views - Usage --> Views - Portable --> Views -``` - -| Module | Owns | Does not own | -| ---------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| PersonaBot | Host-owned ID、display name / role badges、lifecycle、explicit Session ownership | DSH Session lifecycle、Memory 内容 | -| Memory | generic Git-backed repositories、semantic commits、operation events | PersonaBot lifecycle、Inbox、Session | -| Messaging | Source Event、Channel placement、Inbox Admission、Attention、Trigger/Wake Policy、Service Grant、Outbox | Agent execution、provider credentials | -| Assignments | Assignment Directory、Assignment Request/Delivery Intent、capacity admission、report/lifecycle routing | DSH transcript、Subagent runtime | -| Usage | per-PersonaBot/执行类型/实际模型的保留日统计、usage read model 契约 | DSH Session 日志、价格表、金额总账 | -| Workspace Grants | Human 对 DSH Workspace 的授权与撤销、Assignment 创建时的权限快照 | DSH Workspace registry、Session 权限实现 | -| Portability | SoulSnapshot、PersonaBot Export、Profile Backup/Restore/Transfer 协调 | credentials、可执行插件、DSH 私有格式 | -| Read models | 查询、分页、PersonaBot Activity Projection、Human Inbox、UI-friendly projection | 业务事实与写入规则 | - -`botharness.db` 是 BotHarness core 的物理事务宿主,不是共享的 generic repository。Memory 内容与 commit 由 optional Git-backed Provider 掌管;每个 deep module 只通过自己的接口拥有表和不变量,跨模块流程由显式 command/port 协调。 - -Usage 是 application-defined 的保留统计:从 DSH durable SessionEvent 中按实际请求提取 provider/model 与 provider 报告的 input、output、cache-read、cache-write token,按可信 Session ownership 归属 PersonaBot,分别汇总 Orchestrator、Assignment 和 DSH Subagent;同一 Turn 用多个模型时分别入桶,缺失用量标为未知。`botharness.db` 按 `(bot, day, execution role, provider, model)` 保留日汇总,日界取 Host 本地时区;增量折叠必须幂等,普通 Session 删除后不可用剩余日志全表重建或重复计数,彻底清除 PersonaBot 才删除其可识别统计(ADR-0094,#39)。金额由价格表在查询时另行估算,1.0 不落金额总账(#35 留 v1.1)。Browser 只经 read model 查询,不直读 Session 日志或投影表;PersonaBot Profile 的 token 卡是当前消费者(#34 Decisions、#428)。 - -Usage generation 40 在同一 Operational Database 事务中写入匿名 HMAC 去重凭据与日增量;校准只补记未见调用,不清空保留汇总。凭据不保留原始 Session 标识或逐调用内容。旧汇总迁移为 Bot 级时间基线,升级前无法确认的补计明确留诊断;新调用持久幂等。归档保留用量;Registry Purge 清理汇总、凭据与基线,当前 Bot 创建身份与可信根 ownership 防止旧 Session 重新填入。 - -首个可运行切片(#499)扩展现有 26 周有界 `profileActivity` 查询,返回实际 provider/model 的日用量、可为未知的报告分项,以及即使某缓存分项缺失仍可确定的 provider 总量。Profile 按 Host 本地日期查看用量,与允许调用的 Model Plan 独立。逐调用切片(#503)独立结算每条追加的 Assistant 调用事件,成功调用使用实际 source,失败调用使用已记录的请求路由;Profile 区分可信 ownership 对应的 Orchestrator、Assignment 和子代理。Session 序号去重实时通知与重放快照,消息替换不产生新用量。Session 删除后的保留统计仍由 #502 完成。 Profile 在现有 26 周公开查询范围内,以一个有界时间选择联动每日用量、实际 provider/model 用量构成和缓存比例图,默认最近 7 天。模型/提供商切换按选中的实际模型 ID 或提供商 ID 汇总,不在行内嵌套另一维度;Host 原始行和折叠的执行详情保留两个标识。模型/提供商行紧凑地显示选中维度的名称与报告总量;图表悬浮提示展示总输入(未缓存输入加缓存读写)、缓存读/输入及输出/报告总量的比例;缺失分项或零分母的比例保持未知。执行类别默认折叠于详细信息(#592)。 - -筛选查询 `profileUsage`(#507)由 Usage 深模块拥有,经 Typert/API Gateway 提供:真实且非未来的日期最多覆盖 182 天;模型/提供商与执行类别条件同时作用于每日记录与累计汇总,累计值仅忽略日期。Profile 默认近七天,执行类别筛选位于折叠详细信息中;首次打开、改变筛选或手动刷新时查询,不轮询或按时间自动过期,同条件刷新失败明确标为过期,旧条件迟到的响应不会覆盖新筛选。响应包含查询/核对时间、正常/核对中/降级状态、可空未知用量,以及显式明细/选项上限(2,000 行/每维度 1,000 个实际选项);总量完整,截断图表隐藏。Session 来源是否可用不决定保留统计是否存在(#502)。 - -Model Preset 的当前模板存储由 Profile SQLite 的 owning module 管理,Schema Generation 54 将旧 JSON 模板一次性校验迁入,保留 ID/revision,并写入包含空源的切换标记;成功切换后旧文件仅保留为恢复参考,不再读写(#883)。Schema Generation 55 将 PersonaBot Registry 的身份、外观快照、访问开关与独立 Model Plan 一次性校验迁入 `persona_bots`,导入标记与记录同一事务提交;旧 `bot.json` 与 Soul 文件保留,但正常读写仅使用 owning Registry module/Profile Writer Lease(#884)。无效登记使 Core 启动失败并释放 lease,不会把坏数据当成空名单;修复保留的旧源后重试。Generation 72 的完整 Profile Backup 保存可复用模板与独立 Model Plan 的现值和 revision,恢复不会重新套用模板(#886)。 - -Model Preset 是部署本地可复用模板;Human 在 Profile 应用时,PersonaBot 保存独立 Model Plan 快照,后续模板编辑不传播到已应用的 Bot。Plan 固定 Orchestrator 的 provider/model/reasoning effort,并定义 Assignment 可用的精确模型、各模型允许及默认的 effort 和默认模型。Host 在每个执行入口按当前 Plan 验证选择,而 DSH SessionEvent 记录实际调用:Orchestrator 的变更在当前 Turn 结束后生效,已有 Assignment 保留当前路由,之后的显式切换按最新 Plan 校验;新建 DSH Subagent 默认继承仍获允许的父路由,否则选当前 Assignment 默认并告知父 Agent。不可用或有歧义的路由停止请求,交由 Human 修复,不静默回退。Model Preset 与 Model Plan 可进入保持身份的 Profile Backup,不进入 SoulSnapshot 或 PersonaBot Export(ADR-0027、ADR-0093,#488)。 - -快捷模型/effort 切换只修改此 PersonaBot 的 Orchestrator,并在下一 Turn 生效;DM 不猜测某个 Assignment 作为目标。Model Plan 可以同时省略 `assignmentDefault` 与 `assignmentModels`,表示任务继续继承 Profile 全局模型;既有固定任务配置不变。Registry 的 `modelPlanCustomize` 用 revision 校验创建或修改这种独立配置,Web、移动端和 CLI 共用该 Host 操作。已有 Assignment 仅使用自己保留的路由;没有保留路由时继续继承全局,不补用后来出现的 Bot 任务默认值。完整模型设置显示继承状态,选择完整模板或任务模型才建立固定任务策略([ADR-0218](../adr/0218-quick-model-selection-only-customizes-the-orchestrator.md),#2000)。 - -当前命名模板保存在 `botharness.db` 的 `model_presets` 表(#883,取代 #498 初始 JSON 存储),应用后的独立快照及修订号保存在 owning Registry 的 SQLite 记录中(#884),后续模板修改不会自动替换。Profile 经现有 Host Bridge 读取 DSH 当前注册的 provider、model 与 effort 能力,并在创建及应用时校验确切路由;Orchestrator 的 Agent-scoped Model Selection 在每个新 Turn 前读取最新快照,不修改 DSH 部署默认值。DSH 请求头仍是实际调用路由的事实来源(#498)。 - -后续切片允许 Human 修订命名模板而只改变未来应用的值与模板修订;Profile 的紧凑切换按当前模板复制新 Bot 快照,手动改一个 Bot 的 Orchestrator 路由则清除模板来源标记、保留 Assignment 默认值并增加 Bot 计划修订。Host Bridge 在写入前验证现有 DSH 路由,正在执行的 Turn 持续使用其已组装选择,下一 Turn 才重新读取 Bot 快照(#501)。 - -新建 Assignment 的模型选择由当前 Bot Model Plan 管理:Human 在 Profile 保存精确 provider/model 集合、各模型允许及默认的 effort 和整体默认路由,Host Bridge 在写入前对照 DSH 当前 Model Catalog 校验并递增 Bot 计划修订。Orchestrator 通过 `list_assignment_models` 读取该集合,再在 `create_assignment` 中省略选择以使用整体默认值,或指定获准的模型与 effort;Host 在认领 Session 和启动工作前拒绝越界选择。选定路由随 Assignment ownership 记录保存在 `botharness.db`,并通过该 Assignment 独有的 DSH Agent-scoped Model Selection 进入真实请求;读取 Assignment 或 Host 重启不会把选择改为后来更新的 Bot 默认值。这个切片不修改 Orchestrator 路由或 DSH 部署默认值(#504)。 - -PersonaBot 的 Agent scope 提供 `bot_subagent`、`bot_subagent_fork` 和 `list_bot_subagent_models`;Host 在每次启动子代理时从该 Bot 的最新 Model Plan 验证路由,并用 DSH LLM Service 预检可用性,再交给 DSH Subagent Service 创建真正的子会话。Bot scope 拒绝绕过计划的原生 Subagent 工具调用。省略路由时,获准的父路由被继承;已从当前计划移除的旧 Assignment 路由改用当前 Assignment 默认值,并在工具结果中告知父 Agent。子代理沿用根会话的 Workspace Grant 与工作目录;DSH 子会话原生的 `never` 审批策略不放宽根会话的模式约束(#508)。 - -Model Route Readiness 在 Profile 查询及 Orchestrator/Assignment 新 Turn 入口读取同一 PersonaBot Registry。旧 `model` 字符串只有在 DSH Model Catalog 唯一匹配且路由配置有效时才迁移为独立 Model Plan;有歧义或缺失则保留旧值并等待 Human 选择,绝不拼接部署默认 provider。修复状态即时计算,不是第二套策略存储。请求前校验精确 provider/model/effort;provider 在真正执行时拒绝凭据则通过 DSH `turn/end` 和现有 Channel Session failure 通知给出修复指引。成功应用 Model Preset 后移除旧字段,保持 Agent preset 不变,下一次 Turn 使用修复后的路由(#500)。 - -application-defined Memory Service 使用 `Consumer → Service Definition → Provider` capability seam。每个 PersonaBot 都有一个 Git-backed Memory Repository,Orchestrator Session 固定以它为 working directory。当前检出的工作树文件立即是记忆,无论是 Markdown、代码还是二进制文件。Agent 通过原生文件、搜索、Shell 与 Git 能力读写仓库;没有面向模型的 Memory CRUD 工具。新建 PersonaBot 时可选择空白记忆,或由 Host 检查 Git 并以 HTTPS/SSH 地址在暂存目录克隆远端仓库;克隆成功才建立 Bot,远端当前分支及文件立即成为记忆(#298)。私有仓库使用 Host 已配置的 Git 凭证,不经创建页面收集密钥;已有 Bot 仍通过原生 Git 整合远端仓库。外部仓库应合并、替换或放到其他位置若有歧义,Orchestrator 经 DSH 原生 Ask Question 询问 Human。分支、合并与冲突由 Git 决定;Git 改变工作树后,同一 Session 立即看到新文件,但该 Session 已冻结的 Soul 与 Core Memory prompt 不追溯更新(ADR-0060)。Memory 根目录的 `SOUL.md`(Soul)与 `MEMORY.md`(Core Memory)在 Session 首次组装 prompt 时冻结为同一份快照注入,只在 compaction 边界整体刷新;各自受每 Bot 可配置的字符上限约束,超限时截断并明确提示。旧 `PERSONA.md` 在每次 Host 启动时幂等迁移为 `SOUL.md`,读取仍兼容旧名(ADR-0134)。跨设备延续沿用同一路径:Orchestrator 按 Human 请求用原生 Git 与 Human 自配置的远端同步,新设备以 Git URL 创建新的 PersonaBot;不操作 Git 的所有者使用 Profile Backup,而不是 Host 持有的同步服务或一键同步(ADR-0084)。 - -Idle Compaction(#2310):当前 Orchestrator Session 没有进行中的轮次、也没有待处理的 Bot Inbox Admission 时,Host 按最后一次模型回答的时间,在该线路提示缓存寿命的一半(最少 15 分钟)经 DSH 命令注册表为该 Agent 执行 `/compact`(`compactNow`;compaction 服务隔离在每个 Agent 的 preset 组内,Host 无法直接取得),只压缩超过该线路 DSH 默认自动压缩阈值一半的会话;摘要格式与 `compaction/end` 刷新 Soul/Core Memory 的行为不变,期间到达的 Human 消息由 DSH 排队到压缩结束。缓存寿命依次取 Host 设置中的每线路值、模型目录、观测(请求前空闲间隔与该请求缓存命中率)与 DeepSeek 的 2 小时默认值,不足 15 分钟或未知则跳过。Assignment 与审批复核会话不参与,聊天中不显示;每次执行写入 `usage_idle_compactions` 并在 Bot 活动中返回。开关与每线路寿命是 Profile 级 Host 设置,Web 设置页与 `deepseekbot idle-compaction*` 共用同一 bridge 方法。 - -[ADR-0140](../adr/0140-the-host-falls-back-to-a-managed-git.md) 规定 Git 的来源:Host 每次启动时解析 Git,可运行且不低于 2.28 的系统 Git 优先;否则使用 Profile 中的 Managed Git。找不到、macOS 占位程序与版本过低都算不可用。进入 Bot 模式时先检查,不可用时只给一个「安装 Git」按钮并禁用创建与导入。Managed Git 是按 deepseekbot 版本固定版本号与 SHA-256 的便携构建,先从 `media.botharness.ai` 镜像、再从 GitHub 下载,无需管理员权限;使用它时 Host 把其目录加到 DSH 进程 PATH 最前,让 Orchestrator 的 Shell 也能用同一个 Git。SSH 导入地址克隆失败时,Host 自动转换为对应的 HTTPS 地址重试一次并告知 Human。 - -Memory Service 拥有 repository identity 和 lifecycle、可信 Session ownership、受限的 UI 查询,以及审计/恢复检查点。成功回合后,它可以把观察到的状态与可信 Source Event、Session 上下文记录下来;观察不会暂存、提交或隐藏工作树文件,也不推断 Git 内容的作者。当前文件与历史以 Git 仓库为权威,数据库 ledger 只是辅助记录。Memory Service 为每个 PersonaBot 保存一个 commit 游标:回合开始时静默对齐当前 HEAD,回合成功收尾后,把 HEAD 可达而游标不可达的每个 commit 交给 Channel 写成一条 `memoryCommit` Channel Notice(放在该回合的 Cause Channel,不可用时回到 Human DM),并为该 Bot 写一条已处理的 `memory-commit` 记录,随后推进游标;同一 Bot 的同一 sha 只记录一次(ADR-0154)。起因在群或 Bot DM 的 commit 记录同时放入该 Bot 的 Human DM(ADR-0182)。PersonaBot DM 的 Channel sidebar 分为「记忆文件」和「记忆演化」:前者以可展开目录树展示当前工作树,选中文件后在 Channel body 只读查看文本或二进制提示;后者展示所有本地分支及可达提交的 Git graph,选中 commit 查看完整 diff。当前差异默认按「新记忆/已有记忆的更新」对文件去重,切换并持久保存 Git 术语后,显示可折叠的未暂存、已暂存和未跟踪分组及状态标识。打开视图时定时读取、窗口重新聚焦时立即读取外部编辑,侧栏折叠标题栏也有刷新按钮;查询不暂存或提交(ADR-0088)。Human 文本保存服务仍先比较当前 HEAD,再显式生成 Git commit;普通导航不提供页内编辑。Memory 文件查询阻止访问 `.git` 控制路径及指向仓库外的符号链接,但不限制 Git 可保存的文件类型。旧 Repair 归档与检查点记录仍保留兼容;普通未提交改动不会阻止下一回合或强制修复(ADR-0068)。 - -恢复检查点额外记录分支、HEAD、暂存区与工作树,并用隐藏的 Git ref 保留对象。观察记录区分 Host 扫描、Agent 会话上下文和明确的本地 Human 命令;后者使用与 Channel membership 相同的 Host-owned `local-human` 身份,不能区分共享 Host 凭据的多人。Human 在记忆演化页选择检查点并确认恢复;Host 校验当前状态与检查点引用,在副本中准备目标状态,完整归档原仓库后切换。未被观察的中间状态无法恢复;Git ignored 文件留在完整归档中(ADR-0097)。界面上这份列表放在记忆齿轮菜单的「从自动备份恢复…」里。Human 也可以把记忆「恢复」到 Git graph 的任意提交:Host 先强制记录检查点,有未提交改动时把工作树提交为一个备份提交(可顺带用 annotated Git tag 给恢复前的版本起名),再把所选提交的文件树作为新子提交(分支以 compare-and-swap 前移),然后把工作树重置到该提交;历史不被改写,之后的提交仍可再次恢复(ADR-0190)。 - -Memory 外部打开沿用 ADR-0100 的交互,#574 实现当前 Memory 首片:显示路径的区域可点击弹出菜单,密集文件树提供右键菜单和键盘/触屏可到达的入口,合适位置提供带 Tooltip 的图标按钮。普通文件选择仍进内置阅读,目录选择仍展开。首片覆盖 Workspace 的 Memory Repository 路径及当前 Memory 文件/目录,#575 扩展已授权的普通 Workspace 路径,后续扩展 PR #435 的消息附件 chips,不识别任意正文路径。Memory Service 在 Host 从 PersonaBot 仓库根解析相对路径,沿用 `.git`、越界与符号链接检查;其他来源由各自 owning module 解析,不开放通用任意路径 authority。Client 沿用 DSH Typert/API Gateway seam 消费原生打开能力,不运行拼接 Shell 命令。目录菜单取 DSH 探测到的应用,文件菜单取默认和注册关联应用,首片不加自定义程序或持久默认应用设置。 - -默认打开遵循 [ADR-0178](../adr/0178-file-open-defaults-are-explicit-client-preferences.md)(#1461):Memory 当前文件阅读器、聊天普通文件卡及 Workspace 的 Memory Repository/已授权目录行共用一个 application-defined split button——应用图标与「打开」主按钮加独立下拉,下拉即原文件菜单。主动作优先 Human 的显式偏好,其次 DSH 报告的系统默认;pinned DSH 只为文件关联报告系统默认,目录在 Human 设置前没有可信默认项,此时主按钮说明原因并展开选择,不使用列表首项。下拉里的一次性选择不修改偏好;「设为默认」「恢复系统默认」写入同一个 Client 偏好 owner(`file-open-preferences`),文件按扩展名、目录单独,存于浏览器 `localStorage`,并以 Host 提供的 Profile 身份作键;身份未就绪时不读取任何偏好,写入失败如实报错。偏好只保存 Host 曾报告的应用标识,不授予路径权限、不修改 OS 关联、不跨设备同步。每次手势都由对应 file owner 重新解析目标并重新查询可用应用,已卸载或不再报告的应用不会被启动,也不会被静默替换。派发成功只表示请求已发送到 Host,不代表窗口已出现。记忆演化的 commit diff 与未提交差异在每个文件行提供「打开当前文件」(#1462),复用同一 split button 与偏好 owner,提交 PersonaBot slug 与该行路径,由 Memory Service 按现在的仓库状态重新解析;它不读取所选 commit 的内容,也不暂存、提交或修改仓库。Host 的变更列表不做 rename 检测,rename 表现为旧路径删除加新路径新增,因此新路径行打开新文件、旧路径行只检查该路径当前是否存在。历史上标为删除但后来重建的同路径文件可正常打开;当前不存在时提示「这个路径现在没有文件」,仅当该行本身带删除标记时附上「此提交/当前工作区变更将该路径标记为删除」,不从历史内容重建,也不追猜后续 rename。点击按钮不折叠/展开 diff。 - -普通 Workspace 路径复用同一 Client 菜单,但提交 PersonaBot slug 与 Grant ID,由 application-defined Workspace Grant Store 的 `requireActive` 在 Host 检查拥有者、撤销状态和当前 DSH Workspace Registry 身份,重新解析 canonical 目录。显示路径不承担 authority;失效、移动、未注册或缺失目录会拒绝。打开前再次校验,菜单只列 Host 探测到的目录应用,能力不可用时只保留复制 Host 路径。打开不修改 Grant、Session cwd 或访问权限,也不创建 Session;不提供目录下载(#575)。 - -原生打开始终作用于 DSH Host 所在电脑,Tailscale/Cloudflare Tunnel 只提供连接而不证明 Client 与 Host 同机。菜单明确目标和能力不可用原因;文件另提供下载到浏览器设备及复制 Host 路径,二进制/超大文件不因内置预览受限而失去打开和下载能力。下载后的编辑不自动回写远端,首片不做目录下载或历史 Memory 文件导出。 - -手机端 Memory 文件阅读器同样消费现有 Memory Service 的 `memoryFile` 表示和认证 PNG 字节入口(#2214)。应用定义的 `MemoryPreviewIO` 是客户端进程内的字节与临时文件适配器:本地图片引用携带文档的 source/revision,Host 继续拥有路径、Git 版本和陈旧检查;手机只在原生可见区域相交后读取有界 PNG,把校验过的字节写入临时缓存供 `expo-image`、既有缩放查看器和系统分享使用,离开时取消请求并清理。Markdown 使用既有原生文本/表格组件,图片节点单独呈现,原始 HTML 不执行;外部 HTTPS PNG 经逐图点击才读取,不携带 Host 认证或 Cookie,并拒绝重定向。文件树和普通资料行不加载媒体依赖;原生 Git diff 导航、其他格式及附件预览仍是后续切片。 - -Memory 格式表示进一步支持 JPEG/JPG、GIF、WebP 和安全静态 SVG(#2214)。纯客户端可用的 `media/image-format` 统一检测原始字节 MIME、尺寸和处理预算,Host Memory 入口及手机临时文件适配器消费同一规则。SVG 通过有界 XML/CSS 解析,仅允许无外部资源和主动内容的静态图片子集;仍以图片资源呈现,不注入页面标记,也不在 Host 转码或读取 SVG 引用文件。版本 SHA-256、原始字节和独立原文件下载语义保持一致;GIF/WebP 在帧数/总画布像素预算内保留原动画。 - -Memory Service 在 Host 启动、Orchestrator 回合前,以及本地 DM 或群提及并入活动回合前比较各 Bot 当前分支、HEAD、未提交文件内容和 Git index,与数据库中的每 Bot 观察检查点求净变化。首次观察只建立基线;之后在单次事务中写入有界路径摘要的 `memory-change` Source Event、该 Bot 的 Inbox Admission,并推进检查点。事务失败不推进基线,重启后重试;相同状态重复扫描不重复投递。启动扫描只入 Inbox,不主动唤醒 Agent;下一次普通回合在同一 Inbox 上下文领取并处理。Event 不推断编辑者,也不复制文件内容;Agent 需要时用原生文件和 Git 工具查看。并入消息时发现的净变化随消息在下一个安全步骤进入同一回合的 Inbox 上下文,成功投递才标记已观察,并随回合成功或失败处理;拒绝并入时保留待处理,供后续普通回合领取。回合结束更新剩余状态的基线,不额外通知,也不推断回合中变更的编辑者。离线期间改动又复原的中间过程无法从最终文件状态推断。`SOUL.md` 或 `MEMORY.md` 变化可在 Event 中提示,但冻结的 Session 快照不变(ADR-0092、ADR-0134、#350、#352、#464)。 - -## 3 · Host 启动、迁移与 recovery - -```mermaid -flowchart TD - Start["Host starts"] --> Lease{"Acquire profile writer lease"} - Lease -- "busy" --> ReadOnly["Fail closed
read-only diagnostics"] - Lease -- "owned" --> Open["Open botharness.db"] - Open --> Gen{"Schema generation supported?"} - Gen -- "newer / corrupt" --> Recovery["Recovery mode
no operational writes or wakes"] - Gen -- "current" --> Integrity["Integrity checks"] - Gen -- "older" --> Copy["Migrate isolated temp copy"] - Copy --> Verify["Verify schema + integrity"] - Verify -- "fail" --> Recovery - Verify -- "pass" --> Activate["Atomic replace"] - Activate --> Integrity - Integrity -- "fail" --> Recovery - Integrity -- "pass" --> Modules["Start deep modules"] - Modules --> Rebuild["Rebuild DSH-derived projections
reconcile bounded intents"] - Rebuild --> Ready["Enable admissions, wakes and commands"] -``` - -一个 DSH profile 同时只允许一个 BotHarness writer。所有 module migration 合并成单调递增的 Schema Generation;迁移只在临时副本上完成,校验后原子替换。打开、迁移或完整性检查失败时进入 recovery mode,不回退到 NDJSON、storage domain 或内存写入。 - -## 4 · Messaging 事务与外部副作用 - -```mermaid -sequenceDiagram - participant P as Provider adapter - participant M as Messaging command - participant DB as botharness.db - participant N as Post-commit notifier - participant O as Orchestrator - participant X as Provider service - - P->>M: verified event + account fingerprint + capabilities - M->>DB: BEGIN IMMEDIATE - M->>DB: append Source Event / Revision - M->>DB: Channel placement (optional) - M->>DB: evaluate exact Trigger + Wake Policy revision - M->>DB: create Inbox Admission / Attention facts - M->>DB: COMMIT - DB-->>N: committed fact ids - N-->>O: wake at policy-selected safe boundary - O->>M: explicit Channel reply or authorized Service Action - M->>DB: validate current revision + capability + grant and write Outbox Intent - M->>X: execute with stable idempotency identity - X-->>M: receipt / failure / unknown outcome - M->>DB: append attempt and outcome facts -``` - -Source Event 是内容唯一权威;Channel 和 Inbox 都只保存关系。Reply 使用可信 Reply Route 自动选择来源 provider;主动发布属于 Service Action,需要 Provider Capability,以及该 Bot 对应用可触达会话的会话条目,或作为后备的 Human 保存目标 Service Grant。SQLite 事务只覆盖本地事实;外部副作用使用 Outbox Intent、幂等标识和有界 reconciliation,不宣称 exactly-once。不可证明的结果进入 `unknown-outcome`,由 Human 处理。 - -Wake Policy 决定何时让 Orchestrator 看见新 attention:当前 step 完成后的安全边界、当前 turn 结束后,或 idle 时启动新 turn。普通外部消息不打断正在执行的 model/tool step;只有 DSH 明确支持且策略授权的控制路径才能 steer。就绪的 attention 按回合收割:忙碌期间新到的事件只把就绪集合置脏,当前回合结束(即空闲)时由一次 harvest turn 消费全部就绪项;主动 steer 只用于直接 @ 与 DM(ADR-0077)。 - -### Lark 接收与回答反馈(ADR-0149) - -新会话首条消息的 Admission 通知异步等待受校验的回复连接就绪,避免连接建立期间丢失接收反馈;不阻塞收件确认或模型唤醒。SDK HTTP 异常中的明确权限拒绝归为失败;修复不重写或补发既有未知尝试。 - -Messaging 在 Source Event 与 Inbox Admission 提交后异步尝试原消息的 `GLANCE`,只在该来源的 canonical Reply Outbox 成为 `provider-accepted` 后尝试 `DONE`。两个状态由应用定义;原内容、Admission、Outbox 继续是各自唯一权威。可选的 checked Provider Service Capability 重查账户指纹、Registration、独占 Consumer、来源会话/Thread/发送者及当前授权。旧 Provider 缺少能力时保持正常收发。 - -反馈 attempt 元数据保存在既有 Source Event payload,完成记录引用原 Outbox;管理 snapshot 暴露最近状态,不复制消息内容。写入前持久化 attempt,超时/未知/重连/重启均不自动重试;有界异步调用不阻塞接收、模型或回复。静音仍可产生 silent Admission,屏蔽阻止新 Admission;静默结束、委派完成、待确认问题及无关输出不表示已回答。平台接受不表示 Human 已读或样式已经验证。详见 [ADR-0149](../adr/0149-lark-feedback-follows-admission-and-accepted-reply.md)。 - -Slack 的 #1161 候选复用这条 application-defined Messaging 反馈链:checked Provider 把 `received`/`answered` 映射为原始 Human 消息上的 `eyes`/`white_check_mark`,回答后移除自身的 `eyes`。Slack 静音会话不产生反馈;Lark 既有 silent Admission 语义不变。Provider 仅串行同一来源的反应写入,不占用 Bot transition queue 执行网络写入;每次写入重查来源、账号、Consumer 和授权 fence,部分写入/清理不确定保留 unknown,不自动重试。本候选不提升现有 Provider pin;真实 Slack 频道与 DM 证据仍是独立交付门槛。 - -### 绑定的应用接收默认流量(ADR-0142) - -Application-defined IM DM commands (`/commands`, `/help`, `/status`, `/model`, `/effort`, `/stop`, `/new`, `/compact`) reuse this intake's Binding, sender admission, Source Event and deduplication. Only a whole text-only DM is a command; groups, attachments and unrecognized text retain their existing path. Claimed command Admissions are handled without model input or wake; `last_error` retains pending/unconfirmed command work for inspection rather than automatic replay. The Messaging owner serializes commands per PersonaBot across Bindings, rechecks current source authority and management pairing capabilities at execution, and passes a current-authority fence through asynchronous model validation and Session replacement. Model changes use the existing Orchestrator-only customization owner and preserve Assignment inheritance. Replies use the source-bound Outbox's intent, attempt, final-send fences and accepted/failed/unknown outcomes (ADR-0106/0129); a duplicate never executes or sends again. Weixin changes require an admitted paired-owner DM; other platforms require an approved management pairing with current approval capability, or refuse with Web guidance. `/new` and `deepseekbot bot-new-session` share the idle Orchestrator replacement runtime. `/compact`, the Bot DM composer's `/compact` and `deepseekbot bot-compact` share Idle Compaction's DSH `/compact` path: they refuse while the Bot has a running turn or pending Inbox Admission, and compact only a live Orchestrator Session. - -接收以外部身份 Binding 为键,而不是保存的目标。启用的 Binding 把应用的默认流量(私聊和合格的群 @)接收进绑定 Bot 的 Inbox。会话的第一条被接收的消息,会与其 Source Event、Inbox Admission 在同一事务中提交一个隐式会话条目(`messaging_grants` 中 `origin: 'implicit'` 的行),使 Outbox fence、群策略、Channel Bridge 路由和撤销仍挂在同一个锚点上。Binding 的新会话模式(`auto` 或 `ask`)及其上限(每小时 20 个新条目、500 个活跃、200 个等待)决定新会话被接收还是等待处理。屏蔽按 Bot、应用指纹、会话类型和 ID 持久保存;静音只推进 `preferenceRevision`,不改动条目的 Outbox revision。一个 Bot 可以绑定同一平台的多个应用,每个应用仍只属于一个 Bot。Client 中,应用的会话列表管理这些条目并显示已有的 Channel Bridge 路由(新建同步正在围绕外部连接器重新设计),「外部连接器」列出这些路由并保留保存目标的 Service Grant,作为 Provider 无法列出或直接发往会话时的高级后备。 - -外部应用的准备步骤由官网教程维护,既有 **绑定应用** 弹窗提供对应语言的教程链接,不再显示独立 Lark 配置引导卡片。Driver.js 保留为应用内部控件定位工具,不承载平台配置说明。绑定、接收与回复结果仍来自既有 Provider/Host 权威;打开教程不产生授权或发送,也不新增引导存储。 - -### 首条外部出站路径(ADR-0101) - -[ADR-0127](../adr/0127-product-artifacts-compose-an-independently-versioned-im-provider.md) 提议在开发用 fork 政策之外增加产品分发路径:打包的 `deepseekbot` 固定 Core、Client、Browser、Computer 和独立版本的 `@botharness/im-provider`,通过同一 Bundle Patch 激活全部六个组件([ADR-0160](../adr/0160-deepseekbot-default-bundles-browser-computer-and-im-provider.md))。Provider 保留 SDK、凭据、存储身份及公开 Service;Core 保留所有 application-defined authority。构建校验不可变输入、重建运行时来源和 tarball 完整性。原生优先从 CLI 安装位置解析 Bundle,因此产物验收使用独立安装的官方 CLI,避免误用开发链接。初始不连接账号;重复的 standalone Bundle 在启动前被拒绝。Provider 随产品更新。此构建路径不发布 npm、不自动资格验证其他平台,也不免除 Human QA 和生产启用门槛。参见[打包验收指南](../product-im-installation.md)。 产品 Provider 的来源、DSH 版本和运行时摘要独立于开发版固定值;改变产品输入需要递增自身版本并重做实际安装验收([#868](https://github.com/BotHarness/BotHarness/issues/868))。 - -[#1115](https://github.com/BotHarness/DeepSeekBot/issues/1115) 经 application-defined Messaging Provider 的可选可达群查询/checked first-post 契约扩展同一主动发送路径。Host 保留 Binding、会话屏蔽和 Outbox authority;首次发帖只创建既有 implicit 会话条目,不生成 Source Event、Inbox 或本地 DM。Generation 89 在 Binding 保存每小时发帖限额(默认 60,可关闭),以既有 Outbox attempt-start 计数,未知结果不重试。可选契约覆盖 Lark、Slack、Discord;实际平台权限、版本绑定的原生验收及 #1111 Provider 发布仍独立。详见 [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md)。 - -PersonaBot Profile 的 IM 连接经现有 Typert/API Gateway 选择账号和已测试目标,再由 Human 建立 Binding 与单目标主动发送 Grant。application-defined Messaging Provider Registration 由 Consumer Fiber 持有,Binding/Grant/Outbox 使用同一 `botharness.db`。接受和执行都复核活跃 Bot、Grant、Registration、认证账号 fingerprint 和目标内容 digest;删除、改址或账号变化要求显式重新授权。先提交 Intent 和 attempt-start,再调用 provider,结果只记平台接受、明确失败或未知;重启不重发 pending/in-flight,未知结果留待 Human 核对。 - -生产适配需要 dsh-im 公开、版本化的 `describeBot`/`sendChecked` 契约,以在账号 transition 中验证平台身份并冻结已授权路由。这是待上游接受的小型扩展;已发布 `4.32.0` 不满足该契约,BotHarness 默认禁用这条出站 authority。隔离开发可通过 `dev-instance --im-provider` 安装 [ADR-0104](../adr/0104-isolated-im-profiles-pin-a-qualified-temporary-provider-fork.md) 指定的临时 fork 完整 SHA,并在启动前校验运行时代码 digest;这不代表上游已发布或生产启用。dsh-im 仍持有 SDK/连接/凭据与原设置入口;BotHarness 不读取其私有 JSON,不接管 standalone Session 路由。#12 通过公开 `consumeInbound` 独占接收显式授权群内的文字 @:Messaging 先提交 bridge-message Source Event 与独立 Bot Inbox Admission,commit 后确认,再沿既有 group-mention harvest/steer 唤醒 Orchestrator。来源不需要本地 Channel placement,模型从 Inbox 了解接收身份、发送人及 group/thread/root/parent;`bridge_read` 读本地已保存来源,`bridge_reply` 仅接受来源 ID 与正文,Host 推导本 Bot 的有效身份与原回复路由,复用 Outbox 后调用公开 `replyChecked`,不镜像到 Human DM。撤销、归档、consumer 丢失、旧 Grant revision 都不能授权未来收件或尚未开始的回复;重启不重发未知 Outbox。外部来源 turn 不扩展 Memory acceptance 的来源授权,也不自动写入 Memory。详见 [ADR-0106](../adr/0106-exclusive-im-intake-commits-bot-inbox-before-acknowledgement.md)。 #612 的 `bridge_context` 通过同一 Inbox 来源和 Bot 身份调用公开 `historyChecked`,区分群列表、附近 ±5 分钟 Chat 时间窗与可信 Thread 列表;Host 按 JSON 预算仅将完整返回内容 reconcile 到 canonical Source Event,并记录有界读取 metadata。普通历史不建立 Admission、不触发 wake;已经存在且实际返回的 Admission 沿用当前 turn 的成功/失败处理。Provider 返回的显示名仅补充同一 canonical 身份;发送人和 @ 人员 ID 保持身份依据。Inbox 沿用 harvest 的 Message/Source Event 引用格式,上下文返回可用的姓名和 @ 关联,缺失姓名时回退 ID。Human 来源详情显示读取记录与最近一页,不自行读取远端。 - -### Lark 管理员配对(ADR-0136) - -[#1027](https://github.com/BotHarness/BotHarness/issues/1027) 的 application-defined 配对 owner 使用 Messaging 所属的 Operational Database,以当前 PersonaBot、外部身份 Binding、Provider 认证的真实用户及原始私聊路由固定授权范围。已绑定 Lark 身份在既有独占 Consumer fanout 上保留账号级控制入口:纯文本 `/pair` 先提交申请再确认,不创建普通 Source Event、Bot Inbox Admission、模型唤醒或 Memory 写入。普通消息仍需自己的接收授权;删除聊天路线不关闭配对入口,也不产生普通 DM 接收权限。Client 显示实际接收器是否就绪。 - -首次能力只能由认证 Web Human 显式勾选并按当前申请 revision 审核;定位码不能兑换权限,首位申请者不会自动成为管理员。能力仅覆盖当前 Bot 的批准、拒绝、回答及保存规则资格,不扩展 VPS、原生 DSH API、其他 Bot 或审批人管理。每次使用重查当前 Binding、Bot、真实 actor、能力和状态;暂停期间不可用,永久撤销后重新配对必须重新审核。该切片交付配对与 Web 审核,IM 审批/提问控件仍由后续切片接入原生权威。参数、恢复及审计边界见 [ADR-0136](../adr/0136-lark-pairing-is-reviewed-bot-scoped-operational-authority.md)。 - -### Lark 普通角色与自动申请(ADR-0165) - -Sender permission diagnostics validate the Source Event's original ingress authority before reporting that a platform has no Role policy (#1951). Grant-backed sources retain their Binding and Grant checks; Conversation Ingest sources record `ingressBindingId` in the canonical Source Event and validate that original Binding plus the current Ingest. A shared reader needs its own current Binding in the same Provider/account scope, and any resolved Role must belong to that checked Binding. Disabled, revoked, replaced, blocked, or unavailable authority remains a refusal. Legacy Ingest sources without an ingress Binding anchor return `no-current-scoped-identity`; a fresh message supplies the evidence rather than attributing old content to a replacement Binding. This adds no content copy, grant, listener, or schema migration. - -The #1415 candidate extends the same ordinary-chat authority to checked Slack DMs. The existing Bot-wide restriction covers Lark conversations and Slack DMs, remaining off by default; a Slack actor is identified as `slack-workspace-user-id` under the current Bot/App/Provider fingerprint and Binding. Unknown restricted DMs create only pairing metadata and a fixed notice; Web review assigns an existing Role, approval asks again, and only a fresh message enters the canonical Source Event/Admission/reply path. Current permission/directory reads reuse the same owner, and revoke/restart/rebind retain their fences. Slack group and native management parity remain separate. There is no new schema, listener, credential store or native RBAC. The isolated source candidate passed actual Web approval, fresh DM/current permission/directory reply, revoke refusal and restart with matched Source Event/Admission/Outbox facts; Human interaction feedback is accepted. Published product and Slack group/native management qualification remain separate. See [ADR-0174](../adr/0174-slack-dm-chat-pairing-reuses-current-binding-authority.md). - -#1415 候选将同一普通聊天权威扩展到 checked Slack 私聊。Bot 既有发送者限制覆盖 Lark 会话及 Slack 私聊,默认仍关闭;Slack 人员以当前 Bot/App/Provider fingerprint 和 Binding 下的 `slack-workspace-user-id` 标识。未知受限私聊只产生配对元数据和固定通知;Web 审核分配已有角色,批准后要求重问,新的消息才进入规范 Source Event/Admission/回复路径。当前权限与目录读取沿用同一 owner,撤销、重启和重新绑定保留各自 fence;Slack 群聊及原生管理能力仍由独立验收推进。没有新增 schema、监听器、凭据存储或原生 RBAC。隔离源码候选的真实 Web 批准、新私聊/当前权限/目录回复、撤销拦截与重启已通过,Source Event/Admission/Outbox 与原生结果一致,Human 已接受交互。已发布产品、Slack 群聊及原生管理能力仍独立验证。 - -[#1423](https://github.com/BotHarness/DeepSeekBot/issues/1423) adds application-defined Behavior permission items at the same Messaging owner. Groups reference Bot-scoped stable IDs with explicit Allow/Deny; current permission queries deduplicate overlaps, retain every Group revision/source and expose same-item conflicts with Deny priority. One primary behavior Role remains applicable. Settings provide a shared item table, separate edit/create dialogs, searchable Group checkboxes and affected-person previews; stale item/group revisions refuse writes and referenced-item deletion requires removing Group references first. The second slice adds bilateral authored exclusions: a shared pure resolver previews incompatible effective Allows, and the canonical transaction refuses item/group/member edits or Deny deletion that would introduce them. Reciprocal links and revisions remain in the existing item records, with no schema change. Generation 81 persists items in Operational Database; authenticated senderAccess commands and the existing bridge_sender_permissions Tool remain the sole write/read seams. Text guides model behavior, not arbitrary resource isolation or proof of semantic consistency. See [ADR-0173](../adr/0173-group-behavior-items-resolve-by-id-with-explicit-deny.md). - -#1423 的第二片沿用同一 Messaging owner:Human 按稳定权限项 ID 声明双向互斥。共享纯 resolver 预览最终同时有效的 Allow;Host 在既有事务中拒绝会引入不兼容的权限项、组、成员编辑以及 Deny 删除。明确拒绝仍优先;双方引用与版本保存在现有记录,不增加 schema。配置检查也覆盖暂时停用 Binding 的成员配置,避免重新启用暴露潜在冲突。任意文字或主 Role 的语义矛盾不由此自动识别。 - -[#1406](https://github.com/BotHarness/DeepSeekBot/issues/1406) adds application-defined External Authorization Groups and person exceptions at the same Messaging owner. One current Chat pairing can belong to multiple groups in its exact Bot/App Binding while keeping one primary behavior Role. Each current resolution unions Role, group, legacy management and person Allow, then removes any explicit Deny; private native delivery, decision and unused approval checks use this result. Group membership cannot approve chat or mint a management destination, and revoke/re-pair never inherits old member IDs or exceptions. Direct authenticated Web Human input retains default complete product authority and native execution approval. Generation 79 stores bounded revisioned group records in the Operational Database; existing authenticated senderAccess commands, snapshot and on-demand Tools carry the current sources without a second Consumer or cache. The Client adds Groups, scoped member selection and personal exceptions to pairing settings. See [ADR-0168](../adr/0168-external-groups-are-current-scoped-management-authority.md) for migration and boundaries. - -#1406 在同一 Messaging owner 增加应用定义的 External Authorization Group 与个人例外。当前 Chat pairing 可加入同一 Bot/App Binding 下的多个授权组,仍保留一个主行为 Role。每次解析先合并 Role、组、已有管理配对及个人 Allow,再扣除任意显式 Deny;私聊原生审批的投递、决定及尚未使用的批准都读取此结果。入组不批准聊天、不创建管理目的地,撤销后重新配对不继承旧成员 ID 或例外。直接认证的 Web Human 输入默认保留完整产品权限与原生执行审批。Generation 79 在 Operational Database 保存有界且按 revision 修改的组记录;沿用认证 senderAccess 命令、快照与按需 Tools 展示当前来源,不增加 Consumer 或缓存。Client 配对设置加入授权组、范围内成员选择及个人例外;迁移与边界见 ADR-0168。 - -人员、角色与申请表格的创建/修改时间来自 Messaging owner 持久化的 JSON 元数据,不由 Client 加载时间推断;读取和通知投递记账不推进修改时间。旧记录未记录的时间保持未知。时间字段是兼容的可选元数据,沿用 Operational Database authority,不增加 SQL schema generation。 - -[#1414](https://github.com/BotHarness/DeepSeekBot/issues/1414) extends the same owner with explicit `review-pairing` and a separately resolved Management Delegation Ceiling. A Lark group-only request can receive a public pairing card without a private-DM prerequisite; current reviewers with an observed trusted private route may also receive private copies. The qualified Provider retains its exclusive Consumer and a distinct `botharness/pairing-v1` callback namespace. Messaging checks the actual actor, current Binding and route, pending request and selected Role revision, and the reviewer's current use/delegation authority before a single canonical decision. Generation 80 stores bounded card receipts and actor-attributed selections; uncertain sends are not blindly repeated. Cards disclose review metadata rather than the blocked instruction. Approval only notifies the applicant to ask again; a new valid message must enter the normal Admission and model path. Group pairing cards do not change the private destination contract for native execution approvals or Questions. See [ADR-0171](../adr/0171-pairing-cards-check-current-review-and-delegation-authority.md). - -#1414 沿用该 owner,增加显式 `review-pairing` 与独立解析的 Management Delegation Ceiling。仅有群的 Lark 场景可直接收到公开配对卡片,不要求先建立私聊;已有可信私聊路由的当前审核人也可收到私聊副本。合格 Provider 保持独占 Consumer,使用独立的 `botharness/pairing-v1` 回调命名空间。Messaging 在唯一权威决定前检查真实操作者、当前 Binding/路线、待审核申请与选中 Role revision,以及审核人当前的使用/委派权限。Generation 80 持久保存有界卡片回执及具操作者归属的选择;未知发送结果不盲重试。卡片只显示审核元数据,不包含被拦截旧指令。批准只通知申请人重问;新的有效消息才进入既有 Admission 与模型路径。群配对卡片不改变原生执行审批或 Questions 的私聊目的地边界;详见 ADR-0171。 - -#1376 adds an explicit visitor exception on one existing allowed Bot/App group grant, independent of pairing and collection. Unpaired ordinary authors collected through authorized group/Bridge/thread/ingest paths remain passive (`silent` admissions, `context` scheduling evidence, no count/interval), never fill eligible wake thresholds, and may supply trusted author context to a later eligible request. The current requester alone supplies role-governed behavior; no background author union grants management powers. Closing, blocking and rebinding retain the independent authority fences. - -#1376 在已允许的具体 Bot/App 群授权上增加显式访客例外,与配对及采集独立。经授权的群、Bridge、线程或 ingest 采集到的未配对普通作者保持被动上下文(`silent` Admission、`context` 调度证据、无 count/interval),不填充有效请求的唤醒阈值,但可为后续有效请求提供带可信作者身份的上下文。角色行为以当前请求者为准,不合并背景作者的管理能力;关闭、封禁及重新绑定仍保留独立权限检查。 - -The #1375 application-defined Role mutation commands reuse this owner and authenticated sidebar seam. Revision-checked edits and single-role reassignment advance current policy revisions without rewriting historical Tool Results. The existing qualified private approval route reads current `approve`/`reject` Role capabilities for ordinary DM-origin pairings while preserving legacy explicit management grants. Group-origin pairing does not mint a management destination. Exact actor/Binding/pairing/receipt/native-lifetime checks remain Host owned; capability removal refuses delayed decisions and reassignment invalidates old routes. Natural-language behavior is not a resource ACL. - -The #1033 private Lark candidate adds independent `save-rules` management authority to that same resolver. Its distinct checked Provider callback previews the complete native operation and confirms a request/actor/private-receipt-bound nonce before the existing Channel Tool Approval owner can save and activate a rule. Generation 82 stores verified external creator provenance in the existing canonical Tool Approval Rule record. Each later match and final execution guard checks the original pairing and current `approve`/`save-rules` capabilities under the same Bot/App Binding. Authority loss permanently revokes the rule; re-enable and re-pair cannot restore it, while an independent still-effective grant preserves it. Web Human rules retain their prior authority. An IM-only approval creates a missing canonical Bot DM through the existing Channel Store and registry display-name lookup; it does not require opening Web or create a new Inbox Admission. Web and card status read the same canonical rule; fixed-text receipt-only cleanup may remove stale standing-rule claims after creator revocation without granting a destination or disclosing operation details. Native session-wide approval remains Web-only, and Workspace/Service grants are not enlarged. The Human accepted the actual exact-rule slice. The next candidate offers separately disclosed all-opaque scope only with Provider rule-card version 2 and its account capability; kind-bound nonces prevent an exact confirmation from granting that broader rule. External rules cannot auto-match Web-only native requests. The Human accepted the QA1 all-opaque first slice, and actual changed-input, Grant-scope, revoke/restart and final-card checks passed; an interrupted disabled-card update needed explicit idempotent receipt refresh. Published Provider qualification remains separate. See [ADR-0175](../adr/0175-external-tool-rules-retain-current-creator-authority.md). - -The #1041 source candidate observes Runtime-owned committed `sessionFailure` Channel messages and verifies Session ownership. Default-off event settings and bounded delivery metadata live in Messaging's Operational Database. Failure alerts resolve the same current private management route as approvals, qualify its genuine DM source through the checked Provider, then apply a final authorization/Registration fence. Only fixed failure/Session metadata is sent; error bodies and prompts remain canonical in Web. Delivery does not hold the failed turn, grants no decision capability, retries only known-unsent attempts within three tries and preserves Unknown Outcome without replay. Restart cancels pending intents and marks interrupted sends unknown. Bot DM sidebar controls use the existing authenticated Typert/API Gateway seam; group/additional Provider and Host-down monitoring remain separate. See [ADR-0176](../adr/0176-work-failure-alerts-use-committed-facts-and-management-route.md). - -#1041 源码候选观察 Runtime 已提交的 `sessionFailure` Channel 消息并校验 Session 归属。默认关闭的事件设置及有界投递元数据由 Messaging Operational Database 持有。失败通知沿用审批的当前私聊管理目的地,通过 checked Provider 核对真实 DM 来源,并在发送前校验授权及 Registration。仅发送固定失败/Session 元数据,错误正文和提示保留在 Web;通知不阻塞失败工作、不授予决策能力,仅对确定未发送尝试最多三次,未知结果不重放。重启取消等待意图并把中断发送记录为未知。Bot DM 侧栏经现有认证 Typert/API Gateway 配置;群、其他 Provider 和 Host-down 监控仍由独立功能推进。 - -#1375 的应用定义 Role 修改命令沿用同一 owner 和认证侧栏接口。按 revision 编辑角色或为人员重新分配单一角色会推进当前策略 revision,但不改写历史 Tool Result。现有合格私聊审批路线为从 DM 配对的普通人员读取当前角色的 `approve`/`reject` 能力,并保留既有显式管理授权;群内配对不创建管理目的地。真实 actor、Binding、配对、回执和原生请求生命周期仍由 Host 校验;移除能力拒绝延迟决定,重新分配使旧路线失效。自然语言行为不构成资源 ACL。 - -[#1373](https://github.com/BotHarness/DeepSeekBot/issues/1373) 沿用 Messaging owner 与 Operational Database,新增普通 External User Role、默认关闭的发送者限制和 `conversation` purpose 配对;既有 `management` purpose 的 `/pair` 显式能力保持独立。普通角色先支持自然语言行为权限与空管理能力,不新增 DSH 原生 RBAC,也不宣称隔离邮件、代码或任意 Shell 资源。配对按当前 PersonaBot、App Binding/fingerprint 与 Provider 认证的 Lark open ID 限定,可跨允许的 DM/群复用,但不跨 Bot/App/平台或重新绑定继承。Host 在默认接收、显式 Bridge、会话接入、共享来源及模型消费处逐 Bot 校验资格;不同作者的角色不合并。 - -未配对的受限 DM/群 @ 先持久提交有界审核元数据,不保存旧指令正文、不建立请求 Source Event/Inbox Admission、wake 或 Task。认证 Web Human 在 Bot DM Channel sidebar 经既有 API Gateway/Client seam 选择角色并按 revision 审核。批准先提交,再通过当前独占 Consumer 的受检查回复能力发送固定重问通知;只允许新的有效消息进入原有模型回复/Outbox 路径。通知尝试在外部调用前记录,未知结果不盲重试;接收回调先返回,避免与 Provider 账号队列死锁。撤销立即关闭后续资格及尚未执行的相关 Admission,既有历史保留。Generation 78、迁移和边界详见 [ADR-0165](../adr/0165-lark-chat-pairing-is-reviewed-current-binding-authority.md)。三个只读目录与完整首轮真人反馈仍由 #1374 交付,角色编辑/重新分配与显式开放群/被动上下文分别保留 #1375/#1376 的真人依赖。 - -The admitted Inbox context carries compact trusted sender/App/Source Event references rather than automatically injecting roles or permission snapshots. The application-defined read-only `bridge_sender_permissions` Orchestrator Tool calls the existing Messaging owner, resolves one accessible source and returns current pairing status, effective Role, policy revision and query time. It creates no authority or observation. Stable guidance calls for a fresh lookup of the actual requester before a new permission-sensitive operation, while ordinary chat need not query; unresolved lookup defers the governed operation. Historical Tool Results are dated evidence. Deterministic admission and supported management checks remain Host-owned. - -入箱消息只附可信发送者、App 和 Source Event 引用,不自动注入角色或权限快照。应用定义的只读 `bridge_sender_permissions` Orchestrator Tool 沿用 Messaging owner,解析可访问的规范来源,返回当前配对状态、有效角色、policy revision 与查询时间,不创建权限或观察记录。稳定行为指导要求对新的权限敏感请求重新查询真实申请人,普通聊天不强制查询;无法解析时暂缓受该策略约束的操作。历史 Tool Result 只是带日期的证据。确定性的接收资格与已支持管理动作仍由 Host 校验。 - -The #1374 `bridge_directory` Tool adds three bounded/pageable reads through the same Messaging owner and Operational Database: effective ordinary paired people/current Roles, known configured external conversations/states, and paired people observed in the conversation of an accessible trusted Source Event. Observations intersect current Bot/App/Binding pairing with authorized retained sources, including current joined shared placements, and exclude purged or previous-Binding evidence. At most 1000 recent matching sources are examined per query; query time, first/last observation times, truncation and incomplete coverage remain explicit. This is never a complete/current platform member list or a new destination/impersonation authority. Role-governed questions still resolve the actual requester with the current-permission Tool. Directory results are queried as needed, group replies remain visible to the whole group, and the complete first-flow Human feedback remains required before #1375/#1376. - -#1374 的 `bridge_directory` Tool 沿用同一 Messaging owner 与 Operational Database,提供三个有界可分页只读视图:有效普通配对人员与当前角色、已知已配置外部会话及状态,以及在可信可访问 Source Event 所属会话中观察过的配对人员。观察结果将当前 Bot/App/Binding 配对与授权保留来源取交集,包括当前加入的共享 placement,并排除已清除或旧 Binding 的证据;每次最多检查最近 1000 条匹配来源,明确给出查询时间、首末观察时间、截断与不完整覆盖。它不代表完整或当前平台成员名单,不授予新目的地或冒充权限。受角色策略约束的问题仍对真实申请人调用当前权限 Tool;目录按需查询,群回复仍全群可见,完整首轮真人反馈仍是 #1375/#1376 的门槛。 - -### Lark 私聊审批(ADR-0141) - -[#1029](https://github.com/BotHarness/BotHarness/issues/1029) 将已提交的工具审批发往 Web 显式选择的已配对管理私聊;通知路由及回执由 Messaging owner 在现有 Operational Database generation 60 保存,完整请求/决定仍归 canonical Channel 与 native Session。通知、决定接受、原生执行结果和卡片更新分别投影。现有独占 Consumer fanout 单独处理官方 SDK 卡片操作,不建立普通 Source Event、Inbox 或 Memory。公开的受检查 Provider capability 负责原始私聊/实际发送人、自己的卡片回执和平台写入前 fence;Host 校验实际点击者的当前 Bot/Binding/fingerprint/配对能力与 revision、目的地、request↔receipt、完整操作及原生执行者。允许一次/拒绝重入现有工具审批 broker,Web 与 IM 共用一位胜出者,执行前再次核验撤权与完整参数。回调确认不等于批准,批准不等于成功执行;重启使旧卡片失效,未知发送结果不重发。该切片不保存规则、不处理群审批/原生问题,也不释放 native wait。见 [ADR-0141](../adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md)。 - -### Bot 之间的 Channel 协作(ADR-0065) - -Orchestrator Session 中注册的 `group_attention_get` 与 `group_attention_set` 是 DSH model-facing Tool,消费 BotHarness Host 的 application-defined Channel capability;Host 从该 Session 所属 PersonaBot 确定 actor,并在读写时复核当前 Group membership。未保存的 Group 偏好只读为 digest 5 条/30 秒、revision 0;Human Bridge 写入和 Bot Tool 写入共用 Channel record 中的 `wakePolicies`,每次实质变更在同一 SQLite 事务里追加不可改写的 actor/时间/revision 审计事实。审计表只存历史,不作为第二套当前偏好;Admission 在消息提交时固定有效策略与 revision,不会随之后的编辑回写。 - -#366 把 Human DM、Bot DM、群内提及、普通群消息、入群邀请与申请流程、Assignment 报告及生命周期的内建来源规则写成每个 PersonaBot 本地的不可改写修订事实。Host 在建立新 Inbox Admission 的同一事务里读取对应规则并保存来源规则 revision/wake 快照;既有 Admission 不回填。普通群消息的来源默认值沿用 5 条/30 秒 digest,适用的 per-Channel `wakePolicies` 覆盖仍保留在 Group Channel record;Assignment 进度报告仍按报告状态与回复请求决定是否即时唤醒。资料页经 Host Bridge 查询所有有效规则、修订及最后修改者。来源类别修订与 Channel 覆盖分属不同作用域。 - -#370 的 `source_attention_get/set/reset` 是仅对所属 PersonaBot 可见的 Orchestrator Tools,Human Profile 通过 Typert/API Gateway Bridge 编辑同一 `bot_source_policy_revisions` 修订权威。v1.0 可编辑提醒强度的来源为 `assignment-report`(`conditional`/`immediate`)与 `group-ordinary`(`immediate`/`digest`/`mentions`/`silent`,汇总条数及间隔有界);两者始终 `admit`。`human-dm`、`bot-dm`、`group-mention` 始终接收并即时唤醒;Human Bridge 与所属 Orchestrator 的 `source_attention_set/reset` 可编辑/恢复 `delivery: steer | turn`,默认 `steer`。直接来源要求 `wake: immediate` 和显式 delivery,拒绝汇总参数;其他可编辑来源拒绝 delivery。Host 在投递唤醒时读取当前 delivery;Admission 保留入队时的 wake 与修订。`group-invite`、`group-join-request`、`group-join-decision`、`assignment-lifecycle` 保持即时工作流通知;其中与 completed Report 明确配对的成功执行通知只搭乘收割,不重复触发同一因果唤醒(ADR-0077);其余来源没有安全的延后/静默收割语义,因此规则只读,Bot Tool 与 Human Bridge 都拒绝其修改。删除可编辑覆盖以带 Human/Bot actor 的新修订恢复内建默认,不删除历史;后提交的 Admission 才读取新修订。Orchestrator 真正启动时为所纳入的来源类别追加不可改写的 wake-attempt 事实,近七天计数从这些事实投影,而非把 Admission 数或汇总阈值当作唤醒次数。Group Channel 的普通消息覆盖继续独立存于 Channel record,优先于 PersonaBot 来源默认值;冻结规则按 ADR-0076 延后。 - -Attention 已交付契约(ADR-0070/0074/0077、#364):四档偏好均为普通群消息保留该 Bot 的 Inbox Admission。`mentions` 不自行唤醒,但同群直接 @ 可带入有限的待处理上下文;`silent` 不自行唤醒,也不搭乘 @,只在 Bot 显式读取时进入本轮。群聊触发 harvest 时同时选择触发消息附近的上下文与最早待处理的一段;单群至多 100 条,并受整轮文本/token 预算约束。提示中明确省略数量与继续读取位置,未选中消息保持待处理,后续合格轮次继续从最早处推进。`channel_read` 只把实际返回并进入本轮的 Admission 纳入处理集合:进入本轮显示处理中,成功结束才已处理,失败显示需修复。内部 observed 保留审计用途,不新增常用“完成”工具,也不把 Human 打开 Channel 当作 Bot 处理。#362 的通用多来源 Inbox Trigger 与 Attention 聚合仍待后续切片。 - -当前 #47 首个高流量 Group 切片把每位成员的普通消息 Wake Policy 写在 Channel record;该 preference 归 Bot 所有,Bot 可用自己的工具读写(含 count/interval),Human 可在成员侧栏查看并覆盖;四档:`all`(每条普通消息即时成为 attention)、`digest`(N 条 / T 秒汇总;默认)、`mentions`(只有直接 @ 到达)、`silent`(记录但不唤醒、也不搭车,只能显式读取)(ADR-0074)。任一模式下,普通消息作为 Source Event 与该 Bot 的 `group-ordinary` Inbox Admission 在同一 SQLite 事务中提交,Admission 固定当时的 policy revision;即时模式逐条就绪,汇总记录阈值,mentions 与 silent 记录空阈值。未保存偏好时采用 digest 的默认阈值,保留已保存的模式与 revision。直接 @ 走独立的即时 Admission,不等待汇总。Host 在达到数量或时间上限后把该 digest 批次标记为就绪;忙碌期间新到的即时项不再各自排队,只标记就绪集合,当前回合结束或空闲时由一次 harvest turn 消费全部就绪项(ADR-0077)。直接 @ 与 DM 默认 steer:有活动回合时在下一个安全 step 注入,否则并入下一次 harvest。重启从 pending/retryable Admission 重建计时,收割只把实际纳入 Orchestrator context 的 Admission 标为处理中,回合成功结束才标为已处理;Bot 通过 `channel_read` 明确读取到的普通群消息也加入该回合,成功后标为已处理,失败则需修复,未返回的消息保持待处理;是否向 Channel 回复仍由 Bot 决定。静默收件为普通消息保留待处理 Admission,但不设置汇总阈值,不自动唤醒,也不进入 harvest;只有显式 `channel_read` 才会进入本轮处理,直接 @ 仍即时。归档中的 Bot 在消息提交时不产生新的 Inbox Admission;群消息与其他活跃 Bot 的收件仍独立提交。唤醒处理按 Source 类别而非平台分类:外部平台在 Bridge 边界归一化为 Source Event,运行时只认 Source 类别与 admission reason(ADR-0075)。Bot 自管自己的 attention policy(来源类别规则 + per-Channel 覆盖 + count/interval),Human 可覆盖;冻结规则按 ADR-0076 延后,安全闸门不属于 policy。通用多来源 Attention 聚合与 Inbox Trigger 仍待后续切片。 - -五个应用自定义 attention Tools 使用现有逐 Bot 的来源策略与 Channel preference Provider。`source_attention_set` 省略 `sourceClass` 时默认 `assignment-report`,仅允许 `conditional|immediate` 且不能传 digest 参数;`group-ordinary` 允许 `immediate|digest|mentions|silent`,只有 `digest` 接受 digest 参数。计数与间隔使用整数 schema,由 Host 强制执行 1–100 与 1–3600 秒边界,省略时保留有效设置。逐 Channel 的 `group_attention_set` 在所有模式下保留可选 digest 设置。Source reset 恢复内置规则,不清除 Channel override,也不改变历史 Admission。读取、编辑和重置返回有效值、修订、最后编辑者/时间及有界七日 source wake 计数;非法组合在策略写入前失败。 - -当前 Bot-scoped botAttention Bridge 查询直接从 Inbox Admission、Source Event、Channel placement 与 Assignment Directory 投影有界页,按 Source Event 时间与 ID 排序,返回状态、发送者、摘要以及可用的 Channel 消息或 Assignment Session 引用;它不另建收件内容。Assignment Report 与 Source Event 在同一事务中形成该 Bot 的 Inbox Admission,并带报告状态;报告进入 Orchestrator 回合时记录观察,回合完成后标记已处理,是否向 Channel 回复由 Bot 决定。重启后未观察的到期报告可唤醒一次,已进入回合却中断的报告显示需要修复。Human–PersonaBot DM 的 Channel sidebar 在有事实时显示 Bot Inbox entry,按来源 Channel 或 Assignment 分组,待处理项展开,已处理和明确忽略的历史折叠;Bot 在收到或读取某条 Channel 消息后可用 `inbox_ignore` 明确忽略,决定写在同一 Admission 上并保留消息历史;回合完成本身不再 resolve 任何发给 Bot 的条目:它们变为 open,直到 Orchestrator 用 `inbox_resolve` 标记 done 或 ignored(ADR-0188),阅读本身不自动写入长期 Memory。点击来源消息复用 Channel timeline 的 around 定位,点击 Assignment 报告打开事项详情。Group Channel 不显示该 entry;Human 查看侧栏不改变 Bot 的观察事实(ADR-0070、#47、#152)。 - -Human Inbox 的首个可运行切片在 Bot mode 左侧栏的 Messages 上方提供独立入口,默认显示待 Human 处理的群聊加入申请、仍存活的原生提问和工具审批;#546 已交付的未读视图将群聊及 Bot→Human DM 按 Channel 汇总,信息视图保留无 Channel 的事项完成报告。提问项只在 DSH 原生请求仍等待 Human 答复,工具审批项只在 BotHarness 审批请求仍存活、且 Channel 内没有答复、取消或审批决定时出现;打开后定位到对应私聊卡片。事项的最新报告为 `waiting-human` 且 open ask 仍存活,或报告为 `blocked` 且尚未解决时,Human Inbox 从 Assignment Directory 投影同一条按 Session ID 稳定标识的待办;状态升级为受阻时更新摘要与报告来源,不重复建项。Orchestrator 回复后事项运行期间暂隐藏该项;若事项再次空闲或出错但没有新的解除受阻报告,待办继续显示。打开后进入该 Bot 私聊并展开事项详情;完成报告或停止事项后待办消失。事项完成报告则按最新 Source Event 投影到“仅供了解”,Human 可打开来源或忽略该份报告;忽略决定单独保存 Source Event ID、决定与时间,新报告仍会出现,不复制 Inbox 内容。Host 从 Channel record 中的待处理申请、Source Event/Channel placement 以及 Human 的 Channel read position 投影列表,不另存 Inbox 内容;批准或拒绝沿用 Group 决策事务,已了解沿用 Channel 已读位置。查询按时间与稳定 ID 分页,并把游标绑定到分类、Bot/Channel 过滤条件与排序方向;Client 在切换范围时丢弃旧响应。Bot 的 Channel Admission 进入 needs-repair 时,同一权威按受影响 Bot 与 Source Event 投影一条待办;Human 可打开 Bot Inbox 或来源消息,来源 Channel 已删除时降级到 Bot Inbox,修复状态解除后待办消失。Workspace Grant 请求也从 Bot DM Source Event 投影为待办;新提交的回复只有带有效 Grant 引用才会清除,历史已存储的本地化授权文字回复仍按兼容规则识别;unknown-outcome、rebind 与 readiness 等原因仍待各自的 typed Attention facts(ADR-0071、#126)。 - -[#687](https://github.com/BotHarness/BotHarness/issues/687) 切片让事件展开、行动执行和准确来源跳转彼此独立。只有点击整行才打开 Inbox 详情;工作区行动把权威请求上下文载入既有选择器,回复、提问、审批和 Assignment 行动使用原生 Modal 回应表单。重新打开工作区选择器时重查权威状态;这些控件沿用既有 Host 命令,不把决定权复制到 Client。 - -#547 切片允许本地 Human 在 Human Inbox 查看捕获的群聊或 Bot DM 未读消息、展开相邻上下文并原位回复。Channel owner 按该 Human 的可见范围返回 timeline,并在发送时再次验证回复目标;Inbox 复用现有 `channelSend`,只提交一条带来源消息引用的 Human Source Event。草稿与重试身份仅是 Client 临时状态;Inbox 刷新或发送失败保留草稿,未修改内容的重试复用同一消息身份。看到具体来源内容推进其权威已读位置,展开未读摘要不推进。来源及已确认回复均保留准确的 Channel 消息导航。 - -#550 切片允许 Human 在 Inbox 同一来源上下文面板中查看并决定实时工具审批,复用 DM 审批卡及既有 `toolApprovalStatus`/`toolApprovalDecide` Bridge 命令。认证 Host 再次检查来源请求和实时授权范围,提交权威 Channel 审批决定,并只恢复对应原生调用。批准、拒绝或过期后该请求离开待行动投影,其他 Bot 的请求保持独立,默认最久等待优先。成功或过期失败后 Client 刷新 Inbox 与独立行动提示,并从保留的旧分页中移除已确认解决的项。有界附近消息与准确来源跳转沿用现有 Channel timeline 边界,不新增审批存储或生命周期;已处理历史由 #553 投影。 - -#553 从已提交的 typed Human 回应 Source Event,经 Channel placement 关联原始提问、审批、Grant 请求或 Assignment 报告,投影已处理历史。同一请求 Source Event 只保留第一份权威回应的历史行,携带请求、回答、回应时间、Bot 与来源 DM;不复制消息、不增加历史表。游标绑定类别、Bot、Channel 和排序;阅读消息或忽略完成报告不产生已处理行动。Assignment 原生 Inbox 接收答复后才移除 Human 待办;仅收到 Human DM 保留转发地址和未解除待办;旧 blocked ask 已有权威 Human 答复时,后续明确报告建立新请求。历史上下文独立查询准确回答,不依赖请求附近各两条消息或已过期的实时 broker 推断已处理状态。Client 丢弃旧筛选结果,每次刷新最多重查三页、每页 50 项;更多记录可沿刷新后的游标再次加载。Assignment 上下文高亮准确报告,「查看 Session」进入原始 DSH Session,「查看答复」定位所属 DM 的准确回答。 - -#551 在同一 Inbox 来源面板支持实时原生提问,复用来源 DM 提问卡的准确问题、选项与自定义输入,以及既有 `userQuestionStatus`/`userQuestionAnswer` Bridge 操作。拥有请求的 Channel question broker 校验实时 Orchestrator、目标与答案,提交一次权威回答并恢复原生请求。Client 与工具审批共享决定后的刷新,从保留分页移除已确认回答或过期请求,其他 Bot 保持独立。有界上下文与准确来源跳转沿用既有 Channel timeline,请求生命周期及已处理历史仍归各自既有边界。 - -### 本地 Human 名称目标设计(ADR-0103) - -本地 Human 在一个 DSH Profile 内有一个可选默认显示名,在 BotHarness 插件设置中编辑,未设置时使用 `Human`。Human 参与的每个 DM 或 Group Channel 都可用 **Human Channel nickname** 覆盖默认名,通过 Channel 头部菜单中的「我的昵称」编辑。清除昵称恢复继承;修改默认名只影响没有覆盖值的 Channel。这支持 Human 与不同角色聊天时使用不同称呼,本次只保存昵称,不增加角色背景、另一个 Human 账号或另一份 Human Inbox。 - -既有应用自定义 Messaging 权威拥有默认名与 Channel 覆盖值。Host 按 Channel 昵称 → Human 默认显示名 → `Human` 解析名称,浏览器 tab 不拥有独立副本。名字修改经既有 Typert/API Gateway seam 指向 Host-owned 本地 Human。既有成员记录中的默认 `Human` 不视为用户主动设置的 Channel 昵称。Channel 作者名、成员、提及选择器、收件明细及 Human Inbox 上下文使用同一有效名称;Bot 获得的 Channel 成员及新组装或明确查询的消息上下文也使用它。昵称不授予权限,也不提供角色扮演指令;既有 DSH SessionEvent 与已组装的模型输入仍保留执行历史。 - -Human 与 PersonaBot 的提及保存带类型的稳定目标,在显示时解析可见标签,包括历史消息。Human 名称取消息所属 Channel 的当前有效名称,Human Inbox 中也按来源 Channel 解析;PersonaBot 名称取其身份的当前名称。已知目标优先使用当前名字;目标不可用时可保留记录的标签作为展示回退,绝不按名字改指另一个目标。普通文本不会被重新解释为可信提及。Source Event 内容与原提及范围保持不变,显示标签长度变化不修改持久 offsets,也不产生 Source Revision、新通知、Bot Admission 或 wake。 - -名字允许重复,包括同一 Channel 中的 Human 与 PersonaBot 同名;成员与提及界面区分 Human/你和 PersonaBot,并保留目标 ID。Channel 昵称始终标记同一个 Human ID,阅读位置、行动与提及仍归同一份 Human Inbox。外部账号映射与多人登录留待后续 Bridge 工作。默认名路径使用 Messaging 的 `local_human_names` 记录及受信 `humanIdentity`/`humanNameSet` Bridge 操作;Channel 摘要投影当前 Human 成员,作者、回执和可信提及按其与 PersonaBot 当前身份解析名称。Bot 的 Channel 读取在原消息旁提供带类型的 `actorNames`,仍受既有输出预算约束。名称提交沿用 roster 实时通知,不产生 Channel placement 或注意力事实。各 Channel 覆盖使用 Messaging 的 `channel_human_nicknames`,以 Channel ID 与 Human ID 为键;`channelHumanNameSet` 检查当前 Human 参与资格,拒绝只读 Bot-to-Bot 观察。DM/群聊头部菜单经现有 Bridge 编辑或清除覆盖。明确昵称即使文字与默认名相同也独立保存;清除覆盖后恢复继承(#622)。按 ID 引用提及可参考 [Slack 官方提及语法](https://docs.slack.dev/messaging/formatting-message-text/);Channel 昵称覆盖来自本地 roleplay 使用场景。 - -### 活动中心目标设计(ADR-0098、ADR-0099) - -#2026 将活动中心收束到个人 Human Inbox,完整页面和 Dock 使用同一原位工具审批卡,移除总览切换及用量、活跃度等统计呈现。首个切片通过现有 Human Attention 查询定位所属 Channel 消息,再读取权威审批状态;每次提交仍由原有 Channel Tool Approval owner 判断是否待处理。卡片默认提供允许一次、精确规则与拒绝;全范围授权保留在来源聊天。上下文在卡片下展开,摘要不推进已读;批量操作仅在显式选择模式出现。成功后由权威列表移除驱动约 220ms 的不可交互退场,失败原位保留并可重试,不向下一项转移审批焦点。此切片不新增 Host API、持久化表或审批生命周期;三组折叠列表与其搜索排序留待首个切片的 Human QA 后继续。下文总览、Channel 活跃度及 #698/#703/#705 的 UI 描述记录旧交付;查询能力保留,但不再作为当前活动中心的入口。 - -#548 切片从已加入 Group 的 Source Event 与同 Channel placement 的 `replyTo` 关系投影「回复我」:只包含 Bot 对本地 Human 可见消息的直接回复,仍绑定该 Human 的成员可见范围。每条回复使用 Source Event ID 稳定标识,按最近活动排序并允许 Bot/Channel 过滤,已读后保留浏览并从权威读位置计算未读标识。个人回复不重复进入「其他未读」Channel 汇总,入口未读总数仍统计所有不同的未读 Source Event。两个 Client 窗口及 Host 重启均从同一查询重建。原位回复和准确来源导航复用 #547 路径;上下文按原始时间顺序展示作者、头像、时间及回复目标,可展开有界相邻消息,宽屏采用列表与上下文并列,窄屏堆叠。 - -#549 切片将个人视图扩展为「提及与回复」。Orchestrator 通过 `channel_list` 发现当前 Group Human 成员,再用 `channel_send.mention_human_ids` 指定其稳定身份。Channel owner 验证当前成员关系,在已有 Source Event payload 内提交 Human 目标与显示偏移;普通文本不提供身份。一条 Bot 消息同时提及并直接回复本地 Human 时,个人视图只列一项,未读总数只计一次。现有 `replies` RPC category 和 Source Event item 身份保持兼容,可信提及用 `channel-mention` 区分。两种原因共用最近优先排序、Bot/Channel 筛选、已读状态、有界时间序上下文、原位回复及准确导航。Human 提及元数据不改变 Bot Admission 或 Wake Policy;范围仍是单一本地 Human,不新增全体 Human 广播或账号配置。 - -#123 审批提示切片通过现有版本化 Activity 快照及实时流独立发布正数 `attention.approvalCount`,不改变执行状态。process-local 来源是拥有原生工具审批的 broker:仅在权威 Channel 卡片提交后计数,决定、取消、scope 失效或 Fiber 销毁时,先移除提示再恢复 Tool 答案。侧栏、输入框与总览头像共用这个有界数量;请求身份及原始载荷仍只留在所属审批界面。新 Host 没有 live 审批,重启不从历史请求卡片复活提示。原生问题 broker 同样只在卡片提交后发布 `questionCount`。限时问题卡按 Session 和调用定位,不依赖提问的 Agent:Agent 释放后仍计数;新 Host 会重新打开原 Orchestrator Session 仍持有原生问题的未决限时卡片,回答经 Bot 的轮次队列、以卡片自身的 Source Event 为锚点恢复该 Session([ADR-0187](../adr/0187-personabots-use-a-product-preset-with-timed-questions.md))。其他 attention 类别及明确 waiting-on-Assignment 留待后续切片。 - -首个总览切片(#541)通过 `botharness/activityOverview` 读取现有 Human attention 投影的完整明确行动数,并连接 PersonaBot Registry、根 Session 归属、当前 activity 和 DSH 原生 live Agent 状态。首个切片当时将原生提问/审批等待和空闲 Assignment 的未答请求显示为等待或受阻;当前总览则将 Human 待办与执行状态分开,如上文所述。只有 live 且 thinking/working 的根 Session 进入正在执行列表,重启后不把旧日志的执行状态当作当前运行。视图拥有每五秒的查询刷新及卸载清理,离开视图后的旧响应不会覆盖新状态;无新增数据库表或执行权威。 - -在 #2026 之前,Bot 模式用一个「活动中心」入口承载「总览」和个人「收件箱」两个视图;前述 Human Inbox 段落记录已交付的首批投影,以下是后续目标。总览给出未解决的明确 Human 行动数、各 PersonaBot 实时状态,以及正在执行模型或工具的 Orchestrator/Assignment Session;点击 Bot 进入其私聊,点击任一 Session 退出 Bot 模式并打开 DSH 原始模式中的对应 Session。等待、受阻与空闲不计为活跃 Session。今日 Channel 活跃度按已提交消息数统计,主图逐 Channel 区分 Human/Bot,展开后按发送者查看;全局及逐 Bot token 用量可看近七天趋势,不按 Channel 猜测归因;Memory 展示逐 Bot 近七天已提交变更次数和当前未提交提示。各卡片消费各自权威的读模型,不另建消息、用量或运行事实账本(#34、#39、#424)。 - -收件箱面向当前本地 Human;已读事实按 Human 身份寻址,为将来多 Human 留出同一语义,但本切片不交付多账号。明确待行动、提及与回复、其余未读、信息更新和已处理历史分开呈现。待行动只计仍需 Human 决定或解除阻塞的 typed 请求,按等待最久排序;提及及未读按最近活动排序。繁忙 Channel 的未读折叠为一个 Channel 摘要,提及可定位准确消息;原生 Channel 尚无 Thread 子对话,不按 Thread 分组。展开摘要不推进已读位置;实际看到具体消息,或明确标记已读时,才在权威 Channel 读位置推进到相应消息。活动中心入口数字只计去重后的未读 Source Event,另用独立提示表示未解决行动;同一消息即使关联多个 Bot、兼属报告或提及,也只在 Human Channel attention 里出现一次,多个 Bot 分别提出的真实请求则各有行动卡。 - -行动卡在收件箱内显示问题、选项或输入框与简短可展开来源上下文;Human 可沿用来源处相同的授权命令直接回答,也可跳转原消息。已失效或被他人解决的卡片不允许继续提交;处理后从待行动移入可筛选的已处理历史,仍链接权威请求与回答。群组可回应请求须显式指明合格 Human 范围,首份有效回答解决同一请求,不暗含全员提及。#126 下一条 tracer 先交付本地 Human 的 Group 未读汇总、准确来源定位和已读;Human 提及/回复与 Inbox 内回应分步扩展。当前 ChannelMention 只记录 Bot,Human 提及需新的可信目标身份。跨 Bot 总览由 #541 交付,Bot Inbox 仍只负责选中 PersonaBot 的事件时间线。 - -Human 在 Group 中选择「@所有 Bot」时,Host 在发送时把当前 Channel 的活跃 Bot 成员解析为逐个直接提及:只提交一条 Channel Source Event,每位目标 Bot 获得与手动单独 @ 相同的 Inbox Admission、attention 与 wake;发送前向 Human 显示实际目标人数。它既不访问其他 Channel 的 Bot,也不允许 Bot 使用该快捷广播,不引入新的 Wake Policy。此输入能力由 #542 单独交付,不改变上述 Human 未读去重。 - -Bot-to-Bot DM 是两个 PersonaBot 参与的真实 `dm` Channel。Bot A 通过可信 Session ownership 以自己的 Actor 身份向 B 发送消息;Messaging 在同一权威中提交 Source Event、Channel placement 与 B 的 Inbox Admission,Bot-hop guard 限制循环,A 不接收自己的输出。Human 可以只读打开此类默认不在 roster 显示的 Channel,但不会成为第三位成员。A 每次向非 Human DM Channel 发出已提交消息时,引起这次发送的 Channel(群、Human DM 或 Bot DM;不可用时为 Human–A DM)都会出现居中的 Channel Notice,指向这次发信与可查看的对话,而不复制正文;A 的 Bot Inbox 同时得到一条已处理、永不唤醒的记录(ADR-0154)。 - -Human–PersonaBot DM 是这个 Bot 的时间线(ADR-0182):Bot 在群或 Bot DM 中发言时,同一事务在它的 Human DM 写入一条只含 `{ channelId, messageId }` 的 `botChannelPost` Channel Notice,摘录在读取时从原 Source Event 投影,不复制正文;原消息不可用时显示不可用。起因在别处的 Memory commit 记录除了留在起因 Channel,也在 Human DM 增加一个指向同一 Source Event 的 placement,读取时以 `noticeOrigin` 标出来源。Human DM 内的发言、以及已落在 Human DM 的 `botDmAction` 不再重复写入。Client 在 DM 聊天顶部 ··· 菜单中按动作类型(发言、记忆更新)提供只影响本地显示的开关,默认全部显示;同一 Channel 的连续发言合并为一行。 - -应用定义的 `list_bot_contacts` Tool 在所属 Orchestrator 的 Agent Scope 中读取同一 PersonaBot Registry。`query` 对名称、完整简介及稳定 ID 做不区分大小写的子串搜索,最多 200 字;也可直接浏览,默认每页 20 位,`limit` 为 1–50。将 `nextCursor` 原样作为 `cursor`,保持 query 不变;游标绑定所属 Bot 和规范化筛选,结果按稳定 ID 的 ordinal 升序排列。分页读取当前资料:名单及匹配状态不变时续页不重复、不漏人,改名不改变 ID 排序,移除/暂停者不再返回;游标之前的新建或新匹配身份需要重新搜索。结果包含 `botId`、最多 128 字的名称和最多 160 字的简介预览,并显式标记截断;含续页信息的完整 JSON 页最多 12,000 个 UTF-16 字符,因此可少于请求条数。仅传 `bot_id` 可读取单个活跃同事最多 1,000 字的简介预览。资料始终是非可信数据,不提供 Soul、私有 Memory 或凭据;重名用 ID 区分。返回的 `botId` 可原样用于 `bot_dm_send`、群邀请与 mention,发送时仍由 Messaging 重查目标活跃状态及群成员权限;发现过程不引入第二份目录或权限权威([#568](https://github.com/BotHarness/BotHarness/issues/568))。 - -Human–A DM 里选中 `@B` 会在 Human 消息中持久保存 B 的稳定 ID 与文字范围;Host 在 A 的 Orchestrator 回合组装输入时重新读取 B 的当前名称与最多 400 字的简介,并把它们作为有界联系人资料提供给 A。重名靠 ID 区分,改名采用当前资料,已归档或失效的目标在发送时被拒绝;手打或粘贴的 `@名字` 只是普通文字。此操作不唤醒 B 或改变 DM 成员;只有 A 后续显式发送 Bot-to-Bot DM 消息才触发 B 的 Inbox。Group Channel 中 Human 或已加入的 Bot 可 `@` 已加入的 Bot;一条消息仍只有一个 Source Event,每位目标 Bot 独立获得 Inbox Admission。Bot 创建 Group 时,Host 从 Orchestrator ownership 确定创建者,初始成员只有该 Bot;创建者可邀请活跃非成员 Bot。邀请状态存于同一 SQLite Channel record,创建待处理邀请、无 Channel placement 的 system Source Event 和受邀 Bot 的 `group-invite` Inbox Admission 在一个事务内提交。邀请携带可信的 Bot 协作跳数和受邀 Bot 的创建时间,跨 Bot 唤醒不重置循环限制,删除后重建的同名 Bot 也不能继承旧邀请。受邀者收到自己的 DM 上下文中的邀请 prompt,但在接受前没有 Group read/send 权限;接受把 membership 与邀请状态同时更新,拒绝不增加成员。Bot 模式提供默认开启的 Group 邀请自动接受(ADR-0073):设置由原生 Profile Config `botharness-client.autoAcceptGroupInvites` 持久保存,Client Fiber 将其实时读取器绑定到拥有邀请事务的 Core;只影响新邀请。开启时同一事务写入成员关系、accepted 邀请、system Source Event 与 handled Admission,并记录 `respondedBy=profile-policy`,不启动唤醒、不伪造 observed;关闭时保留上述逐邀请决定,Bot 的接受或拒绝也在同一事务结束邀请 Admission。启动时依据已有 accepted/declined 邀请补齐旧 Admission 的终态,避免重投或重启重复唤醒。重复邀请与重复同向决定幂等,归档目标时取消待处理邀请,重启时恢复仍待处理的 Admission。创建者可改群名、移出其他 Bot 成员;任一已入群 Bot 可用 `group_leave` 自行退出,成员关系在同一 Channel record 中更新,读写权限立即撤销,待处理群消息不再唤醒它;若创建者退出,Bot 管理权消失、成员界面标明由 Human 管理,其余成员与历史仍保留。Bot 自主退出或任何现有移出成员操作,都会在同一事务中提交一条 Host 撰写的群内成员变动消息,分别记录“离开”和“被移出”的类型与正文,供留在群内的人查看,并给每位仍在群内且活跃的 Bot 建立独立的普通消息 Inbox Admission,记录各自当时的 Channel 注意力偏好;退出者没有 Admission。all 可即时唤醒,digest 到达数量或时间条件后唤醒,mentions 可随之后的直接提及进入回合,silent 仅在显式读取时处理。进入回合的成员变动提示标明 Channel system,Bot 可按需回复。提交后的界面实时发布与 Bot 唤醒彼此独立;唤醒通知失败时运行中的 Bot Runtime 按有界退避重试持久 Admission,重启后仍由启动扫描恢复。Human 的群聊侧栏将成员名单与群管理分区:成员区标题的加号打开可搜索的邀请弹窗,带待办数字的铃铛打开邀请与入群申请处理弹窗;成员行左键打开 Bot 私聊,右侧菜单或右键菜单提供私聊、消息提醒策略弹窗与移出群聊。群管理区只呈现群头像、群名,次级菜单提供解散命令;Human 裁切 1:1 头像后将有界 WebP 图像写入同一 Channel record,Host 仍校验图像。邀请仍通过同一 Channel 邀请事实及 Bot Inbox Admission;Human 独占整群逻辑删除命令。关闭自动接受时,Human 发起的邀请在 Bot 接受前不授予群访问权限;删除撤销成员访问,保留 Source Event 等运行证据而不作破坏性清除。首个 Group Human `@` 切片是 #254,后续协作切片由 #278 组织。 - -Human 在自己的 PersonaBot DM 输入框中从 `#` 候选选中 Group,消息才记录稳定 Channel ID 与选中文字范围;Human 已发消息中的引用可点击进入该 Group。Host 在该 Bot 的 Orchestrator 输入中重查当前名称,非成员只收到 ID、名称和是否已加入,不读取成员或历史,普通手打的 `#名称` 没有引用身份。Bot 可在该 Human DM 所触发的回合用 `group_join_request` 请求加入选中的 Group;请求本身不会改变 membership。待处理申请保存在同一 Channel record,Bot 创建者收到独立的 system Source Event 与 Bot Inbox Admission,Human 在成员区通知弹窗可批准或拒绝,创建者可用 `group_join_decide` 决定。首个决定与成员更新、申请者的结果通知在本地事务中提交;申请者收到结果后才能按现有成员规则使用 `channel_read` 和 `channel_send`。重试、重启、改名、同名群都使用稳定 ID;归档申请者或删除群会取消待处理申请(ADR-0069、#292)。 - -应用定义的 `group_rename` 与 `group_remove_member` 工具只返回已提交 Channel 的简短确认:`channelId`、当前 `name` 与 `outcome`(`renamed` 或 `member-removed`);移除成员还包含 `memberBotId`。确认中不含头像、邀请/入群申请历史、成员列表或提醒策略。所属 Host 命令仍检查当前 Bot 创建者及成员身份,通过 Channel authority 提交;Human bridge 继续返回完整呈现记录。改名持久化未返回记录时,工具明确失败。查看当前已加入的 Channel 与成员身份应使用 `channel_list`,不能把命令确认当作群快照。 - -`group_create` 确认新建的 `channelId`、当前 `name` 与 `outcome: created`。`group_invite_bot` 确认已检查的 `channelId`、`inviteId`、`inviteeBotId`,并以所属命令返回的实际邀请状态作为 `outcome`,不假定所有结果都为 pending。`group_invite_respond` 返回同样的引用与实际决定状态,并附当前群 `name`。这些确认不包含内部身份版本时间戳或无关的 Channel 呈现状态。拒绝邀请不授予成员、读取或发送权限;重复相同决定保持相同引用,冲突、旧身份、已取消或未获授权的决定保留所属命令的错误。 - -`group_join_request` 确认当前 Human DM 已选中的 `channelId`、`requestId`、`requesterBotId` 与所属命令实际返回的申请状态 `outcome`;`group_join_decide` 返回同样的引用和实际决定状态,并附当前群 `name`。申请确认不要求非成员群出现在已加入的 Channel 查询中,也不授予读取或发送权限。确认不包含完整群记录、成员列表、头像、提醒策略或内部身份版本时间戳;首个决定、成员更新和申请者通知仍由同一 Channel authority 提交。重复同向决定保留相同引用且不再次通知,冲突决定、已归档或旧身份申请者、失去管理权的创建者继续由所属命令拒绝。 - -Orchestrator 的应用定义 channel_list 工具从当前 PersonaBot 的 Session ownership 派生身份,只返回其已加入的 Group、Human DM 和 Bot DM;可按名称、Channel 类型、稳定 Bot 成员 ID 筛选并分页,结果带当前成员身份。它是 canonical Channel record 的授权查询 Consumer,不创建第二份成员目录。Bot 用返回的稳定 Channel ID 调用 channel_send;发送时仍重新检查当前成员资格,旧查询结果不会授予访问权。应用定义的 channel_read 查询在成员校验后对该 Channel 的完整有序消息历史应用正文、作者及日期过滤,再返回有界游标页;回复预览仍解析自原消息,不因过滤失去引用。`channel_read(scope=joined, text=...)` 对当前已加入的 Channel 进行跨频道正文搜索,结果按时间与稳定 ID 排序并分页;成员关系变化会使旧游标失效。原先仅扫描每个 Channel 最近 200 条的 `channel_search` 工具已移除,Bot 不再面对两个含义重叠的搜索入口。 - -应用定义的 Channel 查询工具枚举 `type: group|dm`、`scope: channel|joined` 和 `author_kind: human|bot|bridged|system`,所属 Host 仍防御非法值。`channel_list(channel_id=...)` 在全部过滤后没有可访问匹配时返回 `{channels: [], outcome: no-accessible-match}`;未知、不可访问或与其它过滤不符的目标使用同一确认,不泄露存在性,普通空搜索仍返回 `{channels: []}`。`channel_read(scope=joined)` 必须有非空白 `text`,不能同时指定 `channel_id`;`author_bot_id` 只能与省略或 `bot` 的作者类型组合。日期界限包含端点,日期形式 `YYYY-MM-DD` 的下界为 UTC 当日零时、上界包含整个 UTC 日;不可解析或倒序范围失败。游标仍绑定原过滤,跨已加入 Channel 查询还绑定当前成员集合;读取仅观察实际返回的消息。两个工具的 `limit` 保留 number 与既有兼容行为:默认 20,向下取整后夹取至 list 的 1–100 或 read 的 1–200。固定 DSH 0.2.0-rc.1 转换器支持 integer,但不支持 minimum/maximum;本票不收紧已有小数和越界输入。 - -模型侧 `channel_read` Consumer 使用 Host 所有的可行动消息投影,在精确 ID observation 前对完整序列化结果施加 12,000 个 UTF-16 code unit 的输出预算(ADR-0102)。投影去除 Human receipts、deliveries 和 Channel revision,保留完整正文、回复、可信附件及行动引用。因预算未返回的消息仍为 pending,并提供绑定原过滤的继续游标;首条超长消息提供 `message_id` 完整内容读取路径。有界 JSON 片段绑定当前投影的哈希及偏移,每次复查成员资格,只有同一活跃 turn 接收到完整连续内容后才加入消费集合。Human Channel / Inbox canonical records 和 bridge 呈现保持不变。 - -### 外部 CLI 的在线请求与回复 - -应用定义的在线 CLI 依照 ADR-0159,每次从当前 Host 启动令牌换取内存 cookie,经既有 DSH HTTP unary carrier 调用 Typert Bridge;不另设监听器、凭据缓存或数据库旁路。发送先由 Registry 查询确认 Bot,再由 Channel owner 建立 DM,使用 `human-UUID` 消息 ID 提交;重试去重仍归 Host。新增 `channelSendStatus` 只读查询复用 `source_events`、`inbox_admissions`、`channel_output_origins` 和 `session_ownership`,按原请求 Source Event、同一 DM 与 Bot 所有的 Session 返回已提交回复和当前处理状态,不以时间窗口或最新一条消息猜测对应关系。问题/审批卡片、通知及失败不算回复;超时保留回执供查询,不自动重发。工具审批、正式问题、工作区授权和发布状态沿用各自 Host owner;无新增耐久表或权限权威。见 [CLI 指南](../bot-cli.zh.md) 与 [#1317](https://github.com/BotHarness/DeepSeekBot/issues/1317)。 - -依照 [ADR-0162](../adr/0162-online-cli-management-keeps-explicit-host-authority.md),显式 Host 选择扩展到 Registry 创建/查询、Model Preset/Plan 与 Channel 发现。Zip/目录由 CLI 上传调用者本地字节,经已有 authenticated Bot Zip import owner 验证和创建;Git 获取仍在 Host 执行。创建和模型应用是分阶段 owner 操作,部分失败保留已知身份,未知结果不自动重试;连接失败不切换到离线 Profile。attention、Session 与 activity 查询只读既有投影,提供有界调试摘要;测试脚本经同一业务 CLI 驱动精确请求与持久回复,并可显式调用删除 owner 的 preview/confirm/retry 清理本轮身份,保留 Memory。writer lease 不构成 RPC 全局串行锁,各 owner 的版本/HEAD 与幂等契约保持独立。见 [#1363](https://github.com/BotHarness/DeepSeekBot/issues/1363)。 - -### CLI 的 IM 应用授权 - -应用定义的 CLI Consumer 通过既有 Messaging Service 的 `messagingApps` 发现已资格验证的 Provider setup descriptor,再经同一个已认证 DSH HTTP carrier 调用原生 Connection Fetch Registry 的 `dsh-im/app-setup` endpoint。该 Provider 拥有授权 attempt、过期和原生账号/凭据持久化;CLI 只保留一次调用的内存 cookie,不创建额外 store 或生命周期。飞书凭据和微信验证码仅从 stdin 进入目的明确的 `setup.credentials`/`setup.verify` payload;stdout 只投影经过校验的非敏感状态、二维码、账号引用与指纹。微信扫码由 Human 在手机确认,再用同一 attempt 查询;IM 应用授权不选择 PersonaBot 或建立 Messaging Grant。见 [ADR-0161](../adr/0161-cli-im-authorization-keeps-provider-attempt-authority.md) 与 [#1318](https://github.com/BotHarness/DeepSeekBot/issues/1318)。 - -### 配对设备的远程访问 - -[ADR-0199](../adr/0199-paired-devices-reach-the-host-through-a-same-listener-device-gateway.md) 已由 [#1652](https://github.com/BotHarness/DeepSeekBot/issues/1652) 实现:配对的手机不持有任何 DSH 凭据。应用定义的 Device Pairing 模块(`packages/core/src/devices/`)经 DSH 公开的 `webServer.register`/`registerUpgrade` 在同一 `dsh web` 监听器上提供设备网关:设备在 BotHarness 前缀下以 `Authorization: Device .` 调用与 Web Client 相同的 unary 信封和 Remote stream mux 帧;模块每次按运维数据库中的设备记录(只存哈希)校验并更新最近使用时间,再用 Host 内存中经 `connection.authenticatedUrl()` 自行换取的 loopback cookie 转发至原生 `/api` 与 `/api/remote.mux`。DSH 签名 cookie 的密钥本就跨 Host 重启保留,但 cookie 不标识设备,且 stream 升级不经过 `connection/request`,故不能把 cookie 交给设备。撤销立即拒绝后续请求(编码 401:`device-revoked`/`device-unknown`/`device-credential-missing`),并关闭该设备已打开的 stream socket。配对邀请、列表与撤销只在已认证的 `/api` carrier 上提供给 Human:Web Client「设置 → 设备」显示一次性、10 分钟有效的二维码(即 `deepseekbot://pair?p=…` 深链接,携带 Host 标识与按序路由),列出设备的配对与最近使用时间,并可撤销。见 [#1644](https://github.com/BotHarness/DeepSeekBot/issues/1644)。 - -托管 Connect(目标状态,尚未实现):[ADR-0241](../adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md)([#2387](https://github.com/BotHarness/DeepSeekBot/issues/2387))在路由列表里增加一条 Connect 路由,顺序为 局域网 → Tailscale HTTPS → Connect。账号复用 TOB 的 WorkOS 环境,只用于发现 Host 和经纪一次性配对凭据;本仓库新建的中继 Worker 为每台开启 Connect 的 Host 代管一条 Cloudflare Tunnel,ingress 只放行 `/botharness-device/*`,应用流量不经过 Worker。手机登录后无需在电脑上确认即可配对,新设备会在电脑和其他手机上提示并可一键吊销。先在中国大陆以外实现并验收,大陆可达性之后另找测试者验证,不阻塞实现。 - -### 手机推送 - -[ADR-0202](../adr/0202-phone-push-is-a-content-free-projection-of-the-human-inbox.md)([#1655](https://github.com/BotHarness/DeepSeekBot/issues/1655)):应用定义的 Push delivery 模块(`packages/core/src/push/`)是 Human Inbox 的投影,不另设通知权威。Human Inbox 表或 PersonaBot 的审批、提问、私密输入计数变化时(以及每分钟一次的兜底扫描),模块读取 `action` 项与未读的 `replies` 项,对每台已登记的配对设备只发送一次新项,并经 Expo Push Service 投递;推送标题为频道名(`#名称`,私信为 PersonaBot 名称),正文为所需操作加上工具与主要参数、问题或消息的简短预览(折叠空白,最多 140 字符);私密输入请求不含内容([ADR-0213](../adr/0213-phone-push-shows-the-channel-and-a-short-preview.md))。数据字段只含 Host 标识、Human Inbox 项标识、PersonaBot 标识与读取请求所需的 Channel/消息标识,并携带未处理 Human 操作数作为角标。设备经设备网关的 `/botharness-device/push` 登记 Expo push token、切换各类通知、发送测试通知;撤销设备或 Expo 回报 `DeviceNotRegistered` 时删除 token。发送失败有界重试,结果写入不含内容的 `push` 开发者日志。App 保存每台配对的 Host,点按通知先切换到对应 Host,再打开该审批、提问或私信。 - -## 5 · Orchestrator 与 Assignment control plane - -Human 不负责创建或选择执行 Conversation。Human–PersonaBot DM 是 Human 与该 Bot 直接对话的入口:消息先成为 Source Event,经 Bot Inbox 交给 Orchestrator;Orchestrator 再决定直接回复,或在授权与 capacity 内创建、复用和管理多个 Assignment Session。普通 Orchestrator assistant final 只留在 DSH SessionPersistence;只有显式 Channel messaging command 才产生 Human-facing Channel message。该 command 从可信 Session ownership 推导 PersonaBot Actor,并验证目标 Channel membership,不接受模型自报 bot id 或 author。右侧「会话」只投影明确归属该 PersonaBot 的独立 DSH 根 Session,包括 Orchestrator 与 Assignment,不展示 Subagent;归属与角色来自 Session Ownership,标题、工作区及实时运行状态来自 DSH 原生 Session 目录,不以 cwd 推断归属(ADR-0072)。 - -Workspace Grant 是 application-defined 的持久授权记录:Human 通过 DSH 的 Host 目录选择器或绝对路径输入添加一个现有文件夹,由 DSH Workspace registry 解析真实路径,再为 PersonaBot 创建 Grant。Orchestrator 的 cwd 始终是自己的 Memory Repository;它可读写 Memory,并可读取所有当前有效 Grant 的项目目录,只在 Human 为该 Grant 明确开启 Orchestrator 写入权限后可写项目目录,不能自行创建 Grant 或开启自己的写入权限。每个 Assignment 选择一个有效 Grant,固化 Grant ID、Workspace ID、单一 primary cwd 与权限快照,只能读写所选目录。撤销 Grant 会阻止两个角色后续的文件访问及该 Grant 的新事项创建、请求和恢复;历史 Session 不因重新授权而复活。授权列表中 Memory 是固定内部目录,项目目录可添加、撤销,撤销不会删除 DSH Workspace 或磁盘文件。DSH 原生 workspace-write 仅限制部分写入并不隔离读取,且可能允许临时目录写入。BotHarness 在最终 Tool guard 对 DSH 原生 read、read_image、write、edit、str_replace_editor、glob、grep 的路径按当前 Grant 校验:Orchestrator 可读 Memory 与所有有效 Grant、可写 Memory 与 Human 明确开启 Orchestrator 写入权限的 Grant;Assignment 只可读写固化的单一有效 Grant。Shell 与插件工具按 Session 自身的 DSH 沙箱模式与审批策略运行;DSH 发起的审批(例如 Shell 请求 `sandbox_permissions`)进入应用定义的 Channel Tool Approval owner。普通 Web 卡只有允许这次/始终允许/拒绝;新规则记住同一 Bot、根角色和当前授权范围内的当前工具,输入可变,旧精确与宽规则保持原义。每次原生询问仍获得独立的 `allowed-once` 审计;规则撤销或范围变化使其失效。工具批准不等于目录隔离,也不创建 Grant;Shell 可能触达目录之外,界面需说明。每个 PersonaBot 的 Access Preset 默认为 `workspace-write + ask`;Human 经既有风险确认可开启 `danger-full-access + ask`,按所选 scope 作用于当前 Orchestrator 和/或之后新建的 Assignment。已有 Assignment 保留其 mode/Grant/revision 快照;本次迁移只规范旧 Full Access 的 approval policy。Full Access 普通请求自动回答,识别出的权限/策略变更必须 Human 本次决定;撤销、当前身份和独立能力检查仍适用。详见下文三动作边界及 [ADR-0239](../adr/0239-three-action-tool-approval-and-full-access-use-native-ask.md)。 - -### Three-action tool approval and Full Access (#2080) - -Ordinary Web approval uses Allow once / Always allow / Reject. The application-defined Channel Tool Approval owner records `allowed-always-tool` with the Human decision, activates a revocable `tool` rule in the existing Operational Database, then returns DSH's native `allowed-once`. A tool rule matches the current Bot, root role, authority scope and tool name across changed inputs. Exact, all-opaque and Secret rules keep their old semantics; new ordinary cards do not mint all-tools authority. Current Grant/scope checks, execution guards and rule revocation remain authoritative. The Web Channel, Activity/companion reuse, online CLI and readable mobile history share that decision contract. - -Top-level Full Access composes its existing unconfined execution mode with native `ask`, replacing the earlier `never` combination. The same responder automatically answers ordinary requests only while current Full Access remains valid. Recognized permission/policy management always requires an individual Human decision and skips automatic rules and Reviewer; directory grants still use the authenticated Owner read/write command. Generation 104 preserves existing rules and updates only old Full Access Assignment approval snapshots, without changing mode, scope, Grant or preset revision. Delegated Subagent policy remains native `never`. This is application policy rather than an override of DSH's `never` semantics. See [ADR-0239](../adr/0239-three-action-tool-approval-and-full-access-use-native-ask.md). - -当所需项目尚无有效 Grant 时,Orchestrator 可通过专用工具在自己的 DM 提交一条持久授权请求卡;该工具只能请求,不能发放 Grant,也不会预建 Assignment。Human 在卡上明确确认文件夹读写后,认证 Owner 的共享命令核对当前身份与目录,在单个 Channel 事务中提交可写 Grant、关联 Human 决议和 Inbox Admission;提交并读回后才续接原任务。重复确认沿用已提交的决议,撤销后的旧请求不能重建权限。回复携带经 Host 核验的请求消息 ID 与有效 Grant ID;Human Inbox 从请求和回复 Source Event 投影一条待办,普通回复不解除,授权回复解除。包括历史请求在内,仅有关联有效 Grant 的已提交结构化回复解除授权待办;原始文本保留,普通“已授权”文案不作为授权证明。Inbox 与来源 DM 复用同一目录选择及授权操作,提交事务重新核验请求与 Bot Grant,两窗口只能提交一条有效回应。Orchestrator 重新列出有效 Grant,随后才用选中的 Grant 创建 Assignment;卡片和授权记录是不同的持久事实。普通 Assignment Ask 仍先回到 Orchestrator。Orchestrator 遇到 Human 未指明的 Memory 分支选择时,调用 DSH 原生 `ask_user_question`;BotHarness 在该 Agent 作用域的 `user-questions/request` waterfall 中提交持久 Channel 提问卡,Human 的选项或自定义回答经同一 DM 回到原生 Service,使同一 Session 继续。卡片保留来源 Session,回答和取消各有持久消息;停止、重启或旧请求不得再次回答。明确目标直接切换,不提问。Assignment 不直接向 Human 使用此原生工具,仍通过 Orchestrator 转问。 - -右侧是 Channel sidebar(ADR-0053):group Channel 显示成员与 Channel 管理 entries,Human–PersonaBot DM 显示该 PersonaBot 的 entries(会话、Memory、Bot Inbox、Computer 等);entries 由统一注册 seam 提供、可折叠、按声明顺序排列,注册描述符可提供 Lucide 图标名称与标题操作;会话、Bot 收件箱和记忆演化的显示设置放在各自标题右侧的齿轮里,会话范围/布局保持 per-PersonaBot 浏览器偏好、记忆术语保持全局浏览器偏好,未注册或不可用时直接不显示而不是占位。Chat 始终是中间的 Channel body。「会话」把 Orchestrator 固定在顶部,Assignment 按状态分为可折叠的「进行中」与「空闲」,按最近更新倒序并平滑过渡位置;「全部」保留已停止的历史 Session;Human 可切换按状态或按工作区折叠分组,每个 Bot 的范围、布局与折叠选择仅保存在本浏览器。点击行使用 DSH 原生 `UiWorkspace.openSession` 打开对应 Session,而不是维护一份只读事项详情。归属该 Bot 的根 Session 可通过原生标题栏及 Session 菜单返回 Bot 私聊;空闲时左侧标题前显示 Bot 头像,运行状态及日程标记仍优先显示。Assignment Directory 继续持有 Grant、续接、报告、停止、并发及审计事实。 - -Channel sidebar 的编辑模式只折叠当前呈现而不改写展开偏好,拖动/方向键只改变草稿;完成保存同类 scope 的顺序,取消丢弃草稿,恢复默认也属于草稿。浏览器本地分别保存所有 PersonaBot DM 共用的顺序与所有 group Channel 共用的顺序;未显示/未注册的稳定 ID 保留位置,新 entry 按注册顺序附加。切换选择或关闭侧栏丢弃编辑状态,授权撤销仍经原有 expandable seam 清除禁止展开的状态;排序不写 Host 或 Memory([#721](https://github.com/BotHarness/BotHarness/issues/721))。 - -桌面 Bot 模式(窗口宽 961 px 及以上)把 `main` 区域改为一个 dockview 布局([ADR-0203](../adr/0203-bot-mode-is-a-dockable-layout-that-plugins-extend-with-dock-panels.md)):Channel body 与每个 Channel sidebar entry 都是独立的 **Dock panel**,不叠成标签页、不浮动,只能拆分放到彼此旁边。BotHarness Client 用 `ctx.provide('dockPanels', …)` 提供 Dock panel Registry:其他插件 `inject` 后调用 `register({ id, title, icon, scope, defaultPosition, order, component, headerAction? })` 并获得释放函数,重复 id 抛错,排序按 `order` 再按 id。`scope: 'channel'` 的面板收到当前 Channel,没有 Channel 时显示空状态;`scope: 'global'` 的面板不随 Channel 变化。可选 `headerAction` 在同一面板标题栏渲染,并与正文接收相同的 Channel 范围;活动中心以此提供筛选与排序菜单,正文不再显示分类 tabs。`defaultPosition.zone` 只决定第一次打开的位置:`left` 为最左一列,`center` 在 Channel body 下方,`right`(默认)叠进右侧栏,`bottom` 在新工作区内放到 Channel body 下方,以保留全高控制列;旧布局仍为底部一行;`relativeTo` 指向的面板已打开时,紧接在它后面;Human 关闭过的面板回到它原来的位置。`channelSidebar.register` 现在同时把非设置类 entry 注册为 `channel` 面板,同一 tab 的 entry 默认上下相邻;窄屏遮罩侧栏仍直接读取 `channelSidebar`。同一 Service 还提供 `open`、`close`、`toggle`、`isOpen` 与 `openPanels`,由当前挂载的布局执行;Channel body 标题栏的 **Panels** 菜单与其他入口(例如侧栏快捷卡片)都走这组接口,菜单末尾是「重置布局」。布局只是浏览器本地呈现,保存在 `localStorage` 的 `botharness.bot-mode-layout`(版本 4),不经过 Host,因此没有 CLI 命令;插件自己的数据仍走它自己的 Service 与 Host Provider(#1789、#1791)。新工作区在控制列、旧侧栏回退在品牌行下方为每个已注册的 Dock panel(Channel body 除外)显示一张固定小卡片:卡片在面板打开时高亮,点击经 `toggle` 开关面板,Service 的 `available()` 告诉它当前是否挂载了布局。活动中心是内置的 `global` Dock panel(`id: 'activity-center'`),它的卡片取代原来的活动中心行并保留未读数与待处理红点;旧侧栏回退中没有挂载布局时,这张卡片打开完整的活动中心页面,其他卡片不可用(#1829)。 - -#2017 首片在符合 rc.2 AppFrame 结构的 Web/macOS 宽屏中启用工作区:一个不可关闭、可移到左/右/任意整列之间的 Workspace Control Rail(工作区控制列),以及独立的全局 Channel 列表面板(`bh:channels`)。列表沿用现有 Roster 与选中 Channel 的唯一状态。没有选中 Channel 时,控制列和全局面板仍可操作。`workspace-shell` 只对当前 AppFrame 标记后释放原生侧栏占位,临时将公开 `layout.toggleSidebar()` 命令接到 Channel 列表开关,退出、卸载或收窄时恢复原方法描述符并移除标记;不改原生 Store、宽度或折叠偏好。结构不匹配、命令无法接管或 Windows 标题栏布局使用旧侧栏回退。macOS 仅在红黄绿按钮覆盖的左上角避让,其他面板利用顶部空间,全屏取消避让。窗口拖拽与面板拖拽保持各自区域。 - -**两层布局(会话、记忆与定时任务已迁入,其余分阶段迁移)**:[ADR-0221](../adr/0221-global-and-channel-workspaces-own-panel-placement.md) 与 [#2048](https://github.com/BotHarness/DeepSeekBot/issues/2048) 在上述已交付的平铺布局之上定义 Global workspace → Messages panel → Channel workspace 的归属。#2049 首片把会话移入消息面板:沿用已有会话查询与快捷键命令,新增稳定 DOM 的四方向分割容器;版本 4 的原布局存储按稳定 Channel ID 保存开关、方向、比例和隐藏入口偏好,并保留升级前原始备份。旧 Work 迁移先执行;隐藏父面板保留子状态,会话快捷键恢复父子面板。最终 Control Rail 只管理全局面板和消息面板;频道子面板由消息 header 控制,右键该 header 以图标、名称和复选框管理开关显隐(隐藏开关同时关闭子面板,不设单独管理按钮),并在父面板内分割、移动、按频道保存比例与开关。隐藏父面板不清空子状态,也不改变执行或权限。群聊保留群资料和成员,不提供记忆文件、记忆演化、定时任务、Browser、Computer 或 PersonaBot Profile,即使会话显式查看成员 Bot 也不例外。共享的浏览器/电脑资源设置仍可属于全局;Bot 专属访问与视图不因物理资源共享而变成全局。 - -[#2049](https://github.com/BotHarness/DeepSeekBot/issues/2049) 实现首个可运行切片:仅把会话迁入消息面板,保留 ADR-0220 的 DM/群聊查询与访问校验,其他面板暂留外层。现有 Client 布局 owner 负责外层与每频道内层偏好、带备份的幂等旧布局迁移及首次使用种子;不新建 Host 数据权威。沿用原会话快捷键标识和原生绑定:父隐藏时恢复父子,父显示时仅切换子面板。正式目标只有两层,不引入递归工作区、标签页或浮窗。验收入口为真实 Web 升级旅程与 macOS Desktop,证据记录在该切片的 PR;其余子面板的迁移仍待后续反馈。 - -[#2067](https://github.com/BotHarness/DeepSeekBot/issues/2067) 将记忆文件、记忆演化和定时任务加入同一消息内布局:各分割容器保持稳定内容身份,分别保存方位与比例。方位由 Human 选择,消息面板变窄时不自动换轴或限制停靠方向;布局库的主题与拖放配置保持稳定,只在实际配置变化时更新,避免拖拽中的重排反馈。原布局 owner 的版本 5 在保留会话 v4 字段的同时,新增按 Bot DM Channel ID 保存的三个面板状态与首次使用种子;旧外层面板和 Rail 项移除前备份原记录。原快捷键标识与默认键(Primary+Alt+Shift+6 / E / 9)保持,父隐藏时恢复父子,群聊不响应 Bot 专属操作。右键消息 header 管理图标、名称、复选框形式的开关显隐;隐藏开关同时关闭子面板。所有变化仅涉及呈现,复用既有 Memory 和 Schedule 查询与权限 owner。[#2069](https://github.com/BotHarness/DeepSeekBot/issues/2069) 将浏览器/电脑加入同一 Bot 频道子面板集合:v6 单独备份旧记录并移除旧外层节点,保留 v5 记忆/定时任务状态;默认 Primary+Alt+Shift+B / 0 与自定义/解绑保持。未启用状态复用居中图标说明,启用仍使用原子面板 header 的显式电源操作;布局开闭不改变 Browser/Computer Access 或执行资源。 - -Control Rail switch visibility is a workspace preference (`hiddenControls`) in the existing versioned layout record. The rail context menu updates that preference and closes a hidden Panel through the existing Dock owner; checking it again restores the entry without opening content. A Panel header's Close action changes only open layout state. Explicit shortcuts can still toggle Panels independently of rail visibility. Tooltip keycaps project the effective native shortcut catalog, including customized bindings, and render outside the rail's clipping boundary. - -Application-defined pointer context menus share `ds/PointerMenu`: callers supply viewport coordinates, while the design-system component owns the body portal, measured coordinate conversion under CSS zoom, native frame clearance and viewport edge avoidance. Regular control-anchored dropdowns keep the native DSH Menu. This ownership prevents each Panel or Channel surface from inventing a separate offset. - -频道导航不会重建工作区和 Dock(ADR-0203)。`scoped-state.ts` 按选中频道及视图代次重置聊天临时状态和资源引用,并拒绝上一代次的异步回写;各频道草稿仍由现有草稿模块保存,Host 持久化不变。 - -布局版本 2 在同一浏览器记录中保存按容器尺寸恢复的比例几何、关闭面板的位置、开关顺序、群成员分割比例与开关,以及 0–16px 连续间距(默认 4px);版本 1 保留现有相对布局迁移。背景恢复原生 base 深色,面板采用 85% base/15% surface 混色和小圆角,仅略亮于背景;macOS 红黄绿区域同背景色,header/body 间隙为 `min(4, gap / 2)`。退回旧布局不会覆盖已有新工作区记录。消息正文在工作区中是可关闭的唯一「消息」面板;群成员从消息 header 展开为内部左右分割(20–60%),不再占全局开关。控制列按钮支持拖拽及 Alt+上下排序;选中时仅保留蓝色图标和底色,不显示边框。底部把手在悬停或键盘聚焦时出现,位于齿轮之上;工作区布局通过既有 Bot Settings section Slot 提供,Portal 保留当前 Dock 的设置动作,不创建第二份偏好存储。面板快捷键注册到 DSH Shortcuts Service,由原生键盘设置自定义,离开工作区或弹窗内不触发;不另建配置存储。Dockview 保持 Portal 内容常驻,控制列移动用 reuseExistingPanels 恢复布局,避免重新挂载;Bot 小人拖拽后的 click 被消费,不再误导航。完整平台矩阵及 Windows 原生 caption 留待后续验收。 - -项目显示偏好与排序共用浏览器本地 scope 和编辑事务:完成以一次偏好更新保存二者,取消一起丢弃,恢复默认布局按注册顺序显示全部可用 entry。隐藏项目仍列在编辑模式中,可通过显示按钮恢复;全部隐藏时设置入口仍可访问。正常模式不挂载隐藏项目的内容,但保留 header 的权限监听,让授权撤销继续清除禁止展开的状态;未注册 ID 保留显示偏好,新注册 entry 默认显示。隐藏不改变 Host 权限、执行或 Memory([#809](https://github.com/BotHarness/BotHarness/issues/809))。 - -显示设置二级菜单临时只展开对应 sidebar entry 供预览;连续选择保持菜单打开并立即更新原有显示偏好,关闭菜单恢复先前的展开呈现而不写入展开偏好,权限 gate 仍然生效;切换选择丢弃预览([#807](https://github.com/BotHarness/BotHarness/issues/807))。 - -Memory files 与 Memory evolution 的读取缓存仅属于 Client 的 Bridge action owner 和 Channel,最多保留 30 个 Channel;关闭/展开及后台刷新继续展示上次成功结果,首次成功之前使用 skeleton。成功空结果也是已加载状态;失败通过轻量提示与 Retry 恢复,重试中保留提示,成功后清除。切换 action owner/Channel 时重新挂载读取资源,旧请求不得更新新 scope;缓存不成为持久 Memory/Git 权威([#719](https://github.com/BotHarness/BotHarness/issues/719))。 - -Human–PersonaBot DM header 菜单或频道列表的「查看资料」直接打开 **PersonaBot Profile view**(ADR-0085,#2017 修订),临时替换消息面板内的 Chat 历史与 composer;返回或 Escape 恢复 Chat。从频道列表进入时先打开消息面板;资料导航在新 Dock 布局恢复后再次确保消息面板存在并激活,避免旧的关闭状态覆盖导航,沿用原布局位置。Profile popover 及其 client-local pinned-card 呈现状态已移除;所有可用的 **Profile Card** 直接在详情中展示,Display name 与 Avatar 仍在此编辑。Profile Card 继续通过有序、可增删、scope-aware 的 client 侧 Cordis registry 注册,Host 读模型不变。Group header 也直接进入 Group Profile view,保留有限数量的实时成员活动预览及 Group scope 卡片:Host 从该 Channel 已提交消息分页聚合近 26 周的每日消息数与按 author 分组的活跃度,经 Typert/API Gateway 交给 Client;Human/Bot 作者分别呈现,群管理仍在独立 Channel sidebar。DM Channel 只使用 PersonaBot Profile(#424)。自定义 Avatar 是有界 data URL 存在 owning Registry 的 SQLite 记录中(旧 `bot.json` 仅用于一次性迁入)(512×512 WebP、解码 ≤128 KiB、magic-byte 校验、拒绝远端 URL),经带缓存校验的认证读取 route 提供,缺失或损坏时确定性回退 identity-seeded Blobatar(ADR-0086)。创建 Bot 以及 Display name、岗位或 Avatar 变更后,Registry 把同一份呈现写进该 Bot Memory 的 `.botharness/bot.json`(像素配方、上传图片 `.botharness/avatar.*` 或 seeded 配方),只提交 `.botharness/`,其余键保留;Host 启动时为缺少该文件的 Bot 补齐。分享后 Bot 市场读取它,显示与侧栏一致(#966)。 - -DM composer 的固定 `/` 快捷动作与模型/工作区 chips 仅消费既有模型编辑器及 PersonaBot-scoped Workspace Grant 流程;目录预览是只读操作,授权确认显示 Bot、解析后的目录与访问范围,不暴露权限模式切换。快捷动作移除自己的输入片段,保留正文、引用与附件,不发送模型消息。图标加文字的模型和工作区数量居左,紧凑 token 用量居右。只读总量与 header Profile 使用同 Bot、同 26 周 activity snapshot 及缺失/部分数据语义,在已提交消息变化、打开 Profile 与有界轮询时刷新;从聊天 header 或频道列表「查看资料」直接进入消息面板内的 Profile view,不再经过资料浮层,也不再固定卡片到浮层;从频道列表进入时先开启消息面板。DM-scoped `profileSessionUsage` RPC 选择最新的所属 Orchestrator root 和最多五个最近更新的 Assignment roots,由既有 Usage owner 按需读取 native Session Query 日志,复用 token bucket 口径及继承前缀/所有权/删除校验,每个 root 最多纳入二十个 descendants;这些只读行不形成新的持久统计权威,部分和不可用日志明确标示。Window Companion 的显示/移除沿用原有 owner、生命周期与 Profile-local 偏好存储;入口保留在频道菜单,聊天 header 不再显示图钉,不新增权限权威(#1432、#2017)。 - -模型配置目标在 Profile 顶部提供紧凑的预设切换,详细的单 Bot 快照编辑置于活动图表下方的折叠区或弹窗;用量图表默认显示最近 7 天,可在现有 26 周查询范围内切换时间范围(#592);独立的长期累计与筛选查询由 #507 提供。Profile 的实际模型用量与配置的可用模型分开呈现,后者不冒充已经发生的调用(#488、#39)。 - -Computer 是 profile 级共享资源(ADR-0051、ADR-0082):Profile 的 **Computer Target** 在 Bot settings 中选择本机或容器,新 Bundle 默认本机,旧的无 target 配置保留 Docker 行为。Computer Provider 管理内部目标策略,官方 `ctx.computerUse` 的单一 Provider 注册不变(ADR-0079)。本机目标在 macOS、Windows 和 Linux 上都使用 Host 上按平台校验固定版本与 SHA-256 的 `cua-driver mcp --direct --embedded`,完全不探测 Docker;Human 显式点「检查权限」完成安装与就绪检查:macOS 读取辅助功能/屏幕录制权限(继承运行 DSH 的应用的 TCC 身份),Linux 需要可达的 X11/Wayland 显示(没有 DISPLAY 的服务器由本机目标启动私有的 Xvfb、D-Bus 会话和窗口管理器;DISPLAY 指向无桌面的裸 X 服务器(例如与 Bot Browser 共用的 Xvfb)时复用该显示,只补上窗口管理器和缺失的 D-Bus 会话;关闭驱动时一并停止),Windows 无需授权。Human 直接在自己的屏幕上完成登录。本机没有 Selkies viewer、归档迁移或空闲停机,手机通过 Host 的截图流查看并接管本机桌面(`/api/computer/frame` 长轮询驱动的 `get_desktop_state`,画面不变不重发;`/api/computer/input` 走桌面范围的点击、滚动、输入和按键;接管时先通过 `/api/computer/takeover` 暂停 Bot(拒绝所有 Bot 的 Computer 工具调用,包括截图;帧轮询和输入续租,手机离开 30 秒后自动失效),ADR-0210);容器仍使用 `docker exec` stdio MCP、Selkies viewer、持久卷与导出/导入。目标通过原生 configForms 持久化,volatile 更新关闭旧驱动并停止旧目标,清空工具目录与会话授权;等待 Human 的审批绑定目标版本,不能批准另一个目标的桌面操作。 - -手机远程控制容器 Computer 和容器 Browser 时可以走 WebRTC([ADR-0209](../adr/0209-selkies-webrtc-is-switched-on-per-phone-call-over-the-tailnet.md),[#1799](https://github.com/BotHarness/DeepSeekBot/issues/1799))。Host 有 tailnet 地址时,新建的容器默认仍用 WebSocket,另外配置好 WebRTC:ICE-lite、一个公布在 tailnet 地址上的 UDP 端口(Docker 只在 loopback 发布),以及空的 ICE 服务器列表。手机经设备网关打开信令路由后,Host 在 tailnet 地址上绑定该端口做转发,把容器切到 WebRTC;最后一个通话结束或设备被撤销时切回 WebSocket 并关闭端口。不使用 STUN 或 TURN;不可用时状态路由返回拒绝码,手机继续用 WebSocket 画面。 - -只有 Human 为某个 PersonaBot 打开 **Computer Access** 时,精选的观察/动作/验证工具与目标对应的指引才注入它的 Orchestrator 与 Assignment 会话作用域;每个会话的首次动作经 DSH 原生审批询问 Human 一次(profile 开关可自动允许),每次观察与动作都以脱敏的 **Computer Audit** 记入 `logs.db`(ADR-0080)。Computer 未准备好或权限被拒绝时工具返回可读错误,不威胁 Host 启动。 - -Browser 同样是 profile 级共享资源(ADR-0089):随 `deepseekbot` 默认安装的 `@botharness/browser` Bundle 运行受管的 **Bot Browser**(每个被分配的 browser profile 一个实例;默认共用一个 profile,命名 profile 按需启动、独立空闲停,ADR-0096)——优先复用机器上已装的 Chrome/Edge 加专属 profile(`$DSH_HOME/botharness/browser`),机器上没有可用浏览器时按需安装 version-pinned Chrome for Testing(`$DSH_HOME/botharness/browser-chromium`),CDP 端点收在 loopback,Human 可在窗口里登录一次。只有 Human 为某个 PersonaBot 打开 **Browser Access** 时,工具与指引才注入它的 Orchestrator 与 Assignment 会话作用域;只读的 `browser_open` 与 `browser_observe` 已交付,每个会话的首次动作走与 Computer 相同的原生审批(profile 开关可自动允许),每次观察与动作以脱敏 **Browser Audit** 记入 `logs.db`;每个 Bot 拥有自己的 **Bot Tab**(共享窗口里的后台标签;标签归属是可见性作用域,不是安全边界,ADR-0095)。Browser entry 已提供该 Bot 标签列表与焦点预览、`browser_screenshot`(只作为 model attachment,绝不进审计)与 Human **Browser Pause**(暂停该 Bot 的动作与模型截图,Human 始终可直接操作窗口);模型截图从入队到原生附件处理完成都检查该 Bot 的控制版本,Pause/Resume 或 profile 切换会拒绝未完成的旧截图;交互工具(click/type/press_key/scroll/wait)与多标签管理(`browser_tabs` list/open/select/close、后台标签、空闲收窗)已交付,ref 过期以"重新观察"错误收场;原生鼠标与键盘输入在 CDP Session 内准备焦点,不唤起前台窗口;导航与动作后的页面就绪检查要求相隔一个轮询间隔的两次完整状态,使用 15 秒截止时间,持续未就绪时返回错误而非成功,并要求先观察再决定是否重试可能已执行的动作。浏览器未运行时工具返回可读错误;profile 导出留待后续阶段。 - -Local Browser 的 `localDriver` 可显式选择默认 `current` 或试用 `agent-browser`([ADR-0124](../adr/0124-local-browser-drivers-share-host-authority.md)):两者共用 Browser Provider、专属持久 browser profile、Chrome 启停与 Human 窗口。候选仅在私有 IPC 内以锁定版本的原生进程连接这个受管 CDP 端点,按 profile 串行选择精确 target,ref 绑定当前 DOM 身份,绝不通过同名元素或相邻标签恢复。Browser Access、拥有者 Session、原生审批、Pause、Audit 和模型附件仍由原 Provider 管理;切换驱动撤销旧授权并等待旧实例清理,取消会停止候选与其 Chrome。Container 可独立选择 `containerDriver=current`(默认)或 `agent-browser`(#768):同一个 Host 原生候选进程通过现有专属 Container CDP 中继连接,转发原有已认证共享 Viewer;有界上传 staging、截图、Human 输入前 Pause 和全屏仍遵守原有权限。正常停止 Container 时先有界请求 Chrome 正常关闭,再清理已验证归属的桌面,以保留刚写入的 profile 数据;清理失败仍阻止替换。Stop 在清理前撤销共享该 profile 的全部 Bot 注册与授权;原生审批绑定具体 Session 注册,晚到的 Human 预览由 Bot 失效版本拦截。空闲标签关闭撤销对应 Bot,空闲 runtime 关闭撤销整个 profile;清理失败保留共享执行屏障,直到后续空闲清理重试或 Human Stop 成功。日常浏览器模式保留各自驱动。Local 与 Container 的真实 PersonaBot 对比结果分别记录在 #767、#768,默认值不变。 - -Managed Browser 观察在原有精确引用旁提供有界的非敏感字段值及适用控件状态。可选 Local 候选驱动保守压缩完整 AX 骨架并保留页面/弹窗语义;包含原始控件值时保持 AX 原文,避免误删多行值。两种表示均不改变 Browser 授权和引用身份 ([#787](https://github.com/BotHarness/BotHarness/issues/787)). - -Browser Target 通过原生 Profile 设置选择 Local、Container 或 Daily Browser(ADR-0114/0116),默认仍为 Local。Container 使用固定 digest 的官方 Chrome 镜像、资源上限、命名 profile 独立且带所有权标签的 volume 与内部 CDP,独立于 Computer。Browser 和 Computer 的独立 Client Bundle 编译复用 `packages/client/src/client/remote-viewer/` 的纯展示组件,无 Computer runtime 依赖:容器画面在侧栏默认只读,Human Open 展开同一连接的全屏 Viewer,复用接管、状态、缩放和收起交互。Browser 确认 Host Pause 后才允许输入;关闭操作或收起全屏保持 Pause,恢复 Bot 需要明确 Resume。共享 Viewer 全屏期间,侧栏 overlay 提升层级,避免侧栏开关遮挡全屏收起控件。切换 target、profile、viewer 地址或关闭 Access 均撤销当前 UI 输入权限。Target 切换撤销 Session 授权、待审批作用域、ref 与标签归属,等待 runtime 成功停止后才允许新动作;显式上传经有界文件复制,空闲停止保留数据,不挂载 Host 目录。日常浏览器的只读借用切片见下一段。 -本地 Browser 接管复用同一套 Viewer 家族([ADR-0163](../adr/0163-local-browser-takeover-reuses-computer-viewer-family.md),#1322):Host 以 `/botharness-browser/viewer/local/` 直供本地帧缓冲(截图轮询成流,CDP screencast 只留给 Bot 侧 observe/screenshot),Client 仍走共享 `remote-viewer`。点击、滚动与键盘输入经 Viewer 端点进入当前 Bot Tab(提交后的文本经 `Input.insertText`,IME 在 Human 设备上组字),输入要求 Pause;小视口默认触控板模式(滑动移动虚拟光标、轻点点击),可切直点。验证步骤走 ask-permission 接管:`browser_takeover` 先暂停并签发一次性链接(至多 10 分钟,带说明),Human 在 Viewer 横幅里 Done / Could not finish,Bot 在 `await` 里做服务端页面状态校验后 Resume(旧引用失效,必须重新观察)。键入的秘密只记字符数,审计(mint/accept/input/complete/expire 含完成原因)与会话录制只读、无删除口。 - -Daily Browser([#741](https://github.com/BotHarness/BotHarness/issues/741),[ADR-0116](../adr/0116-daily-browser-tabs-use-explicit-ephemeral-borrowing.md))通过 MV3 扩展显式借用一个 Human 标签页,仅注册 `browser_observe`,保留 Browser Access、可信 Session ownership、原生首次动作审批和既有审计。Browser Service 拥有进程内的一次性配对码、绑定扩展 Origin 的令牌、固定 tab/URL 的临时 lease 与 observation deadline;不建立持久授权库。认证 Client 创建 Bot 配对码;扩展经仅 loopback 的 WebServer prefix 兑换,先展示 Bot/当前页面,再经 Human 单独 Share。只有批准的观察才通过 activeTab/scripting 读取 main document 的有界可见文字和控件名称,不读输入值、Cookie 或其他标签页,URL/documentId 拒绝跨文档结果。侧栏显示当前只读借用与 Return;归还、任何导航/刷新、关闭标签、Access 撤销、Target 切换、Host 关闭或失联会拒绝 pending observation、撤销动作审批,重启不恢复借用;Human 标签保留。扩展只使用 loopback host permission、activeTab、scripting、session storage 和 alarms,不依赖 Computer 或启动 Human 默认 profile。[安装与操作指南](../daily-browser.zh.md)。 - -日常 Chrome 的明确控制是独立的 `daily-control` Target(ADR-0121,#766),不升级 `extension` 只读借用。现有 Browser Tool Provider 仍拥有 Access、可信 Session ownership、原生审批、Pause、Audit 和注册生命周期;独立 Subprocess 运行固定版本官方 Playwright 扩展连接器。Human 先在官方扩展选择一个已有页面,再在 Browser entry 单独允许控制该文档。Bot 只得到 observe、type 和 ref-based click;每次观察的 refs 绑定原始 DOM 节点,Resume 必须重新观察。连接捕获的 Page 不跟随标签组或其他页;主文档导航/刷新、关闭、断连、归还、Access 或 Target/profile 变化、Host 退出撤销进程内授权和在途结果,重启不恢复,Human 标签保留。Computer 授权独立;[安装与操作指南](../daily-browser.zh.md)。 - -显式 `profile-control` 通过第一方受限扩展持久配对一个 Human Chrome Profile;Browser Access 与原生 Session 审批分别生效。只能操作普通网页;配对哈希持久化,但引用、选择、待执行命令和 Session 授权不恢复。多个 Bot 操作串行;导航使引用失效,暂停等待已发出操作结束。见 [ADR-0123](../adr/0123-daily-chrome-profile-control-is-an-explicit-persistent-pairing.md)。 - -Browser entry 的 header Access 开关控制展开:关闭即折叠并锁定,开启在同一次交互中展开。平面标签列表置顶 Provider 当前 Bot Tab,并显示标题与 URL。跟随开启时预览 Bot 当前工作页;关闭时固定当前画面所对应的 target,直到 Human 选择另一个已归属标签。预览只读取 observation,不改变 Provider 的当前标签与 Agent 控制;Pause 仍是独立的 Host command。 - -Human 的「打开 Bot 浏览器」通过现有进程内 per-Bot 标签页 Provider:唤起仍存活且归属此 Bot 的预览页(或当前页),恢复最小化窗口;Human 预览其他页时不改变 Bot 当前页指针。已关闭的归属页被清理,优先复用仍存活的归属页,否则创建一个归属此 Bot 的空白 Human 标签页。重复打开与 Bot 操作共用 per-Bot 队列并复用该页,不唤起或登记共享浏览器 profile 中其他 Bot 的页。Human 前台聚焦与 Agent 后台操作保持独立。 - -临时关闭 Browser Access 会撤销 Agent Scope 工具注册并取消该注册的在途调用;正在等待的调用及时结束,已发出的 CDP 操作等待自身完成后返回撤销错误,队列中的旧调用即使权限重新开启也不会执行。调用者取消信号同样保留,不放弃底层尚未完成的操作;Provider 的进程内标签归属与当前页指针保留,重新开启后工具可继续使用原工作页。显式停止/重置会清空这些记录;此连续性限于同一个 browser profile,标签归属不跨 Host 重启持久化。 - -修改 browser profile 分配时,Core 通过应用定义的 Host Service 调用现有 Browser Provider reset command。Client Profile combobox 合并 PersonaBot record 中已分配的名称与 Browser observation 返回的已存 profile 目录名;目录读取不创建 runtime、不跟随符号链接,也不另建目录清单存储。输入已有名称可选择,新名称通过明确的创建项分配;失焦与 Escape 不保存。Profile 名称先在既有 Host 保存接口验证;保留路径段 `.` 和 `..` 在写入 PersonaBot record 与调用 reset 前被拒绝,当前标签与 Pause 不变。runtime 对已存无效名称继续回退默认 profile,正常名称中的点仍被允许。切换的 Bot 在使用新 runtime 前清空旧的进程内当前页/标签归属记录与 Pause 状态;Browser Access 与 Session 授权仍各自独立。其他 Bot 的标签归属与旧 profile 的浏览器数据保留。重置记录有界生命周期诊断,切回原 profile 时不持久化或重新登记旧 target。 - -同一 browser profile 的并发首次调用等待同一次 Browser 启动;启动失败后可重新尝试,不生成第二个实例。Human 关闭当前 Bot Tab 后,下一次工具调用清除失效的进程内归属与当前页,并提示使用 `browser_tabs list` 选择另一页或 `browser_open` 恢复(#463)。 - -`browser_upload` 使用 Host 文件向当前 Bot Tab 附件字段挂载文件:文件 input 的 ref 精确选择该字段,上传按钮的 ref 在 CDP Session 内拦截原生 chooser,并使用实际 `Page.fileChooserOpened.backendNodeId`,不会猜测其他附件字段;省略 ref 使用第一个 `input[type=file]`。chooser 监听按 CDP Session 隔离,超时或失败后解除监听与拦截;文件不存在、无输入或按钮未打开 chooser 均返回可读错误。Browser Audit 只记录 ref、文件 basename 与大小,失败摘要隐藏传入的完整 Host 路径;发布前仍须 Human 明确确认。 - -Browser Tool Provider 在同一调用完成路径中记录成功与失败,包含授权、Access、Pause 及 Resume 后重新观察检查的即时拒绝;队列入口拒绝与运行失败仍各只记录一次。Browser Audit 使用注册时的 Bot、Session 及可信 Session ownership 的角色归属,写入现有 `logs.db`;输入只记录字符数、上传只记录文件名,不另建日志存储或读取接口。 - -Pause/Resume 在现有进程内 Provider 状态中失效该 Bot 的可操作观察。页面点击(ref 或坐标)、输入、按键、滚动及上传,必须先完成一次在当前控制状态变更后开始、且 Pause 未开启时成功返回的观察;读取失败、Pause 期间的读取以及旧的在途读取均不能满足此要求,截图也不能替代观察。Open 与标签管理保留以恢复缺失页面;Access 开关保留重新观察要求,显式 Profile 重置则重新开始标签归属。其他 Bot 各自独立。 - -```mermaid -flowchart LR - Inbox["Bot Inbox / Attention"] --> O["One active Orchestrator Session"] - O -->|"list / inspect"| Dir["Durable Assignment Directory"] - O -->|"create_assignment"| Gate{"Global active Assignments < limit?
default 8"} - Gate -- "no" --> Error["Structured + LLM-readable failure
no queue, no intent"] - Gate -- "yes" --> Runtime["Assignment Runtime"] - O -->|"send_assignment_request / stop_assignment"| Runtime - Runtime <--> W1["Independent Assignment Session A"] - Runtime <--> W2["Independent Assignment Session B"] - W1 -->|"report_to_orchestrator"| Report["Assignment Report Source Event"] - W1 -->|"completed Turn paired with this Report"| Completion["Host Completion Notice
paired completion #194"] - W2 -->|"confirmed stop / native cancellation / native error / Host recovery"| Notice["Host Lifecycle Notice
stop / cancellation / error #194
Host recovery: unconfirmed outcome"] - Report --> Inbox - Completion --> Inbox - Notice --> Inbox - W1 -.-> Sub["DSH Subagents
aggregate-only"] -``` - -Assignment Session 是 DSH independent root,以 DSH `sessionId` 为 canonical identity;Continuity Key 只是 PersonaBot-local alias。Orchestrator 通过六个工具 `list_assignments`、`inspect_assignment`、`create_assignment`、`send_assignment_request`、`wait_for_assignment`、`stop_assignment` 管理它们。Assignment Agent 只能用 `report_to_orchestrator` 向 Orchestrator 回报;其 Agent Scope 没有 Channel send capability,普通 final 也不会写入 Channel。v1 没有 Assignment-to-Assignment 直连、广播或等待队列。 - -带地址的答复只在原生 Inbox 接收后清除所捕获的原问题;pending followup 不等于投递成功。可证的准备失败保留问题、权限/模型快照和空闲重试路径;原生投递结果不明或重启保持修复可见、不重放。仅收到 Human DM 不隐藏未解除待办或生成已处理历史;可证失败后可在原来源明确重试。旧答复不能清除新问题(#812)。 - -Assignment Runtime 的并发上限覆盖整个 Host 的所有 PersonaBot(默认 8),同时约束新建、按 Session 恢复空闲事项和按 Continuity Key 复用。恢复前先同步占用原有 Assignment Directory 的 working 名额,再交给 DSH;运行中的事项接收更新不增加名额。满额时返回 `assignment-capacity`、当前数量、上限和可重试标记,不启动执行、不清除待答问题、不改变模型或权限快照。事项停止确认前仍占名额;释放名额后,Orchestrator 可重试同一 Session,不引入等待队列(#811)。 - -Orchestrator 通过 `archive_assignment` / `unarchive_assignment` 显式归档和恢复 Assignment(ADR-0188)。归档调用 DSH Workspace Registry 原生的 `archiveSession`,不带 `stopActivity`:事项仍在执行、停止中或等待 Inbox 接收时直接拒绝(`assignment-active`),由 Orchestrator 先停止再归档。成功后 Assignment Directory 记录 `archived_at` 作为原生归档集合的镜像,并清除 Continuity Key;归档进行中和归档后的 Assignment Request 都会被拒绝。不会自动归档。`list_assignments` 支持文本搜索(用途、Continuity Key、最新报告)、活动筛选、`include_archived` 和 opaque cursor 分页,默认不含已归档事项。 - -哪些 event 进入 Bot Inbox、哪些唤醒 Orchestrator 仍由现有 wake 与 source policy 决定;被成功回合观察到的 Inbox Admission 记录 `opened_at` 并保持 open,不再按是否 @ Bot 或来源类型另设规则。`inbox_resolve` 在同一 Admission 上写入 resolution、note 与 Orchestrator Session,按 id 或过滤条件批量处理;过滤条件先返回数量、样例与基于命中集合的确认 token,只有带同一 token 的重复调用才落库。`inbox_list` 按 Channel 对话或 Assignment 分组列出 open 条目,每轮的 `[Open Inbox]` 块只给出数量与前几条一行摘要。attempt state 仍是执行事实,open 由这些列派生,不改写历史已处理记录。`inbox_snooze` 在同一 Admission 上记录 `snoozed_until`、`snoozed_at` 与 note;同一 Channel 对话或 Assignment 的更新 Source Event 被 Bot 实际观察后才提前返回;未唤醒 Bot 的普通群消息不能取消到期唤醒,再次 snooze 不会提前。Host 在启动和每次 snooze 后为每个 Bot 设一个定时器,到期时以最早返回的条目为锚运行一次 Orchestrator 回合,列出返回条目与 note;失败则延后五分钟重试。每次成功回合后若仍有未 snooze 的 open 条目,Host 排入一次 `[Triage]` 回合;某一轮没有让 open 数量下降就停止,直到出现不在这批停滞条目中的新 open 条目才再次触发(ADR-0188)。 - -#1037 有界切片只在无后代、无执行中工具且无已批准待执行调用的 Assignment 等待 Human 审批时释放 live 运行名额。持久 activity 仍为 working,process-local executionWait 区分 waiting-human 与 waiting-capacity;最多 32 个等待中的 Assignment Session 使用独立上限。Human 决定提交后,原生 outcome 或下一模型 Step 必须先取得当前运行名额,实际工具执行前再次检查归属、Grant、原 Source Event 与准确操作。新建和空闲恢复仍在满额时立即拒绝;中止、停止与冷恢复均不重放原调用。同一 Orchestrator 的权限 continuation 与群聊隐私仍单独保持 gate。见 [ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md#live-approval-waits-and-running-permits)。 - -Human 在 Bot 模式设置中将此 Profile 级上限调整为 1–32。DSH 原生 Settings schema 的 Volatile field 由 Profile Config Editor 持久化;UI Plugin 的 Host Fiber 将 live reader 绑定到 application-defined Assignment Runtime,并在 dispose 时释放绑定。Client 只展示 Host 确认的保存值;Runtime 在每次新建或恢复空闲事项的准入时读取当前值。保存后立即影响后续准入,重启后保留;降低上限不中止已有执行,直到使用量低于新上限才允许启动新工作(#825)。 - -待处理 Workspace Grant 请求通过同一持久 Human-action 查询进入独立 Activity attention。只有经过校验的 Grant 关联 Human 回复、Inbox 忽略或来源 DM 移除才清除计数;普通授权文字不会清除。重启从已提交 action 重建计数,不伪造执行状态。 - -报告超过 2 KiB 时,Assignment Runtime 在提交 Inbox Admission 前把完整文本交给 DSH Spill Service 保存,并只将前缀预览、字节数、不透明 locator 与 provider 的检索提示写入 Assignment Directory、Source Event 和 Bot Inbox;单份报告上限为 1 MiB。Orchestrator 的 `inspect_assignment` 可通过 DSH Session Query 读取该事项最近四条原生日志事件(最多 12,000 个字符),也可指定 `report_offset` 从已接收报告的原生 `tool/call` 事件每次读取至多 2,000 个字符,并通过 `nextOffset` 继续;两种读取均先核实事项归属,返回查询条数、实际读取量与估算 token 成本。Spill Service 仅负责保存且 locator 对消费者不透明;Orchestrator 即使不能直接读取本地 Spill 路径,也能通过 Session Query 找回该报告,不额外建立一份全文报告数据库。 - -Agent 可自行选择发送内联报告,或先写入其工作区文件并报告路径;超长内联报告由 Host 自动处理,不触发额外提问。 - -当前 `stop_assignment` 通过 DSH `Agent.cancel({ kind: "user" })` 取消活动回合并清空待执行输入;BotHarness 先持久记录「停止中」,待 Agent 静止后在同一事务里记录「已停止」和 Host 来源的 Lifecycle Notice。通知进入该 PersonaBot 的 Bot Inbox,Orchestrator 可在私聊报告停止结果;未观察的通知在 Host 重启后恢复,已交给 Orchestrator 但在运行中中断的通知标为 needs-repair,以免重复执行不确定的副作用。停止的事项不再接收请求或迟到报告,其 Continuity Key 可用于新 Session;取消不销毁 DSH Session 历史。成功完成路径读取可信 DSH `turn/start` 的编号并记录在独立 Assignment Report 的 payload 中;原生 `turn/end` 确认成功后,Host 仅为同一所属 Session、同一 Turn 的 completed Report 追加一条幂等完成通知,保存该报告 Source Event ID 与结束 seq。报告保持 Bot 来源,通知保持 Host/system 来源;Bot Inbox 只读查询返回 Turn 和关联报告 ID。配对通知搭乘报告触发的收割或下一次真实 Turn,不单独唤醒,也不因报告已处理而伪造通知已观察/已处理;重启仍保持这一规则,正文同时保留报告语义与 Host 完成事实及各自来源引用([ADR-0077](../adr/0077-turn-time-harvest-consumes-the-ready-attention-set.md))。progress-only Turn 不产生完成配对通知。Human 通过 DSH 原生 Session 控制取消活动 Assignment 时,adapter 仅从已提交 reason=aborted 的 turn/end 读取可信 Turn/end seq;Runtime 在同一事务内投影 error、释放 Continuity Key,并按所属 Session/Turn/native-turn-aborted cause 幂等写入 Host/system Lifecycle Notice 与 Inbox Admission。它沿用即时收割,保留原 Report,不伪造成功配对或自动重试;通知与原报告分别可导航,已处理通知重启不重放。Orchestrator stop 路径抑制原生取消回调,继续只发已确认 stop 的通知([ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md))。已提交的原生 error Turn 沿用同一失败结算事务,以 failed/native-turn-error 区分来源;adapter 只传可信 Turn/end 身份,不把原始错误写入通知。Runtime 释放 Continuity Key,保留进度 Report,独立即时收割一次,不生成成功配对、自动重试或替代 Assignment;已处理事实冷重启不重放。Host 启动时把仍为 working/running 的 Directory 行在同一事务内转为 error、释放 Continuity Key,并沿已保存来源策略纳入一条 System interrupted/host-recovery 通知。正文说明旧执行结果未确认,不推断原生 Turn/end 身份或报告配对;保留原报告,既有收割可唤醒 Orchestrator 一次,但不自动恢复 Assignment。状态转换和已处理事实再冷重启不重复通知或收割。准入前失败仍是独立边界,#194 尚待完成的视觉验收记录在 issue。 - -Assignment Request 的 `context-update`、`next-step`、`next-turn` 分别映射到经过验证的 DSH inject、steer、followup seam;普通请求不 cancel 当前 step。跨 SQLite/DSH 边界只保留最小 Assignment Delivery Intent,重启时有界 reconciliation;歧义进入 `needs-repair`,不扩张为通用 workflow engine。 - -统一 **Sessions panel** 保留 `sessions` 身份并退役 `group-work`([ADR-0220](../adr/0220-one-sessions-panel-with-explicit-bot-or-channel-scope.md),#2034)。Header 的范围选择是 Client 本地偏好:DM 固定当前 Bot,Group 默认当前频道,或由 Human 明确选择可访问的成员 Bot。Bot 范围复用 `sessions(slug)` 与原生 Session catalog;群内选择 Bot 使用面板本地查询结果,不覆盖当前 conversation 的共享 Sessions 状态。频道范围复用 `channelAssignments(channelId)` 的 Host 成员权限与原始 Source Event 归属检查,最多 200 条;达到上限显示截断提示。两种范围复用 Session 行状态投影并打开同一原生 Session ID;报告状态单独显示,空闲不等于完成。迟到响应在范围卸载后丢弃,失效 Bot 选择清除并回退频道范围。会话面板默认跟随当前频道时不重复显示范围标签;群聊范围选择收进视图选项菜单,仅显式指定 Bot 时在标题栏显示 `Bot · 名称`。 - -布局 v1/v2 升到 v3 前保留一次本地备份 `botharness.bot-mode-layout.before-sessions-v3`。已有 Sessions 位置优先;只打开 Work 时原位替换;两者关闭时不自动打开,并重写停放邻居和控制列偏好。快捷键通过 DSH 原生 revision-checked 配置接口迁移有效且无冲突的 Work 自定义绑定,保留 Sessions 显式覆盖(包括解绑)。冲突或失败指向快捷键设置;旧 Work 命令不再注册,原生接口不允许删除未注册命令的覆盖,因此保留其休眠配置以便回退。迁移完成标记防止用户后续重置 Sessions 时再次导入。旧版本回退需要恢复布局备份;Host 数据与 Session 生命周期无迁移。 - -### 5.1 · PersonaBot activity、事件与 renderer - -Web 的选中 Channel、Roster 与 Activity 消费在每个文档内共用一个受认证的 EventSource;既有 `/api/botharness/stream` 在选中 Channel 时携带 `channelId`/`after` 与 `roster=1`/`activity=1`,从 owning Channel live hub 组合原有 Channel revision 流、roster 通知、撤销基线与同一 Activity Projection 的有界快照。没有选中 Channel 时复用 roster/Activity 流。各逻辑消费者独立清理,最后一个离开时关闭物理流;Channel 选择变化调整查询,替换构造失败保留健康旧流,Host 合成 adapter 断开时取消两个原流读取者;重连错误清除可回放 Activity 基线,迟到消费者保持 stale 与 HTTP 恢复直到新快照到达。慢消费者只保留当前 Activity 更新,背压时跳过心跳;Channel revision/回放和 Owner 认证不改变。这是过程内传输复用,不创建新的 Activity 或内容权威([#1451](https://github.com/BotHarness/DeepSeekBot/issues/1451))。 - -DSH 失败的 `turn/end` 仍是执行事实权威;BotHarness 在所属 Orchestrator 或 Assignment 回合结束并判定失败后,由 Host 向对应 PersonaBot DM 提交一条带 role、Session ID、错误码、可用 HTTP status、简短错误详情和事项上下文的 application-defined failure notice。该消息是面向 Human 的持久通知,不把 SessionEvent 全文或工具日志复制成第二套执行历史;Client 把它渲染成可读的失败卡,刷新后从 Channel authority 恢复。Orchestrator 的 Human Source Event 仍依原有 retry/reconciliation 语义处理,失败通知不等于完成该 Source Event。 - -DSH SessionEvent 是 durable execution authority;BotHarness 不复制 tool call 或 assistant output 为第二套 Session fact。explicit Session Ownership 把 Session 归属到 PersonaBot 及 `orchestrator` / `assignment` root role,PersonaBot module 再把这些事实与 live liveness 折叠成一个可重建的 Activity Projection。Browser 首先经 Typert/API Gateway 查询 projection,随后消费带单调 revision 的 live update;revision 断档时重新查询,而不是由 Client 自己推导状态。 - -每个页面共用的 Channel/通讯录/Activity 实时流按 ADR-0181 由原生 Remote 流承载:Host 的 `botharness/liveFrames` 流式 Remote 方法打开既有 live hub(与 `/api/botharness/stream` 相同的查询、回放与拒绝语义),把它的事件帧逐条作为流项产出;Client 挂载对应 contribution,以 EventSource 形状的适配器经 Gateway 流监督器消费,断线后携带最后的事件 ID 续接,Host 拒绝(未知 Channel、修订超前、参数非法)时进入关闭态。过渡期的事件帧隧道 `botharness/liveFrames` 已在 #1502 移除;Remote 无法挂载流方法时仍回退到原 SSE 路由。通讯录与 Activity 进一步各用一条带类型的流(`botharness/followRoster`、`botharness/followActivity`):每代先发完整基线再发增量,Client 经官方 `RemoteSnapshotStream` 消费,帧流只剩所选 Channel(#1498)。所选 Channel 随后也有自己的带类型流 `botharness/followChannel(channelId, after)`:`opened` 给出本代起始修订号,`entry` 按修订号顺序给出已提交消息,`notification` 承载回执、已读位置与草稿预览;Host 的 Channel 订阅是唯一的帧源,流方法与 SSE 路由只是两种编码,Client 的 Channel 消费逻辑与传输无关,时间线分页仍按 ADR-0061(#1500)。按 ADR-0179,窗口贴着最新消息时由官方 `RemoteJournalStream` 负责:每代以游标加最新一页开场,条目必须紧接上一修订号,缺口由 `channelJournalPage` 读取限定修订号的一页修复,向上翻页经官方类的 prepend;Client 把整体替换合并进已显示的窗口。定位在已读位置或旧消息附近的窗口仍走 `channelTimeline`(#1503)。Window Companion 的订阅也改为带类型的流 `botharness/followCompanion`:Host 把既有 feed(租约、恢复与背压不变)投影为 baseline、selection、activity、message 帧,Client 经 `RemoteSnapshotStream` 消费,监督器重开时带上最后的 consumer 标识;订阅更新仍是独立的 POST。此后每个页面只剩原生 HMR 一条长连接(#1501)。 - -PersonaBot 活动通过公开 `activitySnapshot` 查询与认证 `scope=activity` SSE stream 交付同一份 Host 拥有的快照(`generation`、单调 `revision`、每个 Bot 的聚合状态)。每次连接先发送完整 baseline,实际聚合状态改变后推送完整快照;重连无需重放第二套活动历史。Client 原子地向置顶、普通行、rail 头像及 DM 输入框应用同一快照,拒绝同 generation 内的旧 revision,并防止过期 roster 响应覆盖当前活动。退出 Bot mode 或隐藏页面时关闭活动连接。 Activity Client 在进入时查询 baseline,修订跳号时重新查询。stream 失败或不可用时,每 10 秒最多一次重试并查询既有 `activitySnapshot` 权威,单次查询时限 5 秒;收到有效 live frame 后停止备用刷新。慢 Activity stream 只保留一个排队的完整快照,后续变化合并到最新快照,不重放原始执行事实。退出 Bot mode、隐藏页面或 Plugin disposal 会取消定时器、请求和订阅;旧 Host generation 的迟到响应不会覆盖新的 live baseline(#121)。执行状态由显式 Session Ownership 和现有 DSH SessionEvent Projection 提供,不来自 roster 轮询或本地发送标记(#120/#536)。 Channel stream 建立连接时还补发已见 cursor 前最多 100 条消息的当前回执,补足 HTTP 快照与实时连接之间的窗口;不引入轮询或第二套 Admission 权威。 - -application-defined `botharness/personabot/activity` Cordis Event 在 projection 改变后以 `emit` 发出,供 Host 内的 Live2D、3D 或其他 Plugin 同步。Orchestrator 活跃时负责呈现;当它明确 `waiting-on-assignment` 时,活动来源切换为 Assignment:同类 tool kind 使用对应 effect,多类并行回退到通用 `working`。waiting、blocked、approval 与 informational attention 单独投影,不进入可配置 priority。 - -PersonaBot Output Committed 已由 canonical Channel writer 的成功提交回调提供:可信 Runtime 传入本次调用的 Session/Source origin,Host 校验显式归属后发布应用定义的 `botharness/personabot/output-committed`。载荷是冻结的公开正文/格式与 PersonaBot、Session、Channel、消息、修订及回复/来源引用白名单,不复制工具/审批卡或私有上下文;origin 不持久化、不进入 SSE。重复/冲突、回滚、模型增量和重启重建不通知。当前 Cordis emit 不隔离 callback 错误,因此同一 EventsService 的 emit dispatch 逐消费者安全调用,Promise 不等待且失败仅记录固定诊断;Consumer Fiber 销毁自动清理,不另建 listener registry、日志权威或 browser API。订阅方法见 [Output guide](../dev/guides/personabot-output.md)。 - -Tool activity notification 只广播 `toolKind`、可选 `toolName`、SessionEvent reference 与 Tool 显式声明的 `publicDetail`;完整 arguments/result 由受控 Capability 按引用读取。Channel output commit 后另发 PersonaBot output notification,TTS 与说话动画消费该 public output,而不是任意 Tool 参数或尚未提交的草稿。 当前 `publicDetail` 是 Tool Provider 在其实际注册的 DSH Tool Definition 上通过应用定义 `withPublicToolDetail` 显式提供的同步声明;Host 不从原生 call-card title/rawInput 推断公开文本。文本限制为 160 字符的非空单行,控制字符与双向格式符拒绝,失效或抛错时省略;并发声明不一致时省略,已有 revisioned Activity 与认证 Gateway 传递同一字段。浏览器标签页 Provider 只返回固定操作文案,不公开 URL、标题或 tab identity。 - -工具 Activity 的首个切片(#122)由待执行 `tool/call` 与配对的 `tool/result` 投影事实,经 Agent Scope 内已注册工具的 presenter 解析类别。Activity 快照只保留有界类别与已注册名称;presenter 标题、原始输入、路径、diff、参数和结果均留在原生 Session。并发同类保留效果,不同类别回退通用工作;一个结果不会隐藏尚未结束的调用。每次投影变更先提交一个 revision,再发布应用定义的 process-local `botharness/personabot/activity` Cordis 通知与完整 SSE 快照,同状态的工具变化也会通知。侧栏 hover/focus 与输入框读取同一安全摘要,键盘展开在每个活跃 Session 的独立卡片内显示最新安全活动,展开区不重复 Bot 名称;既有 motion 偏好控制相同效果。最新记录由同一 Host 投影拥有,携带进程内 opaque 显示 key、可信角色、可选的原生 `session/title` 名称、Host 观察时间与 revision,经 snapshot/SSE 同步;名称最长 1,024 字符,拒绝控制或双向格式字符,创建、改名和重建都读取相同的已提交标题事实。输入框展开区是透明浮层,只有全宽 Session 卡片保留背景;桌面名称与状态同行,窄屏换行,Assignment 使用原生名称并省略过长文字。按实测展开高度给最新消息留出空间,避免遮住审批操作,较早历史仍可滚到透明区域后方。同角色 Session 仍分别显示,完成或销毁后移出,idle 时不留记录,Host 重启后原子重建当前基线。显示 key 不是原始 Session ID,也不是完整详情查询授权;不保留八条历史,不持久化或回放 Session transcript,不包含参数、结果或 reasoning。授权 opaque 完整详情通过 Host-only `botharnessActivityDetails.read(reference)` Capability 读取,角色优先级聚合仍归 #123。 当前执行来源由已记录的 Session Ownership 的 root role 与 Subagent provenance 推导,Activity 聚合字段公开主会话、任务会话、子代理三个类别及各类正在执行工具的会话数,展开区的 per-Session 最新记录另以 opaque key 区分各个活跃 Session;同一会话多个工具只计一个来源。角色分布通过现有 revisioned snapshot、Cordis 通知和相同安全摘要交付,原始 Session 标识、路径和工具数据不会进入该来源字段。该来源信息不改变当前状态或效果选择,角色优先级仍由 #123 完成。 - -活动中心总览读取与侧栏/消息框相同的 Host Tracker 执行选择及有界工具摘要。原生问题/审批卡片和其他 Human 待办保持独立的 `hasAction`/待办数投影,不改写 SessionEvents,也不以 waiting/blocked 覆盖执行。活跃归属根 Session 卡片沿用原生执行事实和运行中根过滤。原生问题的 live owner 在请求卡提交后向同一 Tracker 发布 `questionCount`,与 `approvalCount` 分别保留;Avatar 数字显示两者之和,悬浮说明区分来源。回答、取消、Agent dispose 和 Plugin close 从 owning pending map 清除,Host 重启不重放历史待办。Assignment waiting-human/blocked 待办复用 Human Inbox 的同一权威查询、回复/隐藏/停止条件,事务提交后派生到 Tracker 的 `waitingHumanCount`/`blockedCount`,不把报告状态写成 Session 执行状态;冷启动重建未解决的持久化报告。所有计数使用同一个 Avatar 数字和独立本地化说明。application-defined `wait_for_assignment` Tool 通过 Runtime 等待已提交报告或执行结束,使用原生 Tool AbortSignal,限时 1–120 秒。当 Orchestrator 的全部活跃 Tool 调用都属于明确等待操作时,共享执行选择呈现所属 Assignment;若 Orchestrator 仍有其他工作则保持优先。等待 lease 为 process-local,由报告、完成、取消、超时或 Runtime close 释放,不从历史 Tool 名字推断或重放。其他共享 attention 仍属于 #123 后续切片。 - -Client 通过一个 Avatar module 在侧栏行、响应式 Pin Grid、消息、顶部和 composer activity row 中呈现同一 projection。默认 Blobatar media 可在 thinking/working 时运动;自定义图片保持静止,由外层 Activity Frame 表达状态;group Channel 可用最多三个头像与 `+N` 的 facepile。Human Inbox 则统一投影 Channel Attention 与 PersonaBot Attention,并按 action-required / informational 分类;#546 从持久 Channel placement 与按 Human 身份保存的 read position 投影未读数;同一 Source Event 只计一次,入口另示待行动提示。 - -Tool detail Capability 默认拒绝所有 Consumer。部署 Human 在 `botharness-core.activityDetailConsumers` 显式列出受信任的 Host Plugin runtime name;Cordis Service caller Context 提供实际调用方 Fiber,只有处于 ACTIVE 的允许 Consumer 才可读取。它是受信任 Host Plugin 之间的部署授权边界,不是同进程恶意代码沙箱。服务不注册 Browser RPC 或 Fetch 详情端点;opaque reference 本身不授予权限,也不是 Session 显示 key。每次读取审计 Consumer、结果与字节数,不记录 reference、原生 Session ID、参数或结果。 - -共享状态与 Tool 摘要优先选择活跃 Orchestrator Session,安全的展开列表仍保留双方 Session;Orchestrator 结束后恢复展示剩余已归属 Session 的活动。#123 首片不推断等待标记或建立新的 attention 生命周期;明确等待现由 Runtime 拥有的有界操作提供;其他正交 attention 仍属后续契约。 - -引用表只保存指向原生 SessionEvent 的 process-local locator,最多 256 项,有效期五分钟;请求时校验当前归属及归属模块的进程内单调 repair revision,再从存活 Session 的 canonical snapshot 读取精确 Tool arguments 与配对 result(包括原生 meta),返回独立 JSON 副本,完整数据超过 64 KiB 时拒绝。归属改变、原生事实消失、Turn 边界、Session disposal、projection rebuild 或 Host disposal 均使旧引用失效;无持久 payload 副本或 replay。已完成 Tool 的 Consumer 可在当前 Turn 内使用此前收到的引用读取结果,Activity 则继续只显示当前待执行工具。 - -### 5.2 · 可编辑 Avatar(已接受设计,待交付) - -[ADR-0118](../adr/0118-editable-avatar-appearance-is-independent-of-activity.md) 记录 Human 已确认的设计目标:人物插画与抽象小角色两个 Avatar Family 共用真实 Bot-state 语义与过渡规则,各自适配部件和姿态。保存的有版本 Avatar Appearance 配方归 PersonaBot owning module;执行与 Human attention 消费现有 owning projection;逐帧姿态和过渡时间归 Client renderer,不写 SessionEvent、不改保存造型,也不另建状态聚合。首版编辑目标包含细分发件与有界五官几何参数;抽象角色可短暂变成有界点阵/符号,配件暂时收起、稳定姿态恢复时完整回来;小头像的动作更短、更克制,大形象更丰富。保存配方与派生静态快照;缺少兼容部件/协议版本时保留配方、显示原外形快照,并明确暂不可编辑/播放角色动画,独立活动提示继续消费真实事实。候选渲染路线是现有 React Client 内的受控 SVG、稳定节点与有界姿态采样,不预先引入新动效依赖。首个切片先验证编辑、保存、重读、真实工作动作与独立 attention(依赖 #123 owner 已交付的共享合同),第二家族复用语义合同;完成/错误动作后续另定结果作用域。shared native Human attention 继续由 #123 owner 提供,explicit waiting-on-Assignment 不由头像推断。 - -[#751](https://github.com/BotHarness/BotHarness/issues/751) 的首条人物路径使用 Registry 的 `setAppearance`:受控、严格版本化的部件与十六进制颜色配方和 Host 由同一 SVG 派生的 512×512 PNG 在一次原子写入内保存,SHA-256 revision 绑定配方与快照。DTO 只携带配方/revision 与既有快照 URL;图片覆盖或移除会清除 composed 配方。Client 的 Profile 草稿在 Save 前不写入,侧栏和大图消费同一保存配方;稳定 SVG 节点上的 Web Animations 只持有有界局部姿态,真实 activity 切换会重新定向,减少动画、隐藏、离屏和卸载取消资源。Host 光栅化依赖仅在保存时执行,不进入 Client Bundle;第二家族、细分发件/五官和缺版本兼容回退仍由后续切片验收。 - -### 5.3 · 窗口伙伴(已接受设计,待实现) - -[ADR-0144](../adr/0144-window-companions-consume-owned-activity-and-scoped-output.md) 与 [规格 #1135](https://github.com/BotHarness/BotHarness/issues/1135) 记录 Q1–Q23 共识。Human 主动从 Bot 行右键或 Channel header 的具体 Bot 入口选择“显示为窗口伙伴”,与 Channel 置顶独立;通过官方 RC 的 shell overlay Slot 跨 Harness 页面显示既有半身 Avatar。Client companion owner 持有当前 Client × DSH Profile 的选择、每 Bot 来源/范围/漫游偏好,以及所有伙伴共享的卡片层数/保留数量设置;不更改 Registry 的身份/外观、Memory 或 Channel pin。伙伴像素视图省略画布底色与方形头像框背景,呈现透明人物轮廓;既有侧栏头像与保存配方不变,透明区域仍保留可用 hit area 和独立 attention。图片/缺失 rig 快照不自动抠图。底部漫游、拖拽、受窗口边界约束的位置和锚定像素气泡属于呈现层,逐帧姿态不跨 RPC。 - -消费路径为 owning Registry/Activity Projection/canonical Channel → 认证 Typert/API Gateway 与有界 live/query adapter → 独立 companion Client owner → 既有 Avatar 与卡片。当前 Activity Client 退出 Bot mode 会关闭消费,当前 SSE 只有单 Channel 输出流;伙伴的独立生命周期和按 Bot 跨 Channel 输出合同是待实现工作,不由现有页面组件顺便提供。Host 内其他 Plugin 继续查询同一 Activity Service 并消费 application-defined Cordis 通知;不存在伙伴 UI 到 Activity authority 的反向写入。 - -每 Bot 独立开关 Activity 标签、DM、群聊消息,默认开、开、关。Companion Visibility 默认“Bot 和我都在的 Channel”,另有“仅我和 Bot 的 Channel”与“所有 Bot 加入的 Channel”;Bot–Bot DM 在第三档归 DM 开关。只展示当前 Bot 的已提交输出并标识来源 Channel/DM 双方。第三档需要面向受信 Human Consumer 的 Host-owned Bot 输出只读合同,范围限制在被选 Bot 的 authored output;不能删除通用 timeline 成员检查、授予模型新权限或读取其他作者的完整历史。点击原 Channel 仍检查 owning 读边界,不能访问时解释限制。 - -首次选择/Client 或 Host 重启建立 Host 一致的新消息基线,不重播旧输出,直接查询当前 Activity;短暂后台/断线仅有界恢复本轮基线后尚未播出的消息,canonical identity 去重并拒绝旧 generation 响应。关闭来源清卡片与待播,重新开启或新合格 Channel 从当前基线继续。进程内 output-committed 只是通知,不能充当持久 cursor;查询成本与队列都必须有界。气泡到期/移除不更新 Channel read position。独立 Human attention 即使所有播放关闭仍保留并导航到 owning 活动/Human Inbox,不从计数编造正文或审批控件。 - -每 Bot 一张当前活动卡加独立并行逐字消息卡,默认折叠 3 层、最多保留 20 张未到期卡;全局设置可调整。hover/focus 展开稳定列表,暂停漫游与到期,已显示文字继续播放,新增内容只计数、退出后有界合并;长文预览、列表滚动,基本气泡避让。无自动 +N 折叠或钉选数量上限,Human 通过拖动、暂停漫游、移除调整。归档保留静态伙伴与标记、删除清选择;reduced motion、freshness、上传图片静态 media 和版本快照降级沿既有 Avatar 合同。先完成真实 Host→Client 的一个像素伙伴 Activity/DM 与操作/恢复切片,Human 验证后扩展多个 Bot、群聊与三档范围;闭合/半开/张开文字节奏嘴型作为随后 BotPixel 兼容扩展。独立桌面窗口、fork 分发与全身动作不属于此目标。 - -[ADR-0150](../adr/0150-window-companion-requests-use-their-live-owners.md) 记录 Human 在 #1178 接受的 attention 扩展:伙伴显示真实 owning Tool Approval 的当前待处理请求并可直接决策,独立于普通输出的播放开关和范围。审批正文来自现有 live owner 的已提交请求,不由 attention 计数或旧 Channel 历史重建;原规格保留历史边界。 - -生产 feed 通过 Fiber 拥有的可释放 attachment 观察 `ChannelToolApproval`,在已确认选择快照中投影当前已提交请求;首次钉选、重连都不需要重播历史。`CompanionRequests` 将待处理请求放入有总数提示的常驻滚动区域,独立于装饰气泡容量;普通消息暂缓逐字与到期,显示等待数量。复用 `ToolApprovalCard`、原生控件和既有审批 RPC,只有明确匹配 Bot/DM/Session/call 且 live 的 companion target 可脱离当前 Channel;普通聊天选择检查保留。提交前重新读取状态,Host 仍验证规范请求和 native caller;竞争决定只能一次生效。断线禁用、状态读取失败可重试,拥有者结算或撤销后快照移除请求并恢复键盘焦点,Host 重启不从耐久请求历史重建 live 权限。 - -依赖切片 #1179 将既有 `ChannelUserQuestions` owner 接入同一 feed,并在 `CompanionRequests` 中增加正式提问变体。`UserQuestionCard` 与 Chat、Human Inbox 共用,保留全部问题、选项说明、单选/多选和自定义文字。当前回答草稿不受普通消息和悬浮影响,不另建持久化;明确匹配 Bot/DM/Session 且 live 的目标在提交前重新读状态,可在其他 Channel 仍被选择时直接回答。规范拥有者校验、提交且只接受一次,再恢复原 native request。普通问句不生成交互控件,新 Host 不从历史重建待答问题;审批 attention 与导航保持独立。 - -### 5.4 · 形象物种与自绘部件(部分已实现) - -[ADR-0148](../adr/0148-avatar-species-and-custom-parts-extend-one-pixel-rig.md) 在不新增 rig 的前提下扩展像素 Avatar Family。Avatar Species(人类、精灵、哥布林、矮人、兽人、猫、狗、狐狸、兔子、熊、花)是像素胸像骨架上的基底:轮廓、耳朵、鼻子或吻部、建议身体色与可接受部件;动作、锚点、说话嘴型与伙伴透明轮廓保持共享。头发拆为可分别上色的分片,新增穿过头发的头饰槽(前后两层)。当前物种无法显示的选择保存并隐藏,不被替换。 - -Custom Part 是有界像素网格,每格引用外形颜色或固定色并带明度档位,因此随外形换色。BotPixel 负责其类型、校验、渲染与纯函数绘制算法;PersonaBot 所属模块把副本嵌入 Avatar Appearance,DSH Profile 中由 Host 管理的 Part Library 以内容哈希身份和来源保存可复用部件。导出的 PersonaBot 携带其部件;单个部件以内嵌数据的 PNG 流转。眼睛、眉毛和嘴不可自绘,眨眼与说话帧仍由 rig 负责。 - -新 PersonaBot 记录全域名字种子版本;没有该记录的 PersonaBot 保持仅人类的种子。使用物种、新槽位或 Custom Part 的 recipe 提升 asset/schema 版本,旧 Client 显示保存的快照;既有 recipe 渲染不变。首个切片验证哥布林、左右侧发分片与一个自绘头饰,从编辑器到 Part Library、再到 Window Companion,并经过导出/导入。 - -目前已实现:哥布林、精灵、矮人、兽人和花物种,左右侧发分片,胡子,中世纪服装与头饰,全域种子(#1210、#1212–#1214),第一条 Custom Part 路径(#1211),自绘头发(#1238),自绘其他部件(#1240)、部件文件分享(#1215),完整绘制工具(#1216),每片头发单独配色与内置头饰(#1217),普通 PNG 导入和派生编辑(#1219),以及带毛色花纹的动物物种(#1218)。asset version 4 新增前发、后发和呆毛颜色、单根呆毛,以及与配饰并存、可放内置头饰或 Custom Part 的头饰位;只有 Human 编辑这些项时编辑器才把 recipe 升到 version 4,并把作为配饰戴着的头饰移入头饰位。自绘的前发、侧发或后发会替换对应的内置部件,原来的选择仍会保留;其中发色的像素会像内置头发一样上阴影,所以编辑时从当前样式压平成的格子开始。自绘头饰的 recipe 是 asset version 3。Part Library 是 `avatar-part-library` 模块的 `avatar_part_library` 表(schema generation 73),以部件内容哈希为键,并保存名称、来源和父部件。Client 编辑器通过 `partLibraryList`、`partLibraryAdd`、`partLibraryExport` 和 `partLibraryImport` 四个桥接方法使用它;`part-file` 把单个部件编码为 ×8 预览 PNG,部件数据放在 `botharness-part` tEXt 块中,整个部件库导出为这种 PNG 的 zip,导入时校验每个块的 CRC 并按内容去重;导入 Bot 时其外形穿戴的部件以 `imported-bot` 来源加入部件库,作者为该 Bot 的显示名;编辑器提供带变亮/变暗明暗笔的铅笔、橡皮、四连通填充、按 Shift 锁定角度和正方形的直线与矩形、限定在区域内并带 4×4 或 2×2 Bayer 抖动的明暗渐变、可重新随机的 ±1 明暗杂色、Alt 点击或长按吸管、所有工具的中线镜像、前后两层、每次操作一步撤销(触屏双指撤销、三指重做)、触屏偏移光标和 1× 预览;每个工具的格子都由 BotPixel 计算,结果仍是外形颜色加明暗档位。外形嵌入部件副本,因此部件会随 `.botharness/bot.json` 和 Bot Zip 一起带走。`part-image` 解码部件尺寸(或整数倍放大)的普通 PNG,经 `partLibraryImport` 报告颜色数,`partLibraryImportImage` 保留最常见的颜色(最近色、不抖动),以 `imported-image` 来源存为固定颜色的格子;带部件库父部件保存的部件来源为 `derived`,并以 `parentAuthor` 记录父部件作者。 - -### 5.5 · Bot 主色(已实现) - -[ADR-0177](../adr/0177-bot-primary-color-is-portable-identity-not-theme-state.md) 将 application-defined Bot Primary Color 定义为 PersonaBot 的视觉身份来源,而不是 DSH-native Service、SessionEvent、Client 偏好或全局主题品牌 token。现有 PersonaBot Host owner 管理自动/自定义模式与可移植兜底来源;Client 共享来源解析,再针对实际背景与 light/dark 派生名字颜色。自动模式采用当前 Avatar 的主 `hairColor`,没有可用主发色(上传图片、无头发、line family 或不支持的外形)时使用按 PersonaBot ID 确定的兜底色;自定义覆盖独立于 Avatar Appearance。主题切换不修改 Avatar 或身份来源。 - -已实现(#1471):PersonaBot Registry 记录的可选 `primaryColor` 字段保存来源策略(缺失即自动,无 schema generation 变化);`botPrimaryColorSet` bridge 方法沿用 DM 范围的既有授权,校验后保存,自动模式保留已携带的兜底来源。`primary-color` 模块提供 Avatar renderer 与主色解析共用的外形解析,二者不会各自实现默认头像算法。Bot Profile 的头像弹窗提供独立的 Bot 主色设置(自动/自定义、预设、取色器、HEX)与本地 light/dark 预览,Host 保存成功后才更新共享呈现;取消或失败不发布草稿。DM 的 Bot 作者名按实测背景派生名字色:保持色相与色度,只调整明度,直到对比度不低于 4.5:1,黑白灰保持无彩色。群聊(#1472)沿用同一来源解析:群聊与 DM 的 Bot 作者名、@ 候选名、输入框中选中的 Bot 提及和已发送的 Bot 提及各自按自身实测背景(含半透明提及底色与对方气泡)派生名字色;只给名字文字着色,提及底色、气泡、正文、Human 名字、Channel 引用和“所有 Bot”保持原样,主题切换时重新测量。分享与导入(#1473)沿用既有的 `.botharness/bot.json` descriptor:导出与每次保存主色时写入 `primaryColor`(模式、自定义来源,以及已携带或按来源 PersonaBot ID 得出的自动兜底色),不写任何主题派生色;Git 与 Zip 导入在创建新身份后读取它,新 ID 不改变携带的兜底色,自动模式仍是自动并在有可用主发色时跟随发色。没有该字段的旧 descriptor 按新身份的自动策略处理;字段不合法时沿用 descriptor 既有校验,整份 descriptor 被忽略,不写入 Registry。删除后的归属(#1474)沿用 Registry 保留的历史记录:PersonaBot Deletion 受理时把当时解析出的来源色写入该记录的 `primaryColor.retained`,不新增存储,也不做逐消息或逐统计时间点的颜色快照。`list` 对已删除身份返回带 `retained` 的策略,没有该值的旧记录在读取时按 PersonaBot ID 兜底且不回写;`overviewUsage` 的每个 Bot 行返回 `primaryColor` 来源色(现存 Bot 为当前解析值,已删除为保留值)。Client 用同一套名字色派生给已删除 Bot 的历史作者名、已发送提及和用量列表名字着色;现存 Bot 忽略记录上残留的 `retained`,继续跟随当前来源。按 Bot 的用量图表(#1476)读取 `overviewUsage` 行上的来源色:以图表实测背景派生对比度不低于 3:1 的图表色,再按固定层级(缓存读 100%、其他输入 62%、输出 36%,与背景混合)和固定顺序绘制 token 子类,分项不可用的用量保持中性色;色调图例和 tooltip 身份标记说明含义。概览的每日总用量、Profile 的按模型视图等以 token 类型或模型为主要比较对象的图表保留分类色;单个 Bot 自己的 token 图表(popover 的迷你图和 Profile 的每日总量视图)与按 Bot 的条形使用同一组色调和色调图例;消息框右下角 token 用量弹层里的 Bot 名字和各 Session 占比条也使用该 Bot 的名字色与图表色。颜色只取决于该 Bot 的来源色和背景,不随排序、分页、时间范围或参与的 Bot 改变。单 Bot 活跃度热力图(#1475)沿用同一来源:Profile 与 popover 中的事件活跃和 Memory 提交热力图把按实测背景派生的图表色写入该热力图的 `--bh-heat-color`,四档强度仍按原有比例与中性底色混合,计数、分档和 tooltip 不变,零活动保持主题中性色;主题切换时重新派生。Group Channel 的聚合热力图没有单一 Bot 身份,继续使用品牌强调色。 - -ADR-0177 的六个切片(#1471–#1476)至此全部实现;外部 IM 客户端的渲染不在范围内。这些仍是 ADR-0177 的已接受目标,不是运行时事实;完整范围与验收见[设计记录](../research/2026-10-12-bot-primary-color-design.md)。 - -## 6 · 持久化、导出与恢复边界 - -```mermaid -flowchart TB - subgraph Profile["One DSH profile"] - DB[("botharness.db
operational authority + attachment bindings")] - Files["Optional Memory repositories
Markdown · Git authority"] - Attachments["Attachment files + identity records"] - CAS["Pending legacy attachment / Soul CAS bytes"] - DSHS["DSH SessionPersistence
transcripts · execution"] - Creds["DSH credentials / settings"] - end - - Barrier["Manual Export Profile
backup barrier + consistent snapshots"] - Package["one compressed
.botharness-backup"] - Stage["Import Profile staging
validate · migrate · dependency check"] - Target["v1: new stopped environment only
cold + suspended authorities"] - - DB --> Barrier - Files --> Barrier - Attachments --> Barrier - CAS --> Barrier - DSHS -.->|"adapter-supported facets"| Barrier - Creds -.->|"declarations only; never secrets"| Barrier - Barrier --> Package - Package --> Stage - Stage --> Target -``` - -| Data | Authority | Portability | -| --------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| operational facts | `$DSH_HOME/botharness/botharness.db` | consistent SQLite snapshot inside manual profile backup | -| operational logs (debug timeline) | `$DSH_HOME/botharness/logs.db` (lightweight owner, rebuild-empty) | excluded from backup; emailable as-is | -| optional Memory repositories | Git-backed Memory Provider | selected SoulSnapshot / PersonaBot Export / profile backup | -| attachments | Host-managed files, identity receipts and Messaging bindings; pending legacy CAS | current referenced files and identity mappings | -| Soul bytes | content-addressed files | dependency-closed selected bytes | -| Session transcript / execution | DSH SessionPersistence | only through a verified DSH export adapter; otherwise declared omitted | -| credentials and DSH settings | DSH services | never copied; restore creates suspended rebind requests | - -v1 只有两个备份动作:Export Profile 生成一个 self-contained `.botharness-backup`,Import Profile 选择一个文件。没有自动备份、scheduler、catalog、retention 或 incremental chain。Restore 总是在隔离 staging 中验证;成功后 PersonaBot 仍为 cold,provider authority suspended,Workspace/model/plugin dependencies 必须在目标机重新解析并由 Human 明确激活。 - -#886 的 v1 实现使用 application-defined Portability coordinator;各 owning module 提供历史 Memory 定位、当前附件引用闭包与恢复时的挂起转换。Database owner 在现有 Profile Writer Lease 下阻止事务写入并执行 `VACUUM INTO`;`ContentPurge.withCheckpoint` 覆盖同步快照与受管文件捕获。文件在捕获前后核对身份、时间、大小和 SHA-256,集合变化、缺失引用、超出资源/屏障时限均拒绝发布;屏障释放后压缩并完整校验。包保留 deleted identity 的 retained Memory,只有 canonical erasure proof 才允许省略;源 Git 授权配置替换为安全默认值,恢复后 hooks 隔离。当前界限为 DSH `0.2.0-rc.1`、60,000 entries 与 512 MiB;底层 Session 内容无已验证 adapter,明确标为 unsupported。 - -Settings 只经现有 Connection Fetch seam 预览、下载、检查文件与操作目标就绪度,不接收目标路径或替换正在运行的数据。受信任本地 `botharness-profile restore` 命令仅接受不存在的新目标;在 sibling staging 中校验路径/hash/SQLite/Generation、取得 Writer Lease、应用真实 Purge checkpoint、重定位 Memory,再一次提交。可执行 Bundle/Profile 配置、credentials、Workspace 文件和 debug logs 不导入;目标由固定版本 DSH 与已安装 Bundle 启动。Replace Existing 和 Managed Transfer 不属于此版本的运行入口。 - -Disaster Restore 将全部原生 Session 引用设为内容不可用,保留历史归属;中断 Assignment/Inbox/Outbox 均不重放。Bot、触发器、外部绑定、Service Grant、Workspace 与持久工具授权挂起,Browser/Computer access 关闭。目标 Human 配置明确 Model Plan、本地凭证并授权该 Plan revision,再确认同身份双活风险后显式激活;新 Orchestrator ownership 与恢复收据同一事务建立,执行使用新原生 Session。后续 Plan 变化再次阻止执行,所有实际模型调用仍经当前 catalog 验证,不能静默回退。激活仅开放本地 Bot 执行,外部 authority 与 Workspace 仍须独立重新授权。[操作与限制](../settings.md#complete-environment-backup-and-restore)。 - -Diagnostic Bundle([ADR-0185](../adr/0185-diagnostic-bundles-are-local-redacted-read-only-exports.md),#1533)是另一条只读导出:用户在时间线多选消息(消息菜单“多选”,右键或触屏长按打开)并在“导出”菜单里选择诊断包后,经 Connection Fetch 预览并下载一个本地 zip,范围从第一条到最后一条所选消息(只选一条时到现在)、前后各多 30 分钟,所选消息在 manifest 与时间线中标记,带上参与 Bot 的 Human DM、所导出 Channel 内的 Inbox 收件、其拥有的 Orchestrator/Assignment/Subagent Session 原生事件(起点处正在运行的 turn 从 `turn/start` 算起)和合并时间线。`DiagnosticsExport` 只通过 Channel store、Inbox history、Session Ownership 与原生 `sessionQuery` 读取,标准脱敏后只在内存中保留预览结果,不写盘、不建表、不上传;Memory、Workspace、附件内容、凭据与其他 Channel 不进入导出。 - -图中的 Attachment files 表示新附件真实文件及记录,Messaging 绑定将已转换旧引用解析到当前真实文件,legacy CAS 仅服务未转换依赖。hash 相同不恢复共享,缺少归属或含糊的旧调用明确失败。后续 Backup/Export 要包含当前被引用的文件与身份映射,引用感知清理及显式 Purge 也必须涵盖这些真实文件;一次明确导出保存当前字节,不建立持续附件版本归档。 - -### 删除与清除(已接受设计,待实现) - -[ADR-0130](../adr/0130-deletion-preserves-history-and-makes-memory-erasure-explicit.md) 与 #138 将 PersonaBot/Channel 的结束参与和物理内容清除分开。删除 PersonaBot 的确认框展示实际 Memory Repository 与依赖,提供“打开记忆文件夹”及默认不勾选的“同时删除记忆文件”;打开文件夹不改变勾选或确认状态。未勾选时保留记忆及 Git,勾选后也只能清除经 Host 重查、专属且归属明确的仓库。共享、路径变化或无法证明归属时不可清除;Workspace、原生 DSH Session、远端 Git、外部导出与备份不随之删除。普通删除先关闭执行/收件/授权入口,等待所拥有的 AgentHandle 执行树静止,再保留 deleted identity、历史 Session Ownership 和仍保留记忆的持久位置;它不同于可恢复归档。Channel 删除保留消息及因果归属,只结束其成员和路由,不删除外部会话或其他路由;Hidden Channel 仍可恢复。另行确认的 Content Purge 才清除选中的 Source Event 正文,附件需检查共享引用,并覆盖 legacy CAS 与当前真实文件绑定。 - -现有 roster stream 在提交后和重连时投影不含正文的清除位置选择器。Client 清理当前和缓存会话、侧栏和 attention 摘要,拦截延迟响应回填,并显示清除标记。这些内存选择器来自 Host,不构成第二份持久权威。 - -Purge Ledger 是应用定义的 Host 深模块权威,必须单调持久并位于可恢复数据库快照之外。清除先接受 ledger,再幂等应用 Messaging 清除与受管文件清理;中断时已接受的范围不可重新显示、投递或用于新出站效果,文件失败需明确报告尚未清完。#886 必须依赖真实 ledger/checkpoint 实现,不能用空占位代替;恢复在 Messaging 可读前合并并应用 package/destination union,独立离线旧备份只保证其自身 checkpoint。备份包括保留的 deleted-identity 记忆仓库。设计验收不等于运行功能已交付。 - -### Channel 内容清除运行路径(#897) - -Generation 70 的应用定义 `ContentPurge` Host owner 通过已有 Typert/API Gateway 提供已结束 Group 的只读历史、所选 Source Event 预览和单独确认;不注册模型 Tool。已结束 Group 的身份保持占用,同名新群聊不覆盖旧历史。本地和外部来源可以共享到仍活跃的会话,但全部位置在对应 revision 上均须仍在 Human 当前可读范围内。普通删除只结束所选 Channel 的路由/摄入,保留其他 Channel/Inbox 目标及外部会话。[ADR-0146](../adr/0146-content-purge-retains-file-selectors-and-settles-issued-effects.md) 记录文件、效果与恢复边界。 Memory 通过自己的查询和 retained/deleted 仓库定位提供已记录的来源衍生提交;Workspace Grant 提供可能的副本位置。这些引用参与确认前重验证,不写入清除账本,也不声称扫描过未追踪的文件或导出。 - -独立 `botharness/purge/ledger.db` 由同一 Host 生命周期拥有,SQLite FULL 同步事务先接受无正文的事件身份、作者/时间/因果、Human 审计及托管文件选择器,再移除运行正文;该文件不属于 `botharness.db` 快照。最小墓碑及 placement 保留,secure-delete 后截断 WAL;应用失败关闭运行数据库进入 recovery,启动先应用事实再创建 Messaging Consumers。Attachment owner 重查全部保留来源、迁移/获取绑定和 Outbox 引用后,仅移除已审阅的独占当前文件和 legacy CAS;共享文件保留,受阻清理可见且重启继续。待投递意图脱敏并取消,已发出请求在活跃 Host 中按真实证据结算,冷中断保持结果未知。持久栅栏和原生 Tool Guard 拒绝重复回填、旧命令与新的内容依赖效果。 - -`checkpoint()` 导出已验证事实;`withCheckpoint` 在同步或异步快照回调完整结束前持有清除屏障,Profile Backup 在其更广的 Backup Barrier 内调用。冷 `restoring` 验证 v1/v2 包检查点,单调 union package/destination 事实并保留目标审计,再应用、开放 Messaging。新目标只可从已验证包初始化;已建立账本缺失、损坏/不支持的数据或 union 中缺少运行快照引用事实均关闭入口。v2 包含托管文件身份及外部/系统作者。备份 Settings 与本地恢复入口由 Portability module 提供。 - -预览列出所选来源、全部 placement/Admission、文件移除/保留及依赖效果;info 控件披露仍保留的 Assignment/其他因果来源正文、未改写的 Human Memory/Workspace/导出、原生 DSH Session 提示/结果、外部副本、Git 远程及离线备份。旧独立文件只执行其包含的检查点。[验收指南](../agents/qa-channel-purge.md) 覆盖真实 SQLite/文件恢复及 DSH 共享文件/中断清理。 - -### PersonaBot 删除运行路径(#896) - -Registry 在 Generation 66 持久化不可恢复的身份关闭状态和确认时的记忆位置。活动查询排除已接受删除的身份;历史查询保留身份、Session Ownership、Report、消息和用量归属。Human Profile 确认框复用 Typert/API Gateway 与原生 Host 文件打开能力。“同时删除记忆文件”默认不勾选,清除需要专属 Git 仓库、已记录的目录身份,以及与其他保留仓库和所有注册 Workspace 均无重叠。新建自定义仓库可以记录同样的归属证明;已有自定义路径本身不足以证明独占归属。Host 共享存储也受到保护。 - -删除 owner 先接受持久身份关闭状态,再停止该 Bot 所拥有的原生 AgentHandle 执行树、撤销 Workspace/工具规则,并停用其 Messaging 绑定。已发出的原生/provider 效果保留实际结果。可选文件清理具有明确接受的范围和未完成状态;重试只使用原目录身份,不能选择新路径。DSH Session Persistence 与共享 provider 账号保留。冷启动仍排除已删除身份,即使清理需要修复。后续 Profile Backup 必须枚举历史 Registry 记录及删除位置,区分主动清除的记忆。Channel Content Purge 与支持恢复的 ledger 仍属于 #897。 - -### Bot Marketplace(已接受设计,待实现) - -目录查询将 GitHub topics 与 `.botharness/bot.json` 的 tags(兼容旧 `roles`,存于 D1 的 `roles` 列)一起用于文字搜索、`topic` 筛选和 `/v1/topics` 统计;同一仓库的同名标签只计数一次,统计排除收录标记 `botharness-bot` 及未上架仓库。FTS5 同时索引两类标签,迁移会从已存数据重建索引。每小时刷新不依赖 push 即更新 GitHub topics;检测到 push 后重新读取 README 和 bot.json,并替换旧标签。 - -[ADR-0131](../adr/0131-bot-marketplace-starts-as-a-github-indexed-catalog.md) 与 #18 让 Bot Marketplace 先作为 GitHub 索引目录上线:作者给公开仓库加 `botharness-bot` topic 即同意收录,也可在 Marketplace 贴链接立即抓取。独立的 Cloudflare Worker 与 D1 每日按 topic 切片发现、每小时用 GraphQL 批量刷新,README 进入 FTS5 索引;浏览用 keyset cursor,搜索只取前 200 条。harness 的 Marketplace modal 展示 README 详情;安装复用 #298 的 Git URL 创建路径,确认框显示最新提交并提示第三方仓库风险。贴链接与一键举报共用 ALTCHA 和限流。不建账号、不计下载。[ADR-0135](../adr/0135-a-bot-moves-between-people-as-a-zip-of-its-memory-files.md) 撤销了基于账号的第二阶段:把单个 Bot 交给别人或换设备,改为在 harness 里导出 Bot Zip(默认只含 Memory 文件和 `.botharness/bot.json`,整包导出时可选带 Git 历史),对方从创建菜单的「从 zip 导入」得到新的 PersonaBot。 - -[ADR-0240](../adr/0240-marketplace-listings-localize-author-first-with-generated-summaries.md) 让列表按读者语言(中文/英文)显示:作者在仓库根目录放 `README.zh-CN.md`/`README.en.md`,在 `.botharness/bot.json` 的 `i18n` 里写翻译后的名称和简介;Worker 推送后一并抓取,`lang=zh|en` 时优先返回。某语言没有作者简介时,Worker 在每小时刷新后经 OpenCode Go 的免费模型按 README 生成一句简介(按输入哈希缓存、每轮限量,标记 `bioGenerated`),作者文本总是优先;README 正文不做机器翻译。Host 的 Marketplace modal 与 CLI 按界面语言传 `lang`。 - -## 7 · 关键边界 - -- 正常运行只认 explicit Session ownership;`cwd` 只可作为迁移/修复提示,不能决定 PersonaBot 身份。 -- DSH Session 状态是执行权威;BotHarness 只投影 activity/last-run,并将 semantic Assignment Report 与 Host Lifecycle Notice 分开。 -- Provider capability 不等于授权;发现一个飞书频道也不自动授予向它发消息的权限。 -- UI 不直接读文件或数据库,不自己推导业务状态;它消费 Host read models,并把 command 交回 owning module。 -- PersonaBot archive 先关闭 admissions、wakes 和外部 actions,再停止 Orchestrator、Assignment 与 owned Subagents;purge 是单独的破坏性动作。 -- Browser 与 Host 是两个 Cordis 应用;Host service 不跨进程 inject,统一走 `/api` client bridge。 -- Memory 跨设备同步是 Orchestrator 的原生 Git 行为,不是 Host 服务;BotHarness 不持有远端仓库或凭据,也不提供一键同步,非 Git 所有者经 Portability 迁移记忆(ADR-0084)。 -- Roadmap Project #1 保持 private;文档同步只用 `read:project` 读取显式 `In Progress` 和 Artifact,经过 fail-closed 白名单投影后才提交公开 JSON。Project notes、private items、assignee、backlog 与 ETA 不跨越这条发布边界。 - -### 产品发布身份(已接受设计,执行门禁待交付) - -[ADR-0216](../adr/0216-dsb-product-versions-identify-immutable-package-sets.md) 将电脑端 DSB 软件身份与 npm 产品包身份统一;Core/UI/Browser/Computer 同号,DSH/独立 Provider/手机保持各自版本。既有准备流程的冻结 Release Plan 绑定源码与完整产物,而不是新增版本服务。交付 QA 后版本与字节不可替换;Beta 转正式须验证新的正式包集合。Host 的现有 Release Service 投影实际运行身份和独立的待生效版本,经既有认证 bridge 同时提供 CLI/Web;缺失发布元数据不能伪装源码构建。发布按依赖核验后推进产品入口,失败恢复同一计划/字节,不宣称多包原子性。规则详见[版本规范](../agents/version-policy.zh.md),实施由 [#1979](https://github.com/BotHarness/DeepSeekBot/issues/1979) 的三个切片负责;本节不表示全部发布门禁已实现,也不授予标签迁移、发布或共享设备使用权限。 - -#1980 的运行身份沿用 Release Service:四个第一方 Plugin 在模块加载时将不可变包名、版本与真实目录快照附在各自 callback 上。Core 捕获产品依赖组合,随后从原生 Registry 的 ACTIVE Fiber 读取实际 callback 快照并比对;等待组件启动时返回 `composition-unverified`,关于页面允许重新读取身份,不能只凭已安装清单宣布发布身份。运行版本和待生效状态在当前 Service 内保留;后续请求继续校验实际 callback,组件被替换后身份可失效,但不提前宣布新版本。配置重新应用复用同一 Core 模块捕获的组合;磁盘安装不能提前改变运行版本。该快照是应用定义的进程诊断,不是新持久化版本库,也不是对任意可执行 Plugin 的安全认证。源码构建使用编译时 Git/dirty 身份;具体安装、重启与 Human QA 证据仍由 issue/PR 记录。 - -#2173 的应用定义升级发现仍由同一个 Release Service 负责。Bot 模式进入时通过认证 bridge 查询运行身份、真实 DSH 版本及 registry 版本清单;兼容性复用当前 Host 的原生检查器,覆盖产品与精确依赖组件。绝对最新版本不兼容时继续寻找最新兼容版本;元数据缺失、搜索超时或网络失败保持未知并短期重试,不宣称没有更新。客户端按本地 Profile、发布通道和精确目标保存提醒关闭记录,与已安装版本更新说明分开。确认安装前再次核验兼容性,再进入既有原生安装和权限边界;已暂存版本仍独立提示重启,不提前改变运行身份。CLI `release-check --force` 与 UI 共用该 Service 并绕过查询缓存。 - -## 8 · 实现顺序与可并发范围 - -1. #77 验证 pinned DSH 的 Agent/SessionPersistence/Subagent seams;#79 建立 operational database owner。这两项可并行。 -2. #80 在 #77 与 #79 后实现 explicit Session ownership 和 activity projection。 -3. #81 在 #77、#79、#80 后先交付最小 DM → Orchestrator → Assignment → report → DM reply tracer bullet,并同时提供可验收的事项列表/详情;#47 的后续 Assignment coordination 在该切片通过后扩展。 -4. #78 可与上述工作并行研究 Feishu provider contract,但 #48 的 adapter 实现受它约束。 -5. #74 的 Memory 工作在上述主链通过后,以独立 optional Provider tracer bullet 推进;#75、#76 保持 focused design/grill,避免阻塞首个可体验闭环。 - -## 9 · 如何维护 - -- 模块、数据流、事务边界或 authority 发生结构变化时,同步本文件、英文镜像和 `docs/architecture/diagrams/*.mmd`。 -- 运行 `pnpm diagrams` 提交 light/dark SVG;`scripts/sync-docs.mjs` 将本文和图同步到 `apps/docs`。 -- 配套:BotHarness 产品术语 `CONTEXT.zh.md`(英文为 `CONTEXT.md`);取舍与理由 `docs/adr/`。Platform Spec 与 App PRD 已归档为历史工作草稿,不再作为并列设计权威。 - -## 附件文件操作切片 - -[ADR-0105](../adr/0105-attachments-use-native-file-operations-under-source-authority.md) 沿用现有 Attachment owner 管理原件当前内容。`channel_attachment_save` 校验当前 Channel 成员权限与准确的消息/fileId 归属,再将原始字节流另存到明确允许 Orchestrator 写入的 Grant;已有目的文件不覆盖。原生文件工具处理另存文件,Shell 使用该 Grant 的 `workdir` 并保留 Human 审批。Agent Scope 中复用原生 Tool 注册,通过隔离的 application-defined Policy Provider 在每次 `tools/execute` 选择单一获准根目录,不改 Memory cwd 或全局 Provider。写入权限变更使旧审批规则范围失效;已开始的 Shell 可能完成。 - -`channel_attachment_import` 明确选择当前 Memory/Grant 读取权限内的 canonical 普通文件,交给既有 owner 创建独立可下载 Attachment,再经 `channel_send` 当前发送权限回发。另存与导入不解析文件,不授予 Shell 权限。#632 交付本地 ZIP/CSV 链路。#633 的 `channel_attachment_open` 从精确来源消息与文件生成仅当前回合有效的原生访问选择:`read` 只允许指定原件路径;`edit-original` 另需既有 Human 工具审批或匹配的已保存规则。原生 guard 每次复查当前来源权限与文件可用性,拒绝其他路径,包括同目录兄弟文件。隔离 Policy Provider 为原生修改选用该附件已有的 data 目录,不授权整个附件仓库或元数据目录;不透明 Shell 保留审批。该选择在回合结束时清除,不是持久授权。原生写回修改既有文件,共享身份在刷新/重启后读取当前字节,独立上传仍然独立。不增加 Source Revision、文件变化 Inbox Admission、唤醒、应用锁或版本档案。仅 Inbox 的 Lark 文件链路由 #657/ADR-0107 集成;Slack 仍是后续 provider 切片。 - -[#657 的外部文件切片](../adr/0107-external-files-use-trusted-source-capabilities-and-existing-owner.md) 为既有 dsh-im Service 增加可选公开文件能力。真实文本 @ 仅解析其准确父文件元信息;canonical Source Event/Inbox 保留来源,不产生 Channel placement。首次访问重新校验原账号、群/话题及资源关联,将有界字节流交给既有 Attachment owner;普通 receipt 让稳定文件身份在重启后复用,缺失原件不重建。来源详情下载与 `bridge_attachment_save` 共用该权限。原生文件工具和经审批的 Shell 处理可写 Grant 中的独立副本,当前回合通过 `channel_attachment_import` 明确选择结果后才能 `bridge_reply_file`。既有 Outbox 在外部效果前记录文件引用,再复查当前 Grant/来源,经 provider 原生上传与准确话题回复。不确定结果不重试,不增加文件目录、解析器、SDK 连接、转录存储或文件自动唤醒。 - -### Human Inbox 中回应 Assignment - -等待/受阻卡片以 Assignment Session 聚合,读取准确报告 Source Event 和至多前后各两条报告。Human 在 Inbox 回答时,消息通过现有 Bot DM authority 提交,并携带 `assignmentReply: {sessionId, sourceEventId}`;事务核验所属 Bot、运行状态、当前请求或空闲受阻报告,以及尚无已提交 Human 回应;若之后的权威接收前失败记录指向同一未解除 ask 且事项空闲,可明确重试。新的 Human 回应消费这次机会,并发提交仍只接收一份。此消息由 Orchestrator 接收并转交 Assignment,不直接恢复事项或清除 ask;完成、失败或停止后的报告拒绝新回应。同 ID 重试返回原提交。普通进展保留未解决 ask,较弱等待报告不覆盖较强的受阻 ask。点击 Bot 打开私聊,点击 Assignment Session 切换到 DSH 原始 Session(ADR-0071)。 - -Human 回应目标及时间使用 Messaging 拥有的 SQLite 索引。列表超过 150 项时明确暂停自动轮询,保留正在浏览的旧记录;可见的「刷新回到当前列表」按钮保留筛选条件,重查最多三页并获取新游标。「加载更多」不截断当前记录,成功行动命令仍刷新权威状态。 - -## 共享 Channel Bridge — #634 - -[ADR-0108](../adr/0108-shared-channel-bridge-places-canonical-external-sources.md) 为授权 Lark Grant 增加明确选择已有 Group Channel 的收件位置。Messaging 在 ACK 前将同一个外部 Source Event、canonical Channel placement 与绑定收件 Bot 的 Inbox Admission 原子提交(#634 验证 @;普通文字见 #613)。原生时间线和授权成员读取投影有界的外部发送人/时间/正文/来源;其他成员获得可见性,不复制消息、身份、Admission 或唤醒。既有 harvest/steer 路径以该本地 Channel 作为入站上下文,只有明确使用自身身份的 checked reply 才回到 Lark。当前成员、绑定与 Grant revision 控制收件、唤醒、读取和未开始的回复;退出/撤销保留已有共享事实。默认仍仅入 Inbox;该切片保留每 Grant 一个目标、每 Source 一个 placement,重投不移动历史。多目标、话题跟进与协作继续由 #629 的后续 tracer 交付;普通文字收件沿用下述 #613 策略。 - -```mermaid -flowchart LR - IM["Authorized Lark group
verified Human @"] --> Provider["dsh-im public Service
exclusive Consumer"] - Provider --> Commit["Messaging transaction
canonical Source Event"] - Commit --> Placement["One existing Group Channel placement"] - Commit --> Admission["Addressed bound Bot Inbox Admission"] - Placement --> Members["Current Human and Bot members
native timeline and bounded reads"] - Admission --> Runtime["Existing harvest / steer
one Orchestrator"] - Runtime --> Reply["Explicit own-identity reply
current grant and source checks"] - Reply --> Outbox["Existing durable Outbox"] - Outbox --> Provider - Provider --> IM -``` - -## 外部群收件与唤醒 — #613 - -[ADR-0109](../adr/0109-external-group-collection-is-separate-from-wake.md) 将每个授权 PersonaBot/账号指纹/具体 Chat 的普通文字收件与唤醒分开。Messaging 保存不可改写的策略版本与编辑者;Human Profile 和所属 Orchestrator 的 scoped Tools 可选择仅收 @ 或普通文字全量,以及数量/时间 digest、下一轮 immediate、随本群 @ 阅读或 silent。开启全量要求当前 exclusive Consumer 确实收到一条该群非 @ 消息;仅验证时不保留该消息正文或 Source Event。接收租约与 Host 重启会重置能力验证,已保存策略保持不变,平台缺失的事件不回填。 - -新收件在 ACK 前原子提交 Source、已有目标 Channel placement(如配置)和绑定 Bot 自己的 Admission,固定当时的 Messaging revision 与阈值。Bot Runtime 复用现有有界 harvest 和计时;普通消息在回合边界入队,不 steer 正在执行的模型/工具步骤。@ 保留既有 steer/turn 规则,可同批阅读本群的待处理 digest/mentions 上下文;silent 只在显式读取时进入本轮。重启从 pending Admission 的历史阈值恢复,编辑只影响后续消息,重投不重新分类。是否回复仍由 Bot 独立决定;其他 Channel 成员的普通消息 attention 由 #638 交付,话题跟进由 #614 交付。 - -## 外部话题参与 — #614 - -应用定义的 Messaging Service 为已授权 Lark 群提供明确的话题跟进。PersonaBot 从自己 Inbox 的可信 Source Event 选择 Thread;Host 检查当前 Grant、Consumer、账号 fingerprint、Chat/Thread/root,以及该 Consumer 是否实际交付过本话题的无 @ 回复。发过一次回复不自动跟进。按 Grant/Thread 追加不可变策略版本,Bot 选择跟进或沿用群规则;Human 可跟进、排除普通回复或交回群默认,明确的 Human 覆盖在恢复继承前优先于 Bot 修改。 - -普通消息按 Thread 覆盖再按群 collection 判断,唤醒默认继承群 ordinary wake,亦可提供有界覆盖。直接 @ 继续走原 addressed 路径。退出恢复群规则,不等于在全量收件群中禁收;不回填、不重写历史。canonical Inbox Admission 在 ACK 前冻结 group/Thread revision 和实际 wake/count/interval,后续 harvest 沿用现有安全 turn/steer 边界。持久策略重启后保留;实际投递资格证据属于当前 Consumer 生命周期。撤销、归档、关闭 Consumer 与过期来源/Grant 始终优先。 - -PersonaBot Profile 展示最多 50 个 Inbox 锚定话题的收件方式、修改者和管理 Modal;无话题能力的平台不生成控件。DSH Tool Registry 与 Typert/API Gateway 分别承载 Bot 和 Human Consumer,应用权限仍由 Messaging Host 持有。参见 [ADR-0110](../adr/0110-external-thread-following-is-scoped-and-explicit.md)。 - -### Human Inbox 详情与移除(#687 QA) - -手机 Activity 通过认证 Bridge 读取同一应用定义 Human Attention owner。查询版本 1 在分组分页前执行安全摘要搜索、未读消息筛选,个人消息默认提及优先或显式按时间排序,游标绑定当前筛选。消费者分别加载可见分组;已处理历史需显式筛选。没有 Activity 专用持久化或第二套请求生命周期。悬浮顶部栏仅持有临时筛选、搜索和排序状态,折叠偏好仍按设备与 Host 保存。旧 Host 明确拒绝,避免静默返回未筛选结果。 - -消息窗口上下沿的等宽箭头分别增量读取历史/较新上下文;下沿到达此前末尾后仍能查询后来消息。Channel 沿用原时间线游标,Assignment 报告通过同一个有界查询继续读取边沿。每条消息在悬停或键盘聚焦时显示精确来源按钮,触屏保持可用;Assignment 原始报告则打开所属 DSH Session。常规手动刷新与底部来源按钮移除,失败可重试且保留回复草稿。 - -Human Attention 在原 operational database 拥有 `human_inbox_dismissals`,只保存 Human、item、Source Event key、可选 unread placement revision 和时间,不保存另一份消息。Host 验证可见原来源后提交 Inbox-only Dismiss;它不答复、审批、授权或推进已读,不创建已处理记录。查询分页和待行动计数排除已移除项;入口未读总数仍由原已读位置决定。Channel 未读汇总只隐藏当时 revision 及之前的批次,之后的消息重新出现;新的 Assignment 报告也不被旧决定隐藏。多窗口与重启共享同一状态,原 Channel 卡片仍能处理,随后真正回应的已处理历史仍引用原权威。再次点击同一行只收起详情,不移除。 - -### 总览行动与 Session 行卡(#698) - -总览默认显示非 idle 状态或有权威 Human 待行动的 PersonaBot;「显示空闲 Bot」可查看其余 Bot。等待/受阻工作显示为状态或行动,不计为正在执行。每张 Bot 卡复用 Human Inbox 的原生行动表单与选择器,以独立的 Bot 过滤 Client 查询缓存读取同一 Human Attention Bridge,最久等待优先、有界刷新并沿原游标继续分页。决定与移除后刷新该列表及总行动数;沿用 Inbox 的移除排除规则,不新增持久表或请求生命周期。正在执行的根 Session 用紧凑行卡显示原生 DSH Session 列表的当前 displayTitle;总览打开/刷新时加载该公共列表,订阅名称更新,并用图标区分 Orchestrator 与 Assignment。只有原生名称不可用时才回退用途/角色;点击仍退出 Bot mode 并打开准确原始 Session。 - -## 今日 Channel 活跃度(#703) - -application-defined `botharness/channelActivityToday` 查询由 Channel owner 在既有 SQLite 中聚合 Host 本地日内可见 placement 的不同 Source Event,尊重 Human 成员与可见修订边界,排除已删 Channel、Bot 私聊及其生成的注意力通知。响应给出准确的 Human/Bot/其他计数、当前 Channel Human 昵称、Registry Bot 名字、桥接发送者标签,以及日期/时区/半开时间边界。总览使用同一尺度的紧凑堆叠条,可展开发送者明细并跳转 Channel;默认渲染 20 行且提供继续查看,完整总量不截断。视图拥有每 30 秒、午夜和手动刷新及卸载清理;查询失败显式呈现,不推进已读、不生成 wake,不增加 schema 或第二套计数存储。 - -总览优先排列需 Human 行动的 Bot,并先展示 canonical 行动表单,再显示执行中的 Session。显式全部已读命令先固定每个 Human 可见 Channel 的消息位置,再推进既有已读游标;之后到达的消息仍未读,不改变请求解决、Inbox 移除或 Bot attention。统计复用锁定版本的 TanStack 图表与主题,以前端偏好记忆折叠状态,并保留可访问的发送者明细。 ([#705](https://github.com/BotHarness/BotHarness/issues/705)). - -总览 token 统计(#709)通过应用定义的 `overviewUsage(period, after?)` Typert 查询消费既有保留 Usage 权威。今日或含今日的七个 Host 本地日期返回完整整体总量/每日分项,与有界 Bot 明细分页独立;缺失报告不再让整段合计变成未知:每个桶返回已上报部分的合计,只有完全没有上报值的桶才是空;总量缺失的行用其已上报分项作下限,并以 `partial` 标记该合计不完整,Web 与手机显示为“N+”;核对和历史基线状态明确。当前 Registry 名称标记行;不在当前 Bot 列表中的保留统计仍可见,但不虚构身份或 Profile 入口。Client 复用 Profile UsageChart/主题,以可清理的轮询读取已结算事实,并通过既有 DM 导航打开当前 Bot Profile。不增加账本、Channel token 归因或模型/wake 策略。 - -总览 Memory 统计(#716)通过应用定义的 `overviewMemory(after?)` Typert 查询读取现有 Memory Service 与 Registry。每页至多十个当前 Bot;每个仓库的所有非恢复/stash 引用可达的普通 Git 提交按 committer 时间归入含今日的七个 Host 本地日期,共享提交仅计一次。当前 staged/unstaged/untracked 状态单独显示,不推断文件作者或审批需求;缺失、无效、超时或读取失败的仓库显示不可用,不伪装为零提交或 clean。仓库 Git 查询有超时并异步执行,避免阻塞共享 Host;查询关闭可选 Git 锁与 fsmonitor,不 stage、commit、reconcile 或建立 checkpoint;沿用 ADR-0068 的仓库权威,不增持久统计账本。Client 复用 Profile 紧凑卡片及 TanStack 主题;可展开每日值,保留已加载分页刷新,折叠 Statistics/退出视图时清理查询资源。 - -### 外部身份独立生命周期(#699) - -[ADR-0111](../adr/0111-external-identity-lifecycle-is-independent-of-grants.md) 将应用定义的 PersonaBot 外部身份与会话 Grant 分开。Messaging 既有 bindings 表持久保存启用偏好、本地名称及 revision;仅绑定通过可信 dsh-im 的认证账号元信息,不创建 Grant 或 listener。Profile 以独立身份表/Modal 管理;Group 没有身份表。暂停停止该身份的 Consumer lease,并由同一个 Host 权限门禁拒绝未开始的 Client/Bot 外部效果;原有路线、Source Event 和政策快照保留。恢复校验同一账号和原目标 digest,不扩大范围。撤销单个 Grant 不解绑身份;明确解绑使该身份所有 Grant 失效,保留可检查配置和消息,不删除 Provider 凭据。#700 继续交付 Channel Bridge 表。 - -Messaging 的 bindings/grants 事务提交,以及进程内 Consumer lease 开始、停止、连接成功或失败,均使既有认证 roster 状态流失效。已打开的 Profile 身份与 Channel Bridge 视图收到通知后重查同一 Host 权威;通知不携带身份、凭据或授权内容,也不保存接收连接状态或重放外部事件(#855)。 - -### Channel Bridge 管理(#700) - -[ADR-0112](../adr/0112-channel-bridge-intake-is-managed-at-the-existing-grant.md) 将有 revision 的收件偏好嵌入既有 Messaging Grant(Generation 50),由 Group Profile 的 Bridge 表/Lark Modal 管理。Human 命令校验当前 Human/收件 Bot 成员资格、Grant/配置版本以及原授权账号和目标。暂停保留独占 Provider lease,在 canonical 持久化之前丢弃后续收件;已收来源仍按当前权限读取和回复。删除移除收件范围/目标、递增 Grant revision 并关闭 lease,拒绝旧来源未开始的效果,同时保留历史、身份和独立发送范围;不会自动回退 Inbox。管理界面的添加/恢复持久记录 Provider 发送时间边界;早于边界的迟到消息仅确认、不投递,重启后仍有效。这依赖已验证 Provider 的发送时间与对齐时钟;迁移路线在管理界面激活前保留既有语义。恢复不请求回填或重复 listener。收件条件不替代每个 Bot 的 attention/harvest/wake,收件身份不授权其他成员借用发言身份。旧收件接口更新同一权威;多来源/DM 投递和成员自身身份回复仍由后续切片交付。 - -### 共享普通消息的成员 Attention(#638) - -[ADR-0113](../adr/0113-shared-external-traffic-uses-member-channel-harvest.md) 将明确收件并首次放入 Group Channel 的普通外部来源,在同一 canonical 事务中按当前活跃成员各自的频道覆盖/Bot 默认策略建立 Admission。重投不会补发给后来加入的成员或重写策略快照;直接外部 @ 仍只走接收身份的既有提及路径。每成员使用既有 Channel count/time digest、有界最旧优先 harvest、安全 turn 排队和恢复;已放入 Group 的普通来源不再走身份专属外部 digest。接收 Bot 明确设置的话题 wake 覆盖保持独立分区,不影响其他成员。来源文本保留发送者、平台、外部消息 ID 和 Source Event;共享收件不授权借用身份。群频道侧栏的唤醒策略项按成员卡片读取 Host 的实际有效策略与继承来源,编辑沿用既有审计 owner;频道连接器仍只控制收件。无远端离线回填、新队列或共享 Inbox 存储。 - -## Composer footer preferences and generation speed(#1958) - -The application-defined Client footer owns a browser-local display preference (`botharness.composer-footer.v1`): visible item order and left/right placement. Defaults show model, Workspace and token usage; speed is opt-in. Preferences never grant access or cross devices. A visibility panel opened by right-click, long-press or the `/footer` composer shortcut and a temporary pointer/keyboard arrangement mode share this preference. The panel stays open across toggles. With no rendered items, the footer collapses completely; `/footer` remains available for recovery. Desktop labels stay on one line with ellipsis; phone-width Web keeps its compact layout. - -The Host observes committed DSH `assistant/message` SessionEvents and keeps one process-local measurement per Session until the next step, reply consumption or Session disposal. A completed reply may carry optional `perf` metadata through the existing Channel/Source Event persistence and timeline projection; old messages require no backfill. Provider output counts are used when usable, otherwise reply text yields an estimate. Both reply and footer labels use the compact `36 tps` format; estimation metadata remains available in the message data. The footer uses its own inline size for responsive presentation: at 480px or less, model/workspace labels collapse to named, titled icons while numeric usage/speed remains visible, without resize subscriptions or preference writes. Phone-width Web uses the same applicable controls and visibility/order preferences, with compact 28px-high buttons on all pointer types. This is diagnostic metadata, not billing authority. The Client only loads averages while enabled: at most four pages/200 messages for up to 20 measured replies, refreshed on a completed measured reply, without polling or per-token scans. Hidden or purged content is excluded from displayed measurements. - -## Client-local composer recovery(#1459) - -DM 与 Group 的未发送撰写上下文由 application-defined Client recovery owner 保存于浏览器 localStorage(ADR-0184;原设计编号 0160 已被其他决策占用),不进入 Source Event、Session Persistence、Bot Memory 或 telemetry。Host/Profile 通过 Host bridge 返回持久 opaque identity,组合可信 Human 与 canonical Channel ID 隔离草稿;身份未就绪或切换时不展示旧范围输入。正文、明确 mentions/Channel references、reply ID 和已上传附件引用逐次保存,不依赖卸载事件,也不保存 File 字节。 - -恢复先经现有 Typert/API Gateway seam 校验当前 Human 可见回复及 Attachment owner 引用,再结合当前 roster 校验目标;失效引用保留正文并提示,未完成上传不能伪装 ready,`@all` 需重新选择。不可变版本及因果时钟保留并发标签页的双方输入,显式选择后重新校验;空闲视图同步,正在编辑的内容不被静默覆盖。存储损坏/配额/不可用明确提示,保留有效旧记录与内存输入。此切片不提供跨设备同步。 - -提交快照与输入召回(#1460)在同一恢复范围下增加一份有界的提交尝试列表(`composer-submission-store`,localStorage `botharness.submissions.v1:`)。通过本地校验的提交先以原 client message ID 保存正文、明确 tokens、reply ID 与已上传引用,再清空输入框;提交前被拒绝的输入撤回记录、仍是草稿。状态为 pending/sent/failed/unknown/restored:Host 明确拒绝为 failed,请求没有得到回答为 unknown(界面显示「结果未确认」,不称未发送),页面关闭时遗留的 pending 按 unknown 处理。unknown 只通过向 owning Host 按原 message ID 查询来核对:已提交则记为 sent 并由规范消息取代本地气泡,明确不存在则转为 failed 并可放回输入框,查询不到结果则保持 unknown 且不允许放回;不自动重发,也没有重试队列。Host 的 `channelSend` 对相同 ID 加相同内容返回已有消息,因此 ACK 丢失不会产生重复消息。空输入框按 ↑ 召回本 Channel 的提交尝试(含失败与未确认),↑↓ 浏览,越过最新一条回到召回前的草稿;召回只填入正文与仍有效的明确 tokens,不发送,不带回附件与回复对象并给出提示;编辑后方向键恢复光标移动,IME 与候选菜单优先。普通历史保留最近 100 条且不超过 256 KiB,按最旧裁剪;failed 与 unknown 不受该预算淘汰。这份列表是恢复数据而非消息日志,规范消息仍由 Host 拥有。 - -## Human 群聊全部 Bot 提及(#542) - -群聊组合器的 `@所有 Bot` 是临时的 Human 输入意图,预览显示当前群内已加入且未暂停的接收 Bot 人数;DM 不提供该选项,粘贴文本不携带选择权威。现有 Channel Store 使用 Registry 与成员事实生成包含名单及显示名的预览 revision。提交前及实际提交边界校验同一预览,名单改变(即使人数相同)或人数为零时返回更新后的可信预览,经既有 Typert 错误 details 显示新人数、保留草稿,并等待 Human 再次发送。 - -Host 将选中的单一 token 展开为普通逐个 @Bot 的正文及稳定 ID/范围,提交一个 Source Event 和 placement,再复用每个 Bot 的普通 group-mention Admission、attention 与 wake policy;普通消息 silent 不屏蔽明确提及。重试相同 messageId 在当前成员校验前复用已提交内容,重启保留普通消息及各接收者状态。该预览不写入消息或引入 broadcast Source Class;Bot 的 Tool 不暴露此快捷方式。见 [ADR-0099](../adr/0099-human-all-bot-mention-expands-to-direct-mentions.md)。 - -### 明确分享自己的 Inbox 来源(#636) - -[ADR-0115](../adr/0115-explicit-inbox-sharing-adds-canonical-placement.md) 向当前 Orchestrator 暴露 `bridge_share`。Messaging 在执行时检查自己的 Inbox 来源、当前接收身份/Grant 与已加入的 Group,再原子提交一个 canonical placement 和各成员普通收件记录。接收 Bot 保留原 admission,其他成员沿用自己的 Channel Attention 与数量/时间 harvest。Channel 渲染直接读取 canonical 来源内容,保留发送人、平台和外部 ID。分享不改变后续收件,不镜像到 Human DM,不对外发送,也不授权成员借用接收身份。同目标重试返回已提交结果,不补收给后来成员;首个切片拒绝另一个目标、DM 和已经投递到 Channel 的来源。撤销授权会阻止新的副作用,但保留已共享历史。多 placement 与独立接收路径由下述 #635 切片交付。 - -[ADR-0117](../adr/0117-external-only-reports-use-owned-outbox-correspondence.md) 增加明确请求的纯外部报告。`bridge_targets` 返回自己已有的授权群 Grant,`bridge_post` 沿用 Outbox,不产生 Channel placement 或 admission。可选 Provider 回执保留原生消息/会话 ID;`bridge_outbox` 提供有界预览或一条 canonical 报告,撤销后仍可读。真实收件的 parent/root 只关联同 Bot、账号 fingerprint 和会话的原报告。Profile 与来源 Modal 读取 Outbox 投影。可选且认证的自身回传仅补充已有对应关系,不产生收件提醒;不假定真实 Lark 必定回传。未知结果不自动重试,沿用 @/跟进和 dispatch 前撤销检查。 - -### External platform behavior defaults (#701) - -[ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) assigns qualified Lark collection, ordinary-message harvest and identity enabled preferences to the existing Messaging database owner. Immutable global revisions cross the authenticated Typert/API Gateway; native Bot settings and Profile tables display effective values and inheritance origin. Legacy configurations remain custom; new qualified configurations inherit, and restoring inheritance uses current defaults. Explicit Thread/source collection overrides platform collection; a member's Channel wake override precedes its explicit PersonaBot source rule and then external-platform defaults. Local messages retain local defaults. Sources and per-member admissions save revisions and thresholds; digest buckets partition those snapshots. Identity pause reuses the existing dispatch gate and Consumer lease lifecycle; resume revalidates the same authorization and fences old events without backfill. No new identity, Grant, listener, automatic reply or Inbox store results from a defaults change. - -[ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) 将已资格验证的 Lark 收件、普通消息汇总与身份启停偏好归于既有 Messaging 数据库权威。全局不可变版本经认证 Typert/API Gateway 供 Bot 设置、Profile 与 Bot 工具读取;旧配置保持自定义,新配置明确继承,恢复继承使用当前默认值。话题/来源收件覆盖平台默认;成员 Channel 唤醒覆盖 PersonaBot 显式来源规则,再回退外部平台默认。本地消息保持既有默认。Source 与各成员 Admission 保存版本及阈值,汇总按快照分桶。身份暂停复用既有发送门禁和 Consumer lease,恢复校验原授权并拒收边界前的迟到事件,不回填;修改默认值不产生新身份、Grant、listener、自动回复或 Inbox 存储。 - -### Multiple source and target routes (#635) - -[ADR-0120](../adr/0120-multiple-bridge-routes-retain-canonical-sources.md) keeps multiple explicit Channel/Inbox routes under the existing authorized Messaging Grant. One qualified Provider Consumer lease per verified account fans out to conversation-scoped Grants. A canonical external Source Event has one content authority and multiple per-Channel placements; Inbox-only has no transcript. Immutable per-Bot/per-route snapshots retain authorization, collection and wake evidence beside one admission per Bot/source. Any ready live path can select that source once; handling one Bot cannot consume another member's admission. Current membership and identity gates protect intake and reads; context and attachments retain their own receiving-identity boundaries; explicit shared-source replies use the responder-owned authorization described below. Route pause/delete/reenable do not affect surviving routes or backfill accepted sources. Profile tables expose explicit DM versus Inbox-only destinations, independent switches and native add/edit/delete Modals. Generation 52 requires a pre-upgrade Profile backup or forward recovery, rather than code-only rollback. - -[ADR-0120](../adr/0120-multiple-bridge-routes-retain-canonical-sources.md) 将多条明确的 Channel/Inbox 路由保留在既有 Messaging Grant 下;同一已验证账号的一个 Provider Consumer lease 向各授权会话分发。外部 Source Event 保留一份内容权威和各 Channel placement,Inbox-only 不生成聊天历史。每 Bot/路径的不可变快照独立记录授权、收件与唤醒证据,每 Bot/来源仍只有一条 admission;任一有效路径就绪时只选择来源一次,处理一个成员不会消耗其他成员的工作。收件及读取检查当前成员和身份权限,上下文与附件仍遵守接收身份边界;明确的共享来源回复使用下述回复者自身授权。单条路由暂停、删除或恢复不影响其他路径,也不回填旧来源。Profile 通过独立 Switch 和原生 Modal 管理路由,明确区分 DM 投递与 Inbox-only。Generation 52 需升级前 Profile 备份或向前修复,不能仅回退代码。 - -### Own-identity replies to shared external sources (#637) - -[ADR-0122](../adr/0122-shared-source-replies-use-responder-owned-authorization.md) keeps source content and receiving identity canonical while allowing a current Group member to inspect its shared placement without creating an Inbox Admission. An explicit reply requires that member's own enabled external identity and one verified Grant for the original external group. The optional dsh-im checked reply-context contract resolves the exact source under the responder's application, retaining conversation/thread/root/parent while translating only the app-scoped sender identifier. The existing Outbox records a per-responder/source intent, owned identity, qualified route, receipt and honest outcome; Profile projects these details. Membership, Binding/Grant revisions and Provider Registration are fenced again after remote validation and immediately before SDK dispatch. Missing authority, mismatched routes and unknown outcomes never borrow the receiver, fall back to the group mainline or retry blindly. Text replies add no source copy, automatic ownership lock, wake or schema migration; shared remote context/files remain separately qualified. - -[ADR-0122](../adr/0122-shared-source-replies-use-responder-owned-authorization.md) 保留一份来源内容和原接收身份,让当前 Group 成员读取已共享的 placement,而不新增 Inbox 收件。明确回复必须使用该成员自己启用的外部身份和原外部群的唯一已验证 Grant。dsh-im 可选的回复核验契约以回复者应用读取原消息,保持会话/话题/root/parent,只解析该应用范围内的发送人 ID。既有 Outbox 按回复者/来源持久记录独立请求、身份、路线、回执与真实结果,Profile 展示这些事实。远端核验完成后、SDK 发送前再次检查成员、身份/Grant 版本与 Provider Registration。缺少授权、路线不符或未知结果时,不借用接收身份、不回退群主线、不盲目重试。文本回复不新增来源副本、自动抢单锁、唤醒或数据库迁移;共享远端上下文和附件仍需各自的资格验证。 回复核验前,既有 Consumer fanout 可在收件关闭时取得仅供回复的账号 lease;外部来信确认后丢弃,不新增 Source Event 或 Inbox,自己的准确回显仍可关联 Outbox。Profile 仍显示收件关闭,身份/Provider 生命周期会取消该进程内连接。 - -[ADR-0125](../adr/0125-nearby-context-combines-time-coverage-and-count-minima.md) 更新外部 `bridge_context` 的 nearby 选择:先通过有界分页覆盖来源前后各五分钟内的可见 Human 文字消息,再为不足的一侧补齐默认前 10/后 5 条;Bot 可指定每侧 0–20 条,条数是保底而不是密集窗口上限。Lark Provider 依次遍历时间窗口、最近更早消息、最近更晚消息,按精确时间过滤秒级边界;不等待未来消息。Messaging 的进程内 opaque continuation 绑定 Bot、来源、Grant revision、范围与条数参数,每页续期 30 分钟,每次仍重新校验当前权限与 Consumer/Provider 生命周期。输出预算、略过记录和续页均明确呈现;普通历史仅形成既有 Source Event 观察,不创建 Inbox Admission 或新的消息权威。 - -[ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md) extends the existing checked DSH Service Provider/Consumer seam to Slack Human text mentions. The native Socket Plugin verifies the workspace, Bot and App pairing, persists exclusive Consumer mode, and acknowledges only accepted canonical intake; loss of a lease does not create a standalone Session. Slack channel IDs, message timestamps and event delivery IDs remain distinct. Replies use the original native thread timestamp (or root message timestamp to start a thread), current own-Bot channel/source qualification, the existing pre-dispatch fence and an explicit native receipt. Client identity/connector/source views reuse their existing projections with platform-correct labels. Explicit Slack source context reads now use the same checked Provider: every page requalifies own account/member/source/lease, separately adapts newest-first channel history and chronological native reply chunks (including root reservation/deduplication and time-bounded cursors towards older chunks), and persists only returned canonical Source Events without historical Inbox Admission or wake (#819). Nearby reads paginate the inclusive five-minute window and supplement nearest visible Human text to default 10 before / 5 after, each configurable 0–20. Signed runtime-local continuations retain bounded native IDs/counts, not message bodies; channel-history coverage is distinct from complete native-thread coverage. The #831 file tracer separately opts into one hosted attachment on a Human mention: exact native source-file association is rechecked at read time, safe ID/name/type/size metadata reaches canonical Source Events, and bounded private streams reuse the Attachment owner. Explicit result files use the existing Outbox with a fence before native upload completion in the original thread. Private URLs remain Provider-local; redirects, revoked leases and changed sources refuse. The #837 ordinary-text Consumer opt-in uses native message.channels events with current account/App/public membership/lease checks; own mentions remain on app_mention only. Explicitly authorized groups retain default mention-only collection, real-delivery qualification and existing count/time or immediate wake. Profile reuses the same compact collection editor; no new store, queue, Session or mandatory reply is introduced. #854 extends the existing explicit thread policy to Slack native `thread_ts`: root and thread match, while no parent ID is fabricated. A real unmentioned child (`ts != thread_ts`) on the current lease qualifies follow; a mainline root does not. Source/account/Grant checks, Human overrides, per-member Admission snapshots and count/time wake remain unchanged. Migrated connector Grants retain the Profile thread table and policy Modal. Ordinary files remain separately qualified; #843 extends the existing platform defaults after intake qualification. - -Slack external-only reports (#863) use the same canonical Outbox as Lark: an explicit own-identity `bridge_post` persists text and checked native `channel`/`ts` receipt without a Channel placement or self admission. An eligible Human thread reply associates through native root identity under the same account/fingerprint/conversation; existing connector and wake policy determine intake. Publication does not imply topic follow. Provider account/member/Consumer/Registration checks precede a single non-retrying send; ambiguous outcomes remain unknown. See [ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md). - -[ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md) 将既有 DSH Service Provider/Consumer 契约扩展至 Slack 的 Human 文字 @。原生 Socket Plugin 核验工作区、Bot 与 App 对应关系,持久保存独占 Consumer 模式,仅在 canonical 收件接受后确认;lease 丢失不创建独立 Session。Slack 频道 ID、消息时间戳和事件投递 ID 分别保留。回复沿原生话题时间戳(主线消息则用其时间戳新建话题),重新核验自身身份的群成员资格及原消息,经过既有发送前门禁并保存原生回执。身份表、频道连接器表和来源详情复用既有投影,按实际平台显示名称。Slack 来源上下文通过同一 checked Provider 主动读取:每页复查自身账号/成员/来源/lease,分别适配频道历史倒序和原生话题页内正序/向旧消息块续页,为附加 root 预留空间并去重,只持久化返回的 canonical Source Events,不生成历史 Inbox Admission 或唤醒(#819)。附近查询完整翻页读取前后各五分钟窗口,稀疏时补齐最近的前 10/后 5 条 Human 文本,两侧可各设 0–20。运行实例签名的续页只含有界原生 ID/计数,不含消息正文;频道历史的覆盖范围区别于完整原生话题。#831 单独为 Human @ 消息启用一个 hosted 附件:读取时复查原生消息与文件关联,安全 ID/名称/类型/大小进入 canonical Source Event,有界私有下载流复用 Attachment owner。明确导入的结果文件通过既有 Outbox 回传原生话题,在上传完成可见前执行门禁。私有 URL 留在 Provider 内部,拒绝跳转、已撤销 lease 和已变更来源。#837 通过 Consumer 明确 opt-in 接收原生 message.channels 普通文字,复查当前账号/App/公开群成员/lease;自己的 @ 只走 app_mention。已授权群保留默认只收 @、真实普通投递验证及现有数量/时间汇总或逐条排队唤醒,Profile 复用紧凑收件策略编辑器,不增加存储、队列、Session 或强制回复。#854 将既有显式话题策略适配到 Slack 原生 `thread_ts`:root 与 thread 相同,不制造 parent ID。当前 lease 上真实无 @ 子回复(`ts != thread_ts`)才证明可跟进,主线 root 不提供该证明。来源/账号/Grant 校验、Human 覆盖、每成员 Admission 快照和数量/时间唤醒保持既有契约;迁移后的频道连接器 Grant 保留 Profile 话题表与策略 Modal。普通文件仍需独立资格验证;#843 在收件资格验证后扩展既有全局默认设置。 - -### 已资格验证平台的默认行为 - -[#1156](https://github.com/BotHarness/BotHarness/issues/1156) qualifies two QQ applications through the existing independent binding and Channel Bridge model. Each app-scoped conversation is explicitly mapped; neither matching text nor a shared local Channel equates external locators or lends passive reply proof. The canonical Source Event projects its receiving app reference/name into QQ Channel source labels and details, and Group Profile activity retains app identity. These optional presentation fields add no store or sending authority; earlier projections remain readable. Native acceptance receipts and observed Provider Echo remain distinct capabilities. - -Native own Echo remains unqualified: accepted send receipts and visible original-group replies do not establish a platform callback of the responding App's own message. The current dual-App slice is partial #1156 delivery. - -The optional developer Provider's maintained SDK 1.0.4 build patch observes the WebSocket message boundary before decode/JSON, links bounded transport and Gateway metadata by process-local connection/frame counters, and exposes start/end totals through existing connection health. Diagnostics remain process evidence; they create no Source, Admission, Outbox or Echo authority and are separate from the managed product Provider pin. - -自身原生 Echo 尚未通过验收:发送接受回执与原群可见回复不能证明平台回推了响应应用自己的消息;当前双 App 切片属于 #1156 的阶段性交付。 - -可选开发 Provider 的维护版 SDK 1.0.4 构建补丁在 WebSocket message 进入 decode/JSON 前观测,按进程内 connection/frame 计数关联有界传输与 Gateway 元数据,并通过既有连接健康状态提供首尾累计数。诊断仅为进程证据,不产生 Source、Admission、Outbox 或 Echo 权威,与托管产品 Provider pin 保持独立。 - -[#1156](https://github.com/BotHarness/BotHarness/issues/1156) 沿用独立应用绑定与 Channel Bridge 模型验证两个 QQ 应用。各自应用范围的会话分别显式映射;相同文字或共同本地 Channel 不会合并外部定位,也不会借出被动回复证明。Canonical Source Event 把接收应用 reference/名称投影到 QQ Channel 来源标签和详情,群 Profile 活动保留应用身份。这些可选展示字段不增加存储或发送权限,旧投影仍可阅读;原生接受回执与 Provider Echo 观察保持独立资格。 - -[#1153](https://github.com/BotHarness/BotHarness/issues/1153) exposes Group Channel sync from the received QQ conversation through the existing Channel Bridge command. Inbox Admission and optional Channel placement share the canonical Source Event; disabling placement retains history. Messaging stores bounded reception metadata by Bot, Provider and authenticated app fingerprint in its own database. Local controls and public account inspections record distinguishable boundaries, while restart continuity remains explicitly unverified; no second transcript or remote backfill is introduced. Same-app rebind preserves blocks and mute preferences without restoring revoked Channel authority. See [ADR-0152](../adr/0152-qq-reception-intervals-record-local-observations.md). - -[#1153](https://github.com/BotHarness/BotHarness/issues/1153) 通过既有 Channel Bridge 操作,在已接收的 QQ 会话里提供群 Channel 同步。Inbox Admission 与可选 Channel 落点共享 canonical Source Event,停止落点保留历史。Messaging 在自己的数据库里按 Bot、Provider 和已认证应用 fingerprint 保存有界接收元数据,区分本地控制与公开账号检查的边界;重启连续性明确标为未验证,不引入第二份消息记录或远端补收。重绑同一应用保留屏蔽和静音,但不恢复已撤销的 Channel 权限。见 [ADR-0152](../adr/0152-qq-reception-intervals-record-local-observations.md)。 -The [#1159](https://github.com/BotHarness/DeepSeekBot/issues/1159) incoming-voice extension extends the same checked Service with independent transcript/audio opt-ins and the existing strict native quote proof. Native platform ASR or explicit unavailable provenance reaches canonical Source/Inbox and the existing Orchestrator text reply. Original audio remains source-bound canonical media; explicit playback acquires a separate WAV through the shared bounded SILK Worker. WeChat encoding checks remain platform-specific. There is no second voice store, automatic ASR, ordinary reception or native voice output. One maintained-runtime application qualified native quoted ASR → model → own-app original-group text reply (Human confirmed), original download and actual Client keyboard playback; other application/codec/modern-Host combinations remain independent qualifications. - -[#1159](https://github.com/BotHarness/DeepSeekBot/issues/1159) 语音入站扩展沿同一受检 Service 增加独立转写/原音协商,并复用严格原生引用证明。原生平台转写或明确不可用来源进入 canonical Source/Inbox,沿现有 Orchestrator 在原群回文字。原音保持来源约束,显式播放通过共享有界 SILK Worker 获取独立 WAV;微信编码校验仍由自己的平台边界负责。不新增第二份语音记录、自动 ASR、普通消息接收或原生语音输出,一个维护版运行应用已通过真实引用语音转写 → 模型 → 本应用原群文字答复(Human 确认)、原音下载及实际 Client 键盘播放;其他应用/编码/现代 Host 的组合仍需独立验收。 - -The [#1158](https://github.com/BotHarness/DeepSeekBot/issues/1158) file candidate adds explicit generic-file negotiation to the same QQ Service. Direct native files and qualifying embedded type-103 file quotes belong to the current mention Source Event; a single raw file block, matching message/group/author and consistent native reference indices are required. No adjacent-message matching, history cache or historical Source is introduced; private download tickets stay in Provider memory. Shared Channel projection adds a file card whose download uses the existing source/identity/Grant checks, canonical AttachmentStore and forced attachment response. Processing reuses an independent Workspace working copy and imported result. Upload type 4 uses the same final account/source/lease/Core fence and native receipt as image output; actual application permission remains unqualified. - -[#1158](https://github.com/BotHarness/DeepSeekBot/issues/1158) 文件候选在同一 QQ Service 增加显式普通文件协商。直接原生文件及合格的内嵌 type-103 文件引用均归属当前 @ 消息 Source Event:要求唯一原始文件块、消息/群/作者身份吻合、原生引用索引一致,不关联相邻消息、不增加历史缓存或历史 Source,私有下载票据保留在 Provider 内存。共享 Channel 投影增加文件卡片,下载复用现有来源/身份/Grant 检查与 canonical AttachmentStore,并强制附件响应。处理复用独立 Workspace 工作副本及导入结果。类型 4 上传复用图片输出的最终账号/来源/租约/Core 检查及原生回执;实际应用权限尚未验收。 - -The [#1157](https://github.com/BotHarness/BotHarness/issues/1157) QQ image candidate extends the same checked Service with opt-in image intake, bounded private source tickets and acquisition, and native image output. Canonical Source Event/Inbox and AttachmentStore remain the authorities. Human preview uses the original implicit account Grant plus its current Channel route and canonical placement when no independent reception-path record exists; membership, identity, scope and revocation checks still apply. Preview creates no Observation or wake. Actual model access reuses working-copy save and native `read_image` on an image-capable calling route. Image upload never sends a message itself; account/lease/source and Core authority are checked again immediately before the single final native POST. Its public native message receipt settles the existing Outbox; unknown is not retried. Caption-first presentation preserves attachment association without inventing native interleaving. Real QQ/model/Client acceptance remains separate evidence. - -[#1157](https://github.com/BotHarness/BotHarness/issues/1157) QQ 图片候选沿同一 checked Service 扩展显式图片收件、有界私有来源票据与获取,以及原生图片输出。Canonical Source Event/Inbox 与 AttachmentStore 继续拥有权威。没有独立接收路径记录时,Human 预览使用原 implicit 账号 Grant、其当前 Channel 路由及 canonical placement,仍检查成员、身份、范围和撤销。预览不产生 Observation 或唤醒。实际模型访问复用工作副本保存及支持图片的当前调用路由上的原生 `read_image`。图片上传自身不发消息;最终单次原生 POST 前再次检查账号/租约/来源和 Core 授权。公开原生消息回执结算既有 Outbox,unknown 不重试。说明在前的展示保留附件关联,不虚构原生穿插顺序。真实 QQ/模型/Client 验收证据独立记录。 - -The [#1152](https://github.com/BotHarness/BotHarness/issues/1152) QQ group-text candidate registers through the existing dsh-im Service Provider/Consumer seam. Official application authentication supplies the account fingerprint; trusted group mention text commits into the existing Source Event and Bot Inbox, wakes the existing Orchestrator and replies through its own application in the original group. Reply-only qualification does not require proactive capability. Exact source proof is bounded and process-local: five minutes, five attempted replies, no group-history reread or durable resume cursor. A pre-dispatch authority fence and native acceptance receipt preserve the existing Outbox; unknown results remain unknown without automatic retry. See [ADR-0151](../adr/0151-qq-group-replies-use-authenticated-apps-and-process-local-source-proof.md) and the [QQ guide](../qq-connection.md). Real group/model qualification and public release are separate delivery evidence. - -The [#1154](https://github.com/BotHarness/DeepSeekBot/issues/1154) QQ delayed-result slice reuses [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md)'s shared posting policy through an explicitly authorized saved group target when the Provider lacks QQ discovery. Known reply expiry retains the original failed Outbox intent; an explicit proactive report uses a distinct request identity, own-app/target correspondence, final fence, shared hourly quota and native receipt. Unknown replies do not become proactive reports, and repeated report requests after restart read the existing outcome. This adds no QQ-specific policy, automatic retry, local Human DM placement or additional durable result store. Real current-app delayed-task qualification remains separate from source regression coverage. - -[#1154](https://github.com/BotHarness/DeepSeekBot/issues/1154) 的 QQ 延迟结果切片在 Provider 缺少 QQ 群发现能力时,通过已显式授权的保存群目标复用 [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md) 的共享发送策略。明确的回复过期保留原失败 Outbox 意图;显式主动报告使用独立请求身份、自己的应用与目的地一致性、最终门禁、共享每小时限额和原生回执。不确定回复不会转为主动报告,重启后的重复报告请求读取既有结果。没有 QQ 专属策略、自动重试、Human DM 落点或第二份持久结果存储。当前应用的真实延迟任务验收与源码回归分别记录。 - -The #1155 ordinary-text extension uses the same exclusive checked QQ inbound consumer. Its software capability enables forwarding, while actual ordinary-delivery qualification belongs to the current Core receiver lease and follows the existing account/conversation intake boundary. A mentions-only probe supplies bounded process evidence without canonical ordinary collection. Explicit collection uses the existing Source, Observation, harvest and Channel policies; intentional external speech still requires independent posting authority. Neither a stored collection preference nor a blocked-interval event establishes current eligibility. No QQ history store, permission flag or standalone receiver is added. - -#1155 普通文字扩展沿用同一 QQ 独占受检消费者。软件能力允许转交事件,真实普通投递资格则归属于当前 Core 接收器 lease,并遵循已有账户/会话接收时间边界。只收 @ 模式下的探测提供有界进程证据,不进行普通内容的 canonical 收集。显式收集复用已有 Source、Observation、harvest 与 Channel 策略,主动外部发言仍需独立发送权限。保存偏好及屏蔽期间的旧事件均不证明当前资格,不新增 QQ 历史存储、权限标志或独立接收器。 - -[#1152](https://github.com/BotHarness/BotHarness/issues/1152) 的 QQ 群文字候选沿用现有 dsh-im Service Provider/Consumer 边界。官方应用认证提供账号 fingerprint;可信群提及文字提交到既有 Source Event 和 Bot Inbox,唤醒既有 Orchestrator,并使用自己的应用身份在原群答复。仅回复资格不要求主动发送能力。准确来源证明有界且仅存在于进程内:五分钟、五次回复尝试,没有群历史重读或持久恢复游标。发送前授权门禁和原生接收回执沿用既有 Outbox,unknown 不自动重试。见 [ADR-0151](../adr/0151-qq-group-replies-use-authenticated-apps-and-process-local-source-proof.md) 与 [QQ 指南](../qq-connection.zh.md)。真实群/模型资格与公开发布分别记录交付证据。 - -[#843](https://github.com/BotHarness/BotHarness/issues/843) 按 [ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) 将既有全局偏好扩展至 Slack 群文字。Bot 设置可选择 Lark 或 Slack,各自保留草稿与独立不可变版本;认证读取 RPC 可指定已资格验证的平台,旧调用仍读取 Lark。Generation 53 保留全部旧默认值与显式覆盖,仅扩展允许的平台。新绑定的 Slack 身份默认继承,旧身份保留原选择,由 Human 明确恢复继承。修改只作用于后续事件及仍继承的收件/汇总/身份行为;群授权、真实投递验证、Admission 快照与恢复接收时间边界仍由原模块管理,不新增账号、Grant、消息、队列或回复权威。 - -[#855](https://github.com/BotHarness/BotHarness/issues/855) adds a **development-source qualified mention/reply** Discord checked adapter through the same Provider/Consumer and canonical Inbox seams. App/Bot identity is authenticated and checked against Gateway READY. An explicitly external-only Profile avoids standalone bootstrap; consumer loss preserves external ownership. A public thread maps to its verified parent conversation plus exact child-channel `threadId`, without manufactured message ancestry. Replies check current native permissions/source, run the authority fence and validate native author/destination/receipt without fallback or retry. See [ADR-0128](../adr/0128-discord-checked-replies-preserve-native-child-channel-routing.md). The real channel/thread model, native identity/guild boundary and refusal/recovery checks passed under agent-operated E2E acceptance; [verification](../dev/verification/discord-855-mention-reply.md) bounds the qualification to that development runtime. Product Provider promotion and other Discord capabilities remain separate. - -[#855](https://github.com/BotHarness/BotHarness/issues/855) 通过相同 Provider/Consumer 和 canonical Inbox 边界增加**开发来源提及/回复已取得资格**的 Discord adapter。App/Bot 身份由认证 API 与 Gateway READY 校验,显式 external-only Profile 避免先进入独立 Session,consumer 消失仍保留外部所有权。公开 thread 使用已验证的父会话与准确子频道 `threadId`,不虚构消息祖先。回复复查原生权限/原消息,执行授权门禁并验证原生作者/目的地/回执,不转发、不重试。见 [ADR-0128](../adr/0128-discord-checked-replies-preserve-native-child-channel-routing.md)。真实频道/thread 模型、原生身份/服务器边界与拒绝/恢复检查已按 agent-operated E2E 指令通过;[验证记录](../dev/verification/discord-855-mention-reply.md) 将资格限定于该开发运行时。产品 Provider 晋升与其他 Discord 能力另行处理。 - -[#937](https://github.com/BotHarness/BotHarness/issues/937) extends that candidate Provider through the existing `bridge_context` / `historyChecked` seam: bounded group and existing-public-thread Human-text pages, native App content visibility plus view/history checks before and after the HTTP page, and runtime-local signed cursors bound to account, original source route, scope and limit. Canonical context retention creates no Admission or placement; a conflicting retained source rolls back the page and reports `source-conflict`. Native edits never replace the earlier source evidence. [The exact candidate checkpoint](../dev/verification/discord-937-context-reads.md) separates real single-page model success, direct native cursor checks and restored-permission/restart refusal from still-pending model continuation/conflict acceptance. Nearby, ordinary subscription, files and product dependency promotion remain outside this slice. - -[#937](https://github.com/BotHarness/BotHarness/issues/937) 沿既有 `bridge_context`/`historyChecked` 边界扩展该候选 Provider:有界 group/已有公开 thread Human 文本页,HTTP 页读取前后核对原生 App 正文可见性及查看/历史权限,运行时内签名 cursor 绑定账号、原来源路由、范围和页限。Canonical 上下文留存不创建 Admission 或 placement;留存来源冲突使整页回滚并返回 `source-conflict`,远端编辑不替换早期来源证据。[准确候选检查点](../dev/verification/discord-937-context-reads.zh.md) 区分真实模型单页成功、直接原生 cursor 检查及恢复权限/重启后的拒绝,与仍待完成的模型续页/冲突实测。Nearby、普通订阅、文件和产品依赖晋升不属于本片。 - -[#845](https://github.com/BotHarness/BotHarness/issues/845) qualifies Slack sources through the existing shared Channel routes and member Attention, without a second store. Group Profile exposes separate effective Lark/Slack wake previews; the legacy Lark RPC field remains compatible. The bilingual [IM Provider integration guide](../dev/guides/im-provider-integration.md) records the checked identity, intake, native routing, bounded context/file and acceptance boundaries for future providers. - -[#845](https://github.com/BotHarness/BotHarness/issues/845) 使用既有共享 Channel 路由与成员 Attention 验证 Slack 来源,不增加存储。Group Profile 分别展示 Lark/Slack 的有效唤醒策略,保留旧 Lark RPC 字段兼容。[IM Provider 接入指南](../dev/guides/im-provider-integration.zh.md) 汇总后续平台需要复用与验证的身份、收件、原生路由、有界读取/文件及验收边界。 - -[#878](https://github.com/BotHarness/BotHarness/issues/878) adds a **WeChat paired-owner DM slice with source and installed-product E2E verified and Human QA approved**, through the existing checked Provider/Consumer, canonical Source/Inbox and own-identity Outbox. The QR-paired owner is an explicit native DM Grant, with no fabricated mention/group/thread or local DM mirror. The Provider keeps the original private continuation outside public snapshots and advances polling only after canonical acceptance; absent leases remain external-only. Checked text replies use the source-bound continuation and a pre-send authority fence. Their receipt is explicitly a client acknowledgement, not a native message ID or read/delivery proof. The #903 file slice reuses the same Source Event/Inbox path: one native file is retained as safe metadata, privately downloaded into canonical attachments on demand, and an explicitly selected result replies to the original DM. CDN tickets/AES keys never enter public DTOs; downloads stop at 25 MiB, and a required current-authorization fence runs after upload before the final send. Declared oversized files remain inspectable but not downloadable. The #904 image candidate separately negotiates native image intake, derives format from bounded decrypted bytes, and reuses canonical attachments plus own-identity image replies with a post-upload fence. The Client preview is authenticated and disposable; model understanding additionally requires the actual DSH model route to support images. Installed-product image intake, preview and actual DeepSeek Flash image understanding are verified; the Human confirmed the native image result in the original WeChat DM. Receiver-side byte equality and a public image release are not claimed. The #905 candidate preserves platform voice-transcript availability, native item ID and duration in the existing Source Event and exposes provenance in Inbox/Source UI and the Orchestrator input. It does not implement ASR or audio playback; a real native voice transcript reached the model and the Human confirmed its own-identity text reply in the original DM. The #906 raw-audio extension additionally negotiates a private source-bound audio attachment and optional native codec metadata, retaining the unchanged original in canonical attachments. Explicit playback or working-copy selection runs bounded SILK decoding in a Worker and acquires a separate mono 24 kHz WAV through the same AttachmentStore; authenticated preview resources are disposed on close, and current source/account/Grant fences remain in force even for cached output. Unsupported codecs remain downloadable originals. This adds no ASR, automatic model audio input or native outgoing voice. Fresh raw-audio E2E qualification is tracked separately from the #905 transcript evidence. Remote context, groups and canonical proactive reports remain unavailable in that earlier slice. See [ADR-0129](../adr/0129-wechat-owner-dms-use-private-source-continuations.md). - -[#996](https://github.com/BotHarness/BotHarness/issues/996) extends the existing canonical DM admission/reply path to explicitly authorized Lark private chats. A checked user Target retains its outbound `openId` and an optional exact private `chatId`; only a qualified Provider accepting that locator exposes a DM receive scope. The same PersonaBot can retain Discord and Lark identities, with private messages admitted as `human-dm` into its existing Inbox and source-bound replies through the Lark identity. Missing private-chat locators remain outbound-only; private DM reception is excluded from Group Channel Bridge controls. This adds no local DM mirror, Session store, permission broadening or second Bot. - -### Human Bridge media presentation - -[ADR-0217](../adr/0217-im-conversation-mirroring-is-an-opt-in-projection.md) adds independently opted-in IM conversation presentation and local-DM forwarding. Both default off and share one effective preference per Binding/private conversation, across implicit and explicit Grants. Incoming presentation places the canonical Source Event without another Admission; accepted outgoing presentation carries its Outbox and original-source lineage and participates in Content Purge. Only the new mirror placement reduces media to safe metadata; ordinary connector placements keep their original media checks. Enabling starts a prospective epoch; current epoch and Binding checks fence projection and bounded recovery. Forwarding uses the exact opted-in Binding, existing Outbox and final Provider send fence, including every long-message part. A closed switch or off/on cycle stops unsent work. WeChat qualifies this first slice; the other IM adapters remain separate issues under #1990. - -[ADR-0137](../adr/0137-human-bridge-media-uses-channel-source-authority.md) places Human external-image access in the existing Messaging and Attachment owners. The Source Event retains trusted native descriptors and order; realtime and historical Channel placement projections expose media references without resource keys. The existing attachment route under the authenticated API Gateway rechecks Human Channel access and independent recorded reception paths using Channel, Source Event and attachment IDs. The Provider verifies the original message/resource association before acquisition. Visible images acquire automatically, reuse the deterministic Attachment identity and enlarge in the native Modal. Reception stop refuses new acquisition; unbind/revocation refuses acquisition and cached serving through that path. Missing or purged originals are not recreated. Human viewing creates no Observation, Inbox Admission, model image input or send permission. #1021 is the Lark image tracer; files, audio/video and other providers extend the contract in subsequent slices, with real platform qualification tracked in each issue/PR. - -#1022 沿同一权威接通 Lark 经核验的单个引用文件:投影与取得共享媒体资格判断,未知 MIME/大小不作推断。Human 明确下载才取得有界原件;支持原生打开的 Host 复用既有应用菜单,`channelMediaTarget` 每次按当前 Channel/来源/文件授权解析 canonical Attachment 目标。CLI 提供同一授权路径的目标和下载命令;没有新增 Provider、消息存储或 Bot 注意力。直接文件、多文件/混排与真实平台验收保持独立资格边界。 -[#908](https://github.com/BotHarness/BotHarness/issues/908) extends the same WeChat Source Event authority with opt-in one-level native quote evidence: server message ID, item ID, embedded text, display summary and partial-reference metadata remain distinct. Missing bodies resolve only from readable canonical sources in the same currently authorized account/private conversation, or accepted Outbox records with genuine native server receipts; WeChat client acknowledgements never qualify. No Provider transcript store or standalone Session lookup is introduced. `bridge_context(retained|retained-nearby)` returns bounded canonical local records with explicit `retained-local-sources` coverage, current own-identity/Grant/lease inspection, initial row-boundary snapshot and authorization-bound opaque keyset cursors. These reads audit the anchor Source Event without creating Admissions or wake; unavailable references do not imply remote deletion or a Thread. Client source details and Orchestrator input distinguish native quoted text, local resolution and unavailable content. Remote WeChat history/search remains unavailable. - -[#909](https://github.com/BotHarness/BotHarness/issues/909) routes the explicitly authorized WeChat paired-owner DM through existing Channel connector records: local DM/Group placements and Inbox-only paths reference one canonical Source Event. Group members retain their independent Attention/harvest policy; private Inbox and explicitly connected local DM paths retain the `human-dm` policy. The native origin remains `dm`, never a fabricated WeChat group or Thread. Shared source readability does not transfer the receiving identity or its private continuation: each external reply still requires the acting Bot's own enabled identity and Grant. Existing intake boundaries, placement deduplication, membership checks and dispatch fences apply to switches, deletion, revocation and restart. No schema, Provider transcript store or new Session lifecycle is added. Fresh installed-product qualification is recorded on #909. - -[#910](https://github.com/BotHarness/BotHarness/issues/910) extends the canonical external-only report path to qualified paired-owner WeChat DMs ([ADR-0139](../adr/0139-wechat-external-reports-use-private-owner-context.md)). The Profile form and `bridge_post` share owned Outbox authority. The Provider explicitly negotiates receipt and final-fence support, keeps current owner context private with fingerprint/order/retention checks, and invokes the application's current Binding/Grant/Registration fence immediately before one native send. Genuine server IDs stay separate from client acknowledgements. Missing context and native rejection offer explicit fresh-message recovery; unknown outcomes never trigger automatic retry. No local DM mirror, new Inbox delivery, scheduler or standalone Provider Session is introduced. - -[#911](https://github.com/BotHarness/BotHarness/issues/911) qualifies a process-local checked typing lease for the authorized WeChat owner DM ([ADR-0145](../adr/0145-wechat-typing-follows-owned-processing-leases.md)). Canonical Messaging resolves own source, Binding, Grant and Registration authority; actual Orchestrator/accepted steering and related Assignment work share one bounded native lifecycle. The Provider keeps tickets and continuations private, renews only under a current fence and cancels on the last processing owner or invalidation. Identity persistence stores only the preference; live activity resets on restart and never becomes a SessionEvent, Outbox delivery or read receipt. Windows native visibility, stop and recovery passed Human QA on #911. - -[#912](https://github.com/BotHarness/BotHarness/issues/912) extends the existing immutable defaults owner to WeChat identity enablement and typing with independent inheritance markers ([ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md)). Generation 69 preserves old choices as custom; new bindings inherit. Global typing changes invalidate only inherited live leases, and identity resume retains the existing receive-after fence. The owner-DM editor omits unsupported group/Thread controls; Channel connectors keep separate routing and member policies. No new authority or message store is introduced. Native #912 fresh-message QA remains pending. - -### 窗口伙伴(#1138–#1142) - -EventSource 在暂时 HTTP 失败后进入终态 CLOSED 时,集合 owner 用单一重试计时器重新建立连接,间隔从 1 秒退避到最多 8 秒。最后确认的过程消费 ID 通过 `?resume=` 携带;新连接随后原生自动重连时,`Last-Event-ID` 优先于查询参数。两条路径共享同一认证、有界 Host 租约与规范资格重查。成功基线重置退避;移除最后一个选择或卸载 Plugin 清理计时器和恢复标识。Client 重启不保留恢复标识。 - -Client 独立的 `CompanionMotion` 拥有有界拖拽姿态、速度采样、连续重力、横向阻尼、轻微地面回弹与落地收敛。View 继续拥有既有帧循环;带阻尼的二阶角弹簧在拖拽反向、停留与松手时保留角度和角速度,气泡跟随同一位置。有界积分步长和倾角上限避免延迟帧使姿态失稳。调整窗口保留当前运动并重新收敛边界、协调拖拽原点,指针取消只匹配活动捕获。最终落地只保存归一化横向位置;减少动效时清除弹簧能量、直接回到底部并关闭姿态效果。这些呈现动力学独立实现,参考 Coopanion 的拖拽/空中/落地交互概念,不引入其源码或美术。 - -集合共用一个 Client-owned `CompanionSound`,消费既有文字播放循环新显示的已提交字素,以及 `CompanionMotion` 的抓起、快速拖动、快速释放与首次触地反馈。拖动音效冷却 500ms,地面小反弹不重复播放;取消捕获与减少动效不补播。沿用既有 `speechSound` 存储字段保存统一伙伴音效偏好,避免重置已选设置。全局伙伴声音设置按 Client/Profile 持久化,默认关闭;Web Audio 只在可信 Human 操作时按需创建,受限或不可用不影响消息。短合成音节随 Bot 身份和文字变化,标点与空白静音,并发与频率有界,不排队补播。静音、隐藏/离屏、断线、归档、移除和销毁停止相应声音。该呈现不新增模型工具或 TTS,不持久化音频资源或播放进度([#1173](https://github.com/BotHarness/DeepSeekBot/issues/1173))。 - -Avatar 为伙伴消费者提供可释放的锚点读取器:每次形象替换只采集一次受支持头部的局部顶部中心,既有可见帧循环只读取该局部组当前显示的屏幕矩阵,包含外层旋转、缩放及过渡中的变换原点。像素 Activity 覆盖使用相同头部区域点与 SVG 根矩阵;图片、未知 rig 或不可用的 SVG 几何使用可见 Avatar 盒子顶部中心。不逐帧扫描整个 SVG 边界,也不向 Host 发送姿态 RPC。`CompanionBubbles` 保留原始投射点,让直立卡片受视口、工具栏与多伙伴避让约束,偏移后用像素式连线保持来源关系。阅读延续既有位置与离开宽限;隐藏/离屏停止采样,形象替换/销毁清除读取器([#1177](https://github.com/BotHarness/DeepSeekBot/issues/1177))。 - -应用定义的 Window Companion owner 跟随 Client Plugin 生命周期,位于 Bot 模式页面之外,通过官方 `shell.overlay` Slot 渲染。本地偏好按 Client origin 与原生 `profileContext.dir` 的不透明哈希隔离,消息卡片不落盘。受认证的 Connection Fetch `/api/botharness/companion` 提供 Profile 上下文及只处理未来消息的 SSE 基线;消费者在同一 Host turn 同步注册并读取快照,再投影 Registry 形象、现有全 Bot Activity 和拥有者已提交的 Human–Bot DM 输出。Channel store 与 Session ownership 保持权威;草稿和历史查询不进入首个 feed。 - -应用定义的 `WindowCompanions` 集合拥有 Profile 的钉选集合和共用容量,组合每个 Bot 独立的播放 owner 与运动 View。侧栏菜单与各 Bot 的 header 按钮均操作此集合,与 Channel 置顶无关。偏好从单选 v1 迁移到 v2;只持久化形象选择、来源/范围、位置、走动与容量。多个伙伴复用一条受认证 SSE,通过原生随机的临时 consumer ID 和同路径 POST 原子替换消费集合;控制句柄随流关闭释放,既不是身份也不是耐久权威。递增选择版本阻止迟到的基线初始化被替换的消费;增加或移除其他 Bot 不重置正在阅读的卡片。 - -选中伙伴时,共享 feed 同时供给现有 Client Activity store,替代其独立 Activity SSE。慢消费者分别合并 Activity 和选择快照,按 Bot 有界保留消息并轮流排出。容量默认层叠 3/保留 20,层数 1–10、保留 1–100 且层数不超过保留;每个 Bot 的 Host 待发队列、Client 保留卡片与阅读到达队列各受保留数量约束。阅读中最多两倍保留数量的卡片,离开时合并最新消息;已有文字按完整字素继续播放。View 做基本气泡避让与视口夹取,不设钉选数量上限或自动折叠人物。移除一个 Bot 只释放自身播放,最后一个移除后关闭共享流。透明半身使用 PixelFigure 公共 body/head 结构,仅伙伴 surface 去掉底板,普通头像配方与背景保持原样。每个 Bot 的 DM、群聊与 Activity 标签独立开关;消息开关与 own-dm/shared/all-bot 范围相交。Channel owner 的 `observeOutput` 单条规范查询返回安全正文、作者、当前成员关系与独立的 Human 读取判定,Feed 验证所选 Bot 后推送,队列在范围变化及排出时重新资格判断。来源或范围变化只移除不再符合条件的显示及排队卡片,同一 Host 的确认基线保留仍可见内容与阅读进度,新启用来源不回放禁用期;点击来源先按需刷新 owner 会话列表,再检查锚点 timeline 读取权限,失败在卡片解释。卡片标明 Channel、Bot DM 双方和原会话可读性,不自动加入或改变已读位置。宽范围仅新增所选 Bot 的输出观察,既有通用 timeline 和模型能力不扩权。同一 Host 的 EventSource 断线重连通过 `Last-Event-ID` 识别过程租约;最多保留 60 秒、128 个已断开的租约,每 Bot 的规范消息引用受保留数量约束。Channel owner 的 `outputCheckpoint` 读取最新 placement 位置,`observeOutput` 返回单条位置;首次选择及新开启来源建立 Host 基线,延迟的旧通知不能成为新消息。四类来源各有 Client 过程 epoch,离线开关或范围变更后只恢复连续启用且仍合格的引用;补播重新查询规范正文和成员关系。恢复 GET 原子接管持续收件,等待 POST 确认或握手再次中断时仍有界留存;重复规范引用不占用额外队列容量。订阅确认先于补播;Client 保留同 Host 的阅读进度,按 Channel/message 标识去重。租约或 generation 失效、Client 重启均开始未来基线;无全历史扫描、耐久消息副本或共享已读写入。页面隐藏取消帧采样并暂停文字和到期时间。生命周期扩展仍按规格 [#1135](https://github.com/BotHarness/DeepSeekBot/issues/1135) 交付。 - -Companion Feed 通过一条可释放的订阅直接观察已提交的 `bot-registry` 通知,独立于 Bot 模式。归档保留带标签的静止形象;规范的暂停/重新启用变化重置有界消息引用和来源检查点。过程生命周期标识随流恢复保留,让合并过的归档/重新启用变化仍可被 Client 识别,不新增耐久身份状态。确认删除通过已确认选择快照发送移除 ID;collection 只释放对应子项、偏好和卡片,旧 revision 不能恢复已删除项。当前 owner 的 attention 事实仅用于导航。上传图片保留原媒体与整体交互;不支持的 rig/schema 保留配方和带版本的保存快照,并明确说明动作限制。图片 owner 拒绝过期版本 URL,不以另一形象替换该版本。键盘焦点与 hover 同样展开阅读;菜单键/Shift+F10 打开原生菜单,Escape 返回焦点,关闭气泡选取存活控件。减少动态效果立即归位,离屏/隐藏停止采样,销毁释放 observer、帧及 morph/fade 动画;旧动画完成回调不能再排入动作。性能验收仍须真实多 Bot 帧、内存、资源测量及 Human 同意的预算。 - -支持的像素伙伴通过 BotPixel 的可选 `mouthLayers` 公共合约预绘制 saved/closed/half-open/open 四层,普通头像与 Host 保存快照保持默认输出。`WindowCompanion` 从既有安全字素播放进度派生一份嘴型,不引入说话 Activity、第二个消息队列、计时器或耐久表情状态。生产 Avatar 仅在 companion surface 请求这些层,独立 `AvatarSpeech` 资源在既有 SVG 内做短透明度过渡,不重建人物或重启眨眼/转头;说话时暂时显露脸部,外层 Activity 和 attention 仍反映真实事实。标点闭嘴,播放结束、取消、归档、断线及销毁恢复保存表情;减少动态效果、隐藏与离屏由现有共享策略立即归静。多卡片选择第一条仍在逐字呈现的卡片驱动一套有界嘴型。上传图片、不支持的 family/版本保留原有降级,不拼接替代部件。该呈现不需要 TTS、音素、模型动作标记或 Tool 私有内容([#1143](https://github.com/BotHarness/DeepSeekBot/issues/1143))。 - -### Own-Bot Inbox history (#1278) - -The application-defined `inbox_history` Orchestrator Tool reads only the calling PersonaBot's canonical Inbox Admissions, including handled messages and self-records. Generation 77 adds a rebuildable FTS5 trigram projection over Source Event bodies, notice labels, commit subjects and changed paths. Source Event insert/update/delete and Purge Ledger application maintain the index in the same transaction; both the projection and every returned page exclude purged source/cause content. FTS5 secure-delete is enabled alongside the Purge owner's SQLite secure_delete setting, so purge removes old index entries rather than retaining recoverable delete keys. This query neither observes work nor changes Attention Decisions. The native Tool Consumer obtains Bot identity from trusted Orchestrator ownership; it does not accept a target Bot argument. Channels, Inbox history and Memory remain separate authorities for conversations, the Bot's diary, and distilled memory. - -Pages contain at most 50 items and 300 Unicode characters per snippet. Literal searches require at least three Unicode characters. Chronological pages use a timestamp/id keyset and an initial Source Event rowid boundary. Relevance pages preserve their first FTS order in a bounded process-local snapshot containing only Source Event ids, then rejoin current canonical data on each read so purged, removed or no-longer-matching rows disappear. Ranked cursors expire after ten minutes or Host restart; the cache holds at most 64 searches with at most 10,000 matches each, requiring narrower filters for larger result sets. Neither the cache nor the index creates a second transcript or resumable work queue. New self-record payloads persist their actual cause id for cause filtering; older records without that id remain searchable without inventing provenance. See [ADR-0154](../adr/0154-bot-self-records-keep-memory-commits-and-bot-actions-in-the-timeline-and-inbox.md). - -应用定义的 `inbox_history` Orchestrator Tool 只读取调用者 PersonaBot 的规范 Inbox Admission,包括已处理消息和自我记录。Generation 77 新增可从 Source Event 重建的 FTS5 trigram 投影;事件写入、更新、删除与 Purge Ledger 应用在同一事务维护索引,查询排除被清除的来源及起因内容,同时启用 FTS5 secure-delete 与 Purge owner 既有的 SQLite secure_delete,避免旧索引段残留被清除词条;不观察待办、不改变 Attention Decision。Bot 身份来自可信 Orchestrator ownership,工具不接受目标 Bot 参数。频道、收件箱历史和记忆分别保有对话、Bot 日记与提炼后记忆的权威。 - -每页最多 50 条、摘要最多 300 个 Unicode 字符,字面文本搜索至少需要三个 Unicode 字符。时间排序按时间/id 翻页,并固定首次读取的 Source Event rowid 上界;相关性排序以进程内、有界的 id 快照保留首次 FTS 次序,每次重新联接当前权威内容,移除已清除、已撤回或不再匹配的项。相关性游标十分钟或 Host 重启后失效,最多缓存 64 次搜索,每次最多 10,000 个匹配,超过时要求缩小筛选范围。快照不保存正文、不成为第二份记录或工作队列。新自我记录保存实际起因 id;旧记录缺少 id 时不推测起因。 - -### AX Client 启动证据 - -应用定义的 Client 诊断 collector 扩展既有 ADR-0063/0064 开发者日志边界。在明确启用的 Host 中(隔离 AX launcher 只为自己的子进程启用),Host Cordis Fiber 通过原生 `webServer.tapIndex` 在 Client 导入前安装 observer,并在 `/api/botharness/client-diagnostics` 注册精确、经过认证的 Connection Fetch route。浏览器报告白名单错误/生命周期 code、顺序化文档 attempt、固定保留的首次失败与 shell 挂载记录及有界遗漏;Host 保留有界实时 read model,并将同一过程证据写入既有 `logs.db`。它不创建 SessionEvent、产品状态、额外 SSE 或操作权威。API 健康、已提交且可见的 shell 观测、新鲜度、真实浏览器 console/DOM 核验分别成立;未知、后台延后及失败证据不能隐式变成成功。见 [agent 核验流程](../dev/guides/client-startup-diagnostics.zh.md) 与 [#1184](https://github.com/BotHarness/DeepSeekBot/issues/1184)。 - -### Provider-owned inline app setup (#1111) - -The optional application-defined `setupVersion: 1` / `describeSetup(channel)` capability on the public same-Host Service supplies only a versioned settings endpoint descriptor. The browser calls the Provider-owned `dsh-im/app-setup` management transport directly; BotHarness Host has no credential or QR-session command. Provider-owned bounded attempts retain setup state, create the account in `external-consumer` mode before its first connection, and return only its opaque account reference and authenticated `describeBot` projection. The Client hands the existing Binding command only provider/account reference and fingerprint; the existing identity owner revalidates it and acquires the exclusive intake Registration. Closing the dialog leaves the browser handle resumable for the Provider attempt's lifetime; cancellation prevents a not-yet-committed account, while an account already created remains available to bind. Older Providers keep the existing Settings-and-return path. This adds no operational schema, credential store or second message receiver. The candidate covers Lark credentials and WeChat QR/code setup. QR attempts use the same Provider-owned transport and bounded lifetime, refuse replacement of existing configured accounts, and are revoked with their originating Registration. Lark native DM/group replies are qualified on the earlier packaged candidate; native WeChat pairing and final visual/recording qualification remain required before #1111 is complete. - -### Mobile contextual creation (#2195) - -The mobile app uses its existing Expo Router tab and root Stack owners for an adjacent contextual creation action ([ADR-0235](../adr/0235-mobile-creation-shares-the-existing-tab-navigator.md)). iOS bottom controls use SwiftUI Button and native iOS 26 glass styles; the expanded creation menu uses a native rounded glass backdrop with an opaque accessibility/older-OS fallback. Android retains OCAT controls; navigation state still belongs to Expo Router. A presentation-only menu resolves stable Computer or cloud account/Organization keys; All asks for a target. Computer creation remains in the existing Host full-client flow. Cloud creation rechecks account scope and owner access through CloudClient, then uses the existing idempotent public mutation and opens its returned Channel. No mobile permission store or second messaging authority is added; native evidence is tracked separately from source checks. - -### Mobile presentation (#2028) - -Android chat Markdown has a separate, disposable geometry module. `MarkdownViewportContext` supplies the existing chat viewport and scroll values; the UI thread reports coarse visible-window changes, while the renderer retains measured block heights and minimum bubble width. Offscreen text becomes equal-height spacing and restores through the same Markdown path. Width/font changes discard geometry, native media remains mounted, and screen readers use the full document. This module owns no message data, persistence, playback cursor or Host command. - -The block parser also retains its disposable lexer result with exact source provenance. Android rendering and window-support checks reuse that result; repaired tails and source-only blocks reparse, while document-wide references retain their complete parse context. Append-only boundary lookback preserves unchanged block identities. The [single-paragraph Arena](../research/2183-single-paragraph-arena.md) measures the CPU benefit separately from native drawing limits; this cache owns no conversation state. - -The local Android Expo `StreamReveal` module decorates that existing React Native paragraph with bounded appearance-only spans and a native `Choreographer` clock. Parsing, text, line layout, selection and accessibility retain their existing owners; no React update or text setter runs on animation frames. The shared reveal budget currently limits animation to 48 graphemes, with overflow visible immediately. Corrections, reduced motion and detach settle spans and remove callbacks. Older binaries without the module keep the JavaScript fade, and installing the native module requires a rebuild. The [matched Release Arena](../research/2028-stream-arena.md) records the selected implementation and its performance limits. While an active Android paragraph exceeds two screen heights, the wrapper can draw the same untransformed TextView into a screen-bounded software bitmap; this reduces repeated hardware text recording without changing the child's layout or text. The bitmap is a disposable rendering cache, released when normal drawing resumes or the wrapper detaches. Returning to normal composition invalidates the child's hardware display list. Transformed, translucent or otherwise unsupported child composition retains the normal view path. - -The Expo client keeps profile read caching and reply presentation separate from Host authority. One native drawer stack owns the handle and nested settings/history/edit routes; editor components reuse their Host command ports without creating another modal. Profile usage exposes the same reported lower-bound total and partial marker as global usage, while the underlying strict usage query keeps unknown totals distinct. A bounded TanStack Query resource is scoped to each authenticated Host client and Bot/DM identity; profile navigation opens the native shell before data arrives, reuses successful sections during refresh and retains them on partial failure. Conversation publishes stable timeline snapshots and separately subscribed live draft bodies. A disposable conversation-level playback owner retains a separate grapheme cursor and input reserve, controls semantic release cadence, and carries the same row through draft-to-commit draining without changing canonical messages. Its ticks notify only the active bubble; historical messages render directly. Reading history holds the visual cursor until a completed return gesture or explicit jump. Covered Chats views detach roster/Inbox view subscriptions and catch up on focus while the shared transports keep receiving events. A device-local Streaming / Complete bubbles preference controls whether draft playback is shown. Android renders supported Markdown as stable, memoized source blocks using synchronous React Native text/layout, with enriched-renderer fallback for images and native-only extensions; iOS retains its existing renderer. Neither cache nor preference changes Channel persistence, draft ingestion or model execution. Native text visibility and frame timing require their own device evidence; see [ADR-0219](../adr/0219-mobile-presentation-keeps-caches-and-stream-buffers-local.md). - -### Mobile chat backgrounds (#2118) - -The Expo app owns a device-local default and optional saved-Host-ID/Channel-ID overrides, separate from Host appearance and PersonaBot Profile Banner. Its transactional editor commits versioned local metadata before publishing a new effective choice. Normalized owned JPEG basenames rebase under the current document container; image reclamation protects committed references, live drafts and in-flight imports. Unreadable metadata falls back safely without collecting images. One stable canvas/image/theme-veil layer sits outside scrolling and keyboard-responsive content. This introduces no Host API, model action, Memory or message authority; see [ADR-0228](../adr/0228-mobile-chat-backgrounds-are-transactional-device-local-presentation.md). - -### Optional mobile statistics (#1935) - -The application-defined Mobile Telemetry owner observes the existing Expo saved-Host, Host client and Conversation boundaries. Pairing success follows redemption and saved-Host commit; connection readiness follows authenticated live Roster loading; first-chat acceptance follows Host acceptance or reconciliation of the same outgoing message. Bot-message observation requires a subsequent new committed entry in the foreground conversation. PushSession retains notification authority and gains no telemetry in this slice. - -A local default-on preference preserves saved opt-outs. The switch lives at the bottom of Settings with collapsed details. A once-per-phone informational notice mirrors the Web client, with privacy/source/settings links and a persisted dismissal; collection does not wait for acknowledgment. The owner persists the preference, notice-seen marker, a random Mobile Install ID, three activation markers and a sanitized bounded queue; it never receives Host/Channel/message identifiers or content. One lazy, memory-only official React Native SDK adapter transports the approved events through the existing first-party proxy, with automatic collection and remote configuration disabled. No new Host API, persistence domain or RPC interceptor is introduced. A 30-second connection observation deadline never terminates Host reconnection. See [ADR-0214](../adr/0214-mobile-statistics-use-explicit-consent-and-an-independent-install-id.md) and [privacy/qualification runbook](../mobile-statistics.md). - -手机统计新安装默认开启,保留已有关闭选择,开关独立于 Host 并位于设置最底部,说明默认折叠。首次说明沿用 Web 客户端:可查看隐私、源码和设置,点击知道了后记住已读状态;这是信息告知,不是采集门禁或条款同意。生产资格仍需独立验收。已有配对、Host client、Conversation 和 PushSession 保留产品权威。统计只接收粗粒度结果;关闭后清除本地标识与待发队列,不能撤回服务端已收到的事件。生产配置需真机、PostHog 收数、公开隐私说明和 Human QA 分别验收,不能以自动化测试或 CI 替代。 - -### DSH 原生会话列表的 Bot 文件夹(#1997) - -首个 tracer 增加统一的 DeepSeekBot 产品分组,下面按 Bot/角色建立子组,以显式 PersonaBot ownership 收纳 Orchestrator 与 Assignment 根 Session,与 cwd 无关。两级标题复用 Channel sidebar 的 PanelHeader,并各自保存 Client 本地折叠偏好。现有 botSessionIds 查询在旧 ID 列表之外返回分组元数据;标题、执行状态和导航继续使用原生 Session 事实及 UiWorkspace。分组不修改 Workspace 成员、Grant 或 Session header。RC2 没有附加分组 Slot,因此有界的应用适配层在原生树中增加自有 portal 挂载点,并仅在挂载期间遮蔽重复行;找不到结构时保留原生列表。原生搜索仍独立工作;新增分组行中的原生菜单留待首个 tracer 的 Human 反馈后扩展。 - -### Searchable Bot Settings - -The application-defined Bot Settings modal keeps a single active settings Slot mounted. Its localized search index resolves only currently registered pages, then selects the page and focuses the matching settings row or section; searching never mounts hidden providers or starts their requests. Appearance contains a nested layout Slot so the live workspace retains ownership of gap/reset state. DeepSeekBot appearance is an application-defined Client-local authority: independent light/dark/system selection, separate per-mode palettes and Primary overrides persist per browser/site or Desktop application, without a Host key. Its disposable palette scope covers only owned roots and modal/menu surfaces. Per-mode global Header/Body material overrides resolve against one of four presets, painting Dock header/content surfaces with background alpha and backdrop blur while keeping message/control surfaces independent. Stable functional Panel overrides are stored per mode and edited only from Header context menus, with per-property global inheritance and target-scoped reset. An authenticated, ephemeral Host delivery lease lets the CLI address one online Client and use the same local owner; it stores no preference mirror. Language remains a DSH-native preference through `locale.setLocale`. See [ADR-0236](../adr/0236-deepseekbot-appearance-is-client-local-and-scoped.md). Notifications and Keyboard shortcuts are first-level pages. The shortcuts page reuses existing Bot bindings and the DSH-native Panel command catalog/edit protocol, including revision checks, conflicts and native recording interception. The General tutorial describes the current Panel workspace and retains the narrow-layout fallback. - -### Channel workspace split topology (#2076) - -The Client owns a per-Channel binary split tree inside Messages. A leaf is message content or one registered Channel subpanel; a branch stores its axis and first-child fraction. Hidden/unavailable leaves collapse only in the rendered projection, leaving the saved tree intact. Bot-only availability and shortcut dispatch still use the existing ChannelDock owner. No Host capability, data authority, or execution permission changes. - -Version 7 retains the old per-panel open/hidden-control records and migrates legacy side/fraction geometry lazily into an equivalent tree. It backs up the prior raw record before upgrade. Explicit dragging splits the destination leaf; resizing only updates the selected branch. Stable keyed portals render into leaf-owned DOM containers, which move between layout slots without remounting content. Width changes never rewrite topology. See ADR-0222. - -## DSH compatibility admission - -For the 1.4.0 candidate after the 0.2.1 API migration, application-defined product packaging declares `>=0.2.0-rc.2 <0.3.0-0` in first-party DSH engines. Host-provided peers follow that policy and additionally name the qualified 0.2.1 prerelease tuple for npm default SemVer matching. The DSH-native Bundle/Profile preflight owns admission and includes prereleases; no application exemption bypass is introduced. Exact development and native-patch pins remain reproducible, while `productDshVersions` enumerates only real versions selected for artifact qualification. The application-defined compatibility adapter chooses native legacy or activation-based Subagent APIs before execution, keeps native delivery/cancellation ownership, and validates Owner identity using that Host’s trusted invocation. Native shell Plugins come from the running Host; the bundled scoped file Plugin retains its read_image fix and adapts the older Session cwd locally when the workingDirectory service is absent. The preset uses the common native baseline and the native ComputerUseRegistry child Plugin owned by the product Computer Plugin; its pinned implementation is identical across both qualified releases, without a second incompatible native-package installation. Forward admission does not establish future runtime compatibility. See [ADR-0087's current correction](../adr/0087-dsh-compatibility-is-a-semver-range-with-a-verified-floor.md) and the [1.4.0 qualification plan](../agents/dsh-1.4-release-qualification.md). - -### Mobile cloud service sessions (T1 UAT) - -The phone can enter **Cloud services** without a paired Host. Its application-defined `CloudClient` owns trusted public configuration, the live PKCE transaction and separately stored cloud credentials; `HostSession` continues to own local Host pairing. Strictly validated public JSON from a QR or explicit paste is reviewed by the Human, compared with credential-free discovery, then pinned. System-browser WorkOS login and the service's verified `/api/me` establish instance admission. SecureStore rotation precedes readback; local-only logout is visibly distinct from remote-confirmed revocation. Cold OAuth callbacks cannot resume a transaction. See [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md) and [native UAT gates](../agents/tob-mobile-uat.md). Cloud workspaces use the same client owner: create sends a stable request ID saved before the network call, selection rotates the independent WorkOS session for the target organization, and the service verifies current membership. The phone displays owner/member from the verified response; only owner can submit versioned description updates. Workspace settings live in TOB Org DO storage, not the local Host. With TOB authenticated-user policy enabled, the client accepts explicit admin or user roles and retries server-owned default-workspace onboarding without granting instance administration. An ordinary user can create and select additional workspaces; instance admission and current organization membership remain separate checks. UAT Simulator ordinary-user creation, CLI agreement, restart/refresh and logout isolation are recorded in the multi-user evidence; the next HTTP channel slice adds owner creation, organization-wide channel discovery and durable text through the same CloudClient. Its pending operation is bound to identity, organization and channel before I/O, then retried with the same key after uncertain failure. Channel SQLite remains TOB-owned; The next invitation slice uses this same owner: owner send/list/revoke and verified-recipient inbox/accept, with a user/org/email-bound pending operation persisted before issuance. Accepted members explicitly select the workspace through the existing token refresh; invitation IDs grant no authority. WorkOS owns membership activation and TOB Org DO journals issuance and first active observation for new-member history. Direct channel guests use a separate inbox/channel entry in the same CloudClient; acceptance does not select or join the host organization. Channel DO owns the user-bound grant and exact message-sequence floor. Guest sends persist a distinct channel-bound retry scope. The initial path targets existing verified users without email-delivery claims. Its native/UAT qualification, Agents, WebSocket and physical-device qualification remain separate gates. - -Mobile cloud Agent management uses the TOB channel authority independently of Host pairing. The existing SecureStore vault retains one explicit credential handoff; public snapshots omit its secret. Copy revalidates owner rights, and revoke removes remote access. This does not grant local Bot work authority; see ADR-0229. Members can request a fixed health check through the same CloudClient. SecureStore retains the exact target, deadline and operation before transmission; restart retry cannot extend authority. TOB Channel DO stores the canonical request/result, while the Agent operator separately grants local work. Local retry deletion does not cancel an accepted request. The current phone path reloads history explicitly; Host forwarding and WebSocket remain separate increments. - -### Cloud Agent Host connections (#2155) - -A channel-scoped TOB Agent can be explicitly imported into a Host and bound through the existing Messaging account/Bot surface. `CloudConnections` keeps public identity, policy and cursor in the Operational Database; the DSH native Credentials Provider alone stores the secret. Discovery precedes bearer transmission, and public identity qualification precedes activation. `botharness/tob` consumes bounded HTTP history into the existing Source Event/Admission/Inbox; it preserves remote Agent authorship and original-Human work provenance. Current cloud work policy is rechecked at Messaging admission and source consumption, above ordinary-message and bridge wake settings; context sharing does not transfer execution authority. Canonical commit precedes the transport checkpoint, so interrupted reads replay rather than skip. Existing Outbox operation IDs supply cloud send idempotency. This application-defined connector is separate from the protected local steward Tob. The application-defined `CloudWork` owner and generation-101 intent/result ledger expose the same bounded health operation through an Agent-scoped native Tool and authenticated direct CLI. Canonical source/current Human policy supply authority; durable execution claims prevent silent reruns and a fixed one-hop forwarding intent prevents execute fallback after uncertain delegation. Transport, checkpoint replay and fixed model-mediated A-to-B health work are qualified on two UAT Hosts, including both Host restarts with no repeat execution. A fresh mobile-to-two-Host run and WebSocket remain separate gates; see [ADR-0230](../adr/0230-cloud-agent-connections-use-native-credentials-and-messaging.md). - -### Mobile workspace directory (#2161) - -The root shell renders the existing drawer once across computer and cloud screens. HostSession retains paired-computer authority; CloudClient retains cloud configuration and sessions. An in-memory, paginated directory combines verified organization memberships with organization identity obtained from authorized channel-guest listings. Membership wins per instance/organization. The directory is presentation only: guest selection calls filtered guest routes and cannot acquire organization membership or sibling channels. Opening refreshes current grants; closing invalidates old responses. See the unified-navigation extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). Source tests do not replace native switch/revoke/large-text acceptance. - -### Mobile mixed conversation entry (#2179) - -The application-defined mobile conversation directory combines the selected Host roster and the authenticated cloud account's paginated organization/guest Channel listings. It is a client projection: Computer messages remain Host-owned; cloud messages remain TOB-owned. Cloud directory reads borrow and restore organization token scope under the existing CloudClient lock without changing the selected workspace. Account/config changes discard late directory results. Host routes bind a Host ID and cloud routes bind an account/config scope, refusing an old destination after a source change. Host caches and unsent text are source-namespaced; offline Computer sends are disabled and text drafts have no automatic retry transport. This first slice retains one active Host; parallel Hosts and Web/Desktop follow under #2180. Cloud server read/pin/recent-message summaries follow under #2181, and ordinary cloud wake policy under #2182. Source checks are not native/UAT qualification. - -### Mobile Chats environment paging (#2194) - -Chats shares its persisted device-local conversation filter with Activity. The native source pager derives tabs from verified Organization listings, including empty memberships and authorized guest labels, in enrolled-source order. Source/account-qualified keys preserve identity while swiping; tab selection does not switch Host management authority. Unsupported Cloud Activity has an explicit notice and no borrowed Computer badge; All discloses partial Computer-only coverage. This first slice retains one active Computer. See the Chats environment extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). - -### Browser Remote Hosts and Host Feed (#2500) - -Remote Bot Browser and Computer viewers reuse `apps/mobile/src/host/browser-viewer.ts` and `computer-viewer.ts`. The Web adapter owns one mounted viewer against the selected Host client, projects its state through `useSyncExternalStore`, and uses the existing viewer title bar and design-system controls. Frame polling and input use device-authenticated HTTP; the Host Feed mux retains the one-time stream-ticket handshake. Closing or disconnecting aborts polls and input, waits for pending takeover requests, then releases control on the original Host. Replacement viewers wait for that cleanup. Inputs belong to one explicit control generation: resuming the Bot, losing control or losing frames invalidates that generation, even while the Host Feed remains connected. Recovered frames remain read-only until another explicit takeover. Shared Browser/Computer frame loops discard the cursor after a failed frame request, or a 204 received while not live, and fetch the current frame through the existing route; unchanged screens can therefore recover without a new sequence. The Web panel localizes recovery guidance while retaining the raw notice in the viewer snapshot for diagnostics. Removing a Host first closes its viewers and forgets the local entry, then waits up to five seconds for pending takeover/release cleanup before revoking the pairing; an unresponsive Host does not block removal indefinitely, but cleanup after that deadline may require the Host operator to resume Browser. The Computer remains the profile's shared desktop, while Browser scope includes the Bot slug. No Remote Host full-client fallback or new Host route is introduced. - -The application-defined browser `RemoteHosts` owner keeps its pairings and selected Host tab in device-local storage. Each Remote Host reuses the phone's Host client and conversation module through the device gateway; This Host never relays the Remote Host's credential. The shared `createInbox(client, { activity: true })` supplies each Remote Host's Human Inbox and unread snapshot. The first browser subscriber starts the client and Inbox, and the last stops them while retaining the last Inbox snapshot. Authenticated avatar fetches publish an immutable URL-to-object-URL map; removal, roster replacement and final unsubscribe release obsolete objects and discard late fetch results. The storage-key rename imports existing browser pairings and the selected tab. See [ADR-0242](../adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md) and [ADR-0243](../adr/0243-every-client-reads-every-host-through-one-host-feed.md). - -Remote conversation presentation retains the source Channel Journal entry through the shared conversation and message-display modules, then uses the existing Web message body and request cards. A conversation-scoped adapter supplies the source Host client and validates the active Host/Channel before decisions. Attachment reads resolve through that client's `resource(path)` and device credential, with no local-store or local-URL fallback; changing conversations aborts reads and releases object URLs. Activity and conversation cards share `createHostRequestActions`, with request references from the Inbox and Journal respectively. Only a Host-accepted decision completes a submission; stale decisions trigger the existing cards' status reload. Decisions reuse the shared attention actions and remain subject to the source Host's ownership and permission checks. - -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 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. - -### Version-bound Memory image previews (#2214, first Web slice) - -The application-defined Memory Service exposes bounded PNG representations alongside its existing current-file and Git-diff queries. Metadata identifies worktree/index/exact reachable commit plus a SHA-256 byte revision; authenticated image transfer rechecks that owner, repository selector, path and revision before returning `image/png` with no-store semantics. It does not accept arbitrary blob IDs, revision expressions or Host paths. Current reads retain path containment, control-directory exclusion and safe-open checks; Git reads accept regular-file blobs only. A changed mutable source returns an explicit stale refusal rather than current bytes under an old version label. - -The Web reader and shared diff renderer display PNGs, with before/after panels that stack on narrow screens and reuse the existing image gallery for enlargement. Staged compares HEAD/index, unstaged index/worktree, current HEAD/worktree, and historical commits their first parent. Image bytes stay outside text/JSON diffs and are requested lazily by the browser. `memory-image` exports the same representation through the authenticated Host to a new file, with a JSON receipt. The preview budget is 25 MiB, 16,384 pixels per axis and 40 million pixels; existing text and download budgets remain separate. The second Web slice below adds Markdown rendering; other local image formats, editor/file-card previews and mobile remain in #2214. - -### Web Memory Markdown previews (#2214, second tracer) - -The application-defined Memory Service returns bounded full Markdown documents alongside existing current-file and working/commit diff results. Git remains the durable authority. A document descriptor carries its selected `source`, SHA-256 `revision`, status and original UTF-8 body. The shared safe-open / regular-Git-blob reader preserves containment, reachable-commit, mutable-read and byte limits. Each document is bounded at 4 MiB; commit queries share a 4 MiB budget across document bodies. Existing line-diff and image limits remain independent. - -Web readers render `.md` and `.markdown` by default with a source toggle. Working and historical diffs default to lines and offer labeled rendered before/after documents, stacking on narrow Web viewports. The document renderer uses bundled `react-markdown` / `remark-gfm` because the pinned native Markdown component cannot override HTTP(S) image loading before its request; it keeps DSH tokens and the existing image gallery. Raw HTML remains literal text. Local file links stay inert; ordinary external HTTP(S) links use browser navigation. - -Visible inline local PNGs use the existing authenticated Connection Fetch image route with the document path/source/revision and authored `reference`. The owning Memory query resolves a directory-relative or repository-root path, validates the document revision before and after image reading, and applies the same bounded PNG checks as standalone previews. The Client receives a temporary Blob URL and aborts/releases it on selection changes or source mode. Index and commit references read their actual Git blobs; missing historical images never substitute current files. Mutable images are observations of the selected source at load time; refresh reloads their dependencies. - -External HTTPS images require a per-image click and load directly in the browser with anonymous cross-origin access and no referrer; CORS/network restrictions may refuse them. These are live external references, including in historical documents. No Host proxy, new durable store, SessionEvent or preview-triggered Bot wake is introduced. The existing `memory-image` CLI offers the identical local-image query through `--image-reference`. Other local formats, editor and message-file previews, and native mobile remain later slices. - -## 原生 Panel Plugin 开发目标(ADR-0234) - -首片实现入口见 [Panel Plugin 开发指南](../panel-plugin.md):独立 VRM Bundle 消费 UI 提供的公开 `dockPanels` / `botPresentation` Service;Activity 仍由现有 ClientStore 投影维护,已提交 DM 输出由按 Bot 共享的文字时钟呈现,固定实例元数据扩展现有布局文档。 - -[ADR-0234](../adr/0234-panels-are-native-plugins-with-independent-binding.md) 记录已确认目标;以下为整体目标,已实现的 T1 范围以首片指南与验收记录为准。第一方与独立作者的面板复用原生 Plugin/Fiber 和 Bundle/Profile 组合。领域 owner 的 Host Service 与具名实时 Cordis Event 独立于面板显隐;浏览器 Consumer 沿用认证后的 Typert/API Gateway 与支持的 stream。状态型展示需恢复当前状态,首版不建设通用离线事件补发。后续 Memory 观测要覆盖无对话时的外部工作树变化,保留既有 Observation/Admission owner,并与 Git commit 区分。 - -实现、实例、绑定与工作区位置分开。普通实现允许多个实例,重复安装不重复创建。消息面板内的 Bot-bound Panel 跟随 PersonaBot 私聊;群聊不显示内容和打开入口,也不增加成员选择器。显式绑定的全局工作区实例保留并清楚标明所选 Bot/Channel;Group-channel-bound 复用 Channel 绑定并限制为群聊。现有频道子面板不因此获得跨工作区拖放,绑定不授予数据访问或代码隔离。 - -必需系统角色保留其唯一性、已注册的第一方兜底和独立恢复入口。替换优先复用原生 Slots 的优先级与有界渲染错误退让;这不等于 HMR 回滚,也不是现有应用 Registry 的重复 id 拒绝已经具备的能力。 - -首个纵向切片 [T1 #2222](https://github.com/BotHarness/DeepSeekBot/issues/2222) 为使用 Three.js/three-vrm 的独立 **VRM+Activity+说话表现面板**。动作消费已有 Activity 事实;说话表现独立消费真实、已提交的 Bot 输出,允许边工作边说话。首版使用文字节奏驱动表情、嘴型与轻手势,不要求音素级口型或新增 TTS。验收原生安装、两个 Bot 绑定、群聊无入口、当前状态与实时更新、重连不重播旧话或重复发声、减少动态效果、资产/渲染失败降级及 GPU 资源释放。此方向替代仅复用旧角色 renderer 的提议,不要求整体迁移浮窗交互或构建生活模拟世界。 - -[T2 #2223](https://github.com/BotHarness/DeepSeekBot/issues/2223) 将办公室切片改为无 Bot/Channel 绑定的 **全局 Task Kanban**,管理当前 Profile 内共享的 Task。Human 卡片操作与 Bot 查询/创建/更新工具使用同一授权 Service、持久记录和提交后通知;关闭面板不影响工具工作。演进 #1792 已有 to-do owner 并兼容旧记录,不增加第二份存储。Task 状态与进度独立于 Session 内计划和 Assignment 执行,创建、移动、完成或重开卡片不自动启动执行。验收重启/重开一致性、显式并发冲突及直接 CLI/工具 parity。 - -[Spec #2219](https://github.com/BotHarness/DeepSeekBot/issues/2219)、T1–T7、[T8–T15 迁移父工单 #2234](https://github.com/BotHarness/DeepSeekBot/issues/2234) 及其子工单统一规划在 [Panel Plugins Project #21](https://github.com/orgs/BotHarness/projects/21) 的 v2.0。T1 已进入首片实现与隔离 Host 验证;Human 验收尚待反馈,仍是扩展门槛。交付状态与依赖以工单为准:T2–T6 依赖 T1,T7 依赖 T6,迁移父票与 T8–T15 各自依赖 T1。迁移与 T2–T7 同属 T1 后的扩展分支,子票不等待协调父票完成,也不隐式等待 T7;并与 #1791/#1792 作者能力、#2096 布局 CLI 协调。 - -T4 保留 ADR-0220 的 Sessions 范围:DM 中为 Bot 所有的 Sessions,Group 默认按原始 Source Event 查看 Assignments,并保留已有显式成员 Bot 筛选。群聊不显示 Bot-bound 面板不等于移除该筛选,也不改变 Session ownership。T5 的 Members 在 DM 消息内隐藏,但全局固定 Group 实例不受当前聊天切换影响。Panel Consumer 释放与卸载领域 Provider/整个 Bundle 分开;后者遵循原生生命周期,能力缺失须明确显示不可用。 - -Nested appearance surfaces use the same Client-local owner and functional `dock:` identities as top-level panels. Memory detail views share one identity, while file lists and evolution remain separate. Direct surface paint preserves global inheritance independently of parent customization; real registered Plugin contributions receive the generic wrapper without new domain or layout authority (ADR-0236, #2261). - -DeepSeekBot's image wallpaper has a separate Client-local media owner and one atomic IndexedDB Blob/crop record. One disposable layer spans the enabled workspace behind the sidebar and panels; original bytes stay on the current browser/site or Desktop application. The CLI transfers bounded chunks to one explicitly selected online Client and commits through that same owner, while the Host remains a transient transport (ADR-0236, #2262). - -Video wallpaper extends the same local media record with an original video and generated poster. A disposable Client playback controller observes focus/visibility, reports actual playing/paused/poster/inactive state to the same owner, and unloads sources on exit or replacement. Failure displays the persisted poster without changing media authority or DSH preferences (ADR-0236, #2263). - -Appearance recovery composes the existing local preference and media owners without a third store. Current-mode clear affects only its functional Panel map. Explicit full reset preflights both revisions, saves defaults, then deletes the atomic media record; partial media failure is reported and retryable rather than presented as a cross-store transaction. A transient resource generation invalidates previews on accepted media changes, including removal from an already-empty store (ADR-0236, #2264). - -### Desktop whole-window zoom - -[ADR-0237](../adr/0237-desktop-window-zoom-is-client-owned.md) defines an application-owned Client -zoom Service on the stable desktop origin. Settings, native Shortcut Registry commands and an -explicitly addressed CLI share its versioned local default/current-primary-window document; -the authenticated Host route is an ephemeral delivery relay with no preference copy. A pinned, -MIT-preserving downstream theme composition leases only the original font-shortcut Fiber, -then returns it on disposal. The product build carries that licensed theme and a portable ESM -schema module. Bundle patches retain the original `ui-theme` row and canonical Settings -namespace. An explicit `deepseekbot-zoom-setup enable` command installs one Profile-local -package link through the selected Desktop's bundled package manager and requires a restart. -The macOS path and Windows candidate require Desktop 0.2.1-rc.2, refusing other overrides. -Windows reads executable FileVersion, checks live processes with CIM, and validates owned -junctions under the same Profile writer lock. The Client checks a matching platform lease -before registering the same native Shortcut commands; Ctrl+0 restores the saved default. -`disable` removes only this dependency and its exact owned link, preserving font preferences; -run it before product removal. Installing the Bundle alone does not activate compatibility. -Windows native qualification remains in #2270; Linux remains disabled. -It does not edit the installed desktop shell. Ordinary Web zoom is unchanged. Native-input, -packaged-install and Human QA evidence remain separate from source coverage; numberpad, -broader reliability and other desktop platforms are tracked in #2269/#2270. - -Bot-mode Dockview content overlays and splitter movement convert viewport coordinates to local CSS units through the repository-pinned Dockview compatibility patch. The patch is bundled in the Client; it changes no DSH executable and preserves persistent panel instances and the existing layout store. The macOS workspace corner separately retains the native 88×40 viewport-pixel traffic-light footprint and converts measured group intersections into local header padding; zoom, layout and fullscreen updates do not change that physical footprint. - -Bot-owned choice controls use the shared `ds/Select` → `PointerMenu` geometry owner under -window zoom. It keeps the native Menu renderer and keyboard owner inside the owning Modal, -converts viewport anchor coordinates through effective CSS zoom, clamps to the safe viewport -and returns focus after dismissal. No second shortcut router or Panel state is introduced. +# BotHarness 架构与数据流 + +#2080 第一条切片让 Web 请求卡与在线 CLI 共用 application-defined Workspace 请求批准命令。Grant owner 在 Channel owner 的现有提交事务内写入读写权限,规范决议与 Inbox Admission 同时提交;成功读回后再沿原运行时处理原任务。新目录与旧只读升级均需 Human 明确确认,不由 Client 串联多个独立成功动作。Orchestrator 与 Assignment 的原生文件写入都检查当前 Grant 写权限;Assignment 仍只使用其原 Grant 和单 cwd。普通目录设置、移动端交互和完整重启恢复矩阵仍按后续切片交付,不能从本次 Web/CLI 路径推定已完成。见 [ADR-0238](../adr/0238-workspace-request-approval-commits-write-authority-with-the-channel-decision.md)。 + +#2053 的本地 Group 发送复核试点由 Channel owner 在正式 Source Event / placement 追加的同一事务内比较实际观察依据。 +#2054 复用该 owner:held 标出实际返回的同伴正式消息;Bot 可引用这些消息显式让话,或提交独立补充。Host 只核对引用是否真实观察且仍有效,不判断语义重复,也不自动结清 Inbox;让话依据随现有草稿 JSON 保存。 +待复核草稿保存在现有 Operational Database,仅正式提交进入 Channel/Inbox;仅 drop 不解决 Inbox;显式 `resolve_inbox_ids` 可在 drop 成功后结清所选事项,过期或重启不自动恢复。 +原始依据与修订草稿归同一 owner,purge 清除派生内容。默认关闭,按群 opt-in;CLI 与模型使用同一契约。 +#2056 的显式恢复复用该记录:`review_action: inspect` 只返回当前授权下的有界元数据;重启后通过 `recover` 携带新观察与修正正文,保留原过期时间与预算。成功提交在同一事务留下消息 ID/意图指纹回执并清空草稿正文;重放只确认规范消息,不新增 Inbox 或恢复旧 Tool Promise。权限、来源与附件检查先于回执读回,详见 [ADR-0232](../adr/0232-group-recovery-retains-authorized-commit-receipts.md);本票真实模型验收仍在进行。 +组合两票时,复核记录保存观察所属 Host,避免跨重启比较进程内序号;新观察仍须覆盖全部必读消息并通过当前内容/附件指纹检查。恢复不重置版本、额度或已用的显式继续机会。显式继续与普通发送共享同一原子提交回执。 +重启重新准入本地群任务前,Host 复查当前成员资格;已撤权且未开始副作用的可重试 Admission 记为 Host 策略忽略,保留原因,不伪造 Bot 观察或完成结论。不确定副作用继续待修复。新的 Human DM 进入独立授权回合,原撤权任务的跨 Channel 发送 fence 保持有效。 +附件 owner 的有界当前内容摘要参与观察指纹,等长文件修改也会触发重新复核;摘要不进入公开消息,不改变附件身份,也不锁住外部文件写入。 +held 工具结果复用 Channel 的有界模型读取投影直接返回新增正文与显式观察凭据;仅完整返回的内容计入观察,省略内容仍需读取,后续提交仍在同一事务重查。其持久边界和保守取舍见 [ADR-0227](../adr/0227-group-send-review-shares-the-channel-commit.md),效果与 Human 验收仍由试点评估决定。 + +忙群最多进行两次复核,耗尽后明确 drop、既有 Inbox snooze 或一次显式继续申请(freshness_override)。显式继续仍原子核对当前消息;出现未读变化则拒绝本次申请,保留责任并延后,不能跳过新纠正。 +Override 绑定 owner、原 Group、当前草稿版本与实际观察;只越过自动复核次数上限,不越过当前消息检查,保留停止、权限、过期与 purge fence。 +耗尽预算时的 `suggestedSnoozeUntil` 是 Host 时间之后 30 秒的延后建议,复用现有 Inbox 调度,不自动安排唤醒;Human 的更晚时间、等待条件与取消约束仍优先。 +选择理由、观察与提交 ID 留在原草稿记录;不新增 wake scheduler 或公开系统消息。见 [ADR-0231](../adr/0231-busy-group-review-has-an-explicit-progress-boundary.md)。 + +Group composer 的 `/workspace` 和工作区 chip 复用 DM 的目录选择器与授权确认组件。默认选中当前未暂停、未删除的全部 Bot;Human 可以取消勾选,只授权指定几个。Group 只是逐 Bot 授权的入口,不持有权限;预览固定确认目标,后来加入的 Bot 不自动继承 Grant。共享目录预览相对于权限是只读的:native Workspace controller 可以登记、解析一个 DSH Workspace,但确认前不会建立任何 Workspace Grant。确认展示每个 Bot、canonical 目录、既有 Assignment mode 和 Orchestrator 写入状态;这些权限设置保持不变。逐 Bot 调用既有 `grantCreate`,可选的 application-defined Group scope 仅约束当前 Channel 成员与确认目录,并在异步 Workspace status 检查后、没有后续 await 的实际写入边界再次校验。SQLite `workspace_grants` 仍是唯一授权权威;无 Channel Workspace Binding、Group Grant 或统一 cwd。成功、有效 Grant 复用、拒绝和未知结果分别展示;失败/未知成员重试前通过同一个 `grants` readback 验证成员、撤销状态与当前目录,读回失败不发起写入。原侧栏与 chip 使用既有逐 Bot 变更通知刷新,正常委派仍通过原 Channel、Orchestrator 与 Assignment seam(#1433)。 + +#1386 已在 Human 授权的隔离 Linux Profile 中复验既有 QA2 PersonaBot:真实 Lark → Owner Web 多字段输入 → 既有审批 → 固定 CLI/MCP 引用认证 → 原私聊回帖;普通容器 CLI/MCP、真实 Spill、跨 Bot 拒绝、轮换/撤销及停止活跃使用、重启与自然到期 Schedule 均保留 native/canonical 证据。模型 wire 正文及运行记录扫描未发现私密 canary;结论限于记录的实际安装版本和两个固定消费者,不推导任意 CLI/MCP。Linux 接收器已停止并恢复原 Windows QA2,正常回帖已核对。当前仍为 opt-in,Linux 工作区文件权限需显式准备。详见[复验记录](https://github.com/BotHarness/DeepSeekBot/blob/main/docs/research/2026-10-12-private-input-linux-qualification.md)与[双语 Linux 指南](https://github.com/BotHarness/DeepSeekBot/blob/main/docs/dev/guides/private-input-linux.md)。 + +#1385 让当前已授权 IM Source Event 发起同一个 Owner Web Chat 私密卡;Host 经既有 Messaging Outbox 向原会话投递仅含 Chat/卡片定位 ID 的 Web 链接,实际 Owner Peer 仍是唯一提交 authority。进程内 source/reply lease 关联真实 Session 当前轮次、Bot 创建身份及 Binding,来源变化过期卡片并停止使用,账号替换/撤销禁止迟到结果投递;最终回复在 native Provider 开始前重新验证。受保护 Profile 仅放行实际注册的四个非私密 Messaging Tool Definitions,不增加第二个 receiver、持久表或 Pairing 策略。独立 Lark QA2 私聊已验证真实 Bot、多字段 Web 输入、现有允许一次、引用 CLI 认证 HTTP 200 与原私聊回帖;其余路径不从该结果推断。详见 [ADR-0172](../adr/0172-im-private-input-keeps-owner-web-authority-and-source-reply-fences.md) 与[双语可运行指南](../dev/guides/private-input-im.md)。 + +#1384 新增 application-defined ProtectedMcp Consumer,复用 native MCP Tool 适配器及官方 SDK stdio Client。模型与既有审批仅携带账号及字段引用;同一个 PrivateInputs owner 核对真实 Bot、当前版本和 Channel configured proof 后,才在私有 MCP tools/call 管道内提供原值。固定 Host 进程只执行一次认证 GET,丢弃正文、头部和错误,Host 在 native 结果持久化前只接受有界状态投影。普通 MCP 保留 native Client Plugin 与隔离 Linux 工作区;任意第三方 MCP 不自动获得凭据。详见 [ADR-0170](../adr/0170-private-mcp-consumption-uses-fixed-server-and-bounded-projection.md)。 + +#1383 复用既有 application-defined Bot Schedule → Source Event → Bot Inbox → Orchestrator 路径。Schedule 只保存引用;重启后仍由 native Credentials 及当前账号绑定核验真实消费,既有执行与 Channel 发送授权分别生效。受保护 Profile 允许 owning Orchestrator 使用四个 `bot_schedule_*` 管理工具。新建私密输入卡还需 Host 当前轮次的真实 Human Source Event;旧 Session 历史、目的 Channel 或模型自述不能提供此证明。无人值守缺少有效配置时立即返回安全 Owner-action 错误,不创建 waiter;详见 [ADR-0169](../adr/0169-private-input-interaction-follows-current-turn-source-events.md)。 + +#1382 在同一个 PrivateInputs owner 内加入 PersonaBot 创建身份与账号范围的 native Credentials 版本记录。Owner 从既有 Web Chat 卡片打开空白替换卡或撤销本地访问;新 grant 激活及 canonical Channel configured resolution 都成功后才返回引用。每次真实消费在账号串行队列内核对当前 UUID,登记 AbortSignal 后释放队列;替换/撤销写入新版本或持久 tombstone,并尝试停止既有使用。宽执行授权不能越过版本检查,停止结果区分已停止、已完成与不确定,401/403 只返回状态及安全修复建议。启动恢复先于 Owner 写入,撤销跨重启保留。详见 [ADR-0167](../adr/0167-private-account-versions-fence-consumption-and-revocation.md);旧程序不理解 tombstone,回滚需恢复完整升级前备份或向前修复。 + +#679 将入口收束为 Bot 模式设置左侧的紧凑图标/未读 Chip;折叠侧栏时则在 Bot 模式下方以相同尺寸对齐。展开时仅显示未读数字 badge,最多为 99+;有未读时入口常显,没有未读时仅在 Bot 模式开启时悬停或键盘聚焦才显示,与设置按钮一致;模式关闭时 hover 不显示,也不进入 Tab 顺序;折叠时入口仅在 Bot 模式开启后显示,图标右上角红点表示有未读或待行动;模式关闭时入口隐藏且不进入 Tab 顺序。无障碍名称保留完整未读数量与待行动提示。活动中心始终直接进入个人 Human Inbox;历史总览导航偏好不再改变入口(#2026)。 Web 页面与 Dock 共用三组可独立折叠的 Activity 投影,浏览器按 Host origin 保存折叠偏好;每组拥有独立分页与错误状态。标题栏搜索、排序和筛选经既有应用自定义 `humanAttention` 查询;查询版本 1 将搜索与未读过滤作用于分页前的合资格内容,并支持提及优先排序。Client 丢弃旧查询结果,首次加载与后台刷新分离,后台读保留已加载的空态或卡片。Host 在规范事务提交或原生请求 owner 变更后合并失效通知,经已有 roster RemoteSnapshotStream(HTTP SSE 兼容入口为 `human-attention/changed`)传递;消息列表与活动中心仍查询同一 Source Event、Channel placement、已读位置和请求 owner,不复制持久通知数据。Client 在订阅后读取基线,重连后重新查询,合并突发通知并补读查询期间的变更;频道列表的 latest 预览按 25ms 窗口合并为一次状态发布,保留各 Channel 最新消息与每个 Bot 的最大活动时间,退出或销毁时清除待发布队列;健康连接不轮询,仅断线时使用 10 秒恢复读取。最后一个展示面关闭后释放订阅和计时器。稍后提醒由 Host 调度最近到期时间,通知后重读规范查询。共享查询实现沿用移动端 #1954 / PR #1966 的实现。 #2231 第一切片沿用 Host Roster 的 Human Channel 免打扰偏好,在 Human Attention 消息查询的搜索、聚合、分页前排除静音 Channel(含已读保留的提及/回复);全局未读同口径,Channel 原始未读位置与明确 Human 请求不变。Web 共用 Activity feed 观察已有 Roster 状态变更,重查可见分组并丢弃旧偏好下的在途结果;全局状态同步重查,不新增连接或轮询。control rail 的独立红点范围由浏览器按 Host origin 单独保存,支持全部、待处理、个人未读、其他未读或关闭,同源窗口通过 storage 事件同步。既有 Human Attention 状态查询可请求完整分类摘要;先按 Source Event 去重再分区,不从面板首屏推算。状态失败或旧 Host 缺少分类时显示未知提示,关闭偏好始终生效;Activity 列表筛选与真实状态不受红点设置影响。 + +BotHarness 是 DSH(DeepSeek Harness)之上的插件层,给 Agent 持久产品身份:**PersonaBot**。PersonaBot 用一个 Orchestrator Session 管理 Inbox,并可同时管理多个独立 Assignment Session;Memory 是 optional capability,Persona 是其中的 optional 内容;两者都不是聊天或执行的前置依赖。DeepSeekBot 是首个应用,提供 roster、Bot Inbox、Assignment Directory、委派和 IM 接入。 + +#2290 修正私密输入的本轮自动来源判定:已接受且仍有效的本地 Human DM/Group Source Event 沿用本地 Owner 交互,混入的 IM 批次或 steering 不再强制选择 IM 来源。显式 IM 选择与账号本轮已记住的来源继续受原有 lease、歧义及撤销检查约束;历史消息不提供交互权限。详见 [ADR-0172](../adr/0172-im-private-input-keeps-owner-web-authority-and-source-reply-fences.md)。 + +#1380 的历史 opt-in 私密输入由 application-defined PrivateInputs owner 关联真实 Orchestrator、PersonaBot 创建身份、账号及多字段 Channel 卡片;Web 与 CLI 经现有 Typert/API Gateway,以真实 Owner Peer 提交原值,Host 原子写入一个 native Credentials Service grant。Channel 与模型只接收声明、状态和引用;既有执行审批先批准引用调用,固定 trusted CLI 才解析并消费原值,只投影 HTTP 状态。Human 已认可卡片交互与真实认证成功,#1380 已合并。原生 `bash` Consumer 由 application-defined LinuxShell Provider 经 native Subprocess argv 启动 digest-pinned 容器,挂载当前授权工作区并保护 `.git`;普通 MCP 保留 native MCP Client Consumer,其 SDK 管理的 stdio 进程也由受控 Docker argv 启动。容器不挂载 Host 凭据/socket、不继承 Host 环境;其他未验证的 Host Capability 仍被整个 Profile 拒绝。该 opt-in 不是默认架构;固定秘密 MCP 消费已由 #1384 交付,实际 Linux 安装验收及限制见 #1386 复验记录。详见 [ADR-0164](../adr/0164-private-input-candidate-separates-native-storage-from-qualified-execution.md) 与[可运行验证路径](../dev/guides/private-input-cli.md)。 自 ADR-0189 起,私密输入在普通 Profile 默认可用:`ask_secret`、固定的 `protected_cli` 与 `secret_http` 对每个 Orchestrator 注册,原值仍只进 native Credentials Service 并绑定发起的 PersonaBot。`secret_http` 由 Host 子进程发出请求,目标是 Owner 在卡片中填写的地址,Bot 只决定其下的路径、方法和 `{{字段}}` 占位符。凭据存储的保护有两层:Host 改写 native Sandbox Provider 返回的命令行,为每条 Shell 命令加上“不可读取该存储”的规则(Seatbelt 规则或 bwrap 遮盖),并在审批之前拒绝针对它的文件工具与点名它的命令。`danger-full-access`、Landlock 与 Windows 下只有后一层。历史 opt-in 的 `privateInputExecution` 曾采用 ADR-0164 的容器边界。上述容器 Shell、容器内 MCP 与 `privateInputExecution` 已由 [ADR-0192](../adr/0192-the-docker-protected-profile-is-removed.md) 删除:密钥输入在所有 Profile 默认可用,使用方为 `protected_cli`、`secret_http` 与 `secret_cli`,凭据存储由沙箱读取拒绝和命令文本拦截保护。 + +#1381 让每张私密卡片持有独立 live authority 与十五分钟 Host deadline;Human Inbox/Bot attention 仅投影已提交且仍有效的请求,并跳转原 Web Chat 卡片。配置、取消与过期共享一个幂等 Channel resolution ID,提交在 canonical SQLite transaction 内重新检查 live authority 后才成为最终 winner。版本二 native grant 必须具备完整且匹配的 configured resolution 才能消费,跨存储失败的孤儿 grant 不可用;启动恢复将丢失 waiter 的卡片持久标为 expired 并清理孤儿 grant,保留已配置引用。私密 resolution 不创建 Inbox Admission,不以恢复或过期唤醒停止的 Bot。Host disposal 排空 pending settlement 与启动恢复;Client 清空输入并拒绝旧 mount/迟到 pending poll 覆盖最终状态。详见 Proposed [ADR-0166](../adr/0166-private-input-settlement-commits-before-continuation.md)。 + +本文描述 #71 确认后的目标架构。M1 registry、M2 Memory 与 #66 roster storage 已实现;#77 已验证 DSH runtime seams,显式 Session ownership、Messaging、Assignment Runtime、统一 operational database 和可移植性按 #79–#81 分阶段落地。更新:2026-10-03。 + +Bot 模式首次体验由 application-defined Onboarding owner 在现有 operational database 保存 Profile receipt。五步 Desktop 引导(#2192,[ADR-0233](../adr/0233-desktop-onboarding-follows-tob-creation-receipts.md))依次为模型配置、通过受保护 Tob 的原 DM 创建 Bot、创建包含该 Bot 的群聊、面板布局教学,以及实际点击 Home 标题关闭首页并进入原 Tob 私聊。沿用 Home 与共享输入框,示例只填写草稿。可信 scoped CLI adapter 在执行前保存 operation 关联,owner 从既有 Bot/Channel 创建回执与实际成员关系恢复进度;拒绝、预留和模型文字均不算创建成功。布局教学完成记录属于当前设备,Host 只保存教程生命周期与必要结果引用。旧完成/跳过状态保持安静,旧未完成状态暂停等待主动重开;升级不重排布局、不修改快捷键或草稿。新 Bot 与 Tob 不再写入默认欢迎消息,既有消息与旧欢迎消息渲染保持兼容。旧 first-reply 证据仍保留作为历史事实,而非新版创建步骤的成功条件。 + +历史欢迎消息保留兼容显示,提供能力介绍、每日晚间问候与十分钟定时测试示例,点击后沿用正常 Human DM 请求与现有执行能力。新闻选项由 Host 在 onboarding 查询中投影当前 native 搜索配置和所选 Bot preset 的 `web_search` 可见性,只返回就绪布尔值,不保存第二份配置或凭据。固定 RC1 的已知 DeepSeek 搜索 Provider 与其独立凭据已配置时提供带真实来源链接的今日 AI 新闻;未配置、无法确认或不支持的搜索组合显示通用工作规划示例。配置就绪不保证网络请求成功,实际搜索失败、空结果和不可用必须如实说明。模型保存和问题发送是两次独立操作:单独选择模型不创建问题;因配置受阻的请求保存模型后进入单独的发送确认,关闭或恢复均不自动发送。引导模型配置复用 native model/credential Services,经 Typert/API Gateway 保存并读回 Profile 默认模型;默认勾选后当前 Bot 继承全局,取消勾选则写独立 Model Plan。新 Bot 无独立 plan 时继承全局;保留 model-plan revision 以校验返回继承的编辑。全局修改只影响之后的继承请求,不覆盖固定计划或运行中的 Assignment。模型失败后的“重试这条消息”沿用原 Human 消息与 Admission,经既有 retryability/side-effect gate 拒绝不安全重放,不再追加 Human 消息。 + +当前 Client UI 由独立 `@botharness/ui` Bundle 挂载,源码仍在 `packages/client`;RC2 的插件图把结尾 `/client` 解释为导出子路径,因此包身份依 [ADR-0066](../adr/0066-rc2-client-bundle-identity.md) 避开该后缀。Client HMR 只暂存当前 Bot/Channel 选择以恢复视图,不复制 Host 中的 PersonaBot、Channel 或消息权威。 + +产品术语以根目录 [`CONTEXT.md`](/zh/dev/design/context) 为唯一词表;[BotHarness Runtime 架构](/zh/dev/design/bot-runtime) 单独展开 PersonaBot、Bot Inbox、Orchestrator、Assignment 与 DSH execution 的关系。DSH/Cordis 本身的术语和 Plugin 开发决策位于 `/zh/dsh`,不在这里重复定义。 + +迁移阶段保持可验证:#79 建立 `botharness.db` owner;#885 的 Schema Generation 56 已将 #66 的 roster arrangement 单向迁入 owning SQLite module。旧 `botharness_roster` Storage Domain 仅作一次性输入,校验失败时 roster 只读且返回 `storage-unavailable`,不会当成空排列;成功后排列与 import marker 同一事务提交,旧源保留供恢复参考,正常运行不再打开或读写该 domain。Session ownership 保持既有数据库权威;#886 的 Schema Generation 72 增加恢复收据、逐 Bot 模型授权/激活记录与 Session 内容可用性。 + +#56 and #137 extend the current roster global slot to `{ pins, hidden?, sectionOrder, topOrder? }`: `pins` canonically orders Channel IDs for both group Channels and PersonaBot DMs, `hidden` omits Channels only from roster navigation while retaining their placement, and `topOrder` mixes section blocks with loose Channels while membership remains owned only by section records. The unary client bridge now has ten arrangement methods, including `topReorder`, `hiddenSet`, and bounded `rosterBatch` (one Host completion notice and one final Client snapshot for multi-select); #885 preserves this order, hidden presentation state, and single-membership invariant in the owning `roster_arrangement` singleton; each command mutates a transient draft and commits the complete arrangement atomically before publication, without dual writes. A missing import marker refuses edits until a valid source is imported; an already populated target without a marker is never overwritten. Sorting remains native DSH Settings, and collapse remains per-Client. 已提交的 roster mutation 会在 Host commit 后发送 `roster/changed` live invalidation;其他窗口只重读权威 roster,不接收也不复制拖拽中的预览状态。新提交的非通知 Channel 消息另以 `roster/latest`(类型化流为 `latest` 帧)推送该 Channel 的最新消息,各窗口只更新对应行的预览与时间,不为每条消息整体重读 roster(#1530)。 + +BOT mode 的折叠 rail 复用这份 Channel 排序读模型:置顶项在分隔线上方,其余 DM 与 group Channel 按 section/未分组的扁平顺序排列。`botharness/channels` 额外投影可选 `latestMessage` 供 hover 摘要使用;该字段从当前 Messaging authority 派生,不成为新的持久化权威。“最近更新”模式下,每段相邻的未分组 Channel 按最新可见消息时间排序,section 位置不变;“手动排序”保留存储的扁平顺序。客户端打开或更新某个 Channel 不会单独改变它的位置(ADR-0031 2026-10-13 更新,#1530)。 + +置顶格也是独立的排序 scope:默认继承全局最近更新/手动模式,可用 `ui-bot-mode.sortModes.pinned` 覆盖;拖动置顶卡片会把当前完整顺序(包括被搜索或隐藏过滤的置顶项)冻结为手动模式,通过 `pinsSet` 持久化,不影响 section 归属或其他 scope 的排序。折叠 rail 使用同一置顶顺序。 + +Hidden Channel 是 application-defined 的可逆 roster presentation state:它会从展开列表、pin grid、搜索与折叠 rail 中消失,但不会改动 Channel、消息、PersonaBot、Memory 或原 placement。破坏性删除仍受 ADR-0037 的 dependency report 与独立确认约束(#138)。 + +当前 Channel Chat 的实时显示遵循 ADR-0054:正式消息先写入当前持久化权威,再以该 Channel 的单调 revision 发出进程内通知。#143 的时间线读取遵循 ADR-0061:Host 的 `channelTimeline` 用不透明游标提供 latest / older / newer / around 窗口,Client 仅保留一段连续的按提交顺序排列的可见消息;SQLite Channel placement 的游标解释留在 Host。首次打开 Channel 取最新页;有上次已读锚点时,重开会在锚点附近分页并定位;向上补页维持视口锚点,定位旧消息可前后补页并高亮,并可继续向 newer 分页直至最新消息;用户离开底部后,新消息只更新未读提示,不强制滚到底,也不把实时消息拼接进尚有后续页的旧窗口。旧 `channelMessages` 端点暂作兼容。已选 Channel 通过经过 DSH 认证的 `/api/botharness/stream` SSE 接收已提交消息;重连可回放、缺口则从 Host 修复。同一连接还传送 Orchestrator 显式 `channel_send` 工具参数产生的进程内草稿预览:`channel/draft` / `channel/draft-settled` / `channel/draft-abandoned` 带 attempt ID 与独立于持久消息的进程内草稿 revision;连接时先发送完整 baseline,断档则重读 committed。草稿不从历史回放,不进入 Inbox,提交后由正式消息替换,放弃时移除;呈现层可消费草稿,任何不可逆操作只认已提交消息(ADR-0054)。Human send 使用 Client 生成的幂等 message ID;网络失败时本地气泡保留失败状态,Human 可把原文和附件恢复到 composer 后再次确认发送,而 Host 对响应丢失后的同 ID 重试只接受一次 durable append。Orchestrator 普通 final 与 Assignment 输出不等于 Channel 消息;当前正式 Channel 消息由 SQLite Source Event 与 placement 事务提交,旧 NDJSON 历史只在首次升级时单向导入。Group 中经候选列表选中的多个 Bot mention 在同一事务形成独立 Inbox Admission;Bot 处理状态经同一 SSE 流独立投影。已入群 Bot 的 channel_send 可提交稳定 ID 列表;Host 从可信 Orchestrator ownership 得到发送者,读取当前成员与名称生成 mention 标签,并在同一事务为不同目标建立独立 Admission。Bot-authored Group 消息保留因果根和跳数;同根同目标只投递一次,过限消息保留在群里但不再唤醒目标。收件 Bot 可以在同一群回复,重启后 pending/retryable Admission 仍可恢复。 Group 之外,Bot-to-Bot DM 使用确定性的双成员 Channel ID;Orchestrator 的 `bot_dm_send` 或该 DM 内的 `channel_send` 以可信 Session ownership 确定发送者。每次发送在同一事务中提交 Bot DM Source Event、placement、收件 Bot Admission(受因果根去重与跳数上限约束),以及只链接原消息的无正文 Channel Notice。Notice 放在引起这次发送的 Source Event 所在 Channel(群、Human DM 或 Bot DM),该 Channel 不可用时回到发送者 Human DM;同一事务为发送者写入一条已处理的 `bot-action` Bot Inbox 记录。若引起发送的正是这个 Bot DM 本身(两个 Bot 来回回复),消息已在原对话中可见,不再写 notice 或记录。Channel Notice 不计入 Human 未读、回执、活动统计与 Channel 预览,也不为其他成员建立 Admission(ADR-0154)。重启时恢复 pending/retryable 收件投递;Bot DM 默认不进入 Human roster,但在隐藏频道管理器中可发现、只读打开,Human 无发送或重命名权限。 +Group Channel 的气泡收件圆环只投影该消息已有的 PersonaBot Inbox Admission,分母是实际收件者而不是群成员总数。入队是「已投递」;运行时 claim 本身仍显示已投递,直到 observed_at 标记消息进入 Orchestrator 回合上下文才显示「处理中」。若 Bot 通过 Channel 查询主动读到消息而没有进入处理该消息的回合,Admission 保持 pending 但 observed_at 非空,投影为独立的「已读」;回合结束是「已处理」,并不意味着发出了回复。明确忽略、可重试失败和需修复失败各有独立状态。每次权威状态变化通过 Channel revision/SSE 通知并重读该投影,不新建第二套已读存储。Bot 发送者不产生自己的 Inbox Admission,也不进入收件人数。Group 的本机 Human 成员以 Host 固定身份、显示名、加入时间及首个可见 revision 保存在 Messaging 权威中;Human 阅读位置按 Channel ID 与 Human ID 共同持久化。既有 Group 从首条消息起可见,旧 Channel 阅读位置迁移到本机 Human。Host 仅对有可见权限的非作者 Human 投影已读/未读,Bot 状态仍只取 Inbox Admission;同一圆饼与明细展示两种语义,不再从浏览器 tab 或作者推断已读(ADR-0078、#347)。Human 已读推进只广播一条携带阅读 revision 的 Channel SSE;重连时从权威已读位置发送 baseline,Client 用消息的 placement revision 更新当前可见窗口,避免阅读大量历史时逐条广播。当前只支持本地一个 Human;跨账号多人 Channel 仍需独立身份与授权设计(#377)。 +Channel 的消息引用(#145)只保存同 Channel 的已提交目标 ID;Human 发送与 PersonaBot 显式 `channel_send` 由同一持久化权威校验,跨 Channel/缺失目标不能产生消息。引用作者和截断摘要在时间线读取时由当前历史投影,不复制为另一份持久内容,也不为每条消息单独读取。目标后来不可见则降级为不可点击提示;点击有效引用复用 `around` 窗口和高亮,不改变相邻消息分组与时间语义。 + +新 Channel 附件(#576,ADR-0100)保存到 profile 管理的独立真实文件,消息持久化 `{fileId,name,mime,size}` 引用。Host 在 append 前校验 profile 归属,查询时从当前文件投影 MIME 与大小,不改写 Source Event envelope;保留的旧 `{hash,name,mime,size}` 通过 generation 39 的 Messaging 绑定解析到独立真实文件,转换前后均校验字节且不改写 envelope;失败保留旧对象可读状态并给出有界修复日志。Composer 的固定上传 key 与消息 ID 分别保障传输和发送重试幂等。认证 Fetch 上传仍走 `/api/botharness/attachment/upload`,新下载以 `channelId + messageId + fileId` 验证归属并用 `no-store` 返回当前字节。Human 文件/图片菜单复用现有 DSH 原生打开能力;Orchestrator `channel_read_image` 使用 `attachment_id`(fileId)或 legacy `hash`,先验证当前成员资格、消息引用、当前 MIME 和大小,再传给 DSH attachment service,模型不接触 Host 路径。引用感知清理保护所有保留 Source Event 的文件身份;不启用自动调度或保留期。详见[双语文件指南](../file-open.zh.md)。 + +拥有该 Session 的 Orchestrator 可复制 `channel_read`(含通过 `message_id` / `content_cursor` 完整读取的内容)或旧版授权读取结果中的最多 10 个可信附件引用,四个字段原样保留:`{fileId,name,mime,size}`。#577 后旧消息投影迁移后的文件身份;过期 `{hash,name,mime,size}` 结果必须先重新读取所属消息再转发,旧 hash 图片读取仍须带所属消息以解析当前字节。Orchestrator 还可通过 `channel_attachment_import` 明确选择并导入已授权的本地结果文件;不能猜测身份与元数据。DSH schema 将 `size` 声明为整数;Host 继续校验 0–9007199254740991 的安全非负字节范围。固定版本的 DSH schema 不支持 `maxItems` 或数值上下界,因此数组上限由描述与运行时校验共同表达。Group 最多接受 20 个提及 ID,每个都必须是其他活跃现成员。`channel_send` 只有在 Messaging 权威接受写入后才返回紧凑的 `{channelId,messageId}` JSON;省略目标时使用入站 Channel。该结果取代原文本确认;附件身份、profile 归属、当前文件语义、回复目标、delivery-key 重试与因果循环边界继续沿用([#570](https://github.com/BotHarness/BotHarness/issues/570))。 + +应用定义的 `group_leave` Tool 只在规范成员移除提交后返回 `{channelId,left:true,outcome:"changed"}`;Bot 当前不是该群成员时返回 `{channelId,left:false,outcome:"unchanged",reason:"not-member"}`。无变更既包括重复退出,也包括从未加入的群;不推断历史成员身份,不返回未加入群的名称或名单。缺失 Channel 抛出 `group_leave: channel-unavailable`,非群聊目标抛出 `group_leave: group-required`,替代旧的含糊 `left:false` 成功结果;Tool 异常保持为失败。既有 `{channelId,left}` 字段及 Core 返回结构保持兼容,调用方须处理新的无效目标失败。ADR-0073 的同一事务仍承担创建者移交 Human、待处理 Admission 撤销、单条持久离群通知及剩余成员注意力策略;提交后的实时通知警告不否定已提交变更。读写权限立即撤销,因此 Bot 应通过仍可访问的 Channel 向 Human 报告([#571](https://github.com/BotHarness/BotHarness/issues/571))。 + +The reply Consumer can compose an explicit `resolve_inbox_ids` selection with a successful send/drop, using the existing Inbox triage owner afterward; completion failures remain separate from delivery. For an exhausted drop without observed peer-yield evidence, the draft owner identifies the original Source Event and Group Message IDs as keep-open work; the Consumer excludes them from composed completion while still resolving selected FYI. A later explicit cancellation/completion decision remains with the Inbox owner. A revoked initiating opted-in Group prevents that run from continuing in another Channel. A later independent DM remains available (ADR-0227). + +Home/Tob(#2082,[ADR-0223](../adr/0223-tob-is-a-protected-local-personabot.md))复用现有 PersonaBot、Memory、Workspace、Channel、审批和 native Settings owners。全局 Home 不保存第二份对话;提交进入 Tob 的同一个 Human DM,消息面板继续展示。空 Profile 的 onboarding 使用 Tob,已有普通 DeepSeek Bot 保持原身份和历史。Generation 96 的本地单例引用决定 Tob 身份,名字、Soul 或导入内容不授予权限。首片的 Assignment 并发上限路径在 #2088 扩展为明确的 CLI allowlist:Host 默认模型、Bot 模型计划、模型预设创建/读取/更新/应用、四个 native runtime 字段、telemetry、Git 可用性/安装,以及带显式确认的发布安装/重启。受限 release-install/release-restart 每次必须由 Human 新作决定,full access、已记住规则或自动审批均不能替代;此规则不为 Git 安装新增确认参数。各操作继续使用其已有 owner;preset 删除尚无 owner,凭据仍经原生私密输入,其余设置不因 CLI 已存在就自动授予 Tob。Tob 通过 DSH-native bash/pwsh Tool → 隔离的 Shell Service → DeepSeekBot CLI 发起操作;application-defined CLI broker 为每次 Shell 执行提供短期 loopback capability,不向模型提供 Human 管理凭据。Host 从真实 ToolExecution 绑定受保护 Tob 的 root Orchestrator 与准确的已准入 Human DM Source Event 集合;不接受 CLI 自报身份。解析后的设置操作沿用 native Settings owner 和现有审批,审批前后及写入时重验来源、旧值和 revision,并读回结果。Shell 完成、取消或 Plugin 释放时撤销 broker;原生 Assignment 工具不变。详见 [ADR-0224](../adr/0224-tob-management-uses-scoped-native-shell-cli.md)。布局 v8 保留 v7 子树与比例,并备份升级前原文;Home 默认快捷键使用独立稳定 ID。 + +#2089 扩展同一受限 CLI seam 到空白 Bot 创建、资料展示和生命周期([ADR-0225](../adr/0225-bot-creation-receipts-and-reviewed-profile-lifecycle.md))。Generation 97 的 `bot_creation_operations` 在原 operational database 保存 operation key、输入指纹与预留身份;Registry/Memory 和 Channel owners 仍分别创建规范 Bot、受管 Memory Repository 与 Human DM。同 key、同 payload 的响应丢失重试核对原身份;不同 payload、已归档/删除身份均拒绝。目录已创建但 Registry 身份缺失时保留现场并返回 `creation-memory-conflict`,不假装文件系统与数据库是原子事务。受限资料写入绑定完整 Registry record 的 `profileFingerprint`,审批后重新检查并读回;元数据 patch 仅允许名称、简介和标签。删除复用原 owner token、依赖和准确路径,默认保留 Memory,显式擦除和重试均不能改变已审核范围;受限删除确认/重试每次需 Human 新作决定。Tob 保护身份不变,导入与跨 Bot 既有 Memory 编辑仍为后续独立能力。 + +#2090 首个切片将同一受限 CLI seam 扩展到群创建和邀请([ADR-0226](../adr/0226-group-management-receipts-preserve-channel-authority.md))。Generation 98 在既有 operational database 保存稳定操作回执,绑定输入、Bot 身份代次与预留 Channel/invitation 身份;Channel 和 invitation owners 仍是群与成员状态的权威。创建时显式指定的初始 Bot 按现有 UI 语义直接入群;后续邀请沿用默认开启的 `autoAcceptGroupInvites=true`:管理操作审批通过后,同一事务记录已接受邀请与成员关系,受邀 Bot 立即入群,无需额外邀请决定。仅当 Human 已关闭自动接受时才保留原有逐邀请决定分支;本片不新增 UI 开关,Tob 报告实际状态。邀请的审核指纹包含频道元数据、成员及邀请策略,不含消息流或未读计数;审批后与写入前重验,拒绝过期审核。同 key、同 payload 重试核对原结果,已完成的受限重放只读回,不再次申请变更审批;删除、身份变化或 payload 冲突不回退到新建。Tob 始终从已准入 Human DM 管理其他 Bot,不加入群,群内容不授予管理权限。#2090 下一切片通过同一 seam 加入群名称/头像、成员普通消息唤醒策略与成员移除,不新增模型 Tool、数据库迁移或 UI。所有受限编辑必须携带已观察的 `expectedChannel`,审批绑定旧状态、准确目标和新值;执行时重验 Human 当前群访问权限、频道快照及目标 Bot 身份。指纹也包含每个成员的有效唤醒策略与继承状态,因此 Bot 默认策略变化会使旧审核过期。移除成员保留 Bot、Memory、其他频道与群历史;移除 owner 同时清除其管理身份,转由 Human 管理。这些编辑不使用操作回执;结果不确定时先读回,仍需修改则依据新快照重新审核,不盲目重试。已认证 Human 命令可选指纹以保持兼容;Tob 不能省略。后续待处理决定切片沿用同一 seam,为 Tob 开放 `group-invite-cancel` 和 `group-join-decide`。两者必须携带已读取指纹,绑定准确的待处理邀请/申请及目标 Bot 身份代次,审批后重验 Human 当前群成员资格、频道快照与记录仍待处理。取消不能撤销默认自动接受的邀请,也不移除现有成员;接受/拒绝申请由原 Channel owner 持久保存状态与适用成员关系,原决定通知只记录一次。受限入口只处理待处理记录;Human 入群决定命令未携带指纹时保留原有同向终态的幂等读回。结果不确定仍先查规范状态,不增加操作回执、数据库迁移或模型 Tool。个人展示设置、删除群与外部成员管理仍为后续范围。 + +## 原生 Human 等待的验证边界 + +[#1036 的有界实验](../research/1036-native-wait-experiment.md) 在固定 DSH +`0.2.0-rc.1` 上证明:独立 Assignment 等待原生工具审批时,Orchestrator +可真实处理并回复无关 Inbox 消息,但没有释放 Assignment 运行许可。 +Orchestrator 自己的原生审批或正式提问仍占有当前 Step;Admission 已接收、 +`steer` 已排入下一 Step 不等于模型已处理,原调用决定并返回后才处理无关输入。 +准确 Session/call 归属及当前 Grant 校验仍由既有 authority 承担,变更/撤销 +范围拒绝原操作。[ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md#native-human-wait-qualification-2026-10-09) +记录本次资格边界,不改变 ADR-0035 的单一 Orchestrator root 约束。 +[#1220](https://github.com/BotHarness/DeepSeekBot/issues/1220) 追踪缺失的受支持原生 +continuation 机制;#1036 保持阻塞,#1037/#1038 生产实现尚未交付。 + +后续[隔离 RC2 实验](../research/1220-native-timed-question-experiment.md) 验证原生 timed +question 可先真实返回 pending,问题保持可回答;同一 Session 可回复无关消息,再通过 +带原 call 身份的 qualified Inbox 输入处理稍后答案。这是 Tool 已结束、问题仍持续的机制, +不是挂起未结束调用。[DM 卡片适配](../research/1220-timed-question-card-e2e.md) 以原 Session/call +关联卡片:只有 `ASK_TIMED_OUT` 结束前台等待,卡片仍查询原生 Projection;稍后答案通过 +原生 `userQuestions.answer` 提交,显示已提交,直到原生接收后才写入 Channel 回答记录。 +稍后提交进入既有 per-Bot Runtime 队列,为同一 live Session 建立新的 application run,保留 +原 Source Event 上下文并重验当前归属、DM 与源内容 fence;run 持续到原生 idle,让模型能使用 +既有 Channel 工具,不追加普通输入或 Inbox Admission。 +进程内映射仅关联已存在的 live Agent/卡片,不是第二个问题权威,也不实现冷恢复。 +生产依赖仍为 RC1,timed 模式仅在显式配置的隔离 RC2 Profile 验证。权限审批、容量释放/重获和同群隐私验收 +继续阻塞,不从这次 DM 能力实验推断完成。 + +## 1 · 系统上下文 + +新附件已遵循 [ADR-0100](../adr/0100-file-open-actions-target-real-host-files.md):独立上传彼此独立,显式复用身份才共享编辑结果。原生打开指向真实目标;后续消息读取、预览和下载使用当前内容,上传源独立。保存不保留附件历史,不生成 Source Revision、Inbox Admission、通知或 Bot wake。目标缺失则报告不可用,不自动重建。旧 CAS 迁移(#577)按 Source Event 附件出现位置预留可重启恢复的身份,仅在保留依赖未转换时继续保护旧对象;其他 CAS 数据与 Memory Git 行为保持各自语义。 + +```mermaid +flowchart LR + Human["Human
DSH Web / IM"] + External["Feishu / Lark
webhook / future providers"] + + subgraph Browser["DSH Web Client"] + UI["DeepSeekBot UI
Roster · Chat · Human Inbox · Assignments · Settings"] + end + + subgraph Host["DSH Host · single profile writer"] + API["Client Bridge RPC"] + Identity["PersonaBot identity"] + Memory["Optional Memory Service
Service Definition · Git Provider"] + Messaging["Messaging
Source Events · Inbox · Outbox"] + Assignments["Assignment Runtime
Orchestrator · Assignment Directory"] + Transfer["Portability
Export · Backup · Restore"] + DB[("botharness.db")] + end + + subgraph DSH["DSH-owned runtime"] + Sessions["Agent / SessionPersistence
Orchestrator · Assignment Sessions · Subagent"] + Credentials["Credentials · profile settings"] + end + + Human --> UI + External <--> Messaging + UI <--> API + API --> Identity + API -.-> Memory + API --> Messaging + API --> Assignments + API --> Transfer + Identity --> DB + Identity -. attachment .-> Memory + Messaging --> DB + Assignments --> DB + Transfer --> DB + Identity <--> Sessions + Messaging --> Assignments + Assignments <--> Sessions + Assignments -. scoped Consumer .-> Memory + Messaging -.-> Credentials + Transfer -.-> Sessions +``` + +浏览器只通过 RPC 访问 Host read models 和 commands。Provider adapter 只负责验证、规范化和执行能力;它不拥有 Inbox,也不能直接唤醒 Agent。DSH 继续拥有 Agent 执行、SessionPersistence、Subagent 与凭据;BotHarness 不复制这些 runtime 权威。 + +Assignment 完成报告通过既有 Human Inbox 最新报告、忽略和来源级隐藏事实派生 `informationalCount`,进入同一有 revision 的 Activity snapshot。仅有信息时显示中性的 `i`,但不会单独让输入框活动行常驻,该行只在执行中或有待处理事项时出现;红色待处理数字不包含信息更新,同时存在时悬浮/聚焦摘要分别说明数量。打开报告本身不代表确认;忽略、隐藏,或主会话已处理这条汇报的 Inbox 投递时清除提示,汇报本身仍列在收件箱中;重启可从权威事实恢复,执行状态不变。 + +## 2 · Deep modules 与所有权 + +```mermaid +flowchart TB + Root["Host composition root
lifecycle · dependency wiring"] + DB["Operational Database Owner
writer lease · schema generation · transaction"] + + subgraph Modules["BotHarness deep modules"] + Bots["PersonaBot
identity · lifecycle · Session ownership"] + Memory["Optional Memory Service
repositories · Git commits · events"] + Msg["Messaging
events · channels · inbox · triggers
grants · outbox"] + Assignments["Assignments
directory · capacity · requests · reports"] + Usage["Usage
retained daily model tokens · read model"] + Portable["Portability
Soul · export · backup · restore"] + Views["Read models
RPC · UI projections"] + end + + Root --> DB + Root --> Bots + Root -. optional Provider .-> Memory + Root --> Msg + Root --> Assignments + Root --> Portable + Root --> Usage + Root --> Views + DB --> Bots + DB --> Memory + DB --> Msg + DB --> Assignments + DB --> Portable + DB --> Usage + Bots -. attachment .-> Memory + Bots --> Assignments + Msg --> Assignments + Bots --> Views + Msg --> Views + Assignments -. scoped Consumer .-> Memory + Memory --> Views + Assignments --> Views + Usage --> Views + Portable --> Views +``` + +| Module | Owns | Does not own | +| ---------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| PersonaBot | Host-owned ID、display name / role badges、lifecycle、explicit Session ownership | DSH Session lifecycle、Memory 内容 | +| Memory | generic Git-backed repositories、semantic commits、operation events | PersonaBot lifecycle、Inbox、Session | +| Messaging | Source Event、Channel placement、Inbox Admission、Attention、Trigger/Wake Policy、Service Grant、Outbox | Agent execution、provider credentials | +| Assignments | Assignment Directory、Assignment Request/Delivery Intent、capacity admission、report/lifecycle routing | DSH transcript、Subagent runtime | +| Usage | per-PersonaBot/执行类型/实际模型的保留日统计、usage read model 契约 | DSH Session 日志、价格表、金额总账 | +| Workspace Grants | Human 对 DSH Workspace 的授权与撤销、Assignment 创建时的权限快照 | DSH Workspace registry、Session 权限实现 | +| Portability | SoulSnapshot、PersonaBot Export、Profile Backup/Restore/Transfer 协调 | credentials、可执行插件、DSH 私有格式 | +| Read models | 查询、分页、PersonaBot Activity Projection、Human Inbox、UI-friendly projection | 业务事实与写入规则 | + +`botharness.db` 是 BotHarness core 的物理事务宿主,不是共享的 generic repository。Memory 内容与 commit 由 optional Git-backed Provider 掌管;每个 deep module 只通过自己的接口拥有表和不变量,跨模块流程由显式 command/port 协调。 + +Usage 是 application-defined 的保留统计:从 DSH durable SessionEvent 中按实际请求提取 provider/model 与 provider 报告的 input、output、cache-read、cache-write token,按可信 Session ownership 归属 PersonaBot,分别汇总 Orchestrator、Assignment 和 DSH Subagent;同一 Turn 用多个模型时分别入桶,缺失用量标为未知。`botharness.db` 按 `(bot, day, execution role, provider, model)` 保留日汇总,日界取 Host 本地时区;增量折叠必须幂等,普通 Session 删除后不可用剩余日志全表重建或重复计数,彻底清除 PersonaBot 才删除其可识别统计(ADR-0094,#39)。金额由价格表在查询时另行估算,1.0 不落金额总账(#35 留 v1.1)。Browser 只经 read model 查询,不直读 Session 日志或投影表;PersonaBot Profile 的 token 卡是当前消费者(#34 Decisions、#428)。 + +Usage generation 40 在同一 Operational Database 事务中写入匿名 HMAC 去重凭据与日增量;校准只补记未见调用,不清空保留汇总。凭据不保留原始 Session 标识或逐调用内容。旧汇总迁移为 Bot 级时间基线,升级前无法确认的补计明确留诊断;新调用持久幂等。归档保留用量;Registry Purge 清理汇总、凭据与基线,当前 Bot 创建身份与可信根 ownership 防止旧 Session 重新填入。 + +首个可运行切片(#499)扩展现有 26 周有界 `profileActivity` 查询,返回实际 provider/model 的日用量、可为未知的报告分项,以及即使某缓存分项缺失仍可确定的 provider 总量。Profile 按 Host 本地日期查看用量,与允许调用的 Model Plan 独立。逐调用切片(#503)独立结算每条追加的 Assistant 调用事件,成功调用使用实际 source,失败调用使用已记录的请求路由;Profile 区分可信 ownership 对应的 Orchestrator、Assignment 和子代理。Session 序号去重实时通知与重放快照,消息替换不产生新用量。Session 删除后的保留统计仍由 #502 完成。 Profile 在现有 26 周公开查询范围内,以一个有界时间选择联动每日用量、实际 provider/model 用量构成和缓存比例图,默认最近 7 天。模型/提供商切换按选中的实际模型 ID 或提供商 ID 汇总,不在行内嵌套另一维度;Host 原始行和折叠的执行详情保留两个标识。模型/提供商行紧凑地显示选中维度的名称与报告总量;图表悬浮提示展示总输入(未缓存输入加缓存读写)、缓存读/输入及输出/报告总量的比例;缺失分项或零分母的比例保持未知。执行类别默认折叠于详细信息(#592)。 + +筛选查询 `profileUsage`(#507)由 Usage 深模块拥有,经 Typert/API Gateway 提供:真实且非未来的日期最多覆盖 182 天;模型/提供商与执行类别条件同时作用于每日记录与累计汇总,累计值仅忽略日期。Profile 默认近七天,执行类别筛选位于折叠详细信息中;首次打开、改变筛选或手动刷新时查询,不轮询或按时间自动过期,同条件刷新失败明确标为过期,旧条件迟到的响应不会覆盖新筛选。响应包含查询/核对时间、正常/核对中/降级状态、可空未知用量,以及显式明细/选项上限(2,000 行/每维度 1,000 个实际选项);总量完整,截断图表隐藏。Session 来源是否可用不决定保留统计是否存在(#502)。 + +Model Preset 的当前模板存储由 Profile SQLite 的 owning module 管理,Schema Generation 54 将旧 JSON 模板一次性校验迁入,保留 ID/revision,并写入包含空源的切换标记;成功切换后旧文件仅保留为恢复参考,不再读写(#883)。Schema Generation 55 将 PersonaBot Registry 的身份、外观快照、访问开关与独立 Model Plan 一次性校验迁入 `persona_bots`,导入标记与记录同一事务提交;旧 `bot.json` 与 Soul 文件保留,但正常读写仅使用 owning Registry module/Profile Writer Lease(#884)。无效登记使 Core 启动失败并释放 lease,不会把坏数据当成空名单;修复保留的旧源后重试。Generation 72 的完整 Profile Backup 保存可复用模板与独立 Model Plan 的现值和 revision,恢复不会重新套用模板(#886)。 + +Model Preset 是部署本地可复用模板;Human 在 Profile 应用时,PersonaBot 保存独立 Model Plan 快照,后续模板编辑不传播到已应用的 Bot。Plan 固定 Orchestrator 的 provider/model/reasoning effort,并定义 Assignment 可用的精确模型、各模型允许及默认的 effort 和默认模型。Host 在每个执行入口按当前 Plan 验证选择,而 DSH SessionEvent 记录实际调用:Orchestrator 的变更在当前 Turn 结束后生效,已有 Assignment 保留当前路由,之后的显式切换按最新 Plan 校验;新建 DSH Subagent 默认继承仍获允许的父路由,否则选当前 Assignment 默认并告知父 Agent。不可用或有歧义的路由停止请求,交由 Human 修复,不静默回退。Model Preset 与 Model Plan 可进入保持身份的 Profile Backup,不进入 SoulSnapshot 或 PersonaBot Export(ADR-0027、ADR-0093,#488)。 + +快捷模型/effort 切换只修改此 PersonaBot 的 Orchestrator,并在下一 Turn 生效;DM 不猜测某个 Assignment 作为目标。Model Plan 可以同时省略 `assignmentDefault` 与 `assignmentModels`,表示任务继续继承 Profile 全局模型;既有固定任务配置不变。Registry 的 `modelPlanCustomize` 用 revision 校验创建或修改这种独立配置,Web、移动端和 CLI 共用该 Host 操作。已有 Assignment 仅使用自己保留的路由;没有保留路由时继续继承全局,不补用后来出现的 Bot 任务默认值。完整模型设置显示继承状态,选择完整模板或任务模型才建立固定任务策略([ADR-0218](../adr/0218-quick-model-selection-only-customizes-the-orchestrator.md),#2000)。 + +当前命名模板保存在 `botharness.db` 的 `model_presets` 表(#883,取代 #498 初始 JSON 存储),应用后的独立快照及修订号保存在 owning Registry 的 SQLite 记录中(#884),后续模板修改不会自动替换。Profile 经现有 Host Bridge 读取 DSH 当前注册的 provider、model 与 effort 能力,并在创建及应用时校验确切路由;Orchestrator 的 Agent-scoped Model Selection 在每个新 Turn 前读取最新快照,不修改 DSH 部署默认值。DSH 请求头仍是实际调用路由的事实来源(#498)。 + +后续切片允许 Human 修订命名模板而只改变未来应用的值与模板修订;Profile 的紧凑切换按当前模板复制新 Bot 快照,手动改一个 Bot 的 Orchestrator 路由则清除模板来源标记、保留 Assignment 默认值并增加 Bot 计划修订。Host Bridge 在写入前验证现有 DSH 路由,正在执行的 Turn 持续使用其已组装选择,下一 Turn 才重新读取 Bot 快照(#501)。 + +新建 Assignment 的模型选择由当前 Bot Model Plan 管理:Human 在 Profile 保存精确 provider/model 集合、各模型允许及默认的 effort 和整体默认路由,Host Bridge 在写入前对照 DSH 当前 Model Catalog 校验并递增 Bot 计划修订。Orchestrator 通过 `list_assignment_models` 读取该集合,再在 `create_assignment` 中省略选择以使用整体默认值,或指定获准的模型与 effort;Host 在认领 Session 和启动工作前拒绝越界选择。选定路由随 Assignment ownership 记录保存在 `botharness.db`,并通过该 Assignment 独有的 DSH Agent-scoped Model Selection 进入真实请求;读取 Assignment 或 Host 重启不会把选择改为后来更新的 Bot 默认值。这个切片不修改 Orchestrator 路由或 DSH 部署默认值(#504)。 + +PersonaBot 的 Agent scope 提供 `bot_subagent`、`bot_subagent_fork` 和 `list_bot_subagent_models`;Host 在每次启动子代理时从该 Bot 的最新 Model Plan 验证路由,并用 DSH LLM Service 预检可用性,再交给 DSH Subagent Service 创建真正的子会话。Bot scope 拒绝绕过计划的原生 Subagent 工具调用。省略路由时,获准的父路由被继承;已从当前计划移除的旧 Assignment 路由改用当前 Assignment 默认值,并在工具结果中告知父 Agent。子代理沿用根会话的 Workspace Grant 与工作目录;DSH 子会话原生的 `never` 审批策略不放宽根会话的模式约束(#508)。 + +Model Route Readiness 在 Profile 查询及 Orchestrator/Assignment 新 Turn 入口读取同一 PersonaBot Registry。旧 `model` 字符串只有在 DSH Model Catalog 唯一匹配且路由配置有效时才迁移为独立 Model Plan;有歧义或缺失则保留旧值并等待 Human 选择,绝不拼接部署默认 provider。修复状态即时计算,不是第二套策略存储。请求前校验精确 provider/model/effort;provider 在真正执行时拒绝凭据则通过 DSH `turn/end` 和现有 Channel Session failure 通知给出修复指引。成功应用 Model Preset 后移除旧字段,保持 Agent preset 不变,下一次 Turn 使用修复后的路由(#500)。 + +application-defined Memory Service 使用 `Consumer → Service Definition → Provider` capability seam。每个 PersonaBot 都有一个 Git-backed Memory Repository,Orchestrator Session 固定以它为 working directory。当前检出的工作树文件立即是记忆,无论是 Markdown、代码还是二进制文件。Agent 通过原生文件、搜索、Shell 与 Git 能力读写仓库;没有面向模型的 Memory CRUD 工具。新建 PersonaBot 时可选择空白记忆,或由 Host 检查 Git 并以 HTTPS/SSH 地址在暂存目录克隆远端仓库;克隆成功才建立 Bot,远端当前分支及文件立即成为记忆(#298)。私有仓库使用 Host 已配置的 Git 凭证,不经创建页面收集密钥;已有 Bot 仍通过原生 Git 整合远端仓库。外部仓库应合并、替换或放到其他位置若有歧义,Orchestrator 经 DSH 原生 Ask Question 询问 Human。分支、合并与冲突由 Git 决定;Git 改变工作树后,同一 Session 立即看到新文件,但该 Session 已冻结的 Soul 与 Core Memory prompt 不追溯更新(ADR-0060)。Memory 根目录的 `SOUL.md`(Soul)与 `MEMORY.md`(Core Memory)在 Session 首次组装 prompt 时冻结为同一份快照注入,只在 compaction 边界整体刷新;各自受每 Bot 可配置的字符上限约束,超限时截断并明确提示。旧 `PERSONA.md` 在每次 Host 启动时幂等迁移为 `SOUL.md`,读取仍兼容旧名(ADR-0134)。跨设备延续沿用同一路径:Orchestrator 按 Human 请求用原生 Git 与 Human 自配置的远端同步,新设备以 Git URL 创建新的 PersonaBot;不操作 Git 的所有者使用 Profile Backup,而不是 Host 持有的同步服务或一键同步(ADR-0084)。 + +Idle Compaction(#2310):当前 Orchestrator Session 没有进行中的轮次、也没有待处理的 Bot Inbox Admission 时,Host 按最后一次模型回答的时间,在该线路提示缓存寿命的一半(最少 15 分钟)经 DSH 命令注册表为该 Agent 执行 `/compact`(`compactNow`;compaction 服务隔离在每个 Agent 的 preset 组内,Host 无法直接取得),只压缩超过该线路 DSH 默认自动压缩阈值一半的会话;摘要格式与 `compaction/end` 刷新 Soul/Core Memory 的行为不变,期间到达的 Human 消息由 DSH 排队到压缩结束。缓存寿命依次取 Host 设置中的每线路值、模型目录、观测(请求前空闲间隔与该请求缓存命中率)与 DeepSeek 的 2 小时默认值,不足 15 分钟或未知则跳过。Assignment 与审批复核会话不参与,聊天中不显示;每次执行写入 `usage_idle_compactions` 并在 Bot 活动中返回。开关与每线路寿命是 Profile 级 Host 设置,Web 设置页与 `deepseekbot idle-compaction*` 共用同一 bridge 方法。 + +[ADR-0140](../adr/0140-the-host-falls-back-to-a-managed-git.md) 规定 Git 的来源:Host 每次启动时解析 Git,可运行且不低于 2.28 的系统 Git 优先;否则使用 Profile 中的 Managed Git。找不到、macOS 占位程序与版本过低都算不可用。进入 Bot 模式时先检查,不可用时只给一个「安装 Git」按钮并禁用创建与导入。Managed Git 是按 deepseekbot 版本固定版本号与 SHA-256 的便携构建,先从 `media.botharness.ai` 镜像、再从 GitHub 下载,无需管理员权限;使用它时 Host 把其目录加到 DSH 进程 PATH 最前,让 Orchestrator 的 Shell 也能用同一个 Git。SSH 导入地址克隆失败时,Host 自动转换为对应的 HTTPS 地址重试一次并告知 Human。 + +Memory Service 拥有 repository identity 和 lifecycle、可信 Session ownership、受限的 UI 查询,以及审计/恢复检查点。成功回合后,它可以把观察到的状态与可信 Source Event、Session 上下文记录下来;观察不会暂存、提交或隐藏工作树文件,也不推断 Git 内容的作者。当前文件与历史以 Git 仓库为权威,数据库 ledger 只是辅助记录。Memory Service 为每个 PersonaBot 保存一个 commit 游标:回合开始时静默对齐当前 HEAD,回合成功收尾后,把 HEAD 可达而游标不可达的每个 commit 交给 Channel 写成一条 `memoryCommit` Channel Notice(放在该回合的 Cause Channel,不可用时回到 Human DM),并为该 Bot 写一条已处理的 `memory-commit` 记录,随后推进游标;同一 Bot 的同一 sha 只记录一次(ADR-0154)。起因在群或 Bot DM 的 commit 记录同时放入该 Bot 的 Human DM(ADR-0182)。PersonaBot DM 的 Channel sidebar 分为「记忆文件」和「记忆演化」:前者以可展开目录树展示当前工作树,选中文件后在 Channel body 只读查看文本或二进制提示;后者展示所有本地分支及可达提交的 Git graph,选中 commit 查看完整 diff。当前差异默认按「新记忆/已有记忆的更新」对文件去重,切换并持久保存 Git 术语后,显示可折叠的未暂存、已暂存和未跟踪分组及状态标识。打开视图时定时读取、窗口重新聚焦时立即读取外部编辑,侧栏折叠标题栏也有刷新按钮;查询不暂存或提交(ADR-0088)。Human 文本保存服务仍先比较当前 HEAD,再显式生成 Git commit;普通导航不提供页内编辑。Memory 文件查询阻止访问 `.git` 控制路径及指向仓库外的符号链接,但不限制 Git 可保存的文件类型。旧 Repair 归档与检查点记录仍保留兼容;普通未提交改动不会阻止下一回合或强制修复(ADR-0068)。 + +恢复检查点额外记录分支、HEAD、暂存区与工作树,并用隐藏的 Git ref 保留对象。观察记录区分 Host 扫描、Agent 会话上下文和明确的本地 Human 命令;后者使用与 Channel membership 相同的 Host-owned `local-human` 身份,不能区分共享 Host 凭据的多人。Human 在记忆演化页选择检查点并确认恢复;Host 校验当前状态与检查点引用,在副本中准备目标状态,完整归档原仓库后切换。未被观察的中间状态无法恢复;Git ignored 文件留在完整归档中(ADR-0097)。界面上这份列表放在记忆齿轮菜单的「从自动备份恢复…」里。Human 也可以把记忆「恢复」到 Git graph 的任意提交:Host 先强制记录检查点,有未提交改动时把工作树提交为一个备份提交(可顺带用 annotated Git tag 给恢复前的版本起名),再把所选提交的文件树作为新子提交(分支以 compare-and-swap 前移),然后把工作树重置到该提交;历史不被改写,之后的提交仍可再次恢复(ADR-0190)。 + +Memory 外部打开沿用 ADR-0100 的交互,#574 实现当前 Memory 首片:显示路径的区域可点击弹出菜单,密集文件树提供右键菜单和键盘/触屏可到达的入口,合适位置提供带 Tooltip 的图标按钮。普通文件选择仍进内置阅读,目录选择仍展开。首片覆盖 Workspace 的 Memory Repository 路径及当前 Memory 文件/目录,#575 扩展已授权的普通 Workspace 路径,后续扩展 PR #435 的消息附件 chips,不识别任意正文路径。Memory Service 在 Host 从 PersonaBot 仓库根解析相对路径,沿用 `.git`、越界与符号链接检查;其他来源由各自 owning module 解析,不开放通用任意路径 authority。Client 沿用 DSH Typert/API Gateway seam 消费原生打开能力,不运行拼接 Shell 命令。目录菜单取 DSH 探测到的应用,文件菜单取默认和注册关联应用,首片不加自定义程序或持久默认应用设置。 + +默认打开遵循 [ADR-0178](../adr/0178-file-open-defaults-are-explicit-client-preferences.md)(#1461):Memory 当前文件阅读器、聊天普通文件卡及 Workspace 的 Memory Repository/已授权目录行共用一个 application-defined split button——应用图标与「打开」主按钮加独立下拉,下拉即原文件菜单。主动作优先 Human 的显式偏好,其次 DSH 报告的系统默认;pinned DSH 只为文件关联报告系统默认,目录在 Human 设置前没有可信默认项,此时主按钮说明原因并展开选择,不使用列表首项。下拉里的一次性选择不修改偏好;「设为默认」「恢复系统默认」写入同一个 Client 偏好 owner(`file-open-preferences`),文件按扩展名、目录单独,存于浏览器 `localStorage`,并以 Host 提供的 Profile 身份作键;身份未就绪时不读取任何偏好,写入失败如实报错。偏好只保存 Host 曾报告的应用标识,不授予路径权限、不修改 OS 关联、不跨设备同步。每次手势都由对应 file owner 重新解析目标并重新查询可用应用,已卸载或不再报告的应用不会被启动,也不会被静默替换。派发成功只表示请求已发送到 Host,不代表窗口已出现。记忆演化的 commit diff 与未提交差异在每个文件行提供「打开当前文件」(#1462),复用同一 split button 与偏好 owner,提交 PersonaBot slug 与该行路径,由 Memory Service 按现在的仓库状态重新解析;它不读取所选 commit 的内容,也不暂存、提交或修改仓库。Host 的变更列表不做 rename 检测,rename 表现为旧路径删除加新路径新增,因此新路径行打开新文件、旧路径行只检查该路径当前是否存在。历史上标为删除但后来重建的同路径文件可正常打开;当前不存在时提示「这个路径现在没有文件」,仅当该行本身带删除标记时附上「此提交/当前工作区变更将该路径标记为删除」,不从历史内容重建,也不追猜后续 rename。点击按钮不折叠/展开 diff。 + +普通 Workspace 路径复用同一 Client 菜单,但提交 PersonaBot slug 与 Grant ID,由 application-defined Workspace Grant Store 的 `requireActive` 在 Host 检查拥有者、撤销状态和当前 DSH Workspace Registry 身份,重新解析 canonical 目录。显示路径不承担 authority;失效、移动、未注册或缺失目录会拒绝。打开前再次校验,菜单只列 Host 探测到的目录应用,能力不可用时只保留复制 Host 路径。打开不修改 Grant、Session cwd 或访问权限,也不创建 Session;不提供目录下载(#575)。 + +原生打开始终作用于 DSH Host 所在电脑,Tailscale/Cloudflare Tunnel 只提供连接而不证明 Client 与 Host 同机。菜单明确目标和能力不可用原因;文件另提供下载到浏览器设备及复制 Host 路径,二进制/超大文件不因内置预览受限而失去打开和下载能力。下载后的编辑不自动回写远端,首片不做目录下载或历史 Memory 文件导出。 + +手机端 Memory 文件阅读器同样消费现有 Memory Service 的 `memoryFile` 表示和认证 PNG 字节入口(#2214)。应用定义的 `MemoryPreviewIO` 是客户端进程内的字节与临时文件适配器:本地图片引用携带文档的 source/revision,Host 继续拥有路径、Git 版本和陈旧检查;手机只在原生可见区域相交后读取有界 PNG,把校验过的字节写入临时缓存供 `expo-image`、既有缩放查看器和系统分享使用,离开时取消请求并清理。Markdown 使用既有原生文本/表格组件,图片节点单独呈现,原始 HTML 不执行;外部 HTTPS PNG 经逐图点击才读取,不携带 Host 认证或 Cookie,并拒绝重定向。文件树和普通资料行不加载媒体依赖;原生 Git diff 导航、其他格式及附件预览仍是后续切片。 + +Memory 格式表示进一步支持 JPEG/JPG、GIF、WebP 和安全静态 SVG(#2214)。纯客户端可用的 `media/image-format` 统一检测原始字节 MIME、尺寸和处理预算,Host Memory 入口及手机临时文件适配器消费同一规则。SVG 通过有界 XML/CSS 解析,仅允许无外部资源和主动内容的静态图片子集;仍以图片资源呈现,不注入页面标记,也不在 Host 转码或读取 SVG 引用文件。版本 SHA-256、原始字节和独立原文件下载语义保持一致;GIF/WebP 在帧数/总画布像素预算内保留原动画。 + +Memory Service 在 Host 启动、Orchestrator 回合前,以及本地 DM 或群提及并入活动回合前比较各 Bot 当前分支、HEAD、未提交文件内容和 Git index,与数据库中的每 Bot 观察检查点求净变化。首次观察只建立基线;之后在单次事务中写入有界路径摘要的 `memory-change` Source Event、该 Bot 的 Inbox Admission,并推进检查点。事务失败不推进基线,重启后重试;相同状态重复扫描不重复投递。启动扫描只入 Inbox,不主动唤醒 Agent;下一次普通回合在同一 Inbox 上下文领取并处理。Event 不推断编辑者,也不复制文件内容;Agent 需要时用原生文件和 Git 工具查看。并入消息时发现的净变化随消息在下一个安全步骤进入同一回合的 Inbox 上下文,成功投递才标记已观察,并随回合成功或失败处理;拒绝并入时保留待处理,供后续普通回合领取。回合结束更新剩余状态的基线,不额外通知,也不推断回合中变更的编辑者。离线期间改动又复原的中间过程无法从最终文件状态推断。`SOUL.md` 或 `MEMORY.md` 变化可在 Event 中提示,但冻结的 Session 快照不变(ADR-0092、ADR-0134、#350、#352、#464)。 + +## 3 · Host 启动、迁移与 recovery + +```mermaid +flowchart TD + Start["Host starts"] --> Lease{"Acquire profile writer lease"} + Lease -- "busy" --> ReadOnly["Fail closed
read-only diagnostics"] + Lease -- "owned" --> Open["Open botharness.db"] + Open --> Gen{"Schema generation supported?"} + Gen -- "newer / corrupt" --> Recovery["Recovery mode
no operational writes or wakes"] + Gen -- "current" --> Integrity["Integrity checks"] + Gen -- "older" --> Copy["Migrate isolated temp copy"] + Copy --> Verify["Verify schema + integrity"] + Verify -- "fail" --> Recovery + Verify -- "pass" --> Activate["Atomic replace"] + Activate --> Integrity + Integrity -- "fail" --> Recovery + Integrity -- "pass" --> Modules["Start deep modules"] + Modules --> Rebuild["Rebuild DSH-derived projections
reconcile bounded intents"] + Rebuild --> Ready["Enable admissions, wakes and commands"] +``` + +一个 DSH profile 同时只允许一个 BotHarness writer。所有 module migration 合并成单调递增的 Schema Generation;迁移只在临时副本上完成,校验后原子替换。打开、迁移或完整性检查失败时进入 recovery mode,不回退到 NDJSON、storage domain 或内存写入。 + +## 4 · Messaging 事务与外部副作用 + +```mermaid +sequenceDiagram + participant P as Provider adapter + participant M as Messaging command + participant DB as botharness.db + participant N as Post-commit notifier + participant O as Orchestrator + participant X as Provider service + + P->>M: verified event + account fingerprint + capabilities + M->>DB: BEGIN IMMEDIATE + M->>DB: append Source Event / Revision + M->>DB: Channel placement (optional) + M->>DB: evaluate exact Trigger + Wake Policy revision + M->>DB: create Inbox Admission / Attention facts + M->>DB: COMMIT + DB-->>N: committed fact ids + N-->>O: wake at policy-selected safe boundary + O->>M: explicit Channel reply or authorized Service Action + M->>DB: validate current revision + capability + grant and write Outbox Intent + M->>X: execute with stable idempotency identity + X-->>M: receipt / failure / unknown outcome + M->>DB: append attempt and outcome facts +``` + +Source Event 是内容唯一权威;Channel 和 Inbox 都只保存关系。Reply 使用可信 Reply Route 自动选择来源 provider;主动发布属于 Service Action,需要 Provider Capability,以及该 Bot 对应用可触达会话的会话条目,或作为后备的 Human 保存目标 Service Grant。SQLite 事务只覆盖本地事实;外部副作用使用 Outbox Intent、幂等标识和有界 reconciliation,不宣称 exactly-once。不可证明的结果进入 `unknown-outcome`,由 Human 处理。 + +Wake Policy 决定何时让 Orchestrator 看见新 attention:当前 step 完成后的安全边界、当前 turn 结束后,或 idle 时启动新 turn。普通外部消息不打断正在执行的 model/tool step;只有 DSH 明确支持且策略授权的控制路径才能 steer。就绪的 attention 按回合收割:忙碌期间新到的事件只把就绪集合置脏,当前回合结束(即空闲)时由一次 harvest turn 消费全部就绪项;主动 steer 只用于直接 @ 与 DM(ADR-0077)。 + +### Lark 接收与回答反馈(ADR-0149) + +新会话首条消息的 Admission 通知异步等待受校验的回复连接就绪,避免连接建立期间丢失接收反馈;不阻塞收件确认或模型唤醒。SDK HTTP 异常中的明确权限拒绝归为失败;修复不重写或补发既有未知尝试。 + +Messaging 在 Source Event 与 Inbox Admission 提交后异步尝试原消息的 `GLANCE`,只在该来源的 canonical Reply Outbox 成为 `provider-accepted` 后尝试 `DONE`。两个状态由应用定义;原内容、Admission、Outbox 继续是各自唯一权威。可选的 checked Provider Service Capability 重查账户指纹、Registration、独占 Consumer、来源会话/Thread/发送者及当前授权。旧 Provider 缺少能力时保持正常收发。 + +反馈 attempt 元数据保存在既有 Source Event payload,完成记录引用原 Outbox;管理 snapshot 暴露最近状态,不复制消息内容。写入前持久化 attempt,超时/未知/重连/重启均不自动重试;有界异步调用不阻塞接收、模型或回复。静音仍可产生 silent Admission,屏蔽阻止新 Admission;静默结束、委派完成、待确认问题及无关输出不表示已回答。平台接受不表示 Human 已读或样式已经验证。详见 [ADR-0149](../adr/0149-lark-feedback-follows-admission-and-accepted-reply.md)。 + +Slack 的 #1161 候选复用这条 application-defined Messaging 反馈链:checked Provider 把 `received`/`answered` 映射为原始 Human 消息上的 `eyes`/`white_check_mark`,回答后移除自身的 `eyes`。Slack 静音会话不产生反馈;Lark 既有 silent Admission 语义不变。Provider 仅串行同一来源的反应写入,不占用 Bot transition queue 执行网络写入;每次写入重查来源、账号、Consumer 和授权 fence,部分写入/清理不确定保留 unknown,不自动重试。本候选不提升现有 Provider pin;真实 Slack 频道与 DM 证据仍是独立交付门槛。 + +### 绑定的应用接收默认流量(ADR-0142) + +Application-defined IM DM commands (`/commands`, `/help`, `/status`, `/model`, `/effort`, `/stop`, `/new`, `/compact`) reuse this intake's Binding, sender admission, Source Event and deduplication. Only a whole text-only DM is a command; groups, attachments and unrecognized text retain their existing path. Claimed command Admissions are handled without model input or wake; `last_error` retains pending/unconfirmed command work for inspection rather than automatic replay. The Messaging owner serializes commands per PersonaBot across Bindings, rechecks current source authority and management pairing capabilities at execution, and passes a current-authority fence through asynchronous model validation and Session replacement. Model changes use the existing Orchestrator-only customization owner and preserve Assignment inheritance. Replies use the source-bound Outbox's intent, attempt, final-send fences and accepted/failed/unknown outcomes (ADR-0106/0129); a duplicate never executes or sends again. Weixin changes require an admitted paired-owner DM; other platforms require an approved management pairing with current approval capability, or refuse with Web guidance. `/new` and `deepseekbot bot-new-session` share the idle Orchestrator replacement runtime. `/compact`, the Bot DM composer's `/compact` and `deepseekbot bot-compact` share Idle Compaction's DSH `/compact` path: they refuse while the Bot has a running turn or pending Inbox Admission, and compact only a live Orchestrator Session. + +接收以外部身份 Binding 为键,而不是保存的目标。启用的 Binding 把应用的默认流量(私聊和合格的群 @)接收进绑定 Bot 的 Inbox。会话的第一条被接收的消息,会与其 Source Event、Inbox Admission 在同一事务中提交一个隐式会话条目(`messaging_grants` 中 `origin: 'implicit'` 的行),使 Outbox fence、群策略、Channel Bridge 路由和撤销仍挂在同一个锚点上。Binding 的新会话模式(`auto` 或 `ask`)及其上限(每小时 20 个新条目、500 个活跃、200 个等待)决定新会话被接收还是等待处理。屏蔽按 Bot、应用指纹、会话类型和 ID 持久保存;静音只推进 `preferenceRevision`,不改动条目的 Outbox revision。一个 Bot 可以绑定同一平台的多个应用,每个应用仍只属于一个 Bot。Client 中,应用的会话列表管理这些条目并显示已有的 Channel Bridge 路由(新建同步正在围绕外部连接器重新设计),「外部连接器」列出这些路由并保留保存目标的 Service Grant,作为 Provider 无法列出或直接发往会话时的高级后备。 + +外部应用的准备步骤由官网教程维护,既有 **绑定应用** 弹窗提供对应语言的教程链接,不再显示独立 Lark 配置引导卡片。Driver.js 保留为应用内部控件定位工具,不承载平台配置说明。绑定、接收与回复结果仍来自既有 Provider/Host 权威;打开教程不产生授权或发送,也不新增引导存储。 + +### 首条外部出站路径(ADR-0101) + +[ADR-0127](../adr/0127-product-artifacts-compose-an-independently-versioned-im-provider.md) 提议在开发用 fork 政策之外增加产品分发路径:打包的 `deepseekbot` 固定 Core、Client、Browser、Computer 和独立版本的 `@botharness/im-provider`,通过同一 Bundle Patch 激活全部六个组件([ADR-0160](../adr/0160-deepseekbot-default-bundles-browser-computer-and-im-provider.md))。Provider 保留 SDK、凭据、存储身份及公开 Service;Core 保留所有 application-defined authority。构建校验不可变输入、重建运行时来源和 tarball 完整性。原生优先从 CLI 安装位置解析 Bundle,因此产物验收使用独立安装的官方 CLI,避免误用开发链接。初始不连接账号;重复的 standalone Bundle 在启动前被拒绝。Provider 随产品更新。此构建路径不发布 npm、不自动资格验证其他平台,也不免除 Human QA 和生产启用门槛。参见[打包验收指南](../product-im-installation.md)。 产品 Provider 的来源、DSH 版本和运行时摘要独立于开发版固定值;改变产品输入需要递增自身版本并重做实际安装验收([#868](https://github.com/BotHarness/BotHarness/issues/868))。 + +[#1115](https://github.com/BotHarness/DeepSeekBot/issues/1115) 经 application-defined Messaging Provider 的可选可达群查询/checked first-post 契约扩展同一主动发送路径。Host 保留 Binding、会话屏蔽和 Outbox authority;首次发帖只创建既有 implicit 会话条目,不生成 Source Event、Inbox 或本地 DM。Generation 89 在 Binding 保存每小时发帖限额(默认 60,可关闭),以既有 Outbox attempt-start 计数,未知结果不重试。可选契约覆盖 Lark、Slack、Discord;实际平台权限、版本绑定的原生验收及 #1111 Provider 发布仍独立。详见 [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md)。 + +PersonaBot Profile 的 IM 连接经现有 Typert/API Gateway 选择账号和已测试目标,再由 Human 建立 Binding 与单目标主动发送 Grant。application-defined Messaging Provider Registration 由 Consumer Fiber 持有,Binding/Grant/Outbox 使用同一 `botharness.db`。接受和执行都复核活跃 Bot、Grant、Registration、认证账号 fingerprint 和目标内容 digest;删除、改址或账号变化要求显式重新授权。先提交 Intent 和 attempt-start,再调用 provider,结果只记平台接受、明确失败或未知;重启不重发 pending/in-flight,未知结果留待 Human 核对。 + +生产适配需要 dsh-im 公开、版本化的 `describeBot`/`sendChecked` 契约,以在账号 transition 中验证平台身份并冻结已授权路由。这是待上游接受的小型扩展;已发布 `4.32.0` 不满足该契约,BotHarness 默认禁用这条出站 authority。隔离开发可通过 `dev-instance --im-provider` 安装 [ADR-0104](../adr/0104-isolated-im-profiles-pin-a-qualified-temporary-provider-fork.md) 指定的临时 fork 完整 SHA,并在启动前校验运行时代码 digest;这不代表上游已发布或生产启用。dsh-im 仍持有 SDK/连接/凭据与原设置入口;BotHarness 不读取其私有 JSON,不接管 standalone Session 路由。#12 通过公开 `consumeInbound` 独占接收显式授权群内的文字 @:Messaging 先提交 bridge-message Source Event 与独立 Bot Inbox Admission,commit 后确认,再沿既有 group-mention harvest/steer 唤醒 Orchestrator。来源不需要本地 Channel placement,模型从 Inbox 了解接收身份、发送人及 group/thread/root/parent;`bridge_read` 读本地已保存来源,`bridge_reply` 仅接受来源 ID 与正文,Host 推导本 Bot 的有效身份与原回复路由,复用 Outbox 后调用公开 `replyChecked`,不镜像到 Human DM。撤销、归档、consumer 丢失、旧 Grant revision 都不能授权未来收件或尚未开始的回复;重启不重发未知 Outbox。外部来源 turn 不扩展 Memory acceptance 的来源授权,也不自动写入 Memory。详见 [ADR-0106](../adr/0106-exclusive-im-intake-commits-bot-inbox-before-acknowledgement.md)。 #612 的 `bridge_context` 通过同一 Inbox 来源和 Bot 身份调用公开 `historyChecked`,区分群列表、附近 ±5 分钟 Chat 时间窗与可信 Thread 列表;Host 按 JSON 预算仅将完整返回内容 reconcile 到 canonical Source Event,并记录有界读取 metadata。普通历史不建立 Admission、不触发 wake;已经存在且实际返回的 Admission 沿用当前 turn 的成功/失败处理。Provider 返回的显示名仅补充同一 canonical 身份;发送人和 @ 人员 ID 保持身份依据。Inbox 沿用 harvest 的 Message/Source Event 引用格式,上下文返回可用的姓名和 @ 关联,缺失姓名时回退 ID。Human 来源详情显示读取记录与最近一页,不自行读取远端。 + +### Lark 管理员配对(ADR-0136) + +[#1027](https://github.com/BotHarness/BotHarness/issues/1027) 的 application-defined 配对 owner 使用 Messaging 所属的 Operational Database,以当前 PersonaBot、外部身份 Binding、Provider 认证的真实用户及原始私聊路由固定授权范围。已绑定 Lark 身份在既有独占 Consumer fanout 上保留账号级控制入口:纯文本 `/pair` 先提交申请再确认,不创建普通 Source Event、Bot Inbox Admission、模型唤醒或 Memory 写入。普通消息仍需自己的接收授权;删除聊天路线不关闭配对入口,也不产生普通 DM 接收权限。Client 显示实际接收器是否就绪。 + +首次能力只能由认证 Web Human 显式勾选并按当前申请 revision 审核;定位码不能兑换权限,首位申请者不会自动成为管理员。能力仅覆盖当前 Bot 的批准、拒绝、回答及保存规则资格,不扩展 VPS、原生 DSH API、其他 Bot 或审批人管理。每次使用重查当前 Binding、Bot、真实 actor、能力和状态;暂停期间不可用,永久撤销后重新配对必须重新审核。该切片交付配对与 Web 审核,IM 审批/提问控件仍由后续切片接入原生权威。参数、恢复及审计边界见 [ADR-0136](../adr/0136-lark-pairing-is-reviewed-bot-scoped-operational-authority.md)。 + +### Lark 普通角色与自动申请(ADR-0165) + +Sender permission diagnostics validate the Source Event's original ingress authority before reporting that a platform has no Role policy (#1951). Grant-backed sources retain their Binding and Grant checks; Conversation Ingest sources record `ingressBindingId` in the canonical Source Event and validate that original Binding plus the current Ingest. A shared reader needs its own current Binding in the same Provider/account scope, and any resolved Role must belong to that checked Binding. Disabled, revoked, replaced, blocked, or unavailable authority remains a refusal. Legacy Ingest sources without an ingress Binding anchor return `no-current-scoped-identity`; a fresh message supplies the evidence rather than attributing old content to a replacement Binding. This adds no content copy, grant, listener, or schema migration. + +The #1415 candidate extends the same ordinary-chat authority to checked Slack DMs. The existing Bot-wide restriction covers Lark conversations and Slack DMs, remaining off by default; a Slack actor is identified as `slack-workspace-user-id` under the current Bot/App/Provider fingerprint and Binding. Unknown restricted DMs create only pairing metadata and a fixed notice; Web review assigns an existing Role, approval asks again, and only a fresh message enters the canonical Source Event/Admission/reply path. Current permission/directory reads reuse the same owner, and revoke/restart/rebind retain their fences. Slack group and native management parity remain separate. There is no new schema, listener, credential store or native RBAC. The isolated source candidate passed actual Web approval, fresh DM/current permission/directory reply, revoke refusal and restart with matched Source Event/Admission/Outbox facts; Human interaction feedback is accepted. Published product and Slack group/native management qualification remain separate. See [ADR-0174](../adr/0174-slack-dm-chat-pairing-reuses-current-binding-authority.md). + +#1415 候选将同一普通聊天权威扩展到 checked Slack 私聊。Bot 既有发送者限制覆盖 Lark 会话及 Slack 私聊,默认仍关闭;Slack 人员以当前 Bot/App/Provider fingerprint 和 Binding 下的 `slack-workspace-user-id` 标识。未知受限私聊只产生配对元数据和固定通知;Web 审核分配已有角色,批准后要求重问,新的消息才进入规范 Source Event/Admission/回复路径。当前权限与目录读取沿用同一 owner,撤销、重启和重新绑定保留各自 fence;Slack 群聊及原生管理能力仍由独立验收推进。没有新增 schema、监听器、凭据存储或原生 RBAC。隔离源码候选的真实 Web 批准、新私聊/当前权限/目录回复、撤销拦截与重启已通过,Source Event/Admission/Outbox 与原生结果一致,Human 已接受交互。已发布产品、Slack 群聊及原生管理能力仍独立验证。 + +[#1423](https://github.com/BotHarness/DeepSeekBot/issues/1423) adds application-defined Behavior permission items at the same Messaging owner. Groups reference Bot-scoped stable IDs with explicit Allow/Deny; current permission queries deduplicate overlaps, retain every Group revision/source and expose same-item conflicts with Deny priority. One primary behavior Role remains applicable. Settings provide a shared item table, separate edit/create dialogs, searchable Group checkboxes and affected-person previews; stale item/group revisions refuse writes and referenced-item deletion requires removing Group references first. The second slice adds bilateral authored exclusions: a shared pure resolver previews incompatible effective Allows, and the canonical transaction refuses item/group/member edits or Deny deletion that would introduce them. Reciprocal links and revisions remain in the existing item records, with no schema change. Generation 81 persists items in Operational Database; authenticated senderAccess commands and the existing bridge_sender_permissions Tool remain the sole write/read seams. Text guides model behavior, not arbitrary resource isolation or proof of semantic consistency. See [ADR-0173](../adr/0173-group-behavior-items-resolve-by-id-with-explicit-deny.md). + +#1423 的第二片沿用同一 Messaging owner:Human 按稳定权限项 ID 声明双向互斥。共享纯 resolver 预览最终同时有效的 Allow;Host 在既有事务中拒绝会引入不兼容的权限项、组、成员编辑以及 Deny 删除。明确拒绝仍优先;双方引用与版本保存在现有记录,不增加 schema。配置检查也覆盖暂时停用 Binding 的成员配置,避免重新启用暴露潜在冲突。任意文字或主 Role 的语义矛盾不由此自动识别。 + +[#1406](https://github.com/BotHarness/DeepSeekBot/issues/1406) adds application-defined External Authorization Groups and person exceptions at the same Messaging owner. One current Chat pairing can belong to multiple groups in its exact Bot/App Binding while keeping one primary behavior Role. Each current resolution unions Role, group, legacy management and person Allow, then removes any explicit Deny; private native delivery, decision and unused approval checks use this result. Group membership cannot approve chat or mint a management destination, and revoke/re-pair never inherits old member IDs or exceptions. Direct authenticated Web Human input retains default complete product authority and native execution approval. Generation 79 stores bounded revisioned group records in the Operational Database; existing authenticated senderAccess commands, snapshot and on-demand Tools carry the current sources without a second Consumer or cache. The Client adds Groups, scoped member selection and personal exceptions to pairing settings. See [ADR-0168](../adr/0168-external-groups-are-current-scoped-management-authority.md) for migration and boundaries. + +#1406 在同一 Messaging owner 增加应用定义的 External Authorization Group 与个人例外。当前 Chat pairing 可加入同一 Bot/App Binding 下的多个授权组,仍保留一个主行为 Role。每次解析先合并 Role、组、已有管理配对及个人 Allow,再扣除任意显式 Deny;私聊原生审批的投递、决定及尚未使用的批准都读取此结果。入组不批准聊天、不创建管理目的地,撤销后重新配对不继承旧成员 ID 或例外。直接认证的 Web Human 输入默认保留完整产品权限与原生执行审批。Generation 79 在 Operational Database 保存有界且按 revision 修改的组记录;沿用认证 senderAccess 命令、快照与按需 Tools 展示当前来源,不增加 Consumer 或缓存。Client 配对设置加入授权组、范围内成员选择及个人例外;迁移与边界见 ADR-0168。 + +人员、角色与申请表格的创建/修改时间来自 Messaging owner 持久化的 JSON 元数据,不由 Client 加载时间推断;读取和通知投递记账不推进修改时间。旧记录未记录的时间保持未知。时间字段是兼容的可选元数据,沿用 Operational Database authority,不增加 SQL schema generation。 + +[#1414](https://github.com/BotHarness/DeepSeekBot/issues/1414) extends the same owner with explicit `review-pairing` and a separately resolved Management Delegation Ceiling. A Lark group-only request can receive a public pairing card without a private-DM prerequisite; current reviewers with an observed trusted private route may also receive private copies. The qualified Provider retains its exclusive Consumer and a distinct `botharness/pairing-v1` callback namespace. Messaging checks the actual actor, current Binding and route, pending request and selected Role revision, and the reviewer's current use/delegation authority before a single canonical decision. Generation 80 stores bounded card receipts and actor-attributed selections; uncertain sends are not blindly repeated. Cards disclose review metadata rather than the blocked instruction. Approval only notifies the applicant to ask again; a new valid message must enter the normal Admission and model path. Group pairing cards do not change the private destination contract for native execution approvals or Questions. See [ADR-0171](../adr/0171-pairing-cards-check-current-review-and-delegation-authority.md). + +#1414 沿用该 owner,增加显式 `review-pairing` 与独立解析的 Management Delegation Ceiling。仅有群的 Lark 场景可直接收到公开配对卡片,不要求先建立私聊;已有可信私聊路由的当前审核人也可收到私聊副本。合格 Provider 保持独占 Consumer,使用独立的 `botharness/pairing-v1` 回调命名空间。Messaging 在唯一权威决定前检查真实操作者、当前 Binding/路线、待审核申请与选中 Role revision,以及审核人当前的使用/委派权限。Generation 80 持久保存有界卡片回执及具操作者归属的选择;未知发送结果不盲重试。卡片只显示审核元数据,不包含被拦截旧指令。批准只通知申请人重问;新的有效消息才进入既有 Admission 与模型路径。群配对卡片不改变原生执行审批或 Questions 的私聊目的地边界;详见 ADR-0171。 + +#1376 adds an explicit visitor exception on one existing allowed Bot/App group grant, independent of pairing and collection. Unpaired ordinary authors collected through authorized group/Bridge/thread/ingest paths remain passive (`silent` admissions, `context` scheduling evidence, no count/interval), never fill eligible wake thresholds, and may supply trusted author context to a later eligible request. The current requester alone supplies role-governed behavior; no background author union grants management powers. Closing, blocking and rebinding retain the independent authority fences. + +#1376 在已允许的具体 Bot/App 群授权上增加显式访客例外,与配对及采集独立。经授权的群、Bridge、线程或 ingest 采集到的未配对普通作者保持被动上下文(`silent` Admission、`context` 调度证据、无 count/interval),不填充有效请求的唤醒阈值,但可为后续有效请求提供带可信作者身份的上下文。角色行为以当前请求者为准,不合并背景作者的管理能力;关闭、封禁及重新绑定仍保留独立权限检查。 + +The #1375 application-defined Role mutation commands reuse this owner and authenticated sidebar seam. Revision-checked edits and single-role reassignment advance current policy revisions without rewriting historical Tool Results. The existing qualified private approval route reads current `approve`/`reject` Role capabilities for ordinary DM-origin pairings while preserving legacy explicit management grants. Group-origin pairing does not mint a management destination. Exact actor/Binding/pairing/receipt/native-lifetime checks remain Host owned; capability removal refuses delayed decisions and reassignment invalidates old routes. Natural-language behavior is not a resource ACL. + +The #1033 private Lark candidate adds independent `save-rules` management authority to that same resolver. Its distinct checked Provider callback previews the complete native operation and confirms a request/actor/private-receipt-bound nonce before the existing Channel Tool Approval owner can save and activate a rule. Generation 82 stores verified external creator provenance in the existing canonical Tool Approval Rule record. Each later match and final execution guard checks the original pairing and current `approve`/`save-rules` capabilities under the same Bot/App Binding. Authority loss permanently revokes the rule; re-enable and re-pair cannot restore it, while an independent still-effective grant preserves it. Web Human rules retain their prior authority. An IM-only approval creates a missing canonical Bot DM through the existing Channel Store and registry display-name lookup; it does not require opening Web or create a new Inbox Admission. Web and card status read the same canonical rule; fixed-text receipt-only cleanup may remove stale standing-rule claims after creator revocation without granting a destination or disclosing operation details. Native session-wide approval remains Web-only, and Workspace/Service grants are not enlarged. The Human accepted the actual exact-rule slice. The next candidate offers separately disclosed all-opaque scope only with Provider rule-card version 2 and its account capability; kind-bound nonces prevent an exact confirmation from granting that broader rule. External rules cannot auto-match Web-only native requests. The Human accepted the QA1 all-opaque first slice, and actual changed-input, Grant-scope, revoke/restart and final-card checks passed; an interrupted disabled-card update needed explicit idempotent receipt refresh. Published Provider qualification remains separate. See [ADR-0175](../adr/0175-external-tool-rules-retain-current-creator-authority.md). + +The #1041 source candidate observes Runtime-owned committed `sessionFailure` Channel messages and verifies Session ownership. Default-off event settings and bounded delivery metadata live in Messaging's Operational Database. Failure alerts resolve the same current private management route as approvals, qualify its genuine DM source through the checked Provider, then apply a final authorization/Registration fence. Only fixed failure/Session metadata is sent; error bodies and prompts remain canonical in Web. Delivery does not hold the failed turn, grants no decision capability, retries only known-unsent attempts within three tries and preserves Unknown Outcome without replay. Restart cancels pending intents and marks interrupted sends unknown. Bot DM sidebar controls use the existing authenticated Typert/API Gateway seam; group/additional Provider and Host-down monitoring remain separate. See [ADR-0176](../adr/0176-work-failure-alerts-use-committed-facts-and-management-route.md). + +#1041 源码候选观察 Runtime 已提交的 `sessionFailure` Channel 消息并校验 Session 归属。默认关闭的事件设置及有界投递元数据由 Messaging Operational Database 持有。失败通知沿用审批的当前私聊管理目的地,通过 checked Provider 核对真实 DM 来源,并在发送前校验授权及 Registration。仅发送固定失败/Session 元数据,错误正文和提示保留在 Web;通知不阻塞失败工作、不授予决策能力,仅对确定未发送尝试最多三次,未知结果不重放。重启取消等待意图并把中断发送记录为未知。Bot DM 侧栏经现有认证 Typert/API Gateway 配置;群、其他 Provider 和 Host-down 监控仍由独立功能推进。 + +#1375 的应用定义 Role 修改命令沿用同一 owner 和认证侧栏接口。按 revision 编辑角色或为人员重新分配单一角色会推进当前策略 revision,但不改写历史 Tool Result。现有合格私聊审批路线为从 DM 配对的普通人员读取当前角色的 `approve`/`reject` 能力,并保留既有显式管理授权;群内配对不创建管理目的地。真实 actor、Binding、配对、回执和原生请求生命周期仍由 Host 校验;移除能力拒绝延迟决定,重新分配使旧路线失效。自然语言行为不构成资源 ACL。 + +[#1373](https://github.com/BotHarness/DeepSeekBot/issues/1373) 沿用 Messaging owner 与 Operational Database,新增普通 External User Role、默认关闭的发送者限制和 `conversation` purpose 配对;既有 `management` purpose 的 `/pair` 显式能力保持独立。普通角色先支持自然语言行为权限与空管理能力,不新增 DSH 原生 RBAC,也不宣称隔离邮件、代码或任意 Shell 资源。配对按当前 PersonaBot、App Binding/fingerprint 与 Provider 认证的 Lark open ID 限定,可跨允许的 DM/群复用,但不跨 Bot/App/平台或重新绑定继承。Host 在默认接收、显式 Bridge、会话接入、共享来源及模型消费处逐 Bot 校验资格;不同作者的角色不合并。 + +未配对的受限 DM/群 @ 先持久提交有界审核元数据,不保存旧指令正文、不建立请求 Source Event/Inbox Admission、wake 或 Task。认证 Web Human 在 Bot DM Channel sidebar 经既有 API Gateway/Client seam 选择角色并按 revision 审核。批准先提交,再通过当前独占 Consumer 的受检查回复能力发送固定重问通知;只允许新的有效消息进入原有模型回复/Outbox 路径。通知尝试在外部调用前记录,未知结果不盲重试;接收回调先返回,避免与 Provider 账号队列死锁。撤销立即关闭后续资格及尚未执行的相关 Admission,既有历史保留。Generation 78、迁移和边界详见 [ADR-0165](../adr/0165-lark-chat-pairing-is-reviewed-current-binding-authority.md)。三个只读目录与完整首轮真人反馈仍由 #1374 交付,角色编辑/重新分配与显式开放群/被动上下文分别保留 #1375/#1376 的真人依赖。 + +The admitted Inbox context carries compact trusted sender/App/Source Event references rather than automatically injecting roles or permission snapshots. The application-defined read-only `bridge_sender_permissions` Orchestrator Tool calls the existing Messaging owner, resolves one accessible source and returns current pairing status, effective Role, policy revision and query time. It creates no authority or observation. Stable guidance calls for a fresh lookup of the actual requester before a new permission-sensitive operation, while ordinary chat need not query; unresolved lookup defers the governed operation. Historical Tool Results are dated evidence. Deterministic admission and supported management checks remain Host-owned. + +入箱消息只附可信发送者、App 和 Source Event 引用,不自动注入角色或权限快照。应用定义的只读 `bridge_sender_permissions` Orchestrator Tool 沿用 Messaging owner,解析可访问的规范来源,返回当前配对状态、有效角色、policy revision 与查询时间,不创建权限或观察记录。稳定行为指导要求对新的权限敏感请求重新查询真实申请人,普通聊天不强制查询;无法解析时暂缓受该策略约束的操作。历史 Tool Result 只是带日期的证据。确定性的接收资格与已支持管理动作仍由 Host 校验。 + +The #1374 `bridge_directory` Tool adds three bounded/pageable reads through the same Messaging owner and Operational Database: effective ordinary paired people/current Roles, known configured external conversations/states, and paired people observed in the conversation of an accessible trusted Source Event. Observations intersect current Bot/App/Binding pairing with authorized retained sources, including current joined shared placements, and exclude purged or previous-Binding evidence. At most 1000 recent matching sources are examined per query; query time, first/last observation times, truncation and incomplete coverage remain explicit. This is never a complete/current platform member list or a new destination/impersonation authority. Role-governed questions still resolve the actual requester with the current-permission Tool. Directory results are queried as needed, group replies remain visible to the whole group, and the complete first-flow Human feedback remains required before #1375/#1376. + +#1374 的 `bridge_directory` Tool 沿用同一 Messaging owner 与 Operational Database,提供三个有界可分页只读视图:有效普通配对人员与当前角色、已知已配置外部会话及状态,以及在可信可访问 Source Event 所属会话中观察过的配对人员。观察结果将当前 Bot/App/Binding 配对与授权保留来源取交集,包括当前加入的共享 placement,并排除已清除或旧 Binding 的证据;每次最多检查最近 1000 条匹配来源,明确给出查询时间、首末观察时间、截断与不完整覆盖。它不代表完整或当前平台成员名单,不授予新目的地或冒充权限。受角色策略约束的问题仍对真实申请人调用当前权限 Tool;目录按需查询,群回复仍全群可见,完整首轮真人反馈仍是 #1375/#1376 的门槛。 + +### Lark 私聊审批(ADR-0141) + +[#1029](https://github.com/BotHarness/BotHarness/issues/1029) 将已提交的工具审批发往 Web 显式选择的已配对管理私聊;通知路由及回执由 Messaging owner 在现有 Operational Database generation 60 保存,完整请求/决定仍归 canonical Channel 与 native Session。通知、决定接受、原生执行结果和卡片更新分别投影。现有独占 Consumer fanout 单独处理官方 SDK 卡片操作,不建立普通 Source Event、Inbox 或 Memory。公开的受检查 Provider capability 负责原始私聊/实际发送人、自己的卡片回执和平台写入前 fence;Host 校验实际点击者的当前 Bot/Binding/fingerprint/配对能力与 revision、目的地、request↔receipt、完整操作及原生执行者。允许一次/拒绝重入现有工具审批 broker,Web 与 IM 共用一位胜出者,执行前再次核验撤权与完整参数。回调确认不等于批准,批准不等于成功执行;重启使旧卡片失效,未知发送结果不重发。该切片不保存规则、不处理群审批/原生问题,也不释放 native wait。见 [ADR-0141](../adr/0141-lark-private-approvals-rejoin-the-native-owner-through-checked-controls.md)。 + +### Bot 之间的 Channel 协作(ADR-0065) + +Orchestrator Session 中注册的 `group_attention_get` 与 `group_attention_set` 是 DSH model-facing Tool,消费 BotHarness Host 的 application-defined Channel capability;Host 从该 Session 所属 PersonaBot 确定 actor,并在读写时复核当前 Group membership。未保存的 Group 偏好只读为 digest 5 条/30 秒、revision 0;Human Bridge 写入和 Bot Tool 写入共用 Channel record 中的 `wakePolicies`,每次实质变更在同一 SQLite 事务里追加不可改写的 actor/时间/revision 审计事实。审计表只存历史,不作为第二套当前偏好;Admission 在消息提交时固定有效策略与 revision,不会随之后的编辑回写。 + +#366 把 Human DM、Bot DM、群内提及、普通群消息、入群邀请与申请流程、Assignment 报告及生命周期的内建来源规则写成每个 PersonaBot 本地的不可改写修订事实。Host 在建立新 Inbox Admission 的同一事务里读取对应规则并保存来源规则 revision/wake 快照;既有 Admission 不回填。普通群消息的来源默认值沿用 5 条/30 秒 digest,适用的 per-Channel `wakePolicies` 覆盖仍保留在 Group Channel record;Assignment 进度报告仍按报告状态与回复请求决定是否即时唤醒。资料页经 Host Bridge 查询所有有效规则、修订及最后修改者。来源类别修订与 Channel 覆盖分属不同作用域。 + +#370 的 `source_attention_get/set/reset` 是仅对所属 PersonaBot 可见的 Orchestrator Tools,Human Profile 通过 Typert/API Gateway Bridge 编辑同一 `bot_source_policy_revisions` 修订权威。v1.0 可编辑提醒强度的来源为 `assignment-report`(`conditional`/`immediate`)与 `group-ordinary`(`immediate`/`digest`/`mentions`/`silent`,汇总条数及间隔有界);两者始终 `admit`。`human-dm`、`bot-dm`、`group-mention` 始终接收并即时唤醒;Human Bridge 与所属 Orchestrator 的 `source_attention_set/reset` 可编辑/恢复 `delivery: steer | turn`,默认 `steer`。直接来源要求 `wake: immediate` 和显式 delivery,拒绝汇总参数;其他可编辑来源拒绝 delivery。Host 在投递唤醒时读取当前 delivery;Admission 保留入队时的 wake 与修订。`group-invite`、`group-join-request`、`group-join-decision`、`assignment-lifecycle` 保持即时工作流通知;其中与 completed Report 明确配对的成功执行通知只搭乘收割,不重复触发同一因果唤醒(ADR-0077);其余来源没有安全的延后/静默收割语义,因此规则只读,Bot Tool 与 Human Bridge 都拒绝其修改。删除可编辑覆盖以带 Human/Bot actor 的新修订恢复内建默认,不删除历史;后提交的 Admission 才读取新修订。Orchestrator 真正启动时为所纳入的来源类别追加不可改写的 wake-attempt 事实,近七天计数从这些事实投影,而非把 Admission 数或汇总阈值当作唤醒次数。Group Channel 的普通消息覆盖继续独立存于 Channel record,优先于 PersonaBot 来源默认值;冻结规则按 ADR-0076 延后。 + +Attention 已交付契约(ADR-0070/0074/0077、#364):四档偏好均为普通群消息保留该 Bot 的 Inbox Admission。`mentions` 不自行唤醒,但同群直接 @ 可带入有限的待处理上下文;`silent` 不自行唤醒,也不搭乘 @,只在 Bot 显式读取时进入本轮。群聊触发 harvest 时同时选择触发消息附近的上下文与最早待处理的一段;单群至多 100 条,并受整轮文本/token 预算约束。提示中明确省略数量与继续读取位置,未选中消息保持待处理,后续合格轮次继续从最早处推进。`channel_read` 只把实际返回并进入本轮的 Admission 纳入处理集合:进入本轮显示处理中,成功结束才已处理,失败显示需修复。内部 observed 保留审计用途,不新增常用“完成”工具,也不把 Human 打开 Channel 当作 Bot 处理。#362 的通用多来源 Inbox Trigger 与 Attention 聚合仍待后续切片。 + +当前 #47 首个高流量 Group 切片把每位成员的普通消息 Wake Policy 写在 Channel record;该 preference 归 Bot 所有,Bot 可用自己的工具读写(含 count/interval),Human 可在成员侧栏查看并覆盖;四档:`all`(每条普通消息即时成为 attention)、`digest`(N 条 / T 秒汇总;默认)、`mentions`(只有直接 @ 到达)、`silent`(记录但不唤醒、也不搭车,只能显式读取)(ADR-0074)。任一模式下,普通消息作为 Source Event 与该 Bot 的 `group-ordinary` Inbox Admission 在同一 SQLite 事务中提交,Admission 固定当时的 policy revision;即时模式逐条就绪,汇总记录阈值,mentions 与 silent 记录空阈值。未保存偏好时采用 digest 的默认阈值,保留已保存的模式与 revision。直接 @ 走独立的即时 Admission,不等待汇总。Host 在达到数量或时间上限后把该 digest 批次标记为就绪;忙碌期间新到的即时项不再各自排队,只标记就绪集合,当前回合结束或空闲时由一次 harvest turn 消费全部就绪项(ADR-0077)。直接 @ 与 DM 默认 steer:有活动回合时在下一个安全 step 注入,否则并入下一次 harvest。重启从 pending/retryable Admission 重建计时,收割只把实际纳入 Orchestrator context 的 Admission 标为处理中,回合成功结束才标为已处理;Bot 通过 `channel_read` 明确读取到的普通群消息也加入该回合,成功后标为已处理,失败则需修复,未返回的消息保持待处理;是否向 Channel 回复仍由 Bot 决定。静默收件为普通消息保留待处理 Admission,但不设置汇总阈值,不自动唤醒,也不进入 harvest;只有显式 `channel_read` 才会进入本轮处理,直接 @ 仍即时。归档中的 Bot 在消息提交时不产生新的 Inbox Admission;群消息与其他活跃 Bot 的收件仍独立提交。唤醒处理按 Source 类别而非平台分类:外部平台在 Bridge 边界归一化为 Source Event,运行时只认 Source 类别与 admission reason(ADR-0075)。Bot 自管自己的 attention policy(来源类别规则 + per-Channel 覆盖 + count/interval),Human 可覆盖;冻结规则按 ADR-0076 延后,安全闸门不属于 policy。通用多来源 Attention 聚合与 Inbox Trigger 仍待后续切片。 + +五个应用自定义 attention Tools 使用现有逐 Bot 的来源策略与 Channel preference Provider。`source_attention_set` 省略 `sourceClass` 时默认 `assignment-report`,仅允许 `conditional|immediate` 且不能传 digest 参数;`group-ordinary` 允许 `immediate|digest|mentions|silent`,只有 `digest` 接受 digest 参数。计数与间隔使用整数 schema,由 Host 强制执行 1–100 与 1–3600 秒边界,省略时保留有效设置。逐 Channel 的 `group_attention_set` 在所有模式下保留可选 digest 设置。Source reset 恢复内置规则,不清除 Channel override,也不改变历史 Admission。读取、编辑和重置返回有效值、修订、最后编辑者/时间及有界七日 source wake 计数;非法组合在策略写入前失败。 + +当前 Bot-scoped botAttention Bridge 查询直接从 Inbox Admission、Source Event、Channel placement 与 Assignment Directory 投影有界页,按 Source Event 时间与 ID 排序,返回状态、发送者、摘要以及可用的 Channel 消息或 Assignment Session 引用;它不另建收件内容。Assignment Report 与 Source Event 在同一事务中形成该 Bot 的 Inbox Admission,并带报告状态;报告进入 Orchestrator 回合时记录观察,回合完成后标记已处理,是否向 Channel 回复由 Bot 决定。重启后未观察的到期报告可唤醒一次,已进入回合却中断的报告显示需要修复。Human–PersonaBot DM 的 Channel sidebar 在有事实时显示 Bot Inbox entry,按来源 Channel 或 Assignment 分组,待处理项展开,已处理和明确忽略的历史折叠;Bot 在收到或读取某条 Channel 消息后可用 `inbox_ignore` 明确忽略,决定写在同一 Admission 上并保留消息历史;回合完成本身不再 resolve 任何发给 Bot 的条目:它们变为 open,直到 Orchestrator 用 `inbox_resolve` 标记 done 或 ignored(ADR-0188),阅读本身不自动写入长期 Memory。点击来源消息复用 Channel timeline 的 around 定位,点击 Assignment 报告打开事项详情。Group Channel 不显示该 entry;Human 查看侧栏不改变 Bot 的观察事实(ADR-0070、#47、#152)。 + +Human Inbox 的首个可运行切片在 Bot mode 左侧栏的 Messages 上方提供独立入口,默认显示待 Human 处理的群聊加入申请、仍存活的原生提问和工具审批;#546 已交付的未读视图将群聊及 Bot→Human DM 按 Channel 汇总,信息视图保留无 Channel 的事项完成报告。提问项只在 DSH 原生请求仍等待 Human 答复,工具审批项只在 BotHarness 审批请求仍存活、且 Channel 内没有答复、取消或审批决定时出现;打开后定位到对应私聊卡片。事项的最新报告为 `waiting-human` 且 open ask 仍存活,或报告为 `blocked` 且尚未解决时,Human Inbox 从 Assignment Directory 投影同一条按 Session ID 稳定标识的待办;状态升级为受阻时更新摘要与报告来源,不重复建项。Orchestrator 回复后事项运行期间暂隐藏该项;若事项再次空闲或出错但没有新的解除受阻报告,待办继续显示。打开后进入该 Bot 私聊并展开事项详情;完成报告或停止事项后待办消失。事项完成报告则按最新 Source Event 投影到“仅供了解”,Human 可打开来源或忽略该份报告;忽略决定单独保存 Source Event ID、决定与时间,新报告仍会出现,不复制 Inbox 内容。Host 从 Channel record 中的待处理申请、Source Event/Channel placement 以及 Human 的 Channel read position 投影列表,不另存 Inbox 内容;批准或拒绝沿用 Group 决策事务,已了解沿用 Channel 已读位置。查询按时间与稳定 ID 分页,并把游标绑定到分类、Bot/Channel 过滤条件与排序方向;Client 在切换范围时丢弃旧响应。Bot 的 Channel Admission 进入 needs-repair 时,同一权威按受影响 Bot 与 Source Event 投影一条待办;Human 可打开 Bot Inbox 或来源消息,来源 Channel 已删除时降级到 Bot Inbox,修复状态解除后待办消失。Workspace Grant 请求也从 Bot DM Source Event 投影为待办;新提交的回复只有带有效 Grant 引用才会清除,历史已存储的本地化授权文字回复仍按兼容规则识别;unknown-outcome、rebind 与 readiness 等原因仍待各自的 typed Attention facts(ADR-0071、#126)。 + +[#687](https://github.com/BotHarness/BotHarness/issues/687) 切片让事件展开、行动执行和准确来源跳转彼此独立。只有点击整行才打开 Inbox 详情;工作区行动把权威请求上下文载入既有选择器,回复、提问、审批和 Assignment 行动使用原生 Modal 回应表单。重新打开工作区选择器时重查权威状态;这些控件沿用既有 Host 命令,不把决定权复制到 Client。 + +#547 切片允许本地 Human 在 Human Inbox 查看捕获的群聊或 Bot DM 未读消息、展开相邻上下文并原位回复。Channel owner 按该 Human 的可见范围返回 timeline,并在发送时再次验证回复目标;Inbox 复用现有 `channelSend`,只提交一条带来源消息引用的 Human Source Event。草稿与重试身份仅是 Client 临时状态;Inbox 刷新或发送失败保留草稿,未修改内容的重试复用同一消息身份。看到具体来源内容推进其权威已读位置,展开未读摘要不推进。来源及已确认回复均保留准确的 Channel 消息导航。 + +#550 切片允许 Human 在 Inbox 同一来源上下文面板中查看并决定实时工具审批,复用 DM 审批卡及既有 `toolApprovalStatus`/`toolApprovalDecide` Bridge 命令。认证 Host 再次检查来源请求和实时授权范围,提交权威 Channel 审批决定,并只恢复对应原生调用。批准、拒绝或过期后该请求离开待行动投影,其他 Bot 的请求保持独立,默认最久等待优先。成功或过期失败后 Client 刷新 Inbox 与独立行动提示,并从保留的旧分页中移除已确认解决的项。有界附近消息与准确来源跳转沿用现有 Channel timeline 边界,不新增审批存储或生命周期;已处理历史由 #553 投影。 + +#553 从已提交的 typed Human 回应 Source Event,经 Channel placement 关联原始提问、审批、Grant 请求或 Assignment 报告,投影已处理历史。同一请求 Source Event 只保留第一份权威回应的历史行,携带请求、回答、回应时间、Bot 与来源 DM;不复制消息、不增加历史表。游标绑定类别、Bot、Channel 和排序;阅读消息或忽略完成报告不产生已处理行动。Assignment 原生 Inbox 接收答复后才移除 Human 待办;仅收到 Human DM 保留转发地址和未解除待办;旧 blocked ask 已有权威 Human 答复时,后续明确报告建立新请求。历史上下文独立查询准确回答,不依赖请求附近各两条消息或已过期的实时 broker 推断已处理状态。Client 丢弃旧筛选结果,每次刷新最多重查三页、每页 50 项;更多记录可沿刷新后的游标再次加载。Assignment 上下文高亮准确报告,「查看 Session」进入原始 DSH Session,「查看答复」定位所属 DM 的准确回答。 + +#551 在同一 Inbox 来源面板支持实时原生提问,复用来源 DM 提问卡的准确问题、选项与自定义输入,以及既有 `userQuestionStatus`/`userQuestionAnswer` Bridge 操作。拥有请求的 Channel question broker 校验实时 Orchestrator、目标与答案,提交一次权威回答并恢复原生请求。Client 与工具审批共享决定后的刷新,从保留分页移除已确认回答或过期请求,其他 Bot 保持独立。有界上下文与准确来源跳转沿用既有 Channel timeline,请求生命周期及已处理历史仍归各自既有边界。 + +### 本地 Human 名称目标设计(ADR-0103) + +本地 Human 在一个 DSH Profile 内有一个可选默认显示名,在 BotHarness 插件设置中编辑,未设置时使用 `Human`。Human 参与的每个 DM 或 Group Channel 都可用 **Human Channel nickname** 覆盖默认名,通过 Channel 头部菜单中的「我的昵称」编辑。清除昵称恢复继承;修改默认名只影响没有覆盖值的 Channel。这支持 Human 与不同角色聊天时使用不同称呼,本次只保存昵称,不增加角色背景、另一个 Human 账号或另一份 Human Inbox。 + +既有应用自定义 Messaging 权威拥有默认名与 Channel 覆盖值。Host 按 Channel 昵称 → Human 默认显示名 → `Human` 解析名称,浏览器 tab 不拥有独立副本。名字修改经既有 Typert/API Gateway seam 指向 Host-owned 本地 Human。既有成员记录中的默认 `Human` 不视为用户主动设置的 Channel 昵称。Channel 作者名、成员、提及选择器、收件明细及 Human Inbox 上下文使用同一有效名称;Bot 获得的 Channel 成员及新组装或明确查询的消息上下文也使用它。昵称不授予权限,也不提供角色扮演指令;既有 DSH SessionEvent 与已组装的模型输入仍保留执行历史。 + +Human 与 PersonaBot 的提及保存带类型的稳定目标,在显示时解析可见标签,包括历史消息。Human 名称取消息所属 Channel 的当前有效名称,Human Inbox 中也按来源 Channel 解析;PersonaBot 名称取其身份的当前名称。已知目标优先使用当前名字;目标不可用时可保留记录的标签作为展示回退,绝不按名字改指另一个目标。普通文本不会被重新解释为可信提及。Source Event 内容与原提及范围保持不变,显示标签长度变化不修改持久 offsets,也不产生 Source Revision、新通知、Bot Admission 或 wake。 + +名字允许重复,包括同一 Channel 中的 Human 与 PersonaBot 同名;成员与提及界面区分 Human/你和 PersonaBot,并保留目标 ID。Channel 昵称始终标记同一个 Human ID,阅读位置、行动与提及仍归同一份 Human Inbox。外部账号映射与多人登录留待后续 Bridge 工作。默认名路径使用 Messaging 的 `local_human_names` 记录及受信 `humanIdentity`/`humanNameSet` Bridge 操作;Channel 摘要投影当前 Human 成员,作者、回执和可信提及按其与 PersonaBot 当前身份解析名称。Bot 的 Channel 读取在原消息旁提供带类型的 `actorNames`,仍受既有输出预算约束。名称提交沿用 roster 实时通知,不产生 Channel placement 或注意力事实。各 Channel 覆盖使用 Messaging 的 `channel_human_nicknames`,以 Channel ID 与 Human ID 为键;`channelHumanNameSet` 检查当前 Human 参与资格,拒绝只读 Bot-to-Bot 观察。DM/群聊头部菜单经现有 Bridge 编辑或清除覆盖。明确昵称即使文字与默认名相同也独立保存;清除覆盖后恢复继承(#622)。按 ID 引用提及可参考 [Slack 官方提及语法](https://docs.slack.dev/messaging/formatting-message-text/);Channel 昵称覆盖来自本地 roleplay 使用场景。 + +### 活动中心目标设计(ADR-0098、ADR-0099) + +#2026 将活动中心收束到个人 Human Inbox,完整页面和 Dock 使用同一原位工具审批卡,移除总览切换及用量、活跃度等统计呈现。首个切片通过现有 Human Attention 查询定位所属 Channel 消息,再读取权威审批状态;每次提交仍由原有 Channel Tool Approval owner 判断是否待处理。卡片默认提供允许一次、精确规则与拒绝;全范围授权保留在来源聊天。上下文在卡片下展开,摘要不推进已读;批量操作仅在显式选择模式出现。成功后由权威列表移除驱动约 220ms 的不可交互退场,失败原位保留并可重试,不向下一项转移审批焦点。此切片不新增 Host API、持久化表或审批生命周期;三组折叠列表与其搜索排序留待首个切片的 Human QA 后继续。下文总览、Channel 活跃度及 #698/#703/#705 的 UI 描述记录旧交付;查询能力保留,但不再作为当前活动中心的入口。 + +#548 切片从已加入 Group 的 Source Event 与同 Channel placement 的 `replyTo` 关系投影「回复我」:只包含 Bot 对本地 Human 可见消息的直接回复,仍绑定该 Human 的成员可见范围。每条回复使用 Source Event ID 稳定标识,按最近活动排序并允许 Bot/Channel 过滤,已读后保留浏览并从权威读位置计算未读标识。个人回复不重复进入「其他未读」Channel 汇总,入口未读总数仍统计所有不同的未读 Source Event。两个 Client 窗口及 Host 重启均从同一查询重建。原位回复和准确来源导航复用 #547 路径;上下文按原始时间顺序展示作者、头像、时间及回复目标,可展开有界相邻消息,宽屏采用列表与上下文并列,窄屏堆叠。 + +#549 切片将个人视图扩展为「提及与回复」。Orchestrator 通过 `channel_list` 发现当前 Group Human 成员,再用 `channel_send.mention_human_ids` 指定其稳定身份。Channel owner 验证当前成员关系,在已有 Source Event payload 内提交 Human 目标与显示偏移;普通文本不提供身份。一条 Bot 消息同时提及并直接回复本地 Human 时,个人视图只列一项,未读总数只计一次。现有 `replies` RPC category 和 Source Event item 身份保持兼容,可信提及用 `channel-mention` 区分。两种原因共用最近优先排序、Bot/Channel 筛选、已读状态、有界时间序上下文、原位回复及准确导航。Human 提及元数据不改变 Bot Admission 或 Wake Policy;范围仍是单一本地 Human,不新增全体 Human 广播或账号配置。 + +#123 审批提示切片通过现有版本化 Activity 快照及实时流独立发布正数 `attention.approvalCount`,不改变执行状态。process-local 来源是拥有原生工具审批的 broker:仅在权威 Channel 卡片提交后计数,决定、取消、scope 失效或 Fiber 销毁时,先移除提示再恢复 Tool 答案。侧栏、输入框与总览头像共用这个有界数量;请求身份及原始载荷仍只留在所属审批界面。新 Host 没有 live 审批,重启不从历史请求卡片复活提示。原生问题 broker 同样只在卡片提交后发布 `questionCount`。限时问题卡按 Session 和调用定位,不依赖提问的 Agent:Agent 释放后仍计数;新 Host 会重新打开原 Orchestrator Session 仍持有原生问题的未决限时卡片,回答经 Bot 的轮次队列、以卡片自身的 Source Event 为锚点恢复该 Session([ADR-0187](../adr/0187-personabots-use-a-product-preset-with-timed-questions.md))。其他 attention 类别及明确 waiting-on-Assignment 留待后续切片。 + +首个总览切片(#541)通过 `botharness/activityOverview` 读取现有 Human attention 投影的完整明确行动数,并连接 PersonaBot Registry、根 Session 归属、当前 activity 和 DSH 原生 live Agent 状态。首个切片当时将原生提问/审批等待和空闲 Assignment 的未答请求显示为等待或受阻;当前总览则将 Human 待办与执行状态分开,如上文所述。只有 live 且 thinking/working 的根 Session 进入正在执行列表,重启后不把旧日志的执行状态当作当前运行。视图拥有每五秒的查询刷新及卸载清理,离开视图后的旧响应不会覆盖新状态;无新增数据库表或执行权威。 + +在 #2026 之前,Bot 模式用一个「活动中心」入口承载「总览」和个人「收件箱」两个视图;前述 Human Inbox 段落记录已交付的首批投影,以下是后续目标。总览给出未解决的明确 Human 行动数、各 PersonaBot 实时状态,以及正在执行模型或工具的 Orchestrator/Assignment Session;点击 Bot 进入其私聊,点击任一 Session 退出 Bot 模式并打开 DSH 原始模式中的对应 Session。等待、受阻与空闲不计为活跃 Session。今日 Channel 活跃度按已提交消息数统计,主图逐 Channel 区分 Human/Bot,展开后按发送者查看;全局及逐 Bot token 用量可看近七天趋势,不按 Channel 猜测归因;Memory 展示逐 Bot 近七天已提交变更次数和当前未提交提示。各卡片消费各自权威的读模型,不另建消息、用量或运行事实账本(#34、#39、#424)。 + +收件箱面向当前本地 Human;已读事实按 Human 身份寻址,为将来多 Human 留出同一语义,但本切片不交付多账号。明确待行动、提及与回复、其余未读、信息更新和已处理历史分开呈现。待行动只计仍需 Human 决定或解除阻塞的 typed 请求,按等待最久排序;提及及未读按最近活动排序。繁忙 Channel 的未读折叠为一个 Channel 摘要,提及可定位准确消息;原生 Channel 尚无 Thread 子对话,不按 Thread 分组。展开摘要不推进已读位置;实际看到具体消息,或明确标记已读时,才在权威 Channel 读位置推进到相应消息。活动中心入口数字只计去重后的未读 Source Event,另用独立提示表示未解决行动;同一消息即使关联多个 Bot、兼属报告或提及,也只在 Human Channel attention 里出现一次,多个 Bot 分别提出的真实请求则各有行动卡。 + +行动卡在收件箱内显示问题、选项或输入框与简短可展开来源上下文;Human 可沿用来源处相同的授权命令直接回答,也可跳转原消息。已失效或被他人解决的卡片不允许继续提交;处理后从待行动移入可筛选的已处理历史,仍链接权威请求与回答。群组可回应请求须显式指明合格 Human 范围,首份有效回答解决同一请求,不暗含全员提及。#126 下一条 tracer 先交付本地 Human 的 Group 未读汇总、准确来源定位和已读;Human 提及/回复与 Inbox 内回应分步扩展。当前 ChannelMention 只记录 Bot,Human 提及需新的可信目标身份。跨 Bot 总览由 #541 交付,Bot Inbox 仍只负责选中 PersonaBot 的事件时间线。 + +Human 在 Group 中选择「@所有 Bot」时,Host 在发送时把当前 Channel 的活跃 Bot 成员解析为逐个直接提及:只提交一条 Channel Source Event,每位目标 Bot 获得与手动单独 @ 相同的 Inbox Admission、attention 与 wake;发送前向 Human 显示实际目标人数。它既不访问其他 Channel 的 Bot,也不允许 Bot 使用该快捷广播,不引入新的 Wake Policy。此输入能力由 #542 单独交付,不改变上述 Human 未读去重。 + +Bot-to-Bot DM 是两个 PersonaBot 参与的真实 `dm` Channel。Bot A 通过可信 Session ownership 以自己的 Actor 身份向 B 发送消息;Messaging 在同一权威中提交 Source Event、Channel placement 与 B 的 Inbox Admission,Bot-hop guard 限制循环,A 不接收自己的输出。Human 可以只读打开此类默认不在 roster 显示的 Channel,但不会成为第三位成员。A 每次向非 Human DM Channel 发出已提交消息时,引起这次发送的 Channel(群、Human DM 或 Bot DM;不可用时为 Human–A DM)都会出现居中的 Channel Notice,指向这次发信与可查看的对话,而不复制正文;A 的 Bot Inbox 同时得到一条已处理、永不唤醒的记录(ADR-0154)。 + +Human–PersonaBot DM 是这个 Bot 的时间线(ADR-0182):Bot 在群或 Bot DM 中发言时,同一事务在它的 Human DM 写入一条只含 `{ channelId, messageId }` 的 `botChannelPost` Channel Notice,摘录在读取时从原 Source Event 投影,不复制正文;原消息不可用时显示不可用。起因在别处的 Memory commit 记录除了留在起因 Channel,也在 Human DM 增加一个指向同一 Source Event 的 placement,读取时以 `noticeOrigin` 标出来源。Human DM 内的发言、以及已落在 Human DM 的 `botDmAction` 不再重复写入。Client 在 DM 聊天顶部 ··· 菜单中按动作类型(发言、记忆更新)提供只影响本地显示的开关,默认全部显示;同一 Channel 的连续发言合并为一行。 + +应用定义的 `list_bot_contacts` Tool 在所属 Orchestrator 的 Agent Scope 中读取同一 PersonaBot Registry。`query` 对名称、完整简介及稳定 ID 做不区分大小写的子串搜索,最多 200 字;也可直接浏览,默认每页 20 位,`limit` 为 1–50。将 `nextCursor` 原样作为 `cursor`,保持 query 不变;游标绑定所属 Bot 和规范化筛选,结果按稳定 ID 的 ordinal 升序排列。分页读取当前资料:名单及匹配状态不变时续页不重复、不漏人,改名不改变 ID 排序,移除/暂停者不再返回;游标之前的新建或新匹配身份需要重新搜索。结果包含 `botId`、最多 128 字的名称和最多 160 字的简介预览,并显式标记截断;含续页信息的完整 JSON 页最多 12,000 个 UTF-16 字符,因此可少于请求条数。仅传 `bot_id` 可读取单个活跃同事最多 1,000 字的简介预览。资料始终是非可信数据,不提供 Soul、私有 Memory 或凭据;重名用 ID 区分。返回的 `botId` 可原样用于 `bot_dm_send`、群邀请与 mention,发送时仍由 Messaging 重查目标活跃状态及群成员权限;发现过程不引入第二份目录或权限权威([#568](https://github.com/BotHarness/BotHarness/issues/568))。 + +Human–A DM 里选中 `@B` 会在 Human 消息中持久保存 B 的稳定 ID 与文字范围;Host 在 A 的 Orchestrator 回合组装输入时重新读取 B 的当前名称与最多 400 字的简介,并把它们作为有界联系人资料提供给 A。重名靠 ID 区分,改名采用当前资料,已归档或失效的目标在发送时被拒绝;手打或粘贴的 `@名字` 只是普通文字。此操作不唤醒 B 或改变 DM 成员;只有 A 后续显式发送 Bot-to-Bot DM 消息才触发 B 的 Inbox。Group Channel 中 Human 或已加入的 Bot 可 `@` 已加入的 Bot;一条消息仍只有一个 Source Event,每位目标 Bot 独立获得 Inbox Admission。Bot 创建 Group 时,Host 从 Orchestrator ownership 确定创建者,初始成员只有该 Bot;创建者可邀请活跃非成员 Bot。邀请状态存于同一 SQLite Channel record,创建待处理邀请、无 Channel placement 的 system Source Event 和受邀 Bot 的 `group-invite` Inbox Admission 在一个事务内提交。邀请携带可信的 Bot 协作跳数和受邀 Bot 的创建时间,跨 Bot 唤醒不重置循环限制,删除后重建的同名 Bot 也不能继承旧邀请。受邀者收到自己的 DM 上下文中的邀请 prompt,但在接受前没有 Group read/send 权限;接受把 membership 与邀请状态同时更新,拒绝不增加成员。Bot 模式提供默认开启的 Group 邀请自动接受(ADR-0073):设置由原生 Profile Config `botharness-client.autoAcceptGroupInvites` 持久保存,Client Fiber 将其实时读取器绑定到拥有邀请事务的 Core;只影响新邀请。开启时同一事务写入成员关系、accepted 邀请、system Source Event 与 handled Admission,并记录 `respondedBy=profile-policy`,不启动唤醒、不伪造 observed;关闭时保留上述逐邀请决定,Bot 的接受或拒绝也在同一事务结束邀请 Admission。启动时依据已有 accepted/declined 邀请补齐旧 Admission 的终态,避免重投或重启重复唤醒。重复邀请与重复同向决定幂等,归档目标时取消待处理邀请,重启时恢复仍待处理的 Admission。创建者可改群名、移出其他 Bot 成员;任一已入群 Bot 可用 `group_leave` 自行退出,成员关系在同一 Channel record 中更新,读写权限立即撤销,待处理群消息不再唤醒它;若创建者退出,Bot 管理权消失、成员界面标明由 Human 管理,其余成员与历史仍保留。Bot 自主退出或任何现有移出成员操作,都会在同一事务中提交一条 Host 撰写的群内成员变动消息,分别记录“离开”和“被移出”的类型与正文,供留在群内的人查看,并给每位仍在群内且活跃的 Bot 建立独立的普通消息 Inbox Admission,记录各自当时的 Channel 注意力偏好;退出者没有 Admission。all 可即时唤醒,digest 到达数量或时间条件后唤醒,mentions 可随之后的直接提及进入回合,silent 仅在显式读取时处理。进入回合的成员变动提示标明 Channel system,Bot 可按需回复。提交后的界面实时发布与 Bot 唤醒彼此独立;唤醒通知失败时运行中的 Bot Runtime 按有界退避重试持久 Admission,重启后仍由启动扫描恢复。Human 的群聊侧栏将成员名单与群管理分区:成员区标题的加号打开可搜索的邀请弹窗,带待办数字的铃铛打开邀请与入群申请处理弹窗;成员行左键打开 Bot 私聊,右侧菜单或右键菜单提供私聊、消息提醒策略弹窗与移出群聊。群管理区只呈现群头像、群名,次级菜单提供解散命令;Human 裁切 1:1 头像后将有界 WebP 图像写入同一 Channel record,Host 仍校验图像。邀请仍通过同一 Channel 邀请事实及 Bot Inbox Admission;Human 独占整群逻辑删除命令。关闭自动接受时,Human 发起的邀请在 Bot 接受前不授予群访问权限;删除撤销成员访问,保留 Source Event 等运行证据而不作破坏性清除。首个 Group Human `@` 切片是 #254,后续协作切片由 #278 组织。 + +Human 在自己的 PersonaBot DM 输入框中从 `#` 候选选中 Group,消息才记录稳定 Channel ID 与选中文字范围;Human 已发消息中的引用可点击进入该 Group。Host 在该 Bot 的 Orchestrator 输入中重查当前名称,非成员只收到 ID、名称和是否已加入,不读取成员或历史,普通手打的 `#名称` 没有引用身份。Bot 可在该 Human DM 所触发的回合用 `group_join_request` 请求加入选中的 Group;请求本身不会改变 membership。待处理申请保存在同一 Channel record,Bot 创建者收到独立的 system Source Event 与 Bot Inbox Admission,Human 在成员区通知弹窗可批准或拒绝,创建者可用 `group_join_decide` 决定。首个决定与成员更新、申请者的结果通知在本地事务中提交;申请者收到结果后才能按现有成员规则使用 `channel_read` 和 `channel_send`。重试、重启、改名、同名群都使用稳定 ID;归档申请者或删除群会取消待处理申请(ADR-0069、#292)。 + +应用定义的 `group_rename` 与 `group_remove_member` 工具只返回已提交 Channel 的简短确认:`channelId`、当前 `name` 与 `outcome`(`renamed` 或 `member-removed`);移除成员还包含 `memberBotId`。确认中不含头像、邀请/入群申请历史、成员列表或提醒策略。所属 Host 命令仍检查当前 Bot 创建者及成员身份,通过 Channel authority 提交;Human bridge 继续返回完整呈现记录。改名持久化未返回记录时,工具明确失败。查看当前已加入的 Channel 与成员身份应使用 `channel_list`,不能把命令确认当作群快照。 + +`group_create` 确认新建的 `channelId`、当前 `name` 与 `outcome: created`。`group_invite_bot` 确认已检查的 `channelId`、`inviteId`、`inviteeBotId`,并以所属命令返回的实际邀请状态作为 `outcome`,不假定所有结果都为 pending。`group_invite_respond` 返回同样的引用与实际决定状态,并附当前群 `name`。这些确认不包含内部身份版本时间戳或无关的 Channel 呈现状态。拒绝邀请不授予成员、读取或发送权限;重复相同决定保持相同引用,冲突、旧身份、已取消或未获授权的决定保留所属命令的错误。 + +`group_join_request` 确认当前 Human DM 已选中的 `channelId`、`requestId`、`requesterBotId` 与所属命令实际返回的申请状态 `outcome`;`group_join_decide` 返回同样的引用和实际决定状态,并附当前群 `name`。申请确认不要求非成员群出现在已加入的 Channel 查询中,也不授予读取或发送权限。确认不包含完整群记录、成员列表、头像、提醒策略或内部身份版本时间戳;首个决定、成员更新和申请者通知仍由同一 Channel authority 提交。重复同向决定保留相同引用且不再次通知,冲突决定、已归档或旧身份申请者、失去管理权的创建者继续由所属命令拒绝。 + +Orchestrator 的应用定义 channel_list 工具从当前 PersonaBot 的 Session ownership 派生身份,只返回其已加入的 Group、Human DM 和 Bot DM;可按名称、Channel 类型、稳定 Bot 成员 ID 筛选并分页,结果带当前成员身份。它是 canonical Channel record 的授权查询 Consumer,不创建第二份成员目录。Bot 用返回的稳定 Channel ID 调用 channel_send;发送时仍重新检查当前成员资格,旧查询结果不会授予访问权。应用定义的 channel_read 查询在成员校验后对该 Channel 的完整有序消息历史应用正文、作者及日期过滤,再返回有界游标页;回复预览仍解析自原消息,不因过滤失去引用。`channel_read(scope=joined, text=...)` 对当前已加入的 Channel 进行跨频道正文搜索,结果按时间与稳定 ID 排序并分页;成员关系变化会使旧游标失效。原先仅扫描每个 Channel 最近 200 条的 `channel_search` 工具已移除,Bot 不再面对两个含义重叠的搜索入口。 + +应用定义的 Channel 查询工具枚举 `type: group|dm`、`scope: channel|joined` 和 `author_kind: human|bot|bridged|system`,所属 Host 仍防御非法值。`channel_list(channel_id=...)` 在全部过滤后没有可访问匹配时返回 `{channels: [], outcome: no-accessible-match}`;未知、不可访问或与其它过滤不符的目标使用同一确认,不泄露存在性,普通空搜索仍返回 `{channels: []}`。`channel_read(scope=joined)` 必须有非空白 `text`,不能同时指定 `channel_id`;`author_bot_id` 只能与省略或 `bot` 的作者类型组合。日期界限包含端点,日期形式 `YYYY-MM-DD` 的下界为 UTC 当日零时、上界包含整个 UTC 日;不可解析或倒序范围失败。游标仍绑定原过滤,跨已加入 Channel 查询还绑定当前成员集合;读取仅观察实际返回的消息。两个工具的 `limit` 保留 number 与既有兼容行为:默认 20,向下取整后夹取至 list 的 1–100 或 read 的 1–200。固定 DSH 0.2.0-rc.1 转换器支持 integer,但不支持 minimum/maximum;本票不收紧已有小数和越界输入。 + +模型侧 `channel_read` Consumer 使用 Host 所有的可行动消息投影,在精确 ID observation 前对完整序列化结果施加 12,000 个 UTF-16 code unit 的输出预算(ADR-0102)。投影去除 Human receipts、deliveries 和 Channel revision,保留完整正文、回复、可信附件及行动引用。因预算未返回的消息仍为 pending,并提供绑定原过滤的继续游标;首条超长消息提供 `message_id` 完整内容读取路径。有界 JSON 片段绑定当前投影的哈希及偏移,每次复查成员资格,只有同一活跃 turn 接收到完整连续内容后才加入消费集合。Human Channel / Inbox canonical records 和 bridge 呈现保持不变。 + +### 外部 CLI 的在线请求与回复 + +应用定义的在线 CLI 依照 ADR-0159,每次从当前 Host 启动令牌换取内存 cookie,经既有 DSH HTTP unary carrier 调用 Typert Bridge;不另设监听器、凭据缓存或数据库旁路。发送先由 Registry 查询确认 Bot,再由 Channel owner 建立 DM,使用 `human-UUID` 消息 ID 提交;重试去重仍归 Host。新增 `channelSendStatus` 只读查询复用 `source_events`、`inbox_admissions`、`channel_output_origins` 和 `session_ownership`,按原请求 Source Event、同一 DM 与 Bot 所有的 Session 返回已提交回复和当前处理状态,不以时间窗口或最新一条消息猜测对应关系。问题/审批卡片、通知及失败不算回复;超时保留回执供查询,不自动重发。工具审批、正式问题、工作区授权和发布状态沿用各自 Host owner;无新增耐久表或权限权威。见 [CLI 指南](../bot-cli.zh.md) 与 [#1317](https://github.com/BotHarness/DeepSeekBot/issues/1317)。 + +依照 [ADR-0162](../adr/0162-online-cli-management-keeps-explicit-host-authority.md),显式 Host 选择扩展到 Registry 创建/查询、Model Preset/Plan 与 Channel 发现。Zip/目录由 CLI 上传调用者本地字节,经已有 authenticated Bot Zip import owner 验证和创建;Git 获取仍在 Host 执行。创建和模型应用是分阶段 owner 操作,部分失败保留已知身份,未知结果不自动重试;连接失败不切换到离线 Profile。attention、Session 与 activity 查询只读既有投影,提供有界调试摘要;测试脚本经同一业务 CLI 驱动精确请求与持久回复,并可显式调用删除 owner 的 preview/confirm/retry 清理本轮身份,保留 Memory。writer lease 不构成 RPC 全局串行锁,各 owner 的版本/HEAD 与幂等契约保持独立。见 [#1363](https://github.com/BotHarness/DeepSeekBot/issues/1363)。 + +### CLI 的 IM 应用授权 + +应用定义的 CLI Consumer 通过既有 Messaging Service 的 `messagingApps` 发现已资格验证的 Provider setup descriptor,再经同一个已认证 DSH HTTP carrier 调用原生 Connection Fetch Registry 的 `dsh-im/app-setup` endpoint。该 Provider 拥有授权 attempt、过期和原生账号/凭据持久化;CLI 只保留一次调用的内存 cookie,不创建额外 store 或生命周期。飞书凭据和微信验证码仅从 stdin 进入目的明确的 `setup.credentials`/`setup.verify` payload;stdout 只投影经过校验的非敏感状态、二维码、账号引用与指纹。微信扫码由 Human 在手机确认,再用同一 attempt 查询;IM 应用授权不选择 PersonaBot 或建立 Messaging Grant。见 [ADR-0161](../adr/0161-cli-im-authorization-keeps-provider-attempt-authority.md) 与 [#1318](https://github.com/BotHarness/DeepSeekBot/issues/1318)。 + +### 配对设备的远程访问 + +[ADR-0199](../adr/0199-paired-devices-reach-the-host-through-a-same-listener-device-gateway.md) 已由 [#1652](https://github.com/BotHarness/DeepSeekBot/issues/1652) 实现:配对的手机不持有任何 DSH 凭据。应用定义的 Device Pairing 模块(`packages/core/src/devices/`)经 DSH 公开的 `webServer.register`/`registerUpgrade` 在同一 `dsh web` 监听器上提供设备网关:设备在 BotHarness 前缀下以 `Authorization: Device .` 调用与 Web Client 相同的 unary 信封和 Remote stream mux 帧;模块每次按运维数据库中的设备记录(只存哈希)校验并更新最近使用时间,再用 Host 内存中经 `connection.authenticatedUrl()` 自行换取的 loopback cookie 转发至原生 `/api` 与 `/api/remote.mux`。DSH 签名 cookie 的密钥本就跨 Host 重启保留,但 cookie 不标识设备,且 stream 升级不经过 `connection/request`,故不能把 cookie 交给设备。撤销立即拒绝后续请求(编码 401:`device-revoked`/`device-unknown`/`device-credential-missing`),并关闭该设备已打开的 stream socket。配对邀请、列表与撤销只在已认证的 `/api` carrier 上提供给 Human:Web Client「设置 → 设备」显示一次性、10 分钟有效的二维码(即 `deepseekbot://pair?p=…` 深链接,携带 Host 标识与按序路由),列出设备的配对与最近使用时间,并可撤销。见 [#1644](https://github.com/BotHarness/DeepSeekBot/issues/1644)。 + +托管 Connect(目标状态,尚未实现):[ADR-0241](../adr/0241-hosted-connect-reaches-the-host-through-a-managed-tunnel.md)([#2387](https://github.com/BotHarness/DeepSeekBot/issues/2387))在路由列表里增加一条 Connect 路由,顺序为 局域网 → Tailscale HTTPS → Connect。账号复用 TOB 的 WorkOS 环境,只用于发现 Host 和经纪一次性配对凭据;本仓库新建的中继 Worker 为每台开启 Connect 的 Host 代管一条 Cloudflare Tunnel,ingress 只放行 `/botharness-device/*`,应用流量不经过 Worker。手机登录后无需在电脑上确认即可配对,新设备会在电脑和其他手机上提示并可一键吊销。先在中国大陆以外实现并验收,大陆可达性之后另找测试者验证,不阻塞实现。 + +### 手机推送 + +[ADR-0202](../adr/0202-phone-push-is-a-content-free-projection-of-the-human-inbox.md)([#1655](https://github.com/BotHarness/DeepSeekBot/issues/1655)):应用定义的 Push delivery 模块(`packages/core/src/push/`)是 Human Inbox 的投影,不另设通知权威。Human Inbox 表或 PersonaBot 的审批、提问、私密输入计数变化时(以及每分钟一次的兜底扫描),模块读取 `action` 项与未读的 `replies` 项,对每台已登记的配对设备只发送一次新项,并经 Expo Push Service 投递;推送标题为频道名(`#名称`,私信为 PersonaBot 名称),正文为所需操作加上工具与主要参数、问题或消息的简短预览(折叠空白,最多 140 字符);私密输入请求不含内容([ADR-0213](../adr/0213-phone-push-shows-the-channel-and-a-short-preview.md))。数据字段只含 Host 标识、Human Inbox 项标识、PersonaBot 标识与读取请求所需的 Channel/消息标识,并携带未处理 Human 操作数作为角标。设备经设备网关的 `/botharness-device/push` 登记 Expo push token、切换各类通知、发送测试通知;撤销设备或 Expo 回报 `DeviceNotRegistered` 时删除 token。发送失败有界重试,结果写入不含内容的 `push` 开发者日志。App 保存每台配对的 Host,点按通知先切换到对应 Host,再打开该审批、提问或私信。 + +## 5 · Orchestrator 与 Assignment control plane + +Human 不负责创建或选择执行 Conversation。Human–PersonaBot DM 是 Human 与该 Bot 直接对话的入口:消息先成为 Source Event,经 Bot Inbox 交给 Orchestrator;Orchestrator 再决定直接回复,或在授权与 capacity 内创建、复用和管理多个 Assignment Session。普通 Orchestrator assistant final 只留在 DSH SessionPersistence;只有显式 Channel messaging command 才产生 Human-facing Channel message。该 command 从可信 Session ownership 推导 PersonaBot Actor,并验证目标 Channel membership,不接受模型自报 bot id 或 author。右侧「会话」只投影明确归属该 PersonaBot 的独立 DSH 根 Session,包括 Orchestrator 与 Assignment,不展示 Subagent;归属与角色来自 Session Ownership,标题、工作区及实时运行状态来自 DSH 原生 Session 目录,不以 cwd 推断归属(ADR-0072)。 + +Workspace Grant 是 application-defined 的持久授权记录:Human 通过 DSH 的 Host 目录选择器或绝对路径输入添加一个现有文件夹,由 DSH Workspace registry 解析真实路径,再为 PersonaBot 创建 Grant。Orchestrator 的 cwd 始终是自己的 Memory Repository;它可读写 Memory,并可读取所有当前有效 Grant 的项目目录,只在 Human 为该 Grant 明确开启 Orchestrator 写入权限后可写项目目录,不能自行创建 Grant 或开启自己的写入权限。每个 Assignment 选择一个有效 Grant,固化 Grant ID、Workspace ID、单一 primary cwd 与权限快照,只能读写所选目录。撤销 Grant 会阻止两个角色后续的文件访问及该 Grant 的新事项创建、请求和恢复;历史 Session 不因重新授权而复活。授权列表中 Memory 是固定内部目录,项目目录可添加、撤销,撤销不会删除 DSH Workspace 或磁盘文件。DSH 原生 workspace-write 仅限制部分写入并不隔离读取,且可能允许临时目录写入。BotHarness 在最终 Tool guard 对 DSH 原生 read、read_image、write、edit、str_replace_editor、glob、grep 的路径按当前 Grant 校验:Orchestrator 可读 Memory 与所有有效 Grant、可写 Memory 与 Human 明确开启 Orchestrator 写入权限的 Grant;Assignment 只可读写固化的单一有效 Grant。Shell 与插件工具按 Session 自身的 DSH 沙箱模式与审批策略运行;DSH 发起的审批(例如 Shell 请求 `sandbox_permissions`)进入应用定义的 Channel Tool Approval owner。普通 Web 卡只有允许这次/始终允许/拒绝;新规则记住同一 Bot、根角色和当前授权范围内的当前工具,输入可变,旧精确与宽规则保持原义。每次原生询问仍获得独立的 `allowed-once` 审计;规则撤销或范围变化使其失效。工具批准不等于目录隔离,也不创建 Grant;Shell 可能触达目录之外,界面需说明。每个 PersonaBot 的 Access Preset 默认为 `workspace-write + ask`;Human 经既有风险确认可开启 `danger-full-access + ask`,按所选 scope 作用于当前 Orchestrator 和/或之后新建的 Assignment。已有 Assignment 保留其 mode/Grant/revision 快照;本次迁移只规范旧 Full Access 的 approval policy。Full Access 普通请求自动回答,识别出的权限/策略变更必须 Human 本次决定;撤销、当前身份和独立能力检查仍适用。详见下文三动作边界及 [ADR-0239](../adr/0239-three-action-tool-approval-and-full-access-use-native-ask.md)。 + +### Three-action tool approval and Full Access (#2080) + +Ordinary Web approval uses Allow once / Always allow / Reject. The application-defined Channel Tool Approval owner records `allowed-always-tool` with the Human decision, activates a revocable `tool` rule in the existing Operational Database, then returns DSH's native `allowed-once`. A tool rule matches the current Bot, root role, authority scope and tool name across changed inputs. Exact, all-opaque and Secret rules keep their old semantics; new ordinary cards do not mint all-tools authority. Current Grant/scope checks, execution guards and rule revocation remain authoritative. The Web Channel, Activity/companion reuse, online CLI and readable mobile history share that decision contract. + +Top-level Full Access composes its existing unconfined execution mode with native `ask`, replacing the earlier `never` combination. The same responder automatically answers ordinary requests only while current Full Access remains valid. Recognized permission/policy management always requires an individual Human decision and skips automatic rules and Reviewer; directory grants still use the authenticated Owner read/write command. Generation 104 preserves existing rules and updates only old Full Access Assignment approval snapshots, without changing mode, scope, Grant or preset revision. Delegated Subagent policy remains native `never`. This is application policy rather than an override of DSH's `never` semantics. See [ADR-0239](../adr/0239-three-action-tool-approval-and-full-access-use-native-ask.md). + +当所需项目尚无有效 Grant 时,Orchestrator 可通过专用工具在自己的 DM 提交一条持久授权请求卡;该工具只能请求,不能发放 Grant,也不会预建 Assignment。Human 在卡上明确确认文件夹读写后,认证 Owner 的共享命令核对当前身份与目录,在单个 Channel 事务中提交可写 Grant、关联 Human 决议和 Inbox Admission;提交并读回后才续接原任务。重复确认沿用已提交的决议,撤销后的旧请求不能重建权限。回复携带经 Host 核验的请求消息 ID 与有效 Grant ID;Human Inbox 从请求和回复 Source Event 投影一条待办,普通回复不解除,授权回复解除。包括历史请求在内,仅有关联有效 Grant 的已提交结构化回复解除授权待办;原始文本保留,普通“已授权”文案不作为授权证明。Inbox 与来源 DM 复用同一目录选择及授权操作,提交事务重新核验请求与 Bot Grant,两窗口只能提交一条有效回应。Orchestrator 重新列出有效 Grant,随后才用选中的 Grant 创建 Assignment;卡片和授权记录是不同的持久事实。普通 Assignment Ask 仍先回到 Orchestrator。Orchestrator 遇到 Human 未指明的 Memory 分支选择时,调用 DSH 原生 `ask_user_question`;BotHarness 在该 Agent 作用域的 `user-questions/request` waterfall 中提交持久 Channel 提问卡,Human 的选项或自定义回答经同一 DM 回到原生 Service,使同一 Session 继续。卡片保留来源 Session,回答和取消各有持久消息;停止、重启或旧请求不得再次回答。明确目标直接切换,不提问。Assignment 不直接向 Human 使用此原生工具,仍通过 Orchestrator 转问。 + +右侧是 Channel sidebar(ADR-0053):group Channel 显示成员与 Channel 管理 entries,Human–PersonaBot DM 显示该 PersonaBot 的 entries(会话、Memory、Bot Inbox、Computer 等);entries 由统一注册 seam 提供、可折叠、按声明顺序排列,注册描述符可提供 Lucide 图标名称与标题操作;会话、Bot 收件箱和记忆演化的显示设置放在各自标题右侧的齿轮里,会话范围/布局保持 per-PersonaBot 浏览器偏好、记忆术语保持全局浏览器偏好,未注册或不可用时直接不显示而不是占位。Chat 始终是中间的 Channel body。「会话」把 Orchestrator 固定在顶部,Assignment 按状态分为可折叠的「进行中」与「空闲」,按最近更新倒序并平滑过渡位置;「全部」保留已停止的历史 Session;Human 可切换按状态或按工作区折叠分组,每个 Bot 的范围、布局与折叠选择仅保存在本浏览器。点击行使用 DSH 原生 `UiWorkspace.openSession` 打开对应 Session,而不是维护一份只读事项详情。归属该 Bot 的根 Session 可通过原生标题栏及 Session 菜单返回 Bot 私聊;空闲时左侧标题前显示 Bot 头像,运行状态及日程标记仍优先显示。Assignment Directory 继续持有 Grant、续接、报告、停止、并发及审计事实。 + +Channel sidebar 的编辑模式只折叠当前呈现而不改写展开偏好,拖动/方向键只改变草稿;完成保存同类 scope 的顺序,取消丢弃草稿,恢复默认也属于草稿。浏览器本地分别保存所有 PersonaBot DM 共用的顺序与所有 group Channel 共用的顺序;未显示/未注册的稳定 ID 保留位置,新 entry 按注册顺序附加。切换选择或关闭侧栏丢弃编辑状态,授权撤销仍经原有 expandable seam 清除禁止展开的状态;排序不写 Host 或 Memory([#721](https://github.com/BotHarness/BotHarness/issues/721))。 + +桌面 Bot 模式(窗口宽 961 px 及以上)把 `main` 区域改为一个 dockview 布局([ADR-0203](../adr/0203-bot-mode-is-a-dockable-layout-that-plugins-extend-with-dock-panels.md)):Channel body 与每个 Channel sidebar entry 都是独立的 **Dock panel**,不叠成标签页、不浮动,只能拆分放到彼此旁边。BotHarness Client 用 `ctx.provide('dockPanels', …)` 提供 Dock panel Registry:其他插件 `inject` 后调用 `register({ id, title, icon, scope, defaultPosition, order, component, headerAction? })` 并获得释放函数,重复 id 抛错,排序按 `order` 再按 id。`scope: 'channel'` 的面板收到当前 Channel,没有 Channel 时显示空状态;`scope: 'global'` 的面板不随 Channel 变化。可选 `headerAction` 在同一面板标题栏渲染,并与正文接收相同的 Channel 范围;活动中心以此提供筛选与排序菜单,正文不再显示分类 tabs。`defaultPosition.zone` 只决定第一次打开的位置:`left` 为最左一列,`center` 在 Channel body 下方,`right`(默认)叠进右侧栏,`bottom` 在新工作区内放到 Channel body 下方,以保留全高控制列;旧布局仍为底部一行;`relativeTo` 指向的面板已打开时,紧接在它后面;Human 关闭过的面板回到它原来的位置。`channelSidebar.register` 现在同时把非设置类 entry 注册为 `channel` 面板,同一 tab 的 entry 默认上下相邻;窄屏遮罩侧栏仍直接读取 `channelSidebar`。同一 Service 还提供 `open`、`close`、`toggle`、`isOpen` 与 `openPanels`,由当前挂载的布局执行;Channel body 标题栏的 **Panels** 菜单与其他入口(例如侧栏快捷卡片)都走这组接口,菜单末尾是「重置布局」。布局只是浏览器本地呈现,保存在 `localStorage` 的 `botharness.bot-mode-layout`(版本 4),不经过 Host,因此没有 CLI 命令;插件自己的数据仍走它自己的 Service 与 Host Provider(#1789、#1791)。新工作区在控制列、旧侧栏回退在品牌行下方为每个已注册的 Dock panel(Channel body 除外)显示一张固定小卡片:卡片在面板打开时高亮,点击经 `toggle` 开关面板,Service 的 `available()` 告诉它当前是否挂载了布局。活动中心是内置的 `global` Dock panel(`id: 'activity-center'`),它的卡片取代原来的活动中心行并保留未读数与待处理红点;旧侧栏回退中没有挂载布局时,这张卡片打开完整的活动中心页面,其他卡片不可用(#1829)。 + +#2017 首片在符合 rc.2 AppFrame 结构的 Web/macOS 宽屏中启用工作区:一个不可关闭、可移到左/右/任意整列之间的 Workspace Control Rail(工作区控制列),以及独立的全局 Channel 列表面板(`bh:channels`)。列表沿用现有 Roster 与选中 Channel 的唯一状态。没有选中 Channel 时,控制列和全局面板仍可操作。`workspace-shell` 只对当前 AppFrame 标记后释放原生侧栏占位,临时将公开 `layout.toggleSidebar()` 命令接到 Channel 列表开关,退出、卸载或收窄时恢复原方法描述符并移除标记;不改原生 Store、宽度或折叠偏好。结构不匹配、命令无法接管或 Windows 标题栏布局使用旧侧栏回退。macOS 仅在红黄绿按钮覆盖的左上角避让,其他面板利用顶部空间,全屏取消避让。窗口拖拽与面板拖拽保持各自区域。 + +**两层布局(会话、记忆与定时任务已迁入,其余分阶段迁移)**:[ADR-0221](../adr/0221-global-and-channel-workspaces-own-panel-placement.md) 与 [#2048](https://github.com/BotHarness/DeepSeekBot/issues/2048) 在上述已交付的平铺布局之上定义 Global workspace → Messages panel → Channel workspace 的归属。#2049 首片把会话移入消息面板:沿用已有会话查询与快捷键命令,新增稳定 DOM 的四方向分割容器;版本 4 的原布局存储按稳定 Channel ID 保存开关、方向、比例和隐藏入口偏好,并保留升级前原始备份。旧 Work 迁移先执行;隐藏父面板保留子状态,会话快捷键恢复父子面板。最终 Control Rail 只管理全局面板和消息面板;频道子面板由消息 header 控制,右键该 header 以图标、名称和复选框管理开关显隐(隐藏开关同时关闭子面板,不设单独管理按钮),并在父面板内分割、移动、按频道保存比例与开关。隐藏父面板不清空子状态,也不改变执行或权限。群聊保留群资料和成员,不提供记忆文件、记忆演化、定时任务、Browser、Computer 或 PersonaBot Profile,即使会话显式查看成员 Bot 也不例外。共享的浏览器/电脑资源设置仍可属于全局;Bot 专属访问与视图不因物理资源共享而变成全局。 + +[#2049](https://github.com/BotHarness/DeepSeekBot/issues/2049) 实现首个可运行切片:仅把会话迁入消息面板,保留 ADR-0220 的 DM/群聊查询与访问校验,其他面板暂留外层。现有 Client 布局 owner 负责外层与每频道内层偏好、带备份的幂等旧布局迁移及首次使用种子;不新建 Host 数据权威。沿用原会话快捷键标识和原生绑定:父隐藏时恢复父子,父显示时仅切换子面板。正式目标只有两层,不引入递归工作区、标签页或浮窗。验收入口为真实 Web 升级旅程与 macOS Desktop,证据记录在该切片的 PR;其余子面板的迁移仍待后续反馈。 + +[#2067](https://github.com/BotHarness/DeepSeekBot/issues/2067) 将记忆文件、记忆演化和定时任务加入同一消息内布局:各分割容器保持稳定内容身份,分别保存方位与比例。方位由 Human 选择,消息面板变窄时不自动换轴或限制停靠方向;布局库的主题与拖放配置保持稳定,只在实际配置变化时更新,避免拖拽中的重排反馈。原布局 owner 的版本 5 在保留会话 v4 字段的同时,新增按 Bot DM Channel ID 保存的三个面板状态与首次使用种子;旧外层面板和 Rail 项移除前备份原记录。原快捷键标识与默认键(Primary+Alt+Shift+6 / E / 9)保持,父隐藏时恢复父子,群聊不响应 Bot 专属操作。右键消息 header 管理图标、名称、复选框形式的开关显隐;隐藏开关同时关闭子面板。所有变化仅涉及呈现,复用既有 Memory 和 Schedule 查询与权限 owner。[#2069](https://github.com/BotHarness/DeepSeekBot/issues/2069) 将浏览器/电脑加入同一 Bot 频道子面板集合:v6 单独备份旧记录并移除旧外层节点,保留 v5 记忆/定时任务状态;默认 Primary+Alt+Shift+B / 0 与自定义/解绑保持。未启用状态复用居中图标说明,启用仍使用原子面板 header 的显式电源操作;布局开闭不改变 Browser/Computer Access 或执行资源。 + +Control Rail switch visibility is a workspace preference (`hiddenControls`) in the existing versioned layout record. The rail context menu updates that preference and closes a hidden Panel through the existing Dock owner; checking it again restores the entry without opening content. A Panel header's Close action changes only open layout state. Explicit shortcuts can still toggle Panels independently of rail visibility. Tooltip keycaps project the effective native shortcut catalog, including customized bindings, and render outside the rail's clipping boundary. + +Application-defined pointer context menus share `ds/PointerMenu`: callers supply viewport coordinates, while the design-system component owns the body portal, measured coordinate conversion under CSS zoom, native frame clearance and viewport edge avoidance. Regular control-anchored dropdowns keep the native DSH Menu. This ownership prevents each Panel or Channel surface from inventing a separate offset. + +频道导航不会重建工作区和 Dock(ADR-0203)。`scoped-state.ts` 按选中频道及视图代次重置聊天临时状态和资源引用,并拒绝上一代次的异步回写;各频道草稿仍由现有草稿模块保存,Host 持久化不变。 + +布局版本 2 在同一浏览器记录中保存按容器尺寸恢复的比例几何、关闭面板的位置、开关顺序、群成员分割比例与开关,以及 0–16px 连续间距(默认 4px);版本 1 保留现有相对布局迁移。背景恢复原生 base 深色,面板采用 85% base/15% surface 混色和小圆角,仅略亮于背景;macOS 红黄绿区域同背景色,header/body 间隙为 `min(4, gap / 2)`。退回旧布局不会覆盖已有新工作区记录。消息正文在工作区中是可关闭的唯一「消息」面板;群成员从消息 header 展开为内部左右分割(20–60%),不再占全局开关。控制列按钮支持拖拽及 Alt+上下排序;选中时仅保留蓝色图标和底色,不显示边框。底部把手在悬停或键盘聚焦时出现,位于齿轮之上;工作区布局通过既有 Bot Settings section Slot 提供,Portal 保留当前 Dock 的设置动作,不创建第二份偏好存储。面板快捷键注册到 DSH Shortcuts Service,由原生键盘设置自定义,离开工作区或弹窗内不触发;不另建配置存储。Dockview 保持 Portal 内容常驻,控制列移动用 reuseExistingPanels 恢复布局,避免重新挂载;Bot 小人拖拽后的 click 被消费,不再误导航。完整平台矩阵及 Windows 原生 caption 留待后续验收。 + +项目显示偏好与排序共用浏览器本地 scope 和编辑事务:完成以一次偏好更新保存二者,取消一起丢弃,恢复默认布局按注册顺序显示全部可用 entry。隐藏项目仍列在编辑模式中,可通过显示按钮恢复;全部隐藏时设置入口仍可访问。正常模式不挂载隐藏项目的内容,但保留 header 的权限监听,让授权撤销继续清除禁止展开的状态;未注册 ID 保留显示偏好,新注册 entry 默认显示。隐藏不改变 Host 权限、执行或 Memory([#809](https://github.com/BotHarness/BotHarness/issues/809))。 + +显示设置二级菜单临时只展开对应 sidebar entry 供预览;连续选择保持菜单打开并立即更新原有显示偏好,关闭菜单恢复先前的展开呈现而不写入展开偏好,权限 gate 仍然生效;切换选择丢弃预览([#807](https://github.com/BotHarness/BotHarness/issues/807))。 + +Memory files 与 Memory evolution 的读取缓存仅属于 Client 的 Bridge action owner 和 Channel,最多保留 30 个 Channel;关闭/展开及后台刷新继续展示上次成功结果,首次成功之前使用 skeleton。成功空结果也是已加载状态;失败通过轻量提示与 Retry 恢复,重试中保留提示,成功后清除。切换 action owner/Channel 时重新挂载读取资源,旧请求不得更新新 scope;缓存不成为持久 Memory/Git 权威([#719](https://github.com/BotHarness/BotHarness/issues/719))。 + +Human–PersonaBot DM header 菜单或频道列表的「查看资料」直接打开 **PersonaBot Profile view**(ADR-0085,#2017 修订),临时替换消息面板内的 Chat 历史与 composer;返回或 Escape 恢复 Chat。从频道列表进入时先打开消息面板;资料导航在新 Dock 布局恢复后再次确保消息面板存在并激活,避免旧的关闭状态覆盖导航,沿用原布局位置。Profile popover 及其 client-local pinned-card 呈现状态已移除;所有可用的 **Profile Card** 直接在详情中展示,Display name 与 Avatar 仍在此编辑。Profile Card 继续通过有序、可增删、scope-aware 的 client 侧 Cordis registry 注册,Host 读模型不变。Group header 也直接进入 Group Profile view,保留有限数量的实时成员活动预览及 Group scope 卡片:Host 从该 Channel 已提交消息分页聚合近 26 周的每日消息数与按 author 分组的活跃度,经 Typert/API Gateway 交给 Client;Human/Bot 作者分别呈现,群管理仍在独立 Channel sidebar。DM Channel 只使用 PersonaBot Profile(#424)。自定义 Avatar 是有界 data URL 存在 owning Registry 的 SQLite 记录中(旧 `bot.json` 仅用于一次性迁入)(512×512 WebP、解码 ≤128 KiB、magic-byte 校验、拒绝远端 URL),经带缓存校验的认证读取 route 提供,缺失或损坏时确定性回退 identity-seeded Blobatar(ADR-0086)。创建 Bot 以及 Display name、岗位或 Avatar 变更后,Registry 把同一份呈现写进该 Bot Memory 的 `.botharness/bot.json`(像素配方、上传图片 `.botharness/avatar.*` 或 seeded 配方),只提交 `.botharness/`,其余键保留;Host 启动时为缺少该文件的 Bot 补齐。分享后 Bot 市场读取它,显示与侧栏一致(#966)。 + +DM composer 的固定 `/` 快捷动作与模型/工作区 chips 仅消费既有模型编辑器及 PersonaBot-scoped Workspace Grant 流程;目录预览是只读操作,授权确认显示 Bot、解析后的目录与访问范围,不暴露权限模式切换。快捷动作移除自己的输入片段,保留正文、引用与附件,不发送模型消息。图标加文字的模型和工作区数量居左,紧凑 token 用量居右。只读总量与 header Profile 使用同 Bot、同 26 周 activity snapshot 及缺失/部分数据语义,在已提交消息变化、打开 Profile 与有界轮询时刷新;从聊天 header 或频道列表「查看资料」直接进入消息面板内的 Profile view,不再经过资料浮层,也不再固定卡片到浮层;从频道列表进入时先开启消息面板。DM-scoped `profileSessionUsage` RPC 选择最新的所属 Orchestrator root 和最多五个最近更新的 Assignment roots,由既有 Usage owner 按需读取 native Session Query 日志,复用 token bucket 口径及继承前缀/所有权/删除校验,每个 root 最多纳入二十个 descendants;这些只读行不形成新的持久统计权威,部分和不可用日志明确标示。Window Companion 的显示/移除沿用原有 owner、生命周期与 Profile-local 偏好存储;入口保留在频道菜单,聊天 header 不再显示图钉,不新增权限权威(#1432、#2017)。 + +模型配置目标在 Profile 顶部提供紧凑的预设切换,详细的单 Bot 快照编辑置于活动图表下方的折叠区或弹窗;用量图表默认显示最近 7 天,可在现有 26 周查询范围内切换时间范围(#592);独立的长期累计与筛选查询由 #507 提供。Profile 的实际模型用量与配置的可用模型分开呈现,后者不冒充已经发生的调用(#488、#39)。 + +Computer 是 profile 级共享资源(ADR-0051、ADR-0082):Profile 的 **Computer Target** 在 Bot settings 中选择本机或容器,新 Bundle 默认本机,旧的无 target 配置保留 Docker 行为。Computer Provider 管理内部目标策略,官方 `ctx.computerUse` 的单一 Provider 注册不变(ADR-0079)。本机目标在 macOS、Windows 和 Linux 上都使用 Host 上按平台校验固定版本与 SHA-256 的 `cua-driver mcp --direct --embedded`,完全不探测 Docker;Human 显式点「检查权限」完成安装与就绪检查:macOS 读取辅助功能/屏幕录制权限(继承运行 DSH 的应用的 TCC 身份),Linux 需要可达的 X11/Wayland 显示(没有 DISPLAY 的服务器由本机目标启动私有的 Xvfb、D-Bus 会话和窗口管理器;DISPLAY 指向无桌面的裸 X 服务器(例如与 Bot Browser 共用的 Xvfb)时复用该显示,只补上窗口管理器和缺失的 D-Bus 会话;关闭驱动时一并停止),Windows 无需授权。Human 直接在自己的屏幕上完成登录。本机没有 Selkies viewer、归档迁移或空闲停机,手机通过 Host 的截图流查看并接管本机桌面(`/api/computer/frame` 长轮询驱动的 `get_desktop_state`,画面不变不重发;`/api/computer/input` 走桌面范围的点击、滚动、输入和按键;接管时先通过 `/api/computer/takeover` 暂停 Bot(拒绝所有 Bot 的 Computer 工具调用,包括截图;帧轮询和输入续租,手机离开 30 秒后自动失效),ADR-0210);容器仍使用 `docker exec` stdio MCP、Selkies viewer、持久卷与导出/导入。目标通过原生 configForms 持久化,volatile 更新关闭旧驱动并停止旧目标,清空工具目录与会话授权;等待 Human 的审批绑定目标版本,不能批准另一个目标的桌面操作。 + +手机远程控制容器 Computer 和容器 Browser 时可以走 WebRTC([ADR-0209](../adr/0209-selkies-webrtc-is-switched-on-per-phone-call-over-the-tailnet.md),[#1799](https://github.com/BotHarness/DeepSeekBot/issues/1799))。Host 有 tailnet 地址时,新建的容器默认仍用 WebSocket,另外配置好 WebRTC:ICE-lite、一个公布在 tailnet 地址上的 UDP 端口(Docker 只在 loopback 发布),以及空的 ICE 服务器列表。手机经设备网关打开信令路由后,Host 在 tailnet 地址上绑定该端口做转发,把容器切到 WebRTC;最后一个通话结束或设备被撤销时切回 WebSocket 并关闭端口。不使用 STUN 或 TURN;不可用时状态路由返回拒绝码,手机继续用 WebSocket 画面。 + +只有 Human 为某个 PersonaBot 打开 **Computer Access** 时,精选的观察/动作/验证工具与目标对应的指引才注入它的 Orchestrator 与 Assignment 会话作用域;每个会话的首次动作经 DSH 原生审批询问 Human 一次(profile 开关可自动允许),每次观察与动作都以脱敏的 **Computer Audit** 记入 `logs.db`(ADR-0080)。Computer 未准备好或权限被拒绝时工具返回可读错误,不威胁 Host 启动。 + +Browser 同样是 profile 级共享资源(ADR-0089):随 `deepseekbot` 默认安装的 `@botharness/browser` Bundle 运行受管的 **Bot Browser**(每个被分配的 browser profile 一个实例;默认共用一个 profile,命名 profile 按需启动、独立空闲停,ADR-0096)——优先复用机器上已装的 Chrome/Edge 加专属 profile(`$DSH_HOME/botharness/browser`),机器上没有可用浏览器时按需安装 version-pinned Chrome for Testing(`$DSH_HOME/botharness/browser-chromium`),CDP 端点收在 loopback,Human 可在窗口里登录一次。只有 Human 为某个 PersonaBot 打开 **Browser Access** 时,工具与指引才注入它的 Orchestrator 与 Assignment 会话作用域;只读的 `browser_open` 与 `browser_observe` 已交付,每个会话的首次动作走与 Computer 相同的原生审批(profile 开关可自动允许),每次观察与动作以脱敏 **Browser Audit** 记入 `logs.db`;每个 Bot 拥有自己的 **Bot Tab**(共享窗口里的后台标签;标签归属是可见性作用域,不是安全边界,ADR-0095)。Browser entry 已提供该 Bot 标签列表与焦点预览、`browser_screenshot`(只作为 model attachment,绝不进审计)与 Human **Browser Pause**(暂停该 Bot 的动作与模型截图,Human 始终可直接操作窗口);模型截图从入队到原生附件处理完成都检查该 Bot 的控制版本,Pause/Resume 或 profile 切换会拒绝未完成的旧截图;交互工具(click/type/press_key/scroll/wait)与多标签管理(`browser_tabs` list/open/select/close、后台标签、空闲收窗)已交付,ref 过期以"重新观察"错误收场;原生鼠标与键盘输入在 CDP Session 内准备焦点,不唤起前台窗口;导航与动作后的页面就绪检查要求相隔一个轮询间隔的两次完整状态,使用 15 秒截止时间,持续未就绪时返回错误而非成功,并要求先观察再决定是否重试可能已执行的动作。浏览器未运行时工具返回可读错误;profile 导出留待后续阶段。 + +Local Browser 的 `localDriver` 可显式选择默认 `current` 或试用 `agent-browser`([ADR-0124](../adr/0124-local-browser-drivers-share-host-authority.md)):两者共用 Browser Provider、专属持久 browser profile、Chrome 启停与 Human 窗口。候选仅在私有 IPC 内以锁定版本的原生进程连接这个受管 CDP 端点,按 profile 串行选择精确 target,ref 绑定当前 DOM 身份,绝不通过同名元素或相邻标签恢复。Browser Access、拥有者 Session、原生审批、Pause、Audit 和模型附件仍由原 Provider 管理;切换驱动撤销旧授权并等待旧实例清理,取消会停止候选与其 Chrome。Container 可独立选择 `containerDriver=current`(默认)或 `agent-browser`(#768):同一个 Host 原生候选进程通过现有专属 Container CDP 中继连接,转发原有已认证共享 Viewer;有界上传 staging、截图、Human 输入前 Pause 和全屏仍遵守原有权限。正常停止 Container 时先有界请求 Chrome 正常关闭,再清理已验证归属的桌面,以保留刚写入的 profile 数据;清理失败仍阻止替换。Stop 在清理前撤销共享该 profile 的全部 Bot 注册与授权;原生审批绑定具体 Session 注册,晚到的 Human 预览由 Bot 失效版本拦截。空闲标签关闭撤销对应 Bot,空闲 runtime 关闭撤销整个 profile;清理失败保留共享执行屏障,直到后续空闲清理重试或 Human Stop 成功。日常浏览器模式保留各自驱动。Local 与 Container 的真实 PersonaBot 对比结果分别记录在 #767、#768,默认值不变。 + +Managed Browser 观察在原有精确引用旁提供有界的非敏感字段值及适用控件状态。可选 Local 候选驱动保守压缩完整 AX 骨架并保留页面/弹窗语义;包含原始控件值时保持 AX 原文,避免误删多行值。两种表示均不改变 Browser 授权和引用身份 ([#787](https://github.com/BotHarness/BotHarness/issues/787)). + +Browser Target 通过原生 Profile 设置选择 Local、Container 或 Daily Browser(ADR-0114/0116),默认仍为 Local。Container 使用固定 digest 的官方 Chrome 镜像、资源上限、命名 profile 独立且带所有权标签的 volume 与内部 CDP,独立于 Computer。Browser 和 Computer 的独立 Client Bundle 编译复用 `packages/client/src/client/remote-viewer/` 的纯展示组件,无 Computer runtime 依赖:容器画面在侧栏默认只读,Human Open 展开同一连接的全屏 Viewer,复用接管、状态、缩放和收起交互。Browser 确认 Host Pause 后才允许输入;关闭操作或收起全屏保持 Pause,恢复 Bot 需要明确 Resume。共享 Viewer 全屏期间,侧栏 overlay 提升层级,避免侧栏开关遮挡全屏收起控件。切换 target、profile、viewer 地址或关闭 Access 均撤销当前 UI 输入权限。Target 切换撤销 Session 授权、待审批作用域、ref 与标签归属,等待 runtime 成功停止后才允许新动作;显式上传经有界文件复制,空闲停止保留数据,不挂载 Host 目录。日常浏览器的只读借用切片见下一段。 +本地 Browser 接管复用同一套 Viewer 家族([ADR-0163](../adr/0163-local-browser-takeover-reuses-computer-viewer-family.md),#1322):Host 以 `/botharness-browser/viewer/local/` 直供本地帧缓冲(截图轮询成流,CDP screencast 只留给 Bot 侧 observe/screenshot),Client 仍走共享 `remote-viewer`。点击、滚动与键盘输入经 Viewer 端点进入当前 Bot Tab(提交后的文本经 `Input.insertText`,IME 在 Human 设备上组字),输入要求 Pause;小视口默认触控板模式(滑动移动虚拟光标、轻点点击),可切直点。验证步骤走 ask-permission 接管:`browser_takeover` 先暂停并签发一次性链接(至多 10 分钟,带说明),Human 在 Viewer 横幅里 Done / Could not finish,Bot 在 `await` 里做服务端页面状态校验后 Resume(旧引用失效,必须重新观察)。键入的秘密只记字符数,审计(mint/accept/input/complete/expire 含完成原因)与会话录制只读、无删除口。 + +Daily Browser([#741](https://github.com/BotHarness/BotHarness/issues/741),[ADR-0116](../adr/0116-daily-browser-tabs-use-explicit-ephemeral-borrowing.md))通过 MV3 扩展显式借用一个 Human 标签页,仅注册 `browser_observe`,保留 Browser Access、可信 Session ownership、原生首次动作审批和既有审计。Browser Service 拥有进程内的一次性配对码、绑定扩展 Origin 的令牌、固定 tab/URL 的临时 lease 与 observation deadline;不建立持久授权库。认证 Client 创建 Bot 配对码;扩展经仅 loopback 的 WebServer prefix 兑换,先展示 Bot/当前页面,再经 Human 单独 Share。只有批准的观察才通过 activeTab/scripting 读取 main document 的有界可见文字和控件名称,不读输入值、Cookie 或其他标签页,URL/documentId 拒绝跨文档结果。侧栏显示当前只读借用与 Return;归还、任何导航/刷新、关闭标签、Access 撤销、Target 切换、Host 关闭或失联会拒绝 pending observation、撤销动作审批,重启不恢复借用;Human 标签保留。扩展只使用 loopback host permission、activeTab、scripting、session storage 和 alarms,不依赖 Computer 或启动 Human 默认 profile。[安装与操作指南](../daily-browser.zh.md)。 + +日常 Chrome 的明确控制是独立的 `daily-control` Target(ADR-0121,#766),不升级 `extension` 只读借用。现有 Browser Tool Provider 仍拥有 Access、可信 Session ownership、原生审批、Pause、Audit 和注册生命周期;独立 Subprocess 运行固定版本官方 Playwright 扩展连接器。Human 先在官方扩展选择一个已有页面,再在 Browser entry 单独允许控制该文档。Bot 只得到 observe、type 和 ref-based click;每次观察的 refs 绑定原始 DOM 节点,Resume 必须重新观察。连接捕获的 Page 不跟随标签组或其他页;主文档导航/刷新、关闭、断连、归还、Access 或 Target/profile 变化、Host 退出撤销进程内授权和在途结果,重启不恢复,Human 标签保留。Computer 授权独立;[安装与操作指南](../daily-browser.zh.md)。 + +显式 `profile-control` 通过第一方受限扩展持久配对一个 Human Chrome Profile;Browser Access 与原生 Session 审批分别生效。只能操作普通网页;配对哈希持久化,但引用、选择、待执行命令和 Session 授权不恢复。多个 Bot 操作串行;导航使引用失效,暂停等待已发出操作结束。见 [ADR-0123](../adr/0123-daily-chrome-profile-control-is-an-explicit-persistent-pairing.md)。 + +Browser entry 的 header Access 开关控制展开:关闭即折叠并锁定,开启在同一次交互中展开。平面标签列表置顶 Provider 当前 Bot Tab,并显示标题与 URL。跟随开启时预览 Bot 当前工作页;关闭时固定当前画面所对应的 target,直到 Human 选择另一个已归属标签。预览只读取 observation,不改变 Provider 的当前标签与 Agent 控制;Pause 仍是独立的 Host command。 + +Human 的「打开 Bot 浏览器」通过现有进程内 per-Bot 标签页 Provider:唤起仍存活且归属此 Bot 的预览页(或当前页),恢复最小化窗口;Human 预览其他页时不改变 Bot 当前页指针。已关闭的归属页被清理,优先复用仍存活的归属页,否则创建一个归属此 Bot 的空白 Human 标签页。重复打开与 Bot 操作共用 per-Bot 队列并复用该页,不唤起或登记共享浏览器 profile 中其他 Bot 的页。Human 前台聚焦与 Agent 后台操作保持独立。 + +临时关闭 Browser Access 会撤销 Agent Scope 工具注册并取消该注册的在途调用;正在等待的调用及时结束,已发出的 CDP 操作等待自身完成后返回撤销错误,队列中的旧调用即使权限重新开启也不会执行。调用者取消信号同样保留,不放弃底层尚未完成的操作;Provider 的进程内标签归属与当前页指针保留,重新开启后工具可继续使用原工作页。显式停止/重置会清空这些记录;此连续性限于同一个 browser profile,标签归属不跨 Host 重启持久化。 + +修改 browser profile 分配时,Core 通过应用定义的 Host Service 调用现有 Browser Provider reset command。Client Profile combobox 合并 PersonaBot record 中已分配的名称与 Browser observation 返回的已存 profile 目录名;目录读取不创建 runtime、不跟随符号链接,也不另建目录清单存储。输入已有名称可选择,新名称通过明确的创建项分配;失焦与 Escape 不保存。Profile 名称先在既有 Host 保存接口验证;保留路径段 `.` 和 `..` 在写入 PersonaBot record 与调用 reset 前被拒绝,当前标签与 Pause 不变。runtime 对已存无效名称继续回退默认 profile,正常名称中的点仍被允许。切换的 Bot 在使用新 runtime 前清空旧的进程内当前页/标签归属记录与 Pause 状态;Browser Access 与 Session 授权仍各自独立。其他 Bot 的标签归属与旧 profile 的浏览器数据保留。重置记录有界生命周期诊断,切回原 profile 时不持久化或重新登记旧 target。 + +同一 browser profile 的并发首次调用等待同一次 Browser 启动;启动失败后可重新尝试,不生成第二个实例。Human 关闭当前 Bot Tab 后,下一次工具调用清除失效的进程内归属与当前页,并提示使用 `browser_tabs list` 选择另一页或 `browser_open` 恢复(#463)。 + +`browser_upload` 使用 Host 文件向当前 Bot Tab 附件字段挂载文件:文件 input 的 ref 精确选择该字段,上传按钮的 ref 在 CDP Session 内拦截原生 chooser,并使用实际 `Page.fileChooserOpened.backendNodeId`,不会猜测其他附件字段;省略 ref 使用第一个 `input[type=file]`。chooser 监听按 CDP Session 隔离,超时或失败后解除监听与拦截;文件不存在、无输入或按钮未打开 chooser 均返回可读错误。Browser Audit 只记录 ref、文件 basename 与大小,失败摘要隐藏传入的完整 Host 路径;发布前仍须 Human 明确确认。 + +Browser Tool Provider 在同一调用完成路径中记录成功与失败,包含授权、Access、Pause 及 Resume 后重新观察检查的即时拒绝;队列入口拒绝与运行失败仍各只记录一次。Browser Audit 使用注册时的 Bot、Session 及可信 Session ownership 的角色归属,写入现有 `logs.db`;输入只记录字符数、上传只记录文件名,不另建日志存储或读取接口。 + +Pause/Resume 在现有进程内 Provider 状态中失效该 Bot 的可操作观察。页面点击(ref 或坐标)、输入、按键、滚动及上传,必须先完成一次在当前控制状态变更后开始、且 Pause 未开启时成功返回的观察;读取失败、Pause 期间的读取以及旧的在途读取均不能满足此要求,截图也不能替代观察。Open 与标签管理保留以恢复缺失页面;Access 开关保留重新观察要求,显式 Profile 重置则重新开始标签归属。其他 Bot 各自独立。 + +```mermaid +flowchart LR + Inbox["Bot Inbox / Attention"] --> O["One active Orchestrator Session"] + O -->|"list / inspect"| Dir["Durable Assignment Directory"] + O -->|"create_assignment"| Gate{"Global active Assignments < limit?
default 8"} + Gate -- "no" --> Error["Structured + LLM-readable failure
no queue, no intent"] + Gate -- "yes" --> Runtime["Assignment Runtime"] + O -->|"send_assignment_request / stop_assignment"| Runtime + Runtime <--> W1["Independent Assignment Session A"] + Runtime <--> W2["Independent Assignment Session B"] + W1 -->|"report_to_orchestrator"| Report["Assignment Report Source Event"] + W1 -->|"completed Turn paired with this Report"| Completion["Host Completion Notice
paired completion #194"] + W2 -->|"confirmed stop / native cancellation / native error / Host recovery"| Notice["Host Lifecycle Notice
stop / cancellation / error #194
Host recovery: unconfirmed outcome"] + Report --> Inbox + Completion --> Inbox + Notice --> Inbox + W1 -.-> Sub["DSH Subagents
aggregate-only"] +``` + +Assignment Session 是 DSH independent root,以 DSH `sessionId` 为 canonical identity;Continuity Key 只是 PersonaBot-local alias。Orchestrator 通过六个工具 `list_assignments`、`inspect_assignment`、`create_assignment`、`send_assignment_request`、`wait_for_assignment`、`stop_assignment` 管理它们。Assignment Agent 只能用 `report_to_orchestrator` 向 Orchestrator 回报;其 Agent Scope 没有 Channel send capability,普通 final 也不会写入 Channel。v1 没有 Assignment-to-Assignment 直连、广播或等待队列。 + +带地址的答复只在原生 Inbox 接收后清除所捕获的原问题;pending followup 不等于投递成功。可证的准备失败保留问题、权限/模型快照和空闲重试路径;原生投递结果不明或重启保持修复可见、不重放。仅收到 Human DM 不隐藏未解除待办或生成已处理历史;可证失败后可在原来源明确重试。旧答复不能清除新问题(#812)。 + +Assignment Runtime 的并发上限覆盖整个 Host 的所有 PersonaBot(默认 8),同时约束新建、按 Session 恢复空闲事项和按 Continuity Key 复用。恢复前先同步占用原有 Assignment Directory 的 working 名额,再交给 DSH;运行中的事项接收更新不增加名额。满额时返回 `assignment-capacity`、当前数量、上限和可重试标记,不启动执行、不清除待答问题、不改变模型或权限快照。事项停止确认前仍占名额;释放名额后,Orchestrator 可重试同一 Session,不引入等待队列(#811)。 + +Orchestrator 通过 `archive_assignment` / `unarchive_assignment` 显式归档和恢复 Assignment(ADR-0188)。归档调用 DSH Workspace Registry 原生的 `archiveSession`,不带 `stopActivity`:事项仍在执行、停止中或等待 Inbox 接收时直接拒绝(`assignment-active`),由 Orchestrator 先停止再归档。成功后 Assignment Directory 记录 `archived_at` 作为原生归档集合的镜像,并清除 Continuity Key;归档进行中和归档后的 Assignment Request 都会被拒绝。不会自动归档。`list_assignments` 支持文本搜索(用途、Continuity Key、最新报告)、活动筛选、`include_archived` 和 opaque cursor 分页,默认不含已归档事项。 + +哪些 event 进入 Bot Inbox、哪些唤醒 Orchestrator 仍由现有 wake 与 source policy 决定;被成功回合观察到的 Inbox Admission 记录 `opened_at` 并保持 open,不再按是否 @ Bot 或来源类型另设规则。`inbox_resolve` 在同一 Admission 上写入 resolution、note 与 Orchestrator Session,按 id 或过滤条件批量处理;过滤条件先返回数量、样例与基于命中集合的确认 token,只有带同一 token 的重复调用才落库。`inbox_list` 按 Channel 对话或 Assignment 分组列出 open 条目,每轮的 `[Open Inbox]` 块只给出数量与前几条一行摘要。attempt state 仍是执行事实,open 由这些列派生,不改写历史已处理记录。`inbox_snooze` 在同一 Admission 上记录 `snoozed_until`、`snoozed_at` 与 note;同一 Channel 对话或 Assignment 的更新 Source Event 被 Bot 实际观察后才提前返回;未唤醒 Bot 的普通群消息不能取消到期唤醒,再次 snooze 不会提前。Host 在启动和每次 snooze 后为每个 Bot 设一个定时器,到期时以最早返回的条目为锚运行一次 Orchestrator 回合,列出返回条目与 note;失败则延后五分钟重试。每次成功回合后若仍有未 snooze 的 open 条目,Host 排入一次 `[Triage]` 回合;某一轮没有让 open 数量下降就停止,直到出现不在这批停滞条目中的新 open 条目才再次触发(ADR-0188)。 + +#1037 有界切片只在无后代、无执行中工具且无已批准待执行调用的 Assignment 等待 Human 审批时释放 live 运行名额。持久 activity 仍为 working,process-local executionWait 区分 waiting-human 与 waiting-capacity;最多 32 个等待中的 Assignment Session 使用独立上限。Human 决定提交后,原生 outcome 或下一模型 Step 必须先取得当前运行名额,实际工具执行前再次检查归属、Grant、原 Source Event 与准确操作。新建和空闲恢复仍在满额时立即拒绝;中止、停止与冷恢复均不重放原调用。同一 Orchestrator 的权限 continuation 与群聊隐私仍单独保持 gate。见 [ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md#live-approval-waits-and-running-permits)。 + +Human 在 Bot 模式设置中将此 Profile 级上限调整为 1–32。DSH 原生 Settings schema 的 Volatile field 由 Profile Config Editor 持久化;UI Plugin 的 Host Fiber 将 live reader 绑定到 application-defined Assignment Runtime,并在 dispose 时释放绑定。Client 只展示 Host 确认的保存值;Runtime 在每次新建或恢复空闲事项的准入时读取当前值。保存后立即影响后续准入,重启后保留;降低上限不中止已有执行,直到使用量低于新上限才允许启动新工作(#825)。 + +待处理 Workspace Grant 请求通过同一持久 Human-action 查询进入独立 Activity attention。只有经过校验的 Grant 关联 Human 回复、Inbox 忽略或来源 DM 移除才清除计数;普通授权文字不会清除。重启从已提交 action 重建计数,不伪造执行状态。 + +报告超过 2 KiB 时,Assignment Runtime 在提交 Inbox Admission 前把完整文本交给 DSH Spill Service 保存,并只将前缀预览、字节数、不透明 locator 与 provider 的检索提示写入 Assignment Directory、Source Event 和 Bot Inbox;单份报告上限为 1 MiB。Orchestrator 的 `inspect_assignment` 可通过 DSH Session Query 读取该事项最近四条原生日志事件(最多 12,000 个字符),也可指定 `report_offset` 从已接收报告的原生 `tool/call` 事件每次读取至多 2,000 个字符,并通过 `nextOffset` 继续;两种读取均先核实事项归属,返回查询条数、实际读取量与估算 token 成本。Spill Service 仅负责保存且 locator 对消费者不透明;Orchestrator 即使不能直接读取本地 Spill 路径,也能通过 Session Query 找回该报告,不额外建立一份全文报告数据库。 + +Agent 可自行选择发送内联报告,或先写入其工作区文件并报告路径;超长内联报告由 Host 自动处理,不触发额外提问。 + +当前 `stop_assignment` 通过 DSH `Agent.cancel({ kind: "user" })` 取消活动回合并清空待执行输入;BotHarness 先持久记录「停止中」,待 Agent 静止后在同一事务里记录「已停止」和 Host 来源的 Lifecycle Notice。通知进入该 PersonaBot 的 Bot Inbox,Orchestrator 可在私聊报告停止结果;未观察的通知在 Host 重启后恢复,已交给 Orchestrator 但在运行中中断的通知标为 needs-repair,以免重复执行不确定的副作用。停止的事项不再接收请求或迟到报告,其 Continuity Key 可用于新 Session;取消不销毁 DSH Session 历史。成功完成路径读取可信 DSH `turn/start` 的编号并记录在独立 Assignment Report 的 payload 中;原生 `turn/end` 确认成功后,Host 仅为同一所属 Session、同一 Turn 的 completed Report 追加一条幂等完成通知,保存该报告 Source Event ID 与结束 seq。报告保持 Bot 来源,通知保持 Host/system 来源;Bot Inbox 只读查询返回 Turn 和关联报告 ID。配对通知搭乘报告触发的收割或下一次真实 Turn,不单独唤醒,也不因报告已处理而伪造通知已观察/已处理;重启仍保持这一规则,正文同时保留报告语义与 Host 完成事实及各自来源引用([ADR-0077](../adr/0077-turn-time-harvest-consumes-the-ready-attention-set.md))。progress-only Turn 不产生完成配对通知。Human 通过 DSH 原生 Session 控制取消活动 Assignment 时,adapter 仅从已提交 reason=aborted 的 turn/end 读取可信 Turn/end seq;Runtime 在同一事务内投影 error、释放 Continuity Key,并按所属 Session/Turn/native-turn-aborted cause 幂等写入 Host/system Lifecycle Notice 与 Inbox Admission。它沿用即时收割,保留原 Report,不伪造成功配对或自动重试;通知与原报告分别可导航,已处理通知重启不重放。Orchestrator stop 路径抑制原生取消回调,继续只发已确认 stop 的通知([ADR-0045](../adr/0045-orchestrator-manages-assignments-through-a-durable-directory.md))。已提交的原生 error Turn 沿用同一失败结算事务,以 failed/native-turn-error 区分来源;adapter 只传可信 Turn/end 身份,不把原始错误写入通知。Runtime 释放 Continuity Key,保留进度 Report,独立即时收割一次,不生成成功配对、自动重试或替代 Assignment;已处理事实冷重启不重放。Host 启动时把仍为 working/running 的 Directory 行在同一事务内转为 error、释放 Continuity Key,并沿已保存来源策略纳入一条 System interrupted/host-recovery 通知。正文说明旧执行结果未确认,不推断原生 Turn/end 身份或报告配对;保留原报告,既有收割可唤醒 Orchestrator 一次,但不自动恢复 Assignment。状态转换和已处理事实再冷重启不重复通知或收割。准入前失败仍是独立边界,#194 尚待完成的视觉验收记录在 issue。 + +Assignment Request 的 `context-update`、`next-step`、`next-turn` 分别映射到经过验证的 DSH inject、steer、followup seam;普通请求不 cancel 当前 step。跨 SQLite/DSH 边界只保留最小 Assignment Delivery Intent,重启时有界 reconciliation;歧义进入 `needs-repair`,不扩张为通用 workflow engine。 + +统一 **Sessions panel** 保留 `sessions` 身份并退役 `group-work`([ADR-0220](../adr/0220-one-sessions-panel-with-explicit-bot-or-channel-scope.md),#2034)。Header 的范围选择是 Client 本地偏好:DM 固定当前 Bot,Group 默认当前频道,或由 Human 明确选择可访问的成员 Bot。Bot 范围复用 `sessions(slug)` 与原生 Session catalog;群内选择 Bot 使用面板本地查询结果,不覆盖当前 conversation 的共享 Sessions 状态。频道范围复用 `channelAssignments(channelId)` 的 Host 成员权限与原始 Source Event 归属检查,最多 200 条;达到上限显示截断提示。两种范围复用 Session 行状态投影并打开同一原生 Session ID;报告状态单独显示,空闲不等于完成。迟到响应在范围卸载后丢弃,失效 Bot 选择清除并回退频道范围。会话面板默认跟随当前频道时不重复显示范围标签;群聊范围选择收进视图选项菜单,仅显式指定 Bot 时在标题栏显示 `Bot · 名称`。 + +布局 v1/v2 升到 v3 前保留一次本地备份 `botharness.bot-mode-layout.before-sessions-v3`。已有 Sessions 位置优先;只打开 Work 时原位替换;两者关闭时不自动打开,并重写停放邻居和控制列偏好。快捷键通过 DSH 原生 revision-checked 配置接口迁移有效且无冲突的 Work 自定义绑定,保留 Sessions 显式覆盖(包括解绑)。冲突或失败指向快捷键设置;旧 Work 命令不再注册,原生接口不允许删除未注册命令的覆盖,因此保留其休眠配置以便回退。迁移完成标记防止用户后续重置 Sessions 时再次导入。旧版本回退需要恢复布局备份;Host 数据与 Session 生命周期无迁移。 + +### 5.1 · PersonaBot activity、事件与 renderer + +Web 的选中 Channel、Roster 与 Activity 消费在每个文档内共用一个受认证的 EventSource;既有 `/api/botharness/stream` 在选中 Channel 时携带 `channelId`/`after` 与 `roster=1`/`activity=1`,从 owning Channel live hub 组合原有 Channel revision 流、roster 通知、撤销基线与同一 Activity Projection 的有界快照。没有选中 Channel 时复用 roster/Activity 流。各逻辑消费者独立清理,最后一个离开时关闭物理流;Channel 选择变化调整查询,替换构造失败保留健康旧流,Host 合成 adapter 断开时取消两个原流读取者;重连错误清除可回放 Activity 基线,迟到消费者保持 stale 与 HTTP 恢复直到新快照到达。慢消费者只保留当前 Activity 更新,背压时跳过心跳;Channel revision/回放和 Owner 认证不改变。这是过程内传输复用,不创建新的 Activity 或内容权威([#1451](https://github.com/BotHarness/DeepSeekBot/issues/1451))。 + +DSH 失败的 `turn/end` 仍是执行事实权威;BotHarness 在所属 Orchestrator 或 Assignment 回合结束并判定失败后,由 Host 向对应 PersonaBot DM 提交一条带 role、Session ID、错误码、可用 HTTP status、简短错误详情和事项上下文的 application-defined failure notice。该消息是面向 Human 的持久通知,不把 SessionEvent 全文或工具日志复制成第二套执行历史;Client 把它渲染成可读的失败卡,刷新后从 Channel authority 恢复。Orchestrator 的 Human Source Event 仍依原有 retry/reconciliation 语义处理,失败通知不等于完成该 Source Event。 + +DSH SessionEvent 是 durable execution authority;BotHarness 不复制 tool call 或 assistant output 为第二套 Session fact。explicit Session Ownership 把 Session 归属到 PersonaBot 及 `orchestrator` / `assignment` root role,PersonaBot module 再把这些事实与 live liveness 折叠成一个可重建的 Activity Projection。Browser 首先经 Typert/API Gateway 查询 projection,随后消费带单调 revision 的 live update;revision 断档时重新查询,而不是由 Client 自己推导状态。 + +每个页面共用的 Channel/通讯录/Activity 实时流按 ADR-0181 由原生 Remote 流承载:Host 的 `botharness/liveFrames` 流式 Remote 方法打开既有 live hub(与 `/api/botharness/stream` 相同的查询、回放与拒绝语义),把它的事件帧逐条作为流项产出;Client 挂载对应 contribution,以 EventSource 形状的适配器经 Gateway 流监督器消费,断线后携带最后的事件 ID 续接,Host 拒绝(未知 Channel、修订超前、参数非法)时进入关闭态。过渡期的事件帧隧道 `botharness/liveFrames` 已在 #1502 移除;Remote 无法挂载流方法时仍回退到原 SSE 路由。通讯录与 Activity 进一步各用一条带类型的流(`botharness/followRoster`、`botharness/followActivity`):每代先发完整基线再发增量,Client 经官方 `RemoteSnapshotStream` 消费,帧流只剩所选 Channel(#1498)。所选 Channel 随后也有自己的带类型流 `botharness/followChannel(channelId, after)`:`opened` 给出本代起始修订号,`entry` 按修订号顺序给出已提交消息,`notification` 承载回执、已读位置与草稿预览;Host 的 Channel 订阅是唯一的帧源,流方法与 SSE 路由只是两种编码,Client 的 Channel 消费逻辑与传输无关,时间线分页仍按 ADR-0061(#1500)。按 ADR-0179,窗口贴着最新消息时由官方 `RemoteJournalStream` 负责:每代以游标加最新一页开场,条目必须紧接上一修订号,缺口由 `channelJournalPage` 读取限定修订号的一页修复,向上翻页经官方类的 prepend;Client 把整体替换合并进已显示的窗口。定位在已读位置或旧消息附近的窗口仍走 `channelTimeline`(#1503)。Window Companion 的订阅也改为带类型的流 `botharness/followCompanion`:Host 把既有 feed(租约、恢复与背压不变)投影为 baseline、selection、activity、message 帧,Client 经 `RemoteSnapshotStream` 消费,监督器重开时带上最后的 consumer 标识;订阅更新仍是独立的 POST。此后每个页面只剩原生 HMR 一条长连接(#1501)。 + +PersonaBot 活动通过公开 `activitySnapshot` 查询与认证 `scope=activity` SSE stream 交付同一份 Host 拥有的快照(`generation`、单调 `revision`、每个 Bot 的聚合状态)。每次连接先发送完整 baseline,实际聚合状态改变后推送完整快照;重连无需重放第二套活动历史。Client 原子地向置顶、普通行、rail 头像及 DM 输入框应用同一快照,拒绝同 generation 内的旧 revision,并防止过期 roster 响应覆盖当前活动。退出 Bot mode 或隐藏页面时关闭活动连接。 Activity Client 在进入时查询 baseline,修订跳号时重新查询。stream 失败或不可用时,每 10 秒最多一次重试并查询既有 `activitySnapshot` 权威,单次查询时限 5 秒;收到有效 live frame 后停止备用刷新。慢 Activity stream 只保留一个排队的完整快照,后续变化合并到最新快照,不重放原始执行事实。退出 Bot mode、隐藏页面或 Plugin disposal 会取消定时器、请求和订阅;旧 Host generation 的迟到响应不会覆盖新的 live baseline(#121)。执行状态由显式 Session Ownership 和现有 DSH SessionEvent Projection 提供,不来自 roster 轮询或本地发送标记(#120/#536)。 Channel stream 建立连接时还补发已见 cursor 前最多 100 条消息的当前回执,补足 HTTP 快照与实时连接之间的窗口;不引入轮询或第二套 Admission 权威。 + +application-defined `botharness/personabot/activity` Cordis Event 在 projection 改变后以 `emit` 发出,供 Host 内的 Live2D、3D 或其他 Plugin 同步。Orchestrator 活跃时负责呈现;当它明确 `waiting-on-assignment` 时,活动来源切换为 Assignment:同类 tool kind 使用对应 effect,多类并行回退到通用 `working`。waiting、blocked、approval 与 informational attention 单独投影,不进入可配置 priority。 + +PersonaBot Output Committed 已由 canonical Channel writer 的成功提交回调提供:可信 Runtime 传入本次调用的 Session/Source origin,Host 校验显式归属后发布应用定义的 `botharness/personabot/output-committed`。载荷是冻结的公开正文/格式与 PersonaBot、Session、Channel、消息、修订及回复/来源引用白名单,不复制工具/审批卡或私有上下文;origin 不持久化、不进入 SSE。重复/冲突、回滚、模型增量和重启重建不通知。当前 Cordis emit 不隔离 callback 错误,因此同一 EventsService 的 emit dispatch 逐消费者安全调用,Promise 不等待且失败仅记录固定诊断;Consumer Fiber 销毁自动清理,不另建 listener registry、日志权威或 browser API。订阅方法见 [Output guide](../dev/guides/personabot-output.md)。 + +Tool activity notification 只广播 `toolKind`、可选 `toolName`、SessionEvent reference 与 Tool 显式声明的 `publicDetail`;完整 arguments/result 由受控 Capability 按引用读取。Channel output commit 后另发 PersonaBot output notification,TTS 与说话动画消费该 public output,而不是任意 Tool 参数或尚未提交的草稿。 当前 `publicDetail` 是 Tool Provider 在其实际注册的 DSH Tool Definition 上通过应用定义 `withPublicToolDetail` 显式提供的同步声明;Host 不从原生 call-card title/rawInput 推断公开文本。文本限制为 160 字符的非空单行,控制字符与双向格式符拒绝,失效或抛错时省略;并发声明不一致时省略,已有 revisioned Activity 与认证 Gateway 传递同一字段。浏览器标签页 Provider 只返回固定操作文案,不公开 URL、标题或 tab identity。 + +工具 Activity 的首个切片(#122)由待执行 `tool/call` 与配对的 `tool/result` 投影事实,经 Agent Scope 内已注册工具的 presenter 解析类别。Activity 快照只保留有界类别与已注册名称;presenter 标题、原始输入、路径、diff、参数和结果均留在原生 Session。并发同类保留效果,不同类别回退通用工作;一个结果不会隐藏尚未结束的调用。每次投影变更先提交一个 revision,再发布应用定义的 process-local `botharness/personabot/activity` Cordis 通知与完整 SSE 快照,同状态的工具变化也会通知。侧栏 hover/focus 与输入框读取同一安全摘要,键盘展开在每个活跃 Session 的独立卡片内显示最新安全活动,展开区不重复 Bot 名称;既有 motion 偏好控制相同效果。最新记录由同一 Host 投影拥有,携带进程内 opaque 显示 key、可信角色、可选的原生 `session/title` 名称、Host 观察时间与 revision,经 snapshot/SSE 同步;名称最长 1,024 字符,拒绝控制或双向格式字符,创建、改名和重建都读取相同的已提交标题事实。输入框展开区是透明浮层,只有全宽 Session 卡片保留背景;桌面名称与状态同行,窄屏换行,Assignment 使用原生名称并省略过长文字。按实测展开高度给最新消息留出空间,避免遮住审批操作,较早历史仍可滚到透明区域后方。同角色 Session 仍分别显示,完成或销毁后移出,idle 时不留记录,Host 重启后原子重建当前基线。显示 key 不是原始 Session ID,也不是完整详情查询授权;不保留八条历史,不持久化或回放 Session transcript,不包含参数、结果或 reasoning。授权 opaque 完整详情通过 Host-only `botharnessActivityDetails.read(reference)` Capability 读取,角色优先级聚合仍归 #123。 当前执行来源由已记录的 Session Ownership 的 root role 与 Subagent provenance 推导,Activity 聚合字段公开主会话、任务会话、子代理三个类别及各类正在执行工具的会话数,展开区的 per-Session 最新记录另以 opaque key 区分各个活跃 Session;同一会话多个工具只计一个来源。角色分布通过现有 revisioned snapshot、Cordis 通知和相同安全摘要交付,原始 Session 标识、路径和工具数据不会进入该来源字段。该来源信息不改变当前状态或效果选择,角色优先级仍由 #123 完成。 + +活动中心总览读取与侧栏/消息框相同的 Host Tracker 执行选择及有界工具摘要。原生问题/审批卡片和其他 Human 待办保持独立的 `hasAction`/待办数投影,不改写 SessionEvents,也不以 waiting/blocked 覆盖执行。活跃归属根 Session 卡片沿用原生执行事实和运行中根过滤。原生问题的 live owner 在请求卡提交后向同一 Tracker 发布 `questionCount`,与 `approvalCount` 分别保留;Avatar 数字显示两者之和,悬浮说明区分来源。回答、取消、Agent dispose 和 Plugin close 从 owning pending map 清除,Host 重启不重放历史待办。Assignment waiting-human/blocked 待办复用 Human Inbox 的同一权威查询、回复/隐藏/停止条件,事务提交后派生到 Tracker 的 `waitingHumanCount`/`blockedCount`,不把报告状态写成 Session 执行状态;冷启动重建未解决的持久化报告。所有计数使用同一个 Avatar 数字和独立本地化说明。application-defined `wait_for_assignment` Tool 通过 Runtime 等待已提交报告或执行结束,使用原生 Tool AbortSignal,限时 1–120 秒。当 Orchestrator 的全部活跃 Tool 调用都属于明确等待操作时,共享执行选择呈现所属 Assignment;若 Orchestrator 仍有其他工作则保持优先。等待 lease 为 process-local,由报告、完成、取消、超时或 Runtime close 释放,不从历史 Tool 名字推断或重放。其他共享 attention 仍属于 #123 后续切片。 + +Client 通过一个 Avatar module 在侧栏行、响应式 Pin Grid、消息、顶部和 composer activity row 中呈现同一 projection。默认 Blobatar media 可在 thinking/working 时运动;自定义图片保持静止,由外层 Activity Frame 表达状态;group Channel 可用最多三个头像与 `+N` 的 facepile。Human Inbox 则统一投影 Channel Attention 与 PersonaBot Attention,并按 action-required / informational 分类;#546 从持久 Channel placement 与按 Human 身份保存的 read position 投影未读数;同一 Source Event 只计一次,入口另示待行动提示。 + +Tool detail Capability 默认拒绝所有 Consumer。部署 Human 在 `botharness-core.activityDetailConsumers` 显式列出受信任的 Host Plugin runtime name;Cordis Service caller Context 提供实际调用方 Fiber,只有处于 ACTIVE 的允许 Consumer 才可读取。它是受信任 Host Plugin 之间的部署授权边界,不是同进程恶意代码沙箱。服务不注册 Browser RPC 或 Fetch 详情端点;opaque reference 本身不授予权限,也不是 Session 显示 key。每次读取审计 Consumer、结果与字节数,不记录 reference、原生 Session ID、参数或结果。 + +共享状态与 Tool 摘要优先选择活跃 Orchestrator Session,安全的展开列表仍保留双方 Session;Orchestrator 结束后恢复展示剩余已归属 Session 的活动。#123 首片不推断等待标记或建立新的 attention 生命周期;明确等待现由 Runtime 拥有的有界操作提供;其他正交 attention 仍属后续契约。 + +引用表只保存指向原生 SessionEvent 的 process-local locator,最多 256 项,有效期五分钟;请求时校验当前归属及归属模块的进程内单调 repair revision,再从存活 Session 的 canonical snapshot 读取精确 Tool arguments 与配对 result(包括原生 meta),返回独立 JSON 副本,完整数据超过 64 KiB 时拒绝。归属改变、原生事实消失、Turn 边界、Session disposal、projection rebuild 或 Host disposal 均使旧引用失效;无持久 payload 副本或 replay。已完成 Tool 的 Consumer 可在当前 Turn 内使用此前收到的引用读取结果,Activity 则继续只显示当前待执行工具。 + +### 5.2 · 可编辑 Avatar(已接受设计,待交付) + +[ADR-0118](../adr/0118-editable-avatar-appearance-is-independent-of-activity.md) 记录 Human 已确认的设计目标:人物插画与抽象小角色两个 Avatar Family 共用真实 Bot-state 语义与过渡规则,各自适配部件和姿态。保存的有版本 Avatar Appearance 配方归 PersonaBot owning module;执行与 Human attention 消费现有 owning projection;逐帧姿态和过渡时间归 Client renderer,不写 SessionEvent、不改保存造型,也不另建状态聚合。首版编辑目标包含细分发件与有界五官几何参数;抽象角色可短暂变成有界点阵/符号,配件暂时收起、稳定姿态恢复时完整回来;小头像的动作更短、更克制,大形象更丰富。保存配方与派生静态快照;缺少兼容部件/协议版本时保留配方、显示原外形快照,并明确暂不可编辑/播放角色动画,独立活动提示继续消费真实事实。候选渲染路线是现有 React Client 内的受控 SVG、稳定节点与有界姿态采样,不预先引入新动效依赖。首个切片先验证编辑、保存、重读、真实工作动作与独立 attention(依赖 #123 owner 已交付的共享合同),第二家族复用语义合同;完成/错误动作后续另定结果作用域。shared native Human attention 继续由 #123 owner 提供,explicit waiting-on-Assignment 不由头像推断。 + +[#751](https://github.com/BotHarness/BotHarness/issues/751) 的首条人物路径使用 Registry 的 `setAppearance`:受控、严格版本化的部件与十六进制颜色配方和 Host 由同一 SVG 派生的 512×512 PNG 在一次原子写入内保存,SHA-256 revision 绑定配方与快照。DTO 只携带配方/revision 与既有快照 URL;图片覆盖或移除会清除 composed 配方。Client 的 Profile 草稿在 Save 前不写入,侧栏和大图消费同一保存配方;稳定 SVG 节点上的 Web Animations 只持有有界局部姿态,真实 activity 切换会重新定向,减少动画、隐藏、离屏和卸载取消资源。Host 光栅化依赖仅在保存时执行,不进入 Client Bundle;第二家族、细分发件/五官和缺版本兼容回退仍由后续切片验收。 + +### 5.3 · 窗口伙伴(已接受设计,待实现) + +[ADR-0144](../adr/0144-window-companions-consume-owned-activity-and-scoped-output.md) 与 [规格 #1135](https://github.com/BotHarness/BotHarness/issues/1135) 记录 Q1–Q23 共识。Human 主动从 Bot 行右键或 Channel header 的具体 Bot 入口选择“显示为窗口伙伴”,与 Channel 置顶独立;通过官方 RC 的 shell overlay Slot 跨 Harness 页面显示既有半身 Avatar。Client companion owner 持有当前 Client × DSH Profile 的选择、每 Bot 来源/范围/漫游偏好,以及所有伙伴共享的卡片层数/保留数量设置;不更改 Registry 的身份/外观、Memory 或 Channel pin。伙伴像素视图省略画布底色与方形头像框背景,呈现透明人物轮廓;既有侧栏头像与保存配方不变,透明区域仍保留可用 hit area 和独立 attention。图片/缺失 rig 快照不自动抠图。底部漫游、拖拽、受窗口边界约束的位置和锚定像素气泡属于呈现层,逐帧姿态不跨 RPC。 + +消费路径为 owning Registry/Activity Projection/canonical Channel → 认证 Typert/API Gateway 与有界 live/query adapter → 独立 companion Client owner → 既有 Avatar 与卡片。当前 Activity Client 退出 Bot mode 会关闭消费,当前 SSE 只有单 Channel 输出流;伙伴的独立生命周期和按 Bot 跨 Channel 输出合同是待实现工作,不由现有页面组件顺便提供。Host 内其他 Plugin 继续查询同一 Activity Service 并消费 application-defined Cordis 通知;不存在伙伴 UI 到 Activity authority 的反向写入。 + +每 Bot 独立开关 Activity 标签、DM、群聊消息,默认开、开、关。Companion Visibility 默认“Bot 和我都在的 Channel”,另有“仅我和 Bot 的 Channel”与“所有 Bot 加入的 Channel”;Bot–Bot DM 在第三档归 DM 开关。只展示当前 Bot 的已提交输出并标识来源 Channel/DM 双方。第三档需要面向受信 Human Consumer 的 Host-owned Bot 输出只读合同,范围限制在被选 Bot 的 authored output;不能删除通用 timeline 成员检查、授予模型新权限或读取其他作者的完整历史。点击原 Channel 仍检查 owning 读边界,不能访问时解释限制。 + +首次选择/Client 或 Host 重启建立 Host 一致的新消息基线,不重播旧输出,直接查询当前 Activity;短暂后台/断线仅有界恢复本轮基线后尚未播出的消息,canonical identity 去重并拒绝旧 generation 响应。关闭来源清卡片与待播,重新开启或新合格 Channel 从当前基线继续。进程内 output-committed 只是通知,不能充当持久 cursor;查询成本与队列都必须有界。气泡到期/移除不更新 Channel read position。独立 Human attention 即使所有播放关闭仍保留并导航到 owning 活动/Human Inbox,不从计数编造正文或审批控件。 + +每 Bot 一张当前活动卡加独立并行逐字消息卡,默认折叠 3 层、最多保留 20 张未到期卡;全局设置可调整。hover/focus 展开稳定列表,暂停漫游与到期,已显示文字继续播放,新增内容只计数、退出后有界合并;长文预览、列表滚动,基本气泡避让。无自动 +N 折叠或钉选数量上限,Human 通过拖动、暂停漫游、移除调整。归档保留静态伙伴与标记、删除清选择;reduced motion、freshness、上传图片静态 media 和版本快照降级沿既有 Avatar 合同。先完成真实 Host→Client 的一个像素伙伴 Activity/DM 与操作/恢复切片,Human 验证后扩展多个 Bot、群聊与三档范围;闭合/半开/张开文字节奏嘴型作为随后 BotPixel 兼容扩展。独立桌面窗口、fork 分发与全身动作不属于此目标。 + +[ADR-0150](../adr/0150-window-companion-requests-use-their-live-owners.md) 记录 Human 在 #1178 接受的 attention 扩展:伙伴显示真实 owning Tool Approval 的当前待处理请求并可直接决策,独立于普通输出的播放开关和范围。审批正文来自现有 live owner 的已提交请求,不由 attention 计数或旧 Channel 历史重建;原规格保留历史边界。 + +生产 feed 通过 Fiber 拥有的可释放 attachment 观察 `ChannelToolApproval`,在已确认选择快照中投影当前已提交请求;首次钉选、重连都不需要重播历史。`CompanionRequests` 将待处理请求放入有总数提示的常驻滚动区域,独立于装饰气泡容量;普通消息暂缓逐字与到期,显示等待数量。复用 `ToolApprovalCard`、原生控件和既有审批 RPC,只有明确匹配 Bot/DM/Session/call 且 live 的 companion target 可脱离当前 Channel;普通聊天选择检查保留。提交前重新读取状态,Host 仍验证规范请求和 native caller;竞争决定只能一次生效。断线禁用、状态读取失败可重试,拥有者结算或撤销后快照移除请求并恢复键盘焦点,Host 重启不从耐久请求历史重建 live 权限。 + +依赖切片 #1179 将既有 `ChannelUserQuestions` owner 接入同一 feed,并在 `CompanionRequests` 中增加正式提问变体。`UserQuestionCard` 与 Chat、Human Inbox 共用,保留全部问题、选项说明、单选/多选和自定义文字。当前回答草稿不受普通消息和悬浮影响,不另建持久化;明确匹配 Bot/DM/Session 且 live 的目标在提交前重新读状态,可在其他 Channel 仍被选择时直接回答。规范拥有者校验、提交且只接受一次,再恢复原 native request。普通问句不生成交互控件,新 Host 不从历史重建待答问题;审批 attention 与导航保持独立。 + +### 5.4 · 形象物种与自绘部件(部分已实现) + +[ADR-0148](../adr/0148-avatar-species-and-custom-parts-extend-one-pixel-rig.md) 在不新增 rig 的前提下扩展像素 Avatar Family。Avatar Species(人类、精灵、哥布林、矮人、兽人、猫、狗、狐狸、兔子、熊、花)是像素胸像骨架上的基底:轮廓、耳朵、鼻子或吻部、建议身体色与可接受部件;动作、锚点、说话嘴型与伙伴透明轮廓保持共享。头发拆为可分别上色的分片,新增穿过头发的头饰槽(前后两层)。当前物种无法显示的选择保存并隐藏,不被替换。 + +Custom Part 是有界像素网格,每格引用外形颜色或固定色并带明度档位,因此随外形换色。BotPixel 负责其类型、校验、渲染与纯函数绘制算法;PersonaBot 所属模块把副本嵌入 Avatar Appearance,DSH Profile 中由 Host 管理的 Part Library 以内容哈希身份和来源保存可复用部件。导出的 PersonaBot 携带其部件;单个部件以内嵌数据的 PNG 流转。眼睛、眉毛和嘴不可自绘,眨眼与说话帧仍由 rig 负责。 + +新 PersonaBot 记录全域名字种子版本;没有该记录的 PersonaBot 保持仅人类的种子。使用物种、新槽位或 Custom Part 的 recipe 提升 asset/schema 版本,旧 Client 显示保存的快照;既有 recipe 渲染不变。首个切片验证哥布林、左右侧发分片与一个自绘头饰,从编辑器到 Part Library、再到 Window Companion,并经过导出/导入。 + +目前已实现:哥布林、精灵、矮人、兽人和花物种,左右侧发分片,胡子,中世纪服装与头饰,全域种子(#1210、#1212–#1214),第一条 Custom Part 路径(#1211),自绘头发(#1238),自绘其他部件(#1240)、部件文件分享(#1215),完整绘制工具(#1216),每片头发单独配色与内置头饰(#1217),普通 PNG 导入和派生编辑(#1219),以及带毛色花纹的动物物种(#1218)。asset version 4 新增前发、后发和呆毛颜色、单根呆毛,以及与配饰并存、可放内置头饰或 Custom Part 的头饰位;只有 Human 编辑这些项时编辑器才把 recipe 升到 version 4,并把作为配饰戴着的头饰移入头饰位。自绘的前发、侧发或后发会替换对应的内置部件,原来的选择仍会保留;其中发色的像素会像内置头发一样上阴影,所以编辑时从当前样式压平成的格子开始。自绘头饰的 recipe 是 asset version 3。Part Library 是 `avatar-part-library` 模块的 `avatar_part_library` 表(schema generation 73),以部件内容哈希为键,并保存名称、来源和父部件。Client 编辑器通过 `partLibraryList`、`partLibraryAdd`、`partLibraryExport` 和 `partLibraryImport` 四个桥接方法使用它;`part-file` 把单个部件编码为 ×8 预览 PNG,部件数据放在 `botharness-part` tEXt 块中,整个部件库导出为这种 PNG 的 zip,导入时校验每个块的 CRC 并按内容去重;导入 Bot 时其外形穿戴的部件以 `imported-bot` 来源加入部件库,作者为该 Bot 的显示名;编辑器提供带变亮/变暗明暗笔的铅笔、橡皮、四连通填充、按 Shift 锁定角度和正方形的直线与矩形、限定在区域内并带 4×4 或 2×2 Bayer 抖动的明暗渐变、可重新随机的 ±1 明暗杂色、Alt 点击或长按吸管、所有工具的中线镜像、前后两层、每次操作一步撤销(触屏双指撤销、三指重做)、触屏偏移光标和 1× 预览;每个工具的格子都由 BotPixel 计算,结果仍是外形颜色加明暗档位。外形嵌入部件副本,因此部件会随 `.botharness/bot.json` 和 Bot Zip 一起带走。`part-image` 解码部件尺寸(或整数倍放大)的普通 PNG,经 `partLibraryImport` 报告颜色数,`partLibraryImportImage` 保留最常见的颜色(最近色、不抖动),以 `imported-image` 来源存为固定颜色的格子;带部件库父部件保存的部件来源为 `derived`,并以 `parentAuthor` 记录父部件作者。 + +### 5.5 · Bot 主色(已实现) + +[ADR-0177](../adr/0177-bot-primary-color-is-portable-identity-not-theme-state.md) 将 application-defined Bot Primary Color 定义为 PersonaBot 的视觉身份来源,而不是 DSH-native Service、SessionEvent、Client 偏好或全局主题品牌 token。现有 PersonaBot Host owner 管理自动/自定义模式与可移植兜底来源;Client 共享来源解析,再针对实际背景与 light/dark 派生名字颜色。自动模式采用当前 Avatar 的主 `hairColor`,没有可用主发色(上传图片、无头发、line family 或不支持的外形)时使用按 PersonaBot ID 确定的兜底色;自定义覆盖独立于 Avatar Appearance。主题切换不修改 Avatar 或身份来源。 + +已实现(#1471):PersonaBot Registry 记录的可选 `primaryColor` 字段保存来源策略(缺失即自动,无 schema generation 变化);`botPrimaryColorSet` bridge 方法沿用 DM 范围的既有授权,校验后保存,自动模式保留已携带的兜底来源。`primary-color` 模块提供 Avatar renderer 与主色解析共用的外形解析,二者不会各自实现默认头像算法。Bot Profile 的头像弹窗提供独立的 Bot 主色设置(自动/自定义、预设、取色器、HEX)与本地 light/dark 预览,Host 保存成功后才更新共享呈现;取消或失败不发布草稿。DM 的 Bot 作者名按实测背景派生名字色:保持色相与色度,只调整明度,直到对比度不低于 4.5:1,黑白灰保持无彩色。群聊(#1472)沿用同一来源解析:群聊与 DM 的 Bot 作者名、@ 候选名、输入框中选中的 Bot 提及和已发送的 Bot 提及各自按自身实测背景(含半透明提及底色与对方气泡)派生名字色;只给名字文字着色,提及底色、气泡、正文、Human 名字、Channel 引用和“所有 Bot”保持原样,主题切换时重新测量。分享与导入(#1473)沿用既有的 `.botharness/bot.json` descriptor:导出与每次保存主色时写入 `primaryColor`(模式、自定义来源,以及已携带或按来源 PersonaBot ID 得出的自动兜底色),不写任何主题派生色;Git 与 Zip 导入在创建新身份后读取它,新 ID 不改变携带的兜底色,自动模式仍是自动并在有可用主发色时跟随发色。没有该字段的旧 descriptor 按新身份的自动策略处理;字段不合法时沿用 descriptor 既有校验,整份 descriptor 被忽略,不写入 Registry。删除后的归属(#1474)沿用 Registry 保留的历史记录:PersonaBot Deletion 受理时把当时解析出的来源色写入该记录的 `primaryColor.retained`,不新增存储,也不做逐消息或逐统计时间点的颜色快照。`list` 对已删除身份返回带 `retained` 的策略,没有该值的旧记录在读取时按 PersonaBot ID 兜底且不回写;`overviewUsage` 的每个 Bot 行返回 `primaryColor` 来源色(现存 Bot 为当前解析值,已删除为保留值)。Client 用同一套名字色派生给已删除 Bot 的历史作者名、已发送提及和用量列表名字着色;现存 Bot 忽略记录上残留的 `retained`,继续跟随当前来源。按 Bot 的用量图表(#1476)读取 `overviewUsage` 行上的来源色:以图表实测背景派生对比度不低于 3:1 的图表色,再按固定层级(缓存读 100%、其他输入 62%、输出 36%,与背景混合)和固定顺序绘制 token 子类,分项不可用的用量保持中性色;色调图例和 tooltip 身份标记说明含义。概览的每日总用量、Profile 的按模型视图等以 token 类型或模型为主要比较对象的图表保留分类色;单个 Bot 自己的 token 图表(popover 的迷你图和 Profile 的每日总量视图)与按 Bot 的条形使用同一组色调和色调图例;消息框右下角 token 用量弹层里的 Bot 名字和各 Session 占比条也使用该 Bot 的名字色与图表色。颜色只取决于该 Bot 的来源色和背景,不随排序、分页、时间范围或参与的 Bot 改变。单 Bot 活跃度热力图(#1475)沿用同一来源:Profile 与 popover 中的事件活跃和 Memory 提交热力图把按实测背景派生的图表色写入该热力图的 `--bh-heat-color`,四档强度仍按原有比例与中性底色混合,计数、分档和 tooltip 不变,零活动保持主题中性色;主题切换时重新派生。Group Channel 的聚合热力图没有单一 Bot 身份,继续使用品牌强调色。 + +ADR-0177 的六个切片(#1471–#1476)至此全部实现;外部 IM 客户端的渲染不在范围内。这些仍是 ADR-0177 的已接受目标,不是运行时事实;完整范围与验收见[设计记录](../research/2026-10-12-bot-primary-color-design.md)。 + +## 6 · 持久化、导出与恢复边界 + +```mermaid +flowchart TB + subgraph Profile["One DSH profile"] + DB[("botharness.db
operational authority + attachment bindings")] + Files["Optional Memory repositories
Markdown · Git authority"] + Attachments["Attachment files + identity records"] + CAS["Pending legacy attachment / Soul CAS bytes"] + DSHS["DSH SessionPersistence
transcripts · execution"] + Creds["DSH credentials / settings"] + end + + Barrier["Manual Export Profile
backup barrier + consistent snapshots"] + Package["one compressed
.botharness-backup"] + Stage["Import Profile staging
validate · migrate · dependency check"] + Target["v1: new stopped environment only
cold + suspended authorities"] + + DB --> Barrier + Files --> Barrier + Attachments --> Barrier + CAS --> Barrier + DSHS -.->|"adapter-supported facets"| Barrier + Creds -.->|"declarations only; never secrets"| Barrier + Barrier --> Package + Package --> Stage + Stage --> Target +``` + +| Data | Authority | Portability | +| --------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| operational facts | `$DSH_HOME/botharness/botharness.db` | consistent SQLite snapshot inside manual profile backup | +| operational logs (debug timeline) | `$DSH_HOME/botharness/logs.db` (lightweight owner, rebuild-empty) | excluded from backup; emailable as-is | +| optional Memory repositories | Git-backed Memory Provider | selected SoulSnapshot / PersonaBot Export / profile backup | +| attachments | Host-managed files, identity receipts and Messaging bindings; pending legacy CAS | current referenced files and identity mappings | +| Soul bytes | content-addressed files | dependency-closed selected bytes | +| Session transcript / execution | DSH SessionPersistence | only through a verified DSH export adapter; otherwise declared omitted | +| credentials and DSH settings | DSH services | never copied; restore creates suspended rebind requests | + +v1 只有两个备份动作:Export Profile 生成一个 self-contained `.botharness-backup`,Import Profile 选择一个文件。没有自动备份、scheduler、catalog、retention 或 incremental chain。Restore 总是在隔离 staging 中验证;成功后 PersonaBot 仍为 cold,provider authority suspended,Workspace/model/plugin dependencies 必须在目标机重新解析并由 Human 明确激活。 + +#886 的 v1 实现使用 application-defined Portability coordinator;各 owning module 提供历史 Memory 定位、当前附件引用闭包与恢复时的挂起转换。Database owner 在现有 Profile Writer Lease 下阻止事务写入并执行 `VACUUM INTO`;`ContentPurge.withCheckpoint` 覆盖同步快照与受管文件捕获。文件在捕获前后核对身份、时间、大小和 SHA-256,集合变化、缺失引用、超出资源/屏障时限均拒绝发布;屏障释放后压缩并完整校验。包保留 deleted identity 的 retained Memory,只有 canonical erasure proof 才允许省略;源 Git 授权配置替换为安全默认值,恢复后 hooks 隔离。当前界限为 DSH `0.2.0-rc.1`、60,000 entries 与 512 MiB;底层 Session 内容无已验证 adapter,明确标为 unsupported。 + +Settings 只经现有 Connection Fetch seam 预览、下载、检查文件与操作目标就绪度,不接收目标路径或替换正在运行的数据。受信任本地 `botharness-profile restore` 命令仅接受不存在的新目标;在 sibling staging 中校验路径/hash/SQLite/Generation、取得 Writer Lease、应用真实 Purge checkpoint、重定位 Memory,再一次提交。可执行 Bundle/Profile 配置、credentials、Workspace 文件和 debug logs 不导入;目标由固定版本 DSH 与已安装 Bundle 启动。Replace Existing 和 Managed Transfer 不属于此版本的运行入口。 + +Disaster Restore 将全部原生 Session 引用设为内容不可用,保留历史归属;中断 Assignment/Inbox/Outbox 均不重放。Bot、触发器、外部绑定、Service Grant、Workspace 与持久工具授权挂起,Browser/Computer access 关闭。目标 Human 配置明确 Model Plan、本地凭证并授权该 Plan revision,再确认同身份双活风险后显式激活;新 Orchestrator ownership 与恢复收据同一事务建立,执行使用新原生 Session。后续 Plan 变化再次阻止执行,所有实际模型调用仍经当前 catalog 验证,不能静默回退。激活仅开放本地 Bot 执行,外部 authority 与 Workspace 仍须独立重新授权。[操作与限制](../settings.md#complete-environment-backup-and-restore)。 + +Diagnostic Bundle([ADR-0185](../adr/0185-diagnostic-bundles-are-local-redacted-read-only-exports.md),#1533)是另一条只读导出:用户在时间线多选消息(消息菜单“多选”,右键或触屏长按打开)并在“导出”菜单里选择诊断包后,经 Connection Fetch 预览并下载一个本地 zip,范围从第一条到最后一条所选消息(只选一条时到现在)、前后各多 30 分钟,所选消息在 manifest 与时间线中标记,带上参与 Bot 的 Human DM、所导出 Channel 内的 Inbox 收件、其拥有的 Orchestrator/Assignment/Subagent Session 原生事件(起点处正在运行的 turn 从 `turn/start` 算起)和合并时间线。`DiagnosticsExport` 只通过 Channel store、Inbox history、Session Ownership 与原生 `sessionQuery` 读取,标准脱敏后只在内存中保留预览结果,不写盘、不建表、不上传;Memory、Workspace、附件内容、凭据与其他 Channel 不进入导出。 + +图中的 Attachment files 表示新附件真实文件及记录,Messaging 绑定将已转换旧引用解析到当前真实文件,legacy CAS 仅服务未转换依赖。hash 相同不恢复共享,缺少归属或含糊的旧调用明确失败。后续 Backup/Export 要包含当前被引用的文件与身份映射,引用感知清理及显式 Purge 也必须涵盖这些真实文件;一次明确导出保存当前字节,不建立持续附件版本归档。 + +### 删除与清除(已接受设计,待实现) + +[ADR-0130](../adr/0130-deletion-preserves-history-and-makes-memory-erasure-explicit.md) 与 #138 将 PersonaBot/Channel 的结束参与和物理内容清除分开。删除 PersonaBot 的确认框展示实际 Memory Repository 与依赖,提供“打开记忆文件夹”及默认不勾选的“同时删除记忆文件”;打开文件夹不改变勾选或确认状态。未勾选时保留记忆及 Git,勾选后也只能清除经 Host 重查、专属且归属明确的仓库。共享、路径变化或无法证明归属时不可清除;Workspace、原生 DSH Session、远端 Git、外部导出与备份不随之删除。普通删除先关闭执行/收件/授权入口,等待所拥有的 AgentHandle 执行树静止,再保留 deleted identity、历史 Session Ownership 和仍保留记忆的持久位置;它不同于可恢复归档。Channel 删除保留消息及因果归属,只结束其成员和路由,不删除外部会话或其他路由;Hidden Channel 仍可恢复。另行确认的 Content Purge 才清除选中的 Source Event 正文,附件需检查共享引用,并覆盖 legacy CAS 与当前真实文件绑定。 + +现有 roster stream 在提交后和重连时投影不含正文的清除位置选择器。Client 清理当前和缓存会话、侧栏和 attention 摘要,拦截延迟响应回填,并显示清除标记。这些内存选择器来自 Host,不构成第二份持久权威。 + +Purge Ledger 是应用定义的 Host 深模块权威,必须单调持久并位于可恢复数据库快照之外。清除先接受 ledger,再幂等应用 Messaging 清除与受管文件清理;中断时已接受的范围不可重新显示、投递或用于新出站效果,文件失败需明确报告尚未清完。#886 必须依赖真实 ledger/checkpoint 实现,不能用空占位代替;恢复在 Messaging 可读前合并并应用 package/destination union,独立离线旧备份只保证其自身 checkpoint。备份包括保留的 deleted-identity 记忆仓库。设计验收不等于运行功能已交付。 + +### Channel 内容清除运行路径(#897) + +Generation 70 的应用定义 `ContentPurge` Host owner 通过已有 Typert/API Gateway 提供已结束 Group 的只读历史、所选 Source Event 预览和单独确认;不注册模型 Tool。已结束 Group 的身份保持占用,同名新群聊不覆盖旧历史。本地和外部来源可以共享到仍活跃的会话,但全部位置在对应 revision 上均须仍在 Human 当前可读范围内。普通删除只结束所选 Channel 的路由/摄入,保留其他 Channel/Inbox 目标及外部会话。[ADR-0146](../adr/0146-content-purge-retains-file-selectors-and-settles-issued-effects.md) 记录文件、效果与恢复边界。 Memory 通过自己的查询和 retained/deleted 仓库定位提供已记录的来源衍生提交;Workspace Grant 提供可能的副本位置。这些引用参与确认前重验证,不写入清除账本,也不声称扫描过未追踪的文件或导出。 + +独立 `botharness/purge/ledger.db` 由同一 Host 生命周期拥有,SQLite FULL 同步事务先接受无正文的事件身份、作者/时间/因果、Human 审计及托管文件选择器,再移除运行正文;该文件不属于 `botharness.db` 快照。最小墓碑及 placement 保留,secure-delete 后截断 WAL;应用失败关闭运行数据库进入 recovery,启动先应用事实再创建 Messaging Consumers。Attachment owner 重查全部保留来源、迁移/获取绑定和 Outbox 引用后,仅移除已审阅的独占当前文件和 legacy CAS;共享文件保留,受阻清理可见且重启继续。待投递意图脱敏并取消,已发出请求在活跃 Host 中按真实证据结算,冷中断保持结果未知。持久栅栏和原生 Tool Guard 拒绝重复回填、旧命令与新的内容依赖效果。 + +`checkpoint()` 导出已验证事实;`withCheckpoint` 在同步或异步快照回调完整结束前持有清除屏障,Profile Backup 在其更广的 Backup Barrier 内调用。冷 `restoring` 验证 v1/v2 包检查点,单调 union package/destination 事实并保留目标审计,再应用、开放 Messaging。新目标只可从已验证包初始化;已建立账本缺失、损坏/不支持的数据或 union 中缺少运行快照引用事实均关闭入口。v2 包含托管文件身份及外部/系统作者。备份 Settings 与本地恢复入口由 Portability module 提供。 + +预览列出所选来源、全部 placement/Admission、文件移除/保留及依赖效果;info 控件披露仍保留的 Assignment/其他因果来源正文、未改写的 Human Memory/Workspace/导出、原生 DSH Session 提示/结果、外部副本、Git 远程及离线备份。旧独立文件只执行其包含的检查点。[验收指南](../agents/qa-channel-purge.md) 覆盖真实 SQLite/文件恢复及 DSH 共享文件/中断清理。 + +### PersonaBot 删除运行路径(#896) + +Registry 在 Generation 66 持久化不可恢复的身份关闭状态和确认时的记忆位置。活动查询排除已接受删除的身份;历史查询保留身份、Session Ownership、Report、消息和用量归属。Human Profile 确认框复用 Typert/API Gateway 与原生 Host 文件打开能力。“同时删除记忆文件”默认不勾选,清除需要专属 Git 仓库、已记录的目录身份,以及与其他保留仓库和所有注册 Workspace 均无重叠。新建自定义仓库可以记录同样的归属证明;已有自定义路径本身不足以证明独占归属。Host 共享存储也受到保护。 + +删除 owner 先接受持久身份关闭状态,再停止该 Bot 所拥有的原生 AgentHandle 执行树、撤销 Workspace/工具规则,并停用其 Messaging 绑定。已发出的原生/provider 效果保留实际结果。可选文件清理具有明确接受的范围和未完成状态;重试只使用原目录身份,不能选择新路径。DSH Session Persistence 与共享 provider 账号保留。冷启动仍排除已删除身份,即使清理需要修复。后续 Profile Backup 必须枚举历史 Registry 记录及删除位置,区分主动清除的记忆。Channel Content Purge 与支持恢复的 ledger 仍属于 #897。 + +### Bot Marketplace(已接受设计,待实现) + +目录查询将 GitHub topics 与 `.botharness/bot.json` 的 tags(兼容旧 `roles`,存于 D1 的 `roles` 列)一起用于文字搜索、`topic` 筛选和 `/v1/topics` 统计;同一仓库的同名标签只计数一次,统计排除收录标记 `botharness-bot` 及未上架仓库。FTS5 同时索引两类标签,迁移会从已存数据重建索引。每小时刷新不依赖 push 即更新 GitHub topics;检测到 push 后重新读取 README 和 bot.json,并替换旧标签。 + +[ADR-0131](../adr/0131-bot-marketplace-starts-as-a-github-indexed-catalog.md) 与 #18 让 Bot Marketplace 先作为 GitHub 索引目录上线:作者给公开仓库加 `botharness-bot` topic 即同意收录,也可在 Marketplace 贴链接立即抓取。独立的 Cloudflare Worker 与 D1 每日按 topic 切片发现、每小时用 GraphQL 批量刷新,README 进入 FTS5 索引;浏览用 keyset cursor,搜索只取前 200 条。harness 的 Marketplace modal 展示 README 详情;安装复用 #298 的 Git URL 创建路径,确认框显示最新提交并提示第三方仓库风险。贴链接与一键举报共用 ALTCHA 和限流。不建账号、不计下载。[ADR-0135](../adr/0135-a-bot-moves-between-people-as-a-zip-of-its-memory-files.md) 撤销了基于账号的第二阶段:把单个 Bot 交给别人或换设备,改为在 harness 里导出 Bot Zip(默认只含 Memory 文件和 `.botharness/bot.json`,整包导出时可选带 Git 历史),对方从创建菜单的「从 zip 导入」得到新的 PersonaBot。 + +[ADR-0240](../adr/0240-marketplace-listings-localize-author-first-with-generated-summaries.md) 让列表按读者语言(中文/英文)显示:作者在仓库根目录放 `README.zh-CN.md`/`README.en.md`,在 `.botharness/bot.json` 的 `i18n` 里写翻译后的名称和简介;Worker 推送后一并抓取,`lang=zh|en` 时优先返回。某语言没有作者简介时,Worker 在每小时刷新后经 OpenCode Go 的免费模型按 README 生成一句简介(按输入哈希缓存、每轮限量,标记 `bioGenerated`),作者文本总是优先;README 正文不做机器翻译。Host 的 Marketplace modal 与 CLI 按界面语言传 `lang`。 + +## 7 · 关键边界 + +- 正常运行只认 explicit Session ownership;`cwd` 只可作为迁移/修复提示,不能决定 PersonaBot 身份。 +- DSH Session 状态是执行权威;BotHarness 只投影 activity/last-run,并将 semantic Assignment Report 与 Host Lifecycle Notice 分开。 +- Provider capability 不等于授权;发现一个飞书频道也不自动授予向它发消息的权限。 +- UI 不直接读文件或数据库,不自己推导业务状态;它消费 Host read models,并把 command 交回 owning module。 +- PersonaBot archive 先关闭 admissions、wakes 和外部 actions,再停止 Orchestrator、Assignment 与 owned Subagents;purge 是单独的破坏性动作。 +- Browser 与 Host 是两个 Cordis 应用;Host service 不跨进程 inject,统一走 `/api` client bridge。 +- Memory 跨设备同步是 Orchestrator 的原生 Git 行为,不是 Host 服务;BotHarness 不持有远端仓库或凭据,也不提供一键同步,非 Git 所有者经 Portability 迁移记忆(ADR-0084)。 +- Roadmap Project #1 保持 private;文档同步只用 `read:project` 读取显式 `In Progress` 和 Artifact,经过 fail-closed 白名单投影后才提交公开 JSON。Project notes、private items、assignee、backlog 与 ETA 不跨越这条发布边界。 + +### 产品发布身份(已接受设计,执行门禁待交付) + +[ADR-0216](../adr/0216-dsb-product-versions-identify-immutable-package-sets.md) 将电脑端 DSB 软件身份与 npm 产品包身份统一;Core/UI/Browser/Computer 同号,DSH/独立 Provider/手机保持各自版本。既有准备流程的冻结 Release Plan 绑定源码与完整产物,而不是新增版本服务。交付 QA 后版本与字节不可替换;Beta 转正式须验证新的正式包集合。Host 的现有 Release Service 投影实际运行身份和独立的待生效版本,经既有认证 bridge 同时提供 CLI/Web;缺失发布元数据不能伪装源码构建。发布按依赖核验后推进产品入口,失败恢复同一计划/字节,不宣称多包原子性。规则详见[版本规范](../agents/version-policy.zh.md),实施由 [#1979](https://github.com/BotHarness/DeepSeekBot/issues/1979) 的三个切片负责;本节不表示全部发布门禁已实现,也不授予标签迁移、发布或共享设备使用权限。 + +#1980 的运行身份沿用 Release Service:四个第一方 Plugin 在模块加载时将不可变包名、版本与真实目录快照附在各自 callback 上。Core 捕获产品依赖组合,随后从原生 Registry 的 ACTIVE Fiber 读取实际 callback 快照并比对;等待组件启动时返回 `composition-unverified`,关于页面允许重新读取身份,不能只凭已安装清单宣布发布身份。运行版本和待生效状态在当前 Service 内保留;后续请求继续校验实际 callback,组件被替换后身份可失效,但不提前宣布新版本。配置重新应用复用同一 Core 模块捕获的组合;磁盘安装不能提前改变运行版本。该快照是应用定义的进程诊断,不是新持久化版本库,也不是对任意可执行 Plugin 的安全认证。源码构建使用编译时 Git/dirty 身份;具体安装、重启与 Human QA 证据仍由 issue/PR 记录。 + +#2173 的应用定义升级发现仍由同一个 Release Service 负责。Bot 模式进入时通过认证 bridge 查询运行身份、真实 DSH 版本及 registry 版本清单;兼容性复用当前 Host 的原生检查器,覆盖产品与精确依赖组件。绝对最新版本不兼容时继续寻找最新兼容版本;元数据缺失、搜索超时或网络失败保持未知并短期重试,不宣称没有更新。客户端按本地 Profile、发布通道和精确目标保存提醒关闭记录,与已安装版本更新说明分开。确认安装前再次核验兼容性,再进入既有原生安装和权限边界;已暂存版本仍独立提示重启,不提前改变运行身份。CLI `release-check --force` 与 UI 共用该 Service 并绕过查询缓存。 + +## 8 · 实现顺序与可并发范围 + +1. #77 验证 pinned DSH 的 Agent/SessionPersistence/Subagent seams;#79 建立 operational database owner。这两项可并行。 +2. #80 在 #77 与 #79 后实现 explicit Session ownership 和 activity projection。 +3. #81 在 #77、#79、#80 后先交付最小 DM → Orchestrator → Assignment → report → DM reply tracer bullet,并同时提供可验收的事项列表/详情;#47 的后续 Assignment coordination 在该切片通过后扩展。 +4. #78 可与上述工作并行研究 Feishu provider contract,但 #48 的 adapter 实现受它约束。 +5. #74 的 Memory 工作在上述主链通过后,以独立 optional Provider tracer bullet 推进;#75、#76 保持 focused design/grill,避免阻塞首个可体验闭环。 + +## 9 · 如何维护 + +- 模块、数据流、事务边界或 authority 发生结构变化时,同步本文件、英文镜像和 `docs/architecture/diagrams/*.mmd`。 +- 运行 `pnpm diagrams` 提交 light/dark SVG;`scripts/sync-docs.mjs` 将本文和图同步到 `apps/docs`。 +- 配套:BotHarness 产品术语 `CONTEXT.zh.md`(英文为 `CONTEXT.md`);取舍与理由 `docs/adr/`。Platform Spec 与 App PRD 已归档为历史工作草稿,不再作为并列设计权威。 + +## 附件文件操作切片 + +[ADR-0105](../adr/0105-attachments-use-native-file-operations-under-source-authority.md) 沿用现有 Attachment owner 管理原件当前内容。`channel_attachment_save` 校验当前 Channel 成员权限与准确的消息/fileId 归属,再将原始字节流另存到明确允许 Orchestrator 写入的 Grant;已有目的文件不覆盖。原生文件工具处理另存文件,Shell 使用该 Grant 的 `workdir` 并保留 Human 审批。Agent Scope 中复用原生 Tool 注册,通过隔离的 application-defined Policy Provider 在每次 `tools/execute` 选择单一获准根目录,不改 Memory cwd 或全局 Provider。写入权限变更使旧审批规则范围失效;已开始的 Shell 可能完成。 + +`channel_attachment_import` 明确选择当前 Memory/Grant 读取权限内的 canonical 普通文件,交给既有 owner 创建独立可下载 Attachment,再经 `channel_send` 当前发送权限回发。另存与导入不解析文件,不授予 Shell 权限。#632 交付本地 ZIP/CSV 链路。#633 的 `channel_attachment_open` 从精确来源消息与文件生成仅当前回合有效的原生访问选择:`read` 只允许指定原件路径;`edit-original` 另需既有 Human 工具审批或匹配的已保存规则。原生 guard 每次复查当前来源权限与文件可用性,拒绝其他路径,包括同目录兄弟文件。隔离 Policy Provider 为原生修改选用该附件已有的 data 目录,不授权整个附件仓库或元数据目录;不透明 Shell 保留审批。该选择在回合结束时清除,不是持久授权。原生写回修改既有文件,共享身份在刷新/重启后读取当前字节,独立上传仍然独立。不增加 Source Revision、文件变化 Inbox Admission、唤醒、应用锁或版本档案。仅 Inbox 的 Lark 文件链路由 #657/ADR-0107 集成;Slack 仍是后续 provider 切片。 + +[#657 的外部文件切片](../adr/0107-external-files-use-trusted-source-capabilities-and-existing-owner.md) 为既有 dsh-im Service 增加可选公开文件能力。真实文本 @ 仅解析其准确父文件元信息;canonical Source Event/Inbox 保留来源,不产生 Channel placement。首次访问重新校验原账号、群/话题及资源关联,将有界字节流交给既有 Attachment owner;普通 receipt 让稳定文件身份在重启后复用,缺失原件不重建。来源详情下载与 `bridge_attachment_save` 共用该权限。原生文件工具和经审批的 Shell 处理可写 Grant 中的独立副本,当前回合通过 `channel_attachment_import` 明确选择结果后才能 `bridge_reply_file`。既有 Outbox 在外部效果前记录文件引用,再复查当前 Grant/来源,经 provider 原生上传与准确话题回复。不确定结果不重试,不增加文件目录、解析器、SDK 连接、转录存储或文件自动唤醒。 + +### Human Inbox 中回应 Assignment + +等待/受阻卡片以 Assignment Session 聚合,读取准确报告 Source Event 和至多前后各两条报告。Human 在 Inbox 回答时,消息通过现有 Bot DM authority 提交,并携带 `assignmentReply: {sessionId, sourceEventId}`;事务核验所属 Bot、运行状态、当前请求或空闲受阻报告,以及尚无已提交 Human 回应;若之后的权威接收前失败记录指向同一未解除 ask 且事项空闲,可明确重试。新的 Human 回应消费这次机会,并发提交仍只接收一份。此消息由 Orchestrator 接收并转交 Assignment,不直接恢复事项或清除 ask;完成、失败或停止后的报告拒绝新回应。同 ID 重试返回原提交。普通进展保留未解决 ask,较弱等待报告不覆盖较强的受阻 ask。点击 Bot 打开私聊,点击 Assignment Session 切换到 DSH 原始 Session(ADR-0071)。 + +Human 回应目标及时间使用 Messaging 拥有的 SQLite 索引。列表超过 150 项时明确暂停自动轮询,保留正在浏览的旧记录;可见的「刷新回到当前列表」按钮保留筛选条件,重查最多三页并获取新游标。「加载更多」不截断当前记录,成功行动命令仍刷新权威状态。 + +## 共享 Channel Bridge — #634 + +[ADR-0108](../adr/0108-shared-channel-bridge-places-canonical-external-sources.md) 为授权 Lark Grant 增加明确选择已有 Group Channel 的收件位置。Messaging 在 ACK 前将同一个外部 Source Event、canonical Channel placement 与绑定收件 Bot 的 Inbox Admission 原子提交(#634 验证 @;普通文字见 #613)。原生时间线和授权成员读取投影有界的外部发送人/时间/正文/来源;其他成员获得可见性,不复制消息、身份、Admission 或唤醒。既有 harvest/steer 路径以该本地 Channel 作为入站上下文,只有明确使用自身身份的 checked reply 才回到 Lark。当前成员、绑定与 Grant revision 控制收件、唤醒、读取和未开始的回复;退出/撤销保留已有共享事实。默认仍仅入 Inbox;该切片保留每 Grant 一个目标、每 Source 一个 placement,重投不移动历史。多目标、话题跟进与协作继续由 #629 的后续 tracer 交付;普通文字收件沿用下述 #613 策略。 + +```mermaid +flowchart LR + IM["Authorized Lark group
verified Human @"] --> Provider["dsh-im public Service
exclusive Consumer"] + Provider --> Commit["Messaging transaction
canonical Source Event"] + Commit --> Placement["One existing Group Channel placement"] + Commit --> Admission["Addressed bound Bot Inbox Admission"] + Placement --> Members["Current Human and Bot members
native timeline and bounded reads"] + Admission --> Runtime["Existing harvest / steer
one Orchestrator"] + Runtime --> Reply["Explicit own-identity reply
current grant and source checks"] + Reply --> Outbox["Existing durable Outbox"] + Outbox --> Provider + Provider --> IM +``` + +## 外部群收件与唤醒 — #613 + +[ADR-0109](../adr/0109-external-group-collection-is-separate-from-wake.md) 将每个授权 PersonaBot/账号指纹/具体 Chat 的普通文字收件与唤醒分开。Messaging 保存不可改写的策略版本与编辑者;Human Profile 和所属 Orchestrator 的 scoped Tools 可选择仅收 @ 或普通文字全量,以及数量/时间 digest、下一轮 immediate、随本群 @ 阅读或 silent。开启全量要求当前 exclusive Consumer 确实收到一条该群非 @ 消息;仅验证时不保留该消息正文或 Source Event。接收租约与 Host 重启会重置能力验证,已保存策略保持不变,平台缺失的事件不回填。 + +新收件在 ACK 前原子提交 Source、已有目标 Channel placement(如配置)和绑定 Bot 自己的 Admission,固定当时的 Messaging revision 与阈值。Bot Runtime 复用现有有界 harvest 和计时;普通消息在回合边界入队,不 steer 正在执行的模型/工具步骤。@ 保留既有 steer/turn 规则,可同批阅读本群的待处理 digest/mentions 上下文;silent 只在显式读取时进入本轮。重启从 pending Admission 的历史阈值恢复,编辑只影响后续消息,重投不重新分类。是否回复仍由 Bot 独立决定;其他 Channel 成员的普通消息 attention 由 #638 交付,话题跟进由 #614 交付。 + +## 外部话题参与 — #614 + +应用定义的 Messaging Service 为已授权 Lark 群提供明确的话题跟进。PersonaBot 从自己 Inbox 的可信 Source Event 选择 Thread;Host 检查当前 Grant、Consumer、账号 fingerprint、Chat/Thread/root,以及该 Consumer 是否实际交付过本话题的无 @ 回复。发过一次回复不自动跟进。按 Grant/Thread 追加不可变策略版本,Bot 选择跟进或沿用群规则;Human 可跟进、排除普通回复或交回群默认,明确的 Human 覆盖在恢复继承前优先于 Bot 修改。 + +普通消息按 Thread 覆盖再按群 collection 判断,唤醒默认继承群 ordinary wake,亦可提供有界覆盖。直接 @ 继续走原 addressed 路径。退出恢复群规则,不等于在全量收件群中禁收;不回填、不重写历史。canonical Inbox Admission 在 ACK 前冻结 group/Thread revision 和实际 wake/count/interval,后续 harvest 沿用现有安全 turn/steer 边界。持久策略重启后保留;实际投递资格证据属于当前 Consumer 生命周期。撤销、归档、关闭 Consumer 与过期来源/Grant 始终优先。 + +PersonaBot Profile 展示最多 50 个 Inbox 锚定话题的收件方式、修改者和管理 Modal;无话题能力的平台不生成控件。DSH Tool Registry 与 Typert/API Gateway 分别承载 Bot 和 Human Consumer,应用权限仍由 Messaging Host 持有。参见 [ADR-0110](../adr/0110-external-thread-following-is-scoped-and-explicit.md)。 + +### Human Inbox 详情与移除(#687 QA) + +手机 Activity 通过认证 Bridge 读取同一应用定义 Human Attention owner。查询版本 1 在分组分页前执行安全摘要搜索、未读消息筛选,个人消息默认提及优先或显式按时间排序,游标绑定当前筛选。消费者分别加载可见分组;已处理历史需显式筛选。没有 Activity 专用持久化或第二套请求生命周期。悬浮顶部栏仅持有临时筛选、搜索和排序状态,折叠偏好仍按设备与 Host 保存。旧 Host 明确拒绝,避免静默返回未筛选结果。 + +消息窗口上下沿的等宽箭头分别增量读取历史/较新上下文;下沿到达此前末尾后仍能查询后来消息。Channel 沿用原时间线游标,Assignment 报告通过同一个有界查询继续读取边沿。每条消息在悬停或键盘聚焦时显示精确来源按钮,触屏保持可用;Assignment 原始报告则打开所属 DSH Session。常规手动刷新与底部来源按钮移除,失败可重试且保留回复草稿。 + +Human Attention 在原 operational database 拥有 `human_inbox_dismissals`,只保存 Human、item、Source Event key、可选 unread placement revision 和时间,不保存另一份消息。Host 验证可见原来源后提交 Inbox-only Dismiss;它不答复、审批、授权或推进已读,不创建已处理记录。查询分页和待行动计数排除已移除项;入口未读总数仍由原已读位置决定。Channel 未读汇总只隐藏当时 revision 及之前的批次,之后的消息重新出现;新的 Assignment 报告也不被旧决定隐藏。多窗口与重启共享同一状态,原 Channel 卡片仍能处理,随后真正回应的已处理历史仍引用原权威。再次点击同一行只收起详情,不移除。 + +### 总览行动与 Session 行卡(#698) + +总览默认显示非 idle 状态或有权威 Human 待行动的 PersonaBot;「显示空闲 Bot」可查看其余 Bot。等待/受阻工作显示为状态或行动,不计为正在执行。每张 Bot 卡复用 Human Inbox 的原生行动表单与选择器,以独立的 Bot 过滤 Client 查询缓存读取同一 Human Attention Bridge,最久等待优先、有界刷新并沿原游标继续分页。决定与移除后刷新该列表及总行动数;沿用 Inbox 的移除排除规则,不新增持久表或请求生命周期。正在执行的根 Session 用紧凑行卡显示原生 DSH Session 列表的当前 displayTitle;总览打开/刷新时加载该公共列表,订阅名称更新,并用图标区分 Orchestrator 与 Assignment。只有原生名称不可用时才回退用途/角色;点击仍退出 Bot mode 并打开准确原始 Session。 + +## 今日 Channel 活跃度(#703) + +application-defined `botharness/channelActivityToday` 查询由 Channel owner 在既有 SQLite 中聚合 Host 本地日内可见 placement 的不同 Source Event,尊重 Human 成员与可见修订边界,排除已删 Channel、Bot 私聊及其生成的注意力通知。响应给出准确的 Human/Bot/其他计数、当前 Channel Human 昵称、Registry Bot 名字、桥接发送者标签,以及日期/时区/半开时间边界。总览使用同一尺度的紧凑堆叠条,可展开发送者明细并跳转 Channel;默认渲染 20 行且提供继续查看,完整总量不截断。视图拥有每 30 秒、午夜和手动刷新及卸载清理;查询失败显式呈现,不推进已读、不生成 wake,不增加 schema 或第二套计数存储。 + +总览优先排列需 Human 行动的 Bot,并先展示 canonical 行动表单,再显示执行中的 Session。显式全部已读命令先固定每个 Human 可见 Channel 的消息位置,再推进既有已读游标;之后到达的消息仍未读,不改变请求解决、Inbox 移除或 Bot attention。统计复用锁定版本的 TanStack 图表与主题,以前端偏好记忆折叠状态,并保留可访问的发送者明细。 ([#705](https://github.com/BotHarness/BotHarness/issues/705)). + +总览 token 统计(#709)通过应用定义的 `overviewUsage(period, after?)` Typert 查询消费既有保留 Usage 权威。今日或含今日的七个 Host 本地日期返回完整整体总量/每日分项,与有界 Bot 明细分页独立;缺失报告不再让整段合计变成未知:每个桶返回已上报部分的合计,只有完全没有上报值的桶才是空;总量缺失的行用其已上报分项作下限,并以 `partial` 标记该合计不完整,Web 与手机显示为“N+”;核对和历史基线状态明确。当前 Registry 名称标记行;不在当前 Bot 列表中的保留统计仍可见,但不虚构身份或 Profile 入口。Client 复用 Profile UsageChart/主题,以可清理的轮询读取已结算事实,并通过既有 DM 导航打开当前 Bot Profile。不增加账本、Channel token 归因或模型/wake 策略。 + +总览 Memory 统计(#716)通过应用定义的 `overviewMemory(after?)` Typert 查询读取现有 Memory Service 与 Registry。每页至多十个当前 Bot;每个仓库的所有非恢复/stash 引用可达的普通 Git 提交按 committer 时间归入含今日的七个 Host 本地日期,共享提交仅计一次。当前 staged/unstaged/untracked 状态单独显示,不推断文件作者或审批需求;缺失、无效、超时或读取失败的仓库显示不可用,不伪装为零提交或 clean。仓库 Git 查询有超时并异步执行,避免阻塞共享 Host;查询关闭可选 Git 锁与 fsmonitor,不 stage、commit、reconcile 或建立 checkpoint;沿用 ADR-0068 的仓库权威,不增持久统计账本。Client 复用 Profile 紧凑卡片及 TanStack 主题;可展开每日值,保留已加载分页刷新,折叠 Statistics/退出视图时清理查询资源。 + +### 外部身份独立生命周期(#699) + +[ADR-0111](../adr/0111-external-identity-lifecycle-is-independent-of-grants.md) 将应用定义的 PersonaBot 外部身份与会话 Grant 分开。Messaging 既有 bindings 表持久保存启用偏好、本地名称及 revision;仅绑定通过可信 dsh-im 的认证账号元信息,不创建 Grant 或 listener。Profile 以独立身份表/Modal 管理;Group 没有身份表。暂停停止该身份的 Consumer lease,并由同一个 Host 权限门禁拒绝未开始的 Client/Bot 外部效果;原有路线、Source Event 和政策快照保留。恢复校验同一账号和原目标 digest,不扩大范围。撤销单个 Grant 不解绑身份;明确解绑使该身份所有 Grant 失效,保留可检查配置和消息,不删除 Provider 凭据。#700 继续交付 Channel Bridge 表。 + +Messaging 的 bindings/grants 事务提交,以及进程内 Consumer lease 开始、停止、连接成功或失败,均使既有认证 roster 状态流失效。已打开的 Profile 身份与 Channel Bridge 视图收到通知后重查同一 Host 权威;通知不携带身份、凭据或授权内容,也不保存接收连接状态或重放外部事件(#855)。 + +### Channel Bridge 管理(#700) + +[ADR-0112](../adr/0112-channel-bridge-intake-is-managed-at-the-existing-grant.md) 将有 revision 的收件偏好嵌入既有 Messaging Grant(Generation 50),由 Group Profile 的 Bridge 表/Lark Modal 管理。Human 命令校验当前 Human/收件 Bot 成员资格、Grant/配置版本以及原授权账号和目标。暂停保留独占 Provider lease,在 canonical 持久化之前丢弃后续收件;已收来源仍按当前权限读取和回复。删除移除收件范围/目标、递增 Grant revision 并关闭 lease,拒绝旧来源未开始的效果,同时保留历史、身份和独立发送范围;不会自动回退 Inbox。管理界面的添加/恢复持久记录 Provider 发送时间边界;早于边界的迟到消息仅确认、不投递,重启后仍有效。这依赖已验证 Provider 的发送时间与对齐时钟;迁移路线在管理界面激活前保留既有语义。恢复不请求回填或重复 listener。收件条件不替代每个 Bot 的 attention/harvest/wake,收件身份不授权其他成员借用发言身份。旧收件接口更新同一权威;多来源/DM 投递和成员自身身份回复仍由后续切片交付。 + +### 共享普通消息的成员 Attention(#638) + +[ADR-0113](../adr/0113-shared-external-traffic-uses-member-channel-harvest.md) 将明确收件并首次放入 Group Channel 的普通外部来源,在同一 canonical 事务中按当前活跃成员各自的频道覆盖/Bot 默认策略建立 Admission。重投不会补发给后来加入的成员或重写策略快照;直接外部 @ 仍只走接收身份的既有提及路径。每成员使用既有 Channel count/time digest、有界最旧优先 harvest、安全 turn 排队和恢复;已放入 Group 的普通来源不再走身份专属外部 digest。接收 Bot 明确设置的话题 wake 覆盖保持独立分区,不影响其他成员。来源文本保留发送者、平台、外部消息 ID 和 Source Event;共享收件不授权借用身份。群频道侧栏的唤醒策略项按成员卡片读取 Host 的实际有效策略与继承来源,编辑沿用既有审计 owner;频道连接器仍只控制收件。无远端离线回填、新队列或共享 Inbox 存储。 + +## Composer footer preferences and generation speed(#1958) + +The application-defined Client footer owns a browser-local display preference (`botharness.composer-footer.v1`): visible item order and left/right placement. Defaults show model, Workspace and token usage; speed is opt-in. Preferences never grant access or cross devices. A visibility panel opened by right-click, long-press or the `/footer` composer shortcut and a temporary pointer/keyboard arrangement mode share this preference. The panel stays open across toggles. With no rendered items, the footer collapses completely; `/footer` remains available for recovery. Desktop labels stay on one line with ellipsis; phone-width Web keeps its compact layout. + +The Host observes committed DSH `assistant/message` SessionEvents and keeps one process-local measurement per Session until the next step, reply consumption or Session disposal. A completed reply may carry optional `perf` metadata through the existing Channel/Source Event persistence and timeline projection; old messages require no backfill. Provider output counts are used when usable, otherwise reply text yields an estimate. Both reply and footer labels use the compact `36 tps` format; estimation metadata remains available in the message data. The footer uses its own inline size for responsive presentation: at 480px or less, model/workspace labels collapse to named, titled icons while numeric usage/speed remains visible, without resize subscriptions or preference writes. Phone-width Web uses the same applicable controls and visibility/order preferences, with compact 28px-high buttons on all pointer types. This is diagnostic metadata, not billing authority. The Client only loads averages while enabled: at most four pages/200 messages for up to 20 measured replies, refreshed on a completed measured reply, without polling or per-token scans. Hidden or purged content is excluded from displayed measurements. + +## Client-local composer recovery(#1459) + +DM 与 Group 的未发送撰写上下文由 application-defined Client recovery owner 保存于浏览器 localStorage(ADR-0184;原设计编号 0160 已被其他决策占用),不进入 Source Event、Session Persistence、Bot Memory 或 telemetry。Host/Profile 通过 Host bridge 返回持久 opaque identity,组合可信 Human 与 canonical Channel ID 隔离草稿;身份未就绪或切换时不展示旧范围输入。正文、明确 mentions/Channel references、reply ID 和已上传附件引用逐次保存,不依赖卸载事件,也不保存 File 字节。 + +恢复先经现有 Typert/API Gateway seam 校验当前 Human 可见回复及 Attachment owner 引用,再结合当前 roster 校验目标;失效引用保留正文并提示,未完成上传不能伪装 ready,`@all` 需重新选择。不可变版本及因果时钟保留并发标签页的双方输入,显式选择后重新校验;空闲视图同步,正在编辑的内容不被静默覆盖。存储损坏/配额/不可用明确提示,保留有效旧记录与内存输入。此切片不提供跨设备同步。 + +提交快照与输入召回(#1460)在同一恢复范围下增加一份有界的提交尝试列表(`composer-submission-store`,localStorage `botharness.submissions.v1:`)。通过本地校验的提交先以原 client message ID 保存正文、明确 tokens、reply ID 与已上传引用,再清空输入框;提交前被拒绝的输入撤回记录、仍是草稿。状态为 pending/sent/failed/unknown/restored:Host 明确拒绝为 failed,请求没有得到回答为 unknown(界面显示「结果未确认」,不称未发送),页面关闭时遗留的 pending 按 unknown 处理。unknown 只通过向 owning Host 按原 message ID 查询来核对:已提交则记为 sent 并由规范消息取代本地气泡,明确不存在则转为 failed 并可放回输入框,查询不到结果则保持 unknown 且不允许放回;不自动重发,也没有重试队列。Host 的 `channelSend` 对相同 ID 加相同内容返回已有消息,因此 ACK 丢失不会产生重复消息。空输入框按 ↑ 召回本 Channel 的提交尝试(含失败与未确认),↑↓ 浏览,越过最新一条回到召回前的草稿;召回只填入正文与仍有效的明确 tokens,不发送,不带回附件与回复对象并给出提示;编辑后方向键恢复光标移动,IME 与候选菜单优先。普通历史保留最近 100 条且不超过 256 KiB,按最旧裁剪;failed 与 unknown 不受该预算淘汰。这份列表是恢复数据而非消息日志,规范消息仍由 Host 拥有。 + +## Human 群聊全部 Bot 提及(#542) + +群聊组合器的 `@所有 Bot` 是临时的 Human 输入意图,预览显示当前群内已加入且未暂停的接收 Bot 人数;DM 不提供该选项,粘贴文本不携带选择权威。现有 Channel Store 使用 Registry 与成员事实生成包含名单及显示名的预览 revision。提交前及实际提交边界校验同一预览,名单改变(即使人数相同)或人数为零时返回更新后的可信预览,经既有 Typert 错误 details 显示新人数、保留草稿,并等待 Human 再次发送。 + +Host 将选中的单一 token 展开为普通逐个 @Bot 的正文及稳定 ID/范围,提交一个 Source Event 和 placement,再复用每个 Bot 的普通 group-mention Admission、attention 与 wake policy;普通消息 silent 不屏蔽明确提及。重试相同 messageId 在当前成员校验前复用已提交内容,重启保留普通消息及各接收者状态。该预览不写入消息或引入 broadcast Source Class;Bot 的 Tool 不暴露此快捷方式。见 [ADR-0099](../adr/0099-human-all-bot-mention-expands-to-direct-mentions.md)。 + +### 明确分享自己的 Inbox 来源(#636) + +[ADR-0115](../adr/0115-explicit-inbox-sharing-adds-canonical-placement.md) 向当前 Orchestrator 暴露 `bridge_share`。Messaging 在执行时检查自己的 Inbox 来源、当前接收身份/Grant 与已加入的 Group,再原子提交一个 canonical placement 和各成员普通收件记录。接收 Bot 保留原 admission,其他成员沿用自己的 Channel Attention 与数量/时间 harvest。Channel 渲染直接读取 canonical 来源内容,保留发送人、平台和外部 ID。分享不改变后续收件,不镜像到 Human DM,不对外发送,也不授权成员借用接收身份。同目标重试返回已提交结果,不补收给后来成员;首个切片拒绝另一个目标、DM 和已经投递到 Channel 的来源。撤销授权会阻止新的副作用,但保留已共享历史。多 placement 与独立接收路径由下述 #635 切片交付。 + +[ADR-0117](../adr/0117-external-only-reports-use-owned-outbox-correspondence.md) 增加明确请求的纯外部报告。`bridge_targets` 返回自己已有的授权群 Grant,`bridge_post` 沿用 Outbox,不产生 Channel placement 或 admission。可选 Provider 回执保留原生消息/会话 ID;`bridge_outbox` 提供有界预览或一条 canonical 报告,撤销后仍可读。真实收件的 parent/root 只关联同 Bot、账号 fingerprint 和会话的原报告。Profile 与来源 Modal 读取 Outbox 投影。可选且认证的自身回传仅补充已有对应关系,不产生收件提醒;不假定真实 Lark 必定回传。未知结果不自动重试,沿用 @/跟进和 dispatch 前撤销检查。 + +### External platform behavior defaults (#701) + +[ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) assigns qualified Lark collection, ordinary-message harvest and identity enabled preferences to the existing Messaging database owner. Immutable global revisions cross the authenticated Typert/API Gateway; native Bot settings and Profile tables display effective values and inheritance origin. Legacy configurations remain custom; new qualified configurations inherit, and restoring inheritance uses current defaults. Explicit Thread/source collection overrides platform collection; a member's Channel wake override precedes its explicit PersonaBot source rule and then external-platform defaults. Local messages retain local defaults. Sources and per-member admissions save revisions and thresholds; digest buckets partition those snapshots. Identity pause reuses the existing dispatch gate and Consumer lease lifecycle; resume revalidates the same authorization and fences old events without backfill. No new identity, Grant, listener, automatic reply or Inbox store results from a defaults change. + +[ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) 将已资格验证的 Lark 收件、普通消息汇总与身份启停偏好归于既有 Messaging 数据库权威。全局不可变版本经认证 Typert/API Gateway 供 Bot 设置、Profile 与 Bot 工具读取;旧配置保持自定义,新配置明确继承,恢复继承使用当前默认值。话题/来源收件覆盖平台默认;成员 Channel 唤醒覆盖 PersonaBot 显式来源规则,再回退外部平台默认。本地消息保持既有默认。Source 与各成员 Admission 保存版本及阈值,汇总按快照分桶。身份暂停复用既有发送门禁和 Consumer lease,恢复校验原授权并拒收边界前的迟到事件,不回填;修改默认值不产生新身份、Grant、listener、自动回复或 Inbox 存储。 + +### Multiple source and target routes (#635) + +[ADR-0120](../adr/0120-multiple-bridge-routes-retain-canonical-sources.md) keeps multiple explicit Channel/Inbox routes under the existing authorized Messaging Grant. One qualified Provider Consumer lease per verified account fans out to conversation-scoped Grants. A canonical external Source Event has one content authority and multiple per-Channel placements; Inbox-only has no transcript. Immutable per-Bot/per-route snapshots retain authorization, collection and wake evidence beside one admission per Bot/source. Any ready live path can select that source once; handling one Bot cannot consume another member's admission. Current membership and identity gates protect intake and reads; context and attachments retain their own receiving-identity boundaries; explicit shared-source replies use the responder-owned authorization described below. Route pause/delete/reenable do not affect surviving routes or backfill accepted sources. Profile tables expose explicit DM versus Inbox-only destinations, independent switches and native add/edit/delete Modals. Generation 52 requires a pre-upgrade Profile backup or forward recovery, rather than code-only rollback. + +[ADR-0120](../adr/0120-multiple-bridge-routes-retain-canonical-sources.md) 将多条明确的 Channel/Inbox 路由保留在既有 Messaging Grant 下;同一已验证账号的一个 Provider Consumer lease 向各授权会话分发。外部 Source Event 保留一份内容权威和各 Channel placement,Inbox-only 不生成聊天历史。每 Bot/路径的不可变快照独立记录授权、收件与唤醒证据,每 Bot/来源仍只有一条 admission;任一有效路径就绪时只选择来源一次,处理一个成员不会消耗其他成员的工作。收件及读取检查当前成员和身份权限,上下文与附件仍遵守接收身份边界;明确的共享来源回复使用下述回复者自身授权。单条路由暂停、删除或恢复不影响其他路径,也不回填旧来源。Profile 通过独立 Switch 和原生 Modal 管理路由,明确区分 DM 投递与 Inbox-only。Generation 52 需升级前 Profile 备份或向前修复,不能仅回退代码。 + +### Own-identity replies to shared external sources (#637) + +[ADR-0122](../adr/0122-shared-source-replies-use-responder-owned-authorization.md) keeps source content and receiving identity canonical while allowing a current Group member to inspect its shared placement without creating an Inbox Admission. An explicit reply requires that member's own enabled external identity and one verified Grant for the original external group. The optional dsh-im checked reply-context contract resolves the exact source under the responder's application, retaining conversation/thread/root/parent while translating only the app-scoped sender identifier. The existing Outbox records a per-responder/source intent, owned identity, qualified route, receipt and honest outcome; Profile projects these details. Membership, Binding/Grant revisions and Provider Registration are fenced again after remote validation and immediately before SDK dispatch. Missing authority, mismatched routes and unknown outcomes never borrow the receiver, fall back to the group mainline or retry blindly. Text replies add no source copy, automatic ownership lock, wake or schema migration; shared remote context/files remain separately qualified. + +[ADR-0122](../adr/0122-shared-source-replies-use-responder-owned-authorization.md) 保留一份来源内容和原接收身份,让当前 Group 成员读取已共享的 placement,而不新增 Inbox 收件。明确回复必须使用该成员自己启用的外部身份和原外部群的唯一已验证 Grant。dsh-im 可选的回复核验契约以回复者应用读取原消息,保持会话/话题/root/parent,只解析该应用范围内的发送人 ID。既有 Outbox 按回复者/来源持久记录独立请求、身份、路线、回执与真实结果,Profile 展示这些事实。远端核验完成后、SDK 发送前再次检查成员、身份/Grant 版本与 Provider Registration。缺少授权、路线不符或未知结果时,不借用接收身份、不回退群主线、不盲目重试。文本回复不新增来源副本、自动抢单锁、唤醒或数据库迁移;共享远端上下文和附件仍需各自的资格验证。 回复核验前,既有 Consumer fanout 可在收件关闭时取得仅供回复的账号 lease;外部来信确认后丢弃,不新增 Source Event 或 Inbox,自己的准确回显仍可关联 Outbox。Profile 仍显示收件关闭,身份/Provider 生命周期会取消该进程内连接。 + +[ADR-0125](../adr/0125-nearby-context-combines-time-coverage-and-count-minima.md) 更新外部 `bridge_context` 的 nearby 选择:先通过有界分页覆盖来源前后各五分钟内的可见 Human 文字消息,再为不足的一侧补齐默认前 10/后 5 条;Bot 可指定每侧 0–20 条,条数是保底而不是密集窗口上限。Lark Provider 依次遍历时间窗口、最近更早消息、最近更晚消息,按精确时间过滤秒级边界;不等待未来消息。Messaging 的进程内 opaque continuation 绑定 Bot、来源、Grant revision、范围与条数参数,每页续期 30 分钟,每次仍重新校验当前权限与 Consumer/Provider 生命周期。输出预算、略过记录和续页均明确呈现;普通历史仅形成既有 Source Event 观察,不创建 Inbox Admission 或新的消息权威。 + +[ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md) extends the existing checked DSH Service Provider/Consumer seam to Slack Human text mentions. The native Socket Plugin verifies the workspace, Bot and App pairing, persists exclusive Consumer mode, and acknowledges only accepted canonical intake; loss of a lease does not create a standalone Session. Slack channel IDs, message timestamps and event delivery IDs remain distinct. Replies use the original native thread timestamp (or root message timestamp to start a thread), current own-Bot channel/source qualification, the existing pre-dispatch fence and an explicit native receipt. Client identity/connector/source views reuse their existing projections with platform-correct labels. Explicit Slack source context reads now use the same checked Provider: every page requalifies own account/member/source/lease, separately adapts newest-first channel history and chronological native reply chunks (including root reservation/deduplication and time-bounded cursors towards older chunks), and persists only returned canonical Source Events without historical Inbox Admission or wake (#819). Nearby reads paginate the inclusive five-minute window and supplement nearest visible Human text to default 10 before / 5 after, each configurable 0–20. Signed runtime-local continuations retain bounded native IDs/counts, not message bodies; channel-history coverage is distinct from complete native-thread coverage. The #831 file tracer separately opts into one hosted attachment on a Human mention: exact native source-file association is rechecked at read time, safe ID/name/type/size metadata reaches canonical Source Events, and bounded private streams reuse the Attachment owner. Explicit result files use the existing Outbox with a fence before native upload completion in the original thread. Private URLs remain Provider-local; redirects, revoked leases and changed sources refuse. The #837 ordinary-text Consumer opt-in uses native message.channels events with current account/App/public membership/lease checks; own mentions remain on app_mention only. Explicitly authorized groups retain default mention-only collection, real-delivery qualification and existing count/time or immediate wake. Profile reuses the same compact collection editor; no new store, queue, Session or mandatory reply is introduced. #854 extends the existing explicit thread policy to Slack native `thread_ts`: root and thread match, while no parent ID is fabricated. A real unmentioned child (`ts != thread_ts`) on the current lease qualifies follow; a mainline root does not. Source/account/Grant checks, Human overrides, per-member Admission snapshots and count/time wake remain unchanged. Migrated connector Grants retain the Profile thread table and policy Modal. Ordinary files remain separately qualified; #843 extends the existing platform defaults after intake qualification. + +Slack external-only reports (#863) use the same canonical Outbox as Lark: an explicit own-identity `bridge_post` persists text and checked native `channel`/`ts` receipt without a Channel placement or self admission. An eligible Human thread reply associates through native root identity under the same account/fingerprint/conversation; existing connector and wake policy determine intake. Publication does not imply topic follow. Provider account/member/Consumer/Registration checks precede a single non-retrying send; ambiguous outcomes remain unknown. See [ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md). + +[ADR-0126](../adr/0126-slack-text-intake-uses-exclusive-checked-provider.md) 将既有 DSH Service Provider/Consumer 契约扩展至 Slack 的 Human 文字 @。原生 Socket Plugin 核验工作区、Bot 与 App 对应关系,持久保存独占 Consumer 模式,仅在 canonical 收件接受后确认;lease 丢失不创建独立 Session。Slack 频道 ID、消息时间戳和事件投递 ID 分别保留。回复沿原生话题时间戳(主线消息则用其时间戳新建话题),重新核验自身身份的群成员资格及原消息,经过既有发送前门禁并保存原生回执。身份表、频道连接器表和来源详情复用既有投影,按实际平台显示名称。Slack 来源上下文通过同一 checked Provider 主动读取:每页复查自身账号/成员/来源/lease,分别适配频道历史倒序和原生话题页内正序/向旧消息块续页,为附加 root 预留空间并去重,只持久化返回的 canonical Source Events,不生成历史 Inbox Admission 或唤醒(#819)。附近查询完整翻页读取前后各五分钟窗口,稀疏时补齐最近的前 10/后 5 条 Human 文本,两侧可各设 0–20。运行实例签名的续页只含有界原生 ID/计数,不含消息正文;频道历史的覆盖范围区别于完整原生话题。#831 单独为 Human @ 消息启用一个 hosted 附件:读取时复查原生消息与文件关联,安全 ID/名称/类型/大小进入 canonical Source Event,有界私有下载流复用 Attachment owner。明确导入的结果文件通过既有 Outbox 回传原生话题,在上传完成可见前执行门禁。私有 URL 留在 Provider 内部,拒绝跳转、已撤销 lease 和已变更来源。#837 通过 Consumer 明确 opt-in 接收原生 message.channels 普通文字,复查当前账号/App/公开群成员/lease;自己的 @ 只走 app_mention。已授权群保留默认只收 @、真实普通投递验证及现有数量/时间汇总或逐条排队唤醒,Profile 复用紧凑收件策略编辑器,不增加存储、队列、Session 或强制回复。#854 将既有显式话题策略适配到 Slack 原生 `thread_ts`:root 与 thread 相同,不制造 parent ID。当前 lease 上真实无 @ 子回复(`ts != thread_ts`)才证明可跟进,主线 root 不提供该证明。来源/账号/Grant 校验、Human 覆盖、每成员 Admission 快照和数量/时间唤醒保持既有契约;迁移后的频道连接器 Grant 保留 Profile 话题表与策略 Modal。普通文件仍需独立资格验证;#843 在收件资格验证后扩展既有全局默认设置。 + +### 已资格验证平台的默认行为 + +[#1156](https://github.com/BotHarness/BotHarness/issues/1156) qualifies two QQ applications through the existing independent binding and Channel Bridge model. Each app-scoped conversation is explicitly mapped; neither matching text nor a shared local Channel equates external locators or lends passive reply proof. The canonical Source Event projects its receiving app reference/name into QQ Channel source labels and details, and Group Profile activity retains app identity. These optional presentation fields add no store or sending authority; earlier projections remain readable. Native acceptance receipts and observed Provider Echo remain distinct capabilities. + +Native own Echo remains unqualified: accepted send receipts and visible original-group replies do not establish a platform callback of the responding App's own message. The current dual-App slice is partial #1156 delivery. + +The optional developer Provider's maintained SDK 1.0.4 build patch observes the WebSocket message boundary before decode/JSON, links bounded transport and Gateway metadata by process-local connection/frame counters, and exposes start/end totals through existing connection health. Diagnostics remain process evidence; they create no Source, Admission, Outbox or Echo authority and are separate from the managed product Provider pin. + +自身原生 Echo 尚未通过验收:发送接受回执与原群可见回复不能证明平台回推了响应应用自己的消息;当前双 App 切片属于 #1156 的阶段性交付。 + +可选开发 Provider 的维护版 SDK 1.0.4 构建补丁在 WebSocket message 进入 decode/JSON 前观测,按进程内 connection/frame 计数关联有界传输与 Gateway 元数据,并通过既有连接健康状态提供首尾累计数。诊断仅为进程证据,不产生 Source、Admission、Outbox 或 Echo 权威,与托管产品 Provider pin 保持独立。 + +[#1156](https://github.com/BotHarness/BotHarness/issues/1156) 沿用独立应用绑定与 Channel Bridge 模型验证两个 QQ 应用。各自应用范围的会话分别显式映射;相同文字或共同本地 Channel 不会合并外部定位,也不会借出被动回复证明。Canonical Source Event 把接收应用 reference/名称投影到 QQ Channel 来源标签和详情,群 Profile 活动保留应用身份。这些可选展示字段不增加存储或发送权限,旧投影仍可阅读;原生接受回执与 Provider Echo 观察保持独立资格。 + +[#1153](https://github.com/BotHarness/BotHarness/issues/1153) exposes Group Channel sync from the received QQ conversation through the existing Channel Bridge command. Inbox Admission and optional Channel placement share the canonical Source Event; disabling placement retains history. Messaging stores bounded reception metadata by Bot, Provider and authenticated app fingerprint in its own database. Local controls and public account inspections record distinguishable boundaries, while restart continuity remains explicitly unverified; no second transcript or remote backfill is introduced. Same-app rebind preserves blocks and mute preferences without restoring revoked Channel authority. See [ADR-0152](../adr/0152-qq-reception-intervals-record-local-observations.md). + +[#1153](https://github.com/BotHarness/BotHarness/issues/1153) 通过既有 Channel Bridge 操作,在已接收的 QQ 会话里提供群 Channel 同步。Inbox Admission 与可选 Channel 落点共享 canonical Source Event,停止落点保留历史。Messaging 在自己的数据库里按 Bot、Provider 和已认证应用 fingerprint 保存有界接收元数据,区分本地控制与公开账号检查的边界;重启连续性明确标为未验证,不引入第二份消息记录或远端补收。重绑同一应用保留屏蔽和静音,但不恢复已撤销的 Channel 权限。见 [ADR-0152](../adr/0152-qq-reception-intervals-record-local-observations.md)。 +The [#1159](https://github.com/BotHarness/DeepSeekBot/issues/1159) incoming-voice extension extends the same checked Service with independent transcript/audio opt-ins and the existing strict native quote proof. Native platform ASR or explicit unavailable provenance reaches canonical Source/Inbox and the existing Orchestrator text reply. Original audio remains source-bound canonical media; explicit playback acquires a separate WAV through the shared bounded SILK Worker. WeChat encoding checks remain platform-specific. There is no second voice store, automatic ASR, ordinary reception or native voice output. One maintained-runtime application qualified native quoted ASR → model → own-app original-group text reply (Human confirmed), original download and actual Client keyboard playback; other application/codec/modern-Host combinations remain independent qualifications. + +[#1159](https://github.com/BotHarness/DeepSeekBot/issues/1159) 语音入站扩展沿同一受检 Service 增加独立转写/原音协商,并复用严格原生引用证明。原生平台转写或明确不可用来源进入 canonical Source/Inbox,沿现有 Orchestrator 在原群回文字。原音保持来源约束,显式播放通过共享有界 SILK Worker 获取独立 WAV;微信编码校验仍由自己的平台边界负责。不新增第二份语音记录、自动 ASR、普通消息接收或原生语音输出,一个维护版运行应用已通过真实引用语音转写 → 模型 → 本应用原群文字答复(Human 确认)、原音下载及实际 Client 键盘播放;其他应用/编码/现代 Host 的组合仍需独立验收。 + +The [#1158](https://github.com/BotHarness/DeepSeekBot/issues/1158) file candidate adds explicit generic-file negotiation to the same QQ Service. Direct native files and qualifying embedded type-103 file quotes belong to the current mention Source Event; a single raw file block, matching message/group/author and consistent native reference indices are required. No adjacent-message matching, history cache or historical Source is introduced; private download tickets stay in Provider memory. Shared Channel projection adds a file card whose download uses the existing source/identity/Grant checks, canonical AttachmentStore and forced attachment response. Processing reuses an independent Workspace working copy and imported result. Upload type 4 uses the same final account/source/lease/Core fence and native receipt as image output; actual application permission remains unqualified. + +[#1158](https://github.com/BotHarness/DeepSeekBot/issues/1158) 文件候选在同一 QQ Service 增加显式普通文件协商。直接原生文件及合格的内嵌 type-103 文件引用均归属当前 @ 消息 Source Event:要求唯一原始文件块、消息/群/作者身份吻合、原生引用索引一致,不关联相邻消息、不增加历史缓存或历史 Source,私有下载票据保留在 Provider 内存。共享 Channel 投影增加文件卡片,下载复用现有来源/身份/Grant 检查与 canonical AttachmentStore,并强制附件响应。处理复用独立 Workspace 工作副本及导入结果。类型 4 上传复用图片输出的最终账号/来源/租约/Core 检查及原生回执;实际应用权限尚未验收。 + +The [#1157](https://github.com/BotHarness/BotHarness/issues/1157) QQ image candidate extends the same checked Service with opt-in image intake, bounded private source tickets and acquisition, and native image output. Canonical Source Event/Inbox and AttachmentStore remain the authorities. Human preview uses the original implicit account Grant plus its current Channel route and canonical placement when no independent reception-path record exists; membership, identity, scope and revocation checks still apply. Preview creates no Observation or wake. Actual model access reuses working-copy save and native `read_image` on an image-capable calling route. Image upload never sends a message itself; account/lease/source and Core authority are checked again immediately before the single final native POST. Its public native message receipt settles the existing Outbox; unknown is not retried. Caption-first presentation preserves attachment association without inventing native interleaving. Real QQ/model/Client acceptance remains separate evidence. + +[#1157](https://github.com/BotHarness/BotHarness/issues/1157) QQ 图片候选沿同一 checked Service 扩展显式图片收件、有界私有来源票据与获取,以及原生图片输出。Canonical Source Event/Inbox 与 AttachmentStore 继续拥有权威。没有独立接收路径记录时,Human 预览使用原 implicit 账号 Grant、其当前 Channel 路由及 canonical placement,仍检查成员、身份、范围和撤销。预览不产生 Observation 或唤醒。实际模型访问复用工作副本保存及支持图片的当前调用路由上的原生 `read_image`。图片上传自身不发消息;最终单次原生 POST 前再次检查账号/租约/来源和 Core 授权。公开原生消息回执结算既有 Outbox,unknown 不重试。说明在前的展示保留附件关联,不虚构原生穿插顺序。真实 QQ/模型/Client 验收证据独立记录。 + +The [#1152](https://github.com/BotHarness/BotHarness/issues/1152) QQ group-text candidate registers through the existing dsh-im Service Provider/Consumer seam. Official application authentication supplies the account fingerprint; trusted group mention text commits into the existing Source Event and Bot Inbox, wakes the existing Orchestrator and replies through its own application in the original group. Reply-only qualification does not require proactive capability. Exact source proof is bounded and process-local: five minutes, five attempted replies, no group-history reread or durable resume cursor. A pre-dispatch authority fence and native acceptance receipt preserve the existing Outbox; unknown results remain unknown without automatic retry. See [ADR-0151](../adr/0151-qq-group-replies-use-authenticated-apps-and-process-local-source-proof.md) and the [QQ guide](../qq-connection.md). Real group/model qualification and public release are separate delivery evidence. + +The [#1154](https://github.com/BotHarness/DeepSeekBot/issues/1154) QQ delayed-result slice reuses [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md)'s shared posting policy through an explicitly authorized saved group target when the Provider lacks QQ discovery. Known reply expiry retains the original failed Outbox intent; an explicit proactive report uses a distinct request identity, own-app/target correspondence, final fence, shared hourly quota and native receipt. Unknown replies do not become proactive reports, and repeated report requests after restart read the existing outcome. This adds no QQ-specific policy, automatic retry, local Human DM placement or additional durable result store. Real current-app delayed-task qualification remains separate from source regression coverage. + +[#1154](https://github.com/BotHarness/DeepSeekBot/issues/1154) 的 QQ 延迟结果切片在 Provider 缺少 QQ 群发现能力时,通过已显式授权的保存群目标复用 [ADR-0197](../adr/0197-reachable-posts-reuse-messaging-authority-and-outbox.md) 的共享发送策略。明确的回复过期保留原失败 Outbox 意图;显式主动报告使用独立请求身份、自己的应用与目的地一致性、最终门禁、共享每小时限额和原生回执。不确定回复不会转为主动报告,重启后的重复报告请求读取既有结果。没有 QQ 专属策略、自动重试、Human DM 落点或第二份持久结果存储。当前应用的真实延迟任务验收与源码回归分别记录。 + +The #1155 ordinary-text extension uses the same exclusive checked QQ inbound consumer. Its software capability enables forwarding, while actual ordinary-delivery qualification belongs to the current Core receiver lease and follows the existing account/conversation intake boundary. A mentions-only probe supplies bounded process evidence without canonical ordinary collection. Explicit collection uses the existing Source, Observation, harvest and Channel policies; intentional external speech still requires independent posting authority. Neither a stored collection preference nor a blocked-interval event establishes current eligibility. No QQ history store, permission flag or standalone receiver is added. + +#1155 普通文字扩展沿用同一 QQ 独占受检消费者。软件能力允许转交事件,真实普通投递资格则归属于当前 Core 接收器 lease,并遵循已有账户/会话接收时间边界。只收 @ 模式下的探测提供有界进程证据,不进行普通内容的 canonical 收集。显式收集复用已有 Source、Observation、harvest 与 Channel 策略,主动外部发言仍需独立发送权限。保存偏好及屏蔽期间的旧事件均不证明当前资格,不新增 QQ 历史存储、权限标志或独立接收器。 + +[#1152](https://github.com/BotHarness/BotHarness/issues/1152) 的 QQ 群文字候选沿用现有 dsh-im Service Provider/Consumer 边界。官方应用认证提供账号 fingerprint;可信群提及文字提交到既有 Source Event 和 Bot Inbox,唤醒既有 Orchestrator,并使用自己的应用身份在原群答复。仅回复资格不要求主动发送能力。准确来源证明有界且仅存在于进程内:五分钟、五次回复尝试,没有群历史重读或持久恢复游标。发送前授权门禁和原生接收回执沿用既有 Outbox,unknown 不自动重试。见 [ADR-0151](../adr/0151-qq-group-replies-use-authenticated-apps-and-process-local-source-proof.md) 与 [QQ 指南](../qq-connection.zh.md)。真实群/模型资格与公开发布分别记录交付证据。 + +[#843](https://github.com/BotHarness/BotHarness/issues/843) 按 [ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md) 将既有全局偏好扩展至 Slack 群文字。Bot 设置可选择 Lark 或 Slack,各自保留草稿与独立不可变版本;认证读取 RPC 可指定已资格验证的平台,旧调用仍读取 Lark。Generation 53 保留全部旧默认值与显式覆盖,仅扩展允许的平台。新绑定的 Slack 身份默认继承,旧身份保留原选择,由 Human 明确恢复继承。修改只作用于后续事件及仍继承的收件/汇总/身份行为;群授权、真实投递验证、Admission 快照与恢复接收时间边界仍由原模块管理,不新增账号、Grant、消息、队列或回复权威。 + +[#855](https://github.com/BotHarness/BotHarness/issues/855) adds a **development-source qualified mention/reply** Discord checked adapter through the same Provider/Consumer and canonical Inbox seams. App/Bot identity is authenticated and checked against Gateway READY. An explicitly external-only Profile avoids standalone bootstrap; consumer loss preserves external ownership. A public thread maps to its verified parent conversation plus exact child-channel `threadId`, without manufactured message ancestry. Replies check current native permissions/source, run the authority fence and validate native author/destination/receipt without fallback or retry. See [ADR-0128](../adr/0128-discord-checked-replies-preserve-native-child-channel-routing.md). The real channel/thread model, native identity/guild boundary and refusal/recovery checks passed under agent-operated E2E acceptance; [verification](../dev/verification/discord-855-mention-reply.md) bounds the qualification to that development runtime. Product Provider promotion and other Discord capabilities remain separate. + +[#855](https://github.com/BotHarness/BotHarness/issues/855) 通过相同 Provider/Consumer 和 canonical Inbox 边界增加**开发来源提及/回复已取得资格**的 Discord adapter。App/Bot 身份由认证 API 与 Gateway READY 校验,显式 external-only Profile 避免先进入独立 Session,consumer 消失仍保留外部所有权。公开 thread 使用已验证的父会话与准确子频道 `threadId`,不虚构消息祖先。回复复查原生权限/原消息,执行授权门禁并验证原生作者/目的地/回执,不转发、不重试。见 [ADR-0128](../adr/0128-discord-checked-replies-preserve-native-child-channel-routing.md)。真实频道/thread 模型、原生身份/服务器边界与拒绝/恢复检查已按 agent-operated E2E 指令通过;[验证记录](../dev/verification/discord-855-mention-reply.md) 将资格限定于该开发运行时。产品 Provider 晋升与其他 Discord 能力另行处理。 + +[#937](https://github.com/BotHarness/BotHarness/issues/937) extends that candidate Provider through the existing `bridge_context` / `historyChecked` seam: bounded group and existing-public-thread Human-text pages, native App content visibility plus view/history checks before and after the HTTP page, and runtime-local signed cursors bound to account, original source route, scope and limit. Canonical context retention creates no Admission or placement; a conflicting retained source rolls back the page and reports `source-conflict`. Native edits never replace the earlier source evidence. [The exact candidate checkpoint](../dev/verification/discord-937-context-reads.md) separates real single-page model success, direct native cursor checks and restored-permission/restart refusal from still-pending model continuation/conflict acceptance. Nearby, ordinary subscription, files and product dependency promotion remain outside this slice. + +[#937](https://github.com/BotHarness/BotHarness/issues/937) 沿既有 `bridge_context`/`historyChecked` 边界扩展该候选 Provider:有界 group/已有公开 thread Human 文本页,HTTP 页读取前后核对原生 App 正文可见性及查看/历史权限,运行时内签名 cursor 绑定账号、原来源路由、范围和页限。Canonical 上下文留存不创建 Admission 或 placement;留存来源冲突使整页回滚并返回 `source-conflict`,远端编辑不替换早期来源证据。[准确候选检查点](../dev/verification/discord-937-context-reads.zh.md) 区分真实模型单页成功、直接原生 cursor 检查及恢复权限/重启后的拒绝,与仍待完成的模型续页/冲突实测。Nearby、普通订阅、文件和产品依赖晋升不属于本片。 + +[#845](https://github.com/BotHarness/BotHarness/issues/845) qualifies Slack sources through the existing shared Channel routes and member Attention, without a second store. Group Profile exposes separate effective Lark/Slack wake previews; the legacy Lark RPC field remains compatible. The bilingual [IM Provider integration guide](../dev/guides/im-provider-integration.md) records the checked identity, intake, native routing, bounded context/file and acceptance boundaries for future providers. + +[#845](https://github.com/BotHarness/BotHarness/issues/845) 使用既有共享 Channel 路由与成员 Attention 验证 Slack 来源,不增加存储。Group Profile 分别展示 Lark/Slack 的有效唤醒策略,保留旧 Lark RPC 字段兼容。[IM Provider 接入指南](../dev/guides/im-provider-integration.zh.md) 汇总后续平台需要复用与验证的身份、收件、原生路由、有界读取/文件及验收边界。 + +[#878](https://github.com/BotHarness/BotHarness/issues/878) adds a **WeChat paired-owner DM slice with source and installed-product E2E verified and Human QA approved**, through the existing checked Provider/Consumer, canonical Source/Inbox and own-identity Outbox. The QR-paired owner is an explicit native DM Grant, with no fabricated mention/group/thread or local DM mirror. The Provider keeps the original private continuation outside public snapshots and advances polling only after canonical acceptance; absent leases remain external-only. Checked text replies use the source-bound continuation and a pre-send authority fence. Their receipt is explicitly a client acknowledgement, not a native message ID or read/delivery proof. The #903 file slice reuses the same Source Event/Inbox path: one native file is retained as safe metadata, privately downloaded into canonical attachments on demand, and an explicitly selected result replies to the original DM. CDN tickets/AES keys never enter public DTOs; downloads stop at 25 MiB, and a required current-authorization fence runs after upload before the final send. Declared oversized files remain inspectable but not downloadable. The #904 image candidate separately negotiates native image intake, derives format from bounded decrypted bytes, and reuses canonical attachments plus own-identity image replies with a post-upload fence. The Client preview is authenticated and disposable; model understanding additionally requires the actual DSH model route to support images. Installed-product image intake, preview and actual DeepSeek Flash image understanding are verified; the Human confirmed the native image result in the original WeChat DM. Receiver-side byte equality and a public image release are not claimed. The #905 candidate preserves platform voice-transcript availability, native item ID and duration in the existing Source Event and exposes provenance in Inbox/Source UI and the Orchestrator input. It does not implement ASR or audio playback; a real native voice transcript reached the model and the Human confirmed its own-identity text reply in the original DM. The #906 raw-audio extension additionally negotiates a private source-bound audio attachment and optional native codec metadata, retaining the unchanged original in canonical attachments. Explicit playback or working-copy selection runs bounded SILK decoding in a Worker and acquires a separate mono 24 kHz WAV through the same AttachmentStore; authenticated preview resources are disposed on close, and current source/account/Grant fences remain in force even for cached output. Unsupported codecs remain downloadable originals. This adds no ASR, automatic model audio input or native outgoing voice. Fresh raw-audio E2E qualification is tracked separately from the #905 transcript evidence. Remote context, groups and canonical proactive reports remain unavailable in that earlier slice. See [ADR-0129](../adr/0129-wechat-owner-dms-use-private-source-continuations.md). + +[#996](https://github.com/BotHarness/BotHarness/issues/996) extends the existing canonical DM admission/reply path to explicitly authorized Lark private chats. A checked user Target retains its outbound `openId` and an optional exact private `chatId`; only a qualified Provider accepting that locator exposes a DM receive scope. The same PersonaBot can retain Discord and Lark identities, with private messages admitted as `human-dm` into its existing Inbox and source-bound replies through the Lark identity. Missing private-chat locators remain outbound-only; private DM reception is excluded from Group Channel Bridge controls. This adds no local DM mirror, Session store, permission broadening or second Bot. + +### Human Bridge media presentation + +[ADR-0217](../adr/0217-im-conversation-mirroring-is-an-opt-in-projection.md) adds independently opted-in IM conversation presentation and local-DM forwarding. Both default off and share one effective preference per Binding/private conversation, across implicit and explicit Grants. Incoming presentation places the canonical Source Event without another Admission; accepted outgoing presentation carries its Outbox and original-source lineage and participates in Content Purge. Only the new mirror placement reduces media to safe metadata; ordinary connector placements keep their original media checks. Enabling starts a prospective epoch; current epoch and Binding checks fence projection and bounded recovery. Forwarding uses the exact opted-in Binding, existing Outbox and final Provider send fence, including every long-message part. A closed switch or off/on cycle stops unsent work. WeChat qualifies this first slice; the other IM adapters remain separate issues under #1990. + +[ADR-0137](../adr/0137-human-bridge-media-uses-channel-source-authority.md) places Human external-image access in the existing Messaging and Attachment owners. The Source Event retains trusted native descriptors and order; realtime and historical Channel placement projections expose media references without resource keys. The existing attachment route under the authenticated API Gateway rechecks Human Channel access and independent recorded reception paths using Channel, Source Event and attachment IDs. The Provider verifies the original message/resource association before acquisition. Visible images acquire automatically, reuse the deterministic Attachment identity and enlarge in the native Modal. Reception stop refuses new acquisition; unbind/revocation refuses acquisition and cached serving through that path. Missing or purged originals are not recreated. Human viewing creates no Observation, Inbox Admission, model image input or send permission. #1021 is the Lark image tracer; files, audio/video and other providers extend the contract in subsequent slices, with real platform qualification tracked in each issue/PR. + +#1022 沿同一权威接通 Lark 经核验的单个引用文件:投影与取得共享媒体资格判断,未知 MIME/大小不作推断。Human 明确下载才取得有界原件;支持原生打开的 Host 复用既有应用菜单,`channelMediaTarget` 每次按当前 Channel/来源/文件授权解析 canonical Attachment 目标。CLI 提供同一授权路径的目标和下载命令;没有新增 Provider、消息存储或 Bot 注意力。直接文件、多文件/混排与真实平台验收保持独立资格边界。 +[#908](https://github.com/BotHarness/BotHarness/issues/908) extends the same WeChat Source Event authority with opt-in one-level native quote evidence: server message ID, item ID, embedded text, display summary and partial-reference metadata remain distinct. Missing bodies resolve only from readable canonical sources in the same currently authorized account/private conversation, or accepted Outbox records with genuine native server receipts; WeChat client acknowledgements never qualify. No Provider transcript store or standalone Session lookup is introduced. `bridge_context(retained|retained-nearby)` returns bounded canonical local records with explicit `retained-local-sources` coverage, current own-identity/Grant/lease inspection, initial row-boundary snapshot and authorization-bound opaque keyset cursors. These reads audit the anchor Source Event without creating Admissions or wake; unavailable references do not imply remote deletion or a Thread. Client source details and Orchestrator input distinguish native quoted text, local resolution and unavailable content. Remote WeChat history/search remains unavailable. + +[#909](https://github.com/BotHarness/BotHarness/issues/909) routes the explicitly authorized WeChat paired-owner DM through existing Channel connector records: local DM/Group placements and Inbox-only paths reference one canonical Source Event. Group members retain their independent Attention/harvest policy; private Inbox and explicitly connected local DM paths retain the `human-dm` policy. The native origin remains `dm`, never a fabricated WeChat group or Thread. Shared source readability does not transfer the receiving identity or its private continuation: each external reply still requires the acting Bot's own enabled identity and Grant. Existing intake boundaries, placement deduplication, membership checks and dispatch fences apply to switches, deletion, revocation and restart. No schema, Provider transcript store or new Session lifecycle is added. Fresh installed-product qualification is recorded on #909. + +[#910](https://github.com/BotHarness/BotHarness/issues/910) extends the canonical external-only report path to qualified paired-owner WeChat DMs ([ADR-0139](../adr/0139-wechat-external-reports-use-private-owner-context.md)). The Profile form and `bridge_post` share owned Outbox authority. The Provider explicitly negotiates receipt and final-fence support, keeps current owner context private with fingerprint/order/retention checks, and invokes the application's current Binding/Grant/Registration fence immediately before one native send. Genuine server IDs stay separate from client acknowledgements. Missing context and native rejection offer explicit fresh-message recovery; unknown outcomes never trigger automatic retry. No local DM mirror, new Inbox delivery, scheduler or standalone Provider Session is introduced. + +[#911](https://github.com/BotHarness/BotHarness/issues/911) qualifies a process-local checked typing lease for the authorized WeChat owner DM ([ADR-0145](../adr/0145-wechat-typing-follows-owned-processing-leases.md)). Canonical Messaging resolves own source, Binding, Grant and Registration authority; actual Orchestrator/accepted steering and related Assignment work share one bounded native lifecycle. The Provider keeps tickets and continuations private, renews only under a current fence and cancels on the last processing owner or invalidation. Identity persistence stores only the preference; live activity resets on restart and never becomes a SessionEvent, Outbox delivery or read receipt. Windows native visibility, stop and recovery passed Human QA on #911. + +[#912](https://github.com/BotHarness/BotHarness/issues/912) extends the existing immutable defaults owner to WeChat identity enablement and typing with independent inheritance markers ([ADR-0119](../adr/0119-external-platform-defaults-retain-explicit-inheritance.md)). Generation 69 preserves old choices as custom; new bindings inherit. Global typing changes invalidate only inherited live leases, and identity resume retains the existing receive-after fence. The owner-DM editor omits unsupported group/Thread controls; Channel connectors keep separate routing and member policies. No new authority or message store is introduced. Native #912 fresh-message QA remains pending. + +### 窗口伙伴(#1138–#1142) + +EventSource 在暂时 HTTP 失败后进入终态 CLOSED 时,集合 owner 用单一重试计时器重新建立连接,间隔从 1 秒退避到最多 8 秒。最后确认的过程消费 ID 通过 `?resume=` 携带;新连接随后原生自动重连时,`Last-Event-ID` 优先于查询参数。两条路径共享同一认证、有界 Host 租约与规范资格重查。成功基线重置退避;移除最后一个选择或卸载 Plugin 清理计时器和恢复标识。Client 重启不保留恢复标识。 + +Client 独立的 `CompanionMotion` 拥有有界拖拽姿态、速度采样、连续重力、横向阻尼、轻微地面回弹与落地收敛。View 继续拥有既有帧循环;带阻尼的二阶角弹簧在拖拽反向、停留与松手时保留角度和角速度,气泡跟随同一位置。有界积分步长和倾角上限避免延迟帧使姿态失稳。调整窗口保留当前运动并重新收敛边界、协调拖拽原点,指针取消只匹配活动捕获。最终落地只保存归一化横向位置;减少动效时清除弹簧能量、直接回到底部并关闭姿态效果。这些呈现动力学独立实现,参考 Coopanion 的拖拽/空中/落地交互概念,不引入其源码或美术。 + +集合共用一个 Client-owned `CompanionSound`,消费既有文字播放循环新显示的已提交字素,以及 `CompanionMotion` 的抓起、快速拖动、快速释放与首次触地反馈。拖动音效冷却 500ms,地面小反弹不重复播放;取消捕获与减少动效不补播。沿用既有 `speechSound` 存储字段保存统一伙伴音效偏好,避免重置已选设置。全局伙伴声音设置按 Client/Profile 持久化,默认关闭;Web Audio 只在可信 Human 操作时按需创建,受限或不可用不影响消息。短合成音节随 Bot 身份和文字变化,标点与空白静音,并发与频率有界,不排队补播。静音、隐藏/离屏、断线、归档、移除和销毁停止相应声音。该呈现不新增模型工具或 TTS,不持久化音频资源或播放进度([#1173](https://github.com/BotHarness/DeepSeekBot/issues/1173))。 + +Avatar 为伙伴消费者提供可释放的锚点读取器:每次形象替换只采集一次受支持头部的局部顶部中心,既有可见帧循环只读取该局部组当前显示的屏幕矩阵,包含外层旋转、缩放及过渡中的变换原点。像素 Activity 覆盖使用相同头部区域点与 SVG 根矩阵;图片、未知 rig 或不可用的 SVG 几何使用可见 Avatar 盒子顶部中心。不逐帧扫描整个 SVG 边界,也不向 Host 发送姿态 RPC。`CompanionBubbles` 保留原始投射点,让直立卡片受视口、工具栏与多伙伴避让约束,偏移后用像素式连线保持来源关系。阅读延续既有位置与离开宽限;隐藏/离屏停止采样,形象替换/销毁清除读取器([#1177](https://github.com/BotHarness/DeepSeekBot/issues/1177))。 + +应用定义的 Window Companion owner 跟随 Client Plugin 生命周期,位于 Bot 模式页面之外,通过官方 `shell.overlay` Slot 渲染。本地偏好按 Client origin 与原生 `profileContext.dir` 的不透明哈希隔离,消息卡片不落盘。受认证的 Connection Fetch `/api/botharness/companion` 提供 Profile 上下文及只处理未来消息的 SSE 基线;消费者在同一 Host turn 同步注册并读取快照,再投影 Registry 形象、现有全 Bot Activity 和拥有者已提交的 Human–Bot DM 输出。Channel store 与 Session ownership 保持权威;草稿和历史查询不进入首个 feed。 + +应用定义的 `WindowCompanions` 集合拥有 Profile 的钉选集合和共用容量,组合每个 Bot 独立的播放 owner 与运动 View。侧栏菜单与各 Bot 的 header 按钮均操作此集合,与 Channel 置顶无关。偏好从单选 v1 迁移到 v2;只持久化形象选择、来源/范围、位置、走动与容量。多个伙伴复用一条受认证 SSE,通过原生随机的临时 consumer ID 和同路径 POST 原子替换消费集合;控制句柄随流关闭释放,既不是身份也不是耐久权威。递增选择版本阻止迟到的基线初始化被替换的消费;增加或移除其他 Bot 不重置正在阅读的卡片。 + +选中伙伴时,共享 feed 同时供给现有 Client Activity store,替代其独立 Activity SSE。慢消费者分别合并 Activity 和选择快照,按 Bot 有界保留消息并轮流排出。容量默认层叠 3/保留 20,层数 1–10、保留 1–100 且层数不超过保留;每个 Bot 的 Host 待发队列、Client 保留卡片与阅读到达队列各受保留数量约束。阅读中最多两倍保留数量的卡片,离开时合并最新消息;已有文字按完整字素继续播放。View 做基本气泡避让与视口夹取,不设钉选数量上限或自动折叠人物。移除一个 Bot 只释放自身播放,最后一个移除后关闭共享流。透明半身使用 PixelFigure 公共 body/head 结构,仅伙伴 surface 去掉底板,普通头像配方与背景保持原样。每个 Bot 的 DM、群聊与 Activity 标签独立开关;消息开关与 own-dm/shared/all-bot 范围相交。Channel owner 的 `observeOutput` 单条规范查询返回安全正文、作者、当前成员关系与独立的 Human 读取判定,Feed 验证所选 Bot 后推送,队列在范围变化及排出时重新资格判断。来源或范围变化只移除不再符合条件的显示及排队卡片,同一 Host 的确认基线保留仍可见内容与阅读进度,新启用来源不回放禁用期;点击来源先按需刷新 owner 会话列表,再检查锚点 timeline 读取权限,失败在卡片解释。卡片标明 Channel、Bot DM 双方和原会话可读性,不自动加入或改变已读位置。宽范围仅新增所选 Bot 的输出观察,既有通用 timeline 和模型能力不扩权。同一 Host 的 EventSource 断线重连通过 `Last-Event-ID` 识别过程租约;最多保留 60 秒、128 个已断开的租约,每 Bot 的规范消息引用受保留数量约束。Channel owner 的 `outputCheckpoint` 读取最新 placement 位置,`observeOutput` 返回单条位置;首次选择及新开启来源建立 Host 基线,延迟的旧通知不能成为新消息。四类来源各有 Client 过程 epoch,离线开关或范围变更后只恢复连续启用且仍合格的引用;补播重新查询规范正文和成员关系。恢复 GET 原子接管持续收件,等待 POST 确认或握手再次中断时仍有界留存;重复规范引用不占用额外队列容量。订阅确认先于补播;Client 保留同 Host 的阅读进度,按 Channel/message 标识去重。租约或 generation 失效、Client 重启均开始未来基线;无全历史扫描、耐久消息副本或共享已读写入。页面隐藏取消帧采样并暂停文字和到期时间。生命周期扩展仍按规格 [#1135](https://github.com/BotHarness/DeepSeekBot/issues/1135) 交付。 + +Companion Feed 通过一条可释放的订阅直接观察已提交的 `bot-registry` 通知,独立于 Bot 模式。归档保留带标签的静止形象;规范的暂停/重新启用变化重置有界消息引用和来源检查点。过程生命周期标识随流恢复保留,让合并过的归档/重新启用变化仍可被 Client 识别,不新增耐久身份状态。确认删除通过已确认选择快照发送移除 ID;collection 只释放对应子项、偏好和卡片,旧 revision 不能恢复已删除项。当前 owner 的 attention 事实仅用于导航。上传图片保留原媒体与整体交互;不支持的 rig/schema 保留配方和带版本的保存快照,并明确说明动作限制。图片 owner 拒绝过期版本 URL,不以另一形象替换该版本。键盘焦点与 hover 同样展开阅读;菜单键/Shift+F10 打开原生菜单,Escape 返回焦点,关闭气泡选取存活控件。减少动态效果立即归位,离屏/隐藏停止采样,销毁释放 observer、帧及 morph/fade 动画;旧动画完成回调不能再排入动作。性能验收仍须真实多 Bot 帧、内存、资源测量及 Human 同意的预算。 + +支持的像素伙伴通过 BotPixel 的可选 `mouthLayers` 公共合约预绘制 saved/closed/half-open/open 四层,普通头像与 Host 保存快照保持默认输出。`WindowCompanion` 从既有安全字素播放进度派生一份嘴型,不引入说话 Activity、第二个消息队列、计时器或耐久表情状态。生产 Avatar 仅在 companion surface 请求这些层,独立 `AvatarSpeech` 资源在既有 SVG 内做短透明度过渡,不重建人物或重启眨眼/转头;说话时暂时显露脸部,外层 Activity 和 attention 仍反映真实事实。标点闭嘴,播放结束、取消、归档、断线及销毁恢复保存表情;减少动态效果、隐藏与离屏由现有共享策略立即归静。多卡片选择第一条仍在逐字呈现的卡片驱动一套有界嘴型。上传图片、不支持的 family/版本保留原有降级,不拼接替代部件。该呈现不需要 TTS、音素、模型动作标记或 Tool 私有内容([#1143](https://github.com/BotHarness/DeepSeekBot/issues/1143))。 + +### Own-Bot Inbox history (#1278) + +The application-defined `inbox_history` Orchestrator Tool reads only the calling PersonaBot's canonical Inbox Admissions, including handled messages and self-records. Generation 77 adds a rebuildable FTS5 trigram projection over Source Event bodies, notice labels, commit subjects and changed paths. Source Event insert/update/delete and Purge Ledger application maintain the index in the same transaction; both the projection and every returned page exclude purged source/cause content. FTS5 secure-delete is enabled alongside the Purge owner's SQLite secure_delete setting, so purge removes old index entries rather than retaining recoverable delete keys. This query neither observes work nor changes Attention Decisions. The native Tool Consumer obtains Bot identity from trusted Orchestrator ownership; it does not accept a target Bot argument. Channels, Inbox history and Memory remain separate authorities for conversations, the Bot's diary, and distilled memory. + +Pages contain at most 50 items and 300 Unicode characters per snippet. Literal searches require at least three Unicode characters. Chronological pages use a timestamp/id keyset and an initial Source Event rowid boundary. Relevance pages preserve their first FTS order in a bounded process-local snapshot containing only Source Event ids, then rejoin current canonical data on each read so purged, removed or no-longer-matching rows disappear. Ranked cursors expire after ten minutes or Host restart; the cache holds at most 64 searches with at most 10,000 matches each, requiring narrower filters for larger result sets. Neither the cache nor the index creates a second transcript or resumable work queue. New self-record payloads persist their actual cause id for cause filtering; older records without that id remain searchable without inventing provenance. See [ADR-0154](../adr/0154-bot-self-records-keep-memory-commits-and-bot-actions-in-the-timeline-and-inbox.md). + +应用定义的 `inbox_history` Orchestrator Tool 只读取调用者 PersonaBot 的规范 Inbox Admission,包括已处理消息和自我记录。Generation 77 新增可从 Source Event 重建的 FTS5 trigram 投影;事件写入、更新、删除与 Purge Ledger 应用在同一事务维护索引,查询排除被清除的来源及起因内容,同时启用 FTS5 secure-delete 与 Purge owner 既有的 SQLite secure_delete,避免旧索引段残留被清除词条;不观察待办、不改变 Attention Decision。Bot 身份来自可信 Orchestrator ownership,工具不接受目标 Bot 参数。频道、收件箱历史和记忆分别保有对话、Bot 日记与提炼后记忆的权威。 + +每页最多 50 条、摘要最多 300 个 Unicode 字符,字面文本搜索至少需要三个 Unicode 字符。时间排序按时间/id 翻页,并固定首次读取的 Source Event rowid 上界;相关性排序以进程内、有界的 id 快照保留首次 FTS 次序,每次重新联接当前权威内容,移除已清除、已撤回或不再匹配的项。相关性游标十分钟或 Host 重启后失效,最多缓存 64 次搜索,每次最多 10,000 个匹配,超过时要求缩小筛选范围。快照不保存正文、不成为第二份记录或工作队列。新自我记录保存实际起因 id;旧记录缺少 id 时不推测起因。 + +### AX Client 启动证据 + +应用定义的 Client 诊断 collector 扩展既有 ADR-0063/0064 开发者日志边界。在明确启用的 Host 中(隔离 AX launcher 只为自己的子进程启用),Host Cordis Fiber 通过原生 `webServer.tapIndex` 在 Client 导入前安装 observer,并在 `/api/botharness/client-diagnostics` 注册精确、经过认证的 Connection Fetch route。浏览器报告白名单错误/生命周期 code、顺序化文档 attempt、固定保留的首次失败与 shell 挂载记录及有界遗漏;Host 保留有界实时 read model,并将同一过程证据写入既有 `logs.db`。它不创建 SessionEvent、产品状态、额外 SSE 或操作权威。API 健康、已提交且可见的 shell 观测、新鲜度、真实浏览器 console/DOM 核验分别成立;未知、后台延后及失败证据不能隐式变成成功。见 [agent 核验流程](../dev/guides/client-startup-diagnostics.zh.md) 与 [#1184](https://github.com/BotHarness/DeepSeekBot/issues/1184)。 + +### Provider-owned inline app setup (#1111) + +The optional application-defined `setupVersion: 1` / `describeSetup(channel)` capability on the public same-Host Service supplies only a versioned settings endpoint descriptor. The browser calls the Provider-owned `dsh-im/app-setup` management transport directly; BotHarness Host has no credential or QR-session command. Provider-owned bounded attempts retain setup state, create the account in `external-consumer` mode before its first connection, and return only its opaque account reference and authenticated `describeBot` projection. The Client hands the existing Binding command only provider/account reference and fingerprint; the existing identity owner revalidates it and acquires the exclusive intake Registration. Closing the dialog leaves the browser handle resumable for the Provider attempt's lifetime; cancellation prevents a not-yet-committed account, while an account already created remains available to bind. Older Providers keep the existing Settings-and-return path. This adds no operational schema, credential store or second message receiver. The candidate covers Lark credentials and WeChat QR/code setup. QR attempts use the same Provider-owned transport and bounded lifetime, refuse replacement of existing configured accounts, and are revoked with their originating Registration. Lark native DM/group replies are qualified on the earlier packaged candidate; native WeChat pairing and final visual/recording qualification remain required before #1111 is complete. + +### Mobile contextual creation (#2195) + +The mobile app uses its existing Expo Router tab and root Stack owners for an adjacent contextual creation action ([ADR-0235](../adr/0235-mobile-creation-shares-the-existing-tab-navigator.md)). iOS bottom controls use SwiftUI Button and native iOS 26 glass styles; the expanded creation menu uses a native rounded glass backdrop with an opaque accessibility/older-OS fallback. Android retains OCAT controls; navigation state still belongs to Expo Router. A presentation-only menu resolves stable Computer or cloud account/Organization keys; All asks for a target. Computer creation remains in the existing Host full-client flow. Cloud creation rechecks account scope and owner access through CloudClient, then uses the existing idempotent public mutation and opens its returned Channel. No mobile permission store or second messaging authority is added; native evidence is tracked separately from source checks. + +### Mobile presentation (#2028) + +Android chat Markdown has a separate, disposable geometry module. `MarkdownViewportContext` supplies the existing chat viewport and scroll values; the UI thread reports coarse visible-window changes, while the renderer retains measured block heights and minimum bubble width. Offscreen text becomes equal-height spacing and restores through the same Markdown path. Width/font changes discard geometry, native media remains mounted, and screen readers use the full document. This module owns no message data, persistence, playback cursor or Host command. + +The block parser also retains its disposable lexer result with exact source provenance. Android rendering and window-support checks reuse that result; repaired tails and source-only blocks reparse, while document-wide references retain their complete parse context. Append-only boundary lookback preserves unchanged block identities. The [single-paragraph Arena](../research/2183-single-paragraph-arena.md) measures the CPU benefit separately from native drawing limits; this cache owns no conversation state. + +The local Android Expo `StreamReveal` module decorates that existing React Native paragraph with bounded appearance-only spans and a native `Choreographer` clock. Parsing, text, line layout, selection and accessibility retain their existing owners; no React update or text setter runs on animation frames. The shared reveal budget currently limits animation to 48 graphemes, with overflow visible immediately. Corrections, reduced motion and detach settle spans and remove callbacks. Older binaries without the module keep the JavaScript fade, and installing the native module requires a rebuild. The [matched Release Arena](../research/2028-stream-arena.md) records the selected implementation and its performance limits. While an active Android paragraph exceeds two screen heights, the wrapper can draw the same untransformed TextView into a screen-bounded software bitmap; this reduces repeated hardware text recording without changing the child's layout or text. The bitmap is a disposable rendering cache, released when normal drawing resumes or the wrapper detaches. Returning to normal composition invalidates the child's hardware display list. Transformed, translucent or otherwise unsupported child composition retains the normal view path. + +The Expo client keeps profile read caching and reply presentation separate from Host authority. One native drawer stack owns the handle and nested settings/history/edit routes; editor components reuse their Host command ports without creating another modal. Profile usage exposes the same reported lower-bound total and partial marker as global usage, while the underlying strict usage query keeps unknown totals distinct. A bounded TanStack Query resource is scoped to each authenticated Host client and Bot/DM identity; profile navigation opens the native shell before data arrives, reuses successful sections during refresh and retains them on partial failure. Conversation publishes stable timeline snapshots and separately subscribed live draft bodies. A disposable conversation-level playback owner retains a separate grapheme cursor and input reserve, controls semantic release cadence, and carries the same row through draft-to-commit draining without changing canonical messages. Its ticks notify only the active bubble; historical messages render directly. Reading history holds the visual cursor until a completed return gesture or explicit jump. Covered Chats views detach roster/Inbox view subscriptions and catch up on focus while the shared transports keep receiving events. A device-local Streaming / Complete bubbles preference controls whether draft playback is shown. Android renders supported Markdown as stable, memoized source blocks using synchronous React Native text/layout, with enriched-renderer fallback for images and native-only extensions; iOS retains its existing renderer. Neither cache nor preference changes Channel persistence, draft ingestion or model execution. Native text visibility and frame timing require their own device evidence; see [ADR-0219](../adr/0219-mobile-presentation-keeps-caches-and-stream-buffers-local.md). + +### Mobile chat backgrounds (#2118) + +The Expo app owns a device-local default and optional saved-Host-ID/Channel-ID overrides, separate from Host appearance and PersonaBot Profile Banner. Its transactional editor commits versioned local metadata before publishing a new effective choice. Normalized owned JPEG basenames rebase under the current document container; image reclamation protects committed references, live drafts and in-flight imports. Unreadable metadata falls back safely without collecting images. One stable canvas/image/theme-veil layer sits outside scrolling and keyboard-responsive content. This introduces no Host API, model action, Memory or message authority; see [ADR-0228](../adr/0228-mobile-chat-backgrounds-are-transactional-device-local-presentation.md). + +### Optional mobile statistics (#1935) + +The application-defined Mobile Telemetry owner observes the existing Expo saved-Host, Host client and Conversation boundaries. Pairing success follows redemption and saved-Host commit; connection readiness follows authenticated live Roster loading; first-chat acceptance follows Host acceptance or reconciliation of the same outgoing message. Bot-message observation requires a subsequent new committed entry in the foreground conversation. PushSession retains notification authority and gains no telemetry in this slice. + +A local default-on preference preserves saved opt-outs. The switch lives at the bottom of Settings with collapsed details. A once-per-phone informational notice mirrors the Web client, with privacy/source/settings links and a persisted dismissal; collection does not wait for acknowledgment. The owner persists the preference, notice-seen marker, a random Mobile Install ID, three activation markers and a sanitized bounded queue; it never receives Host/Channel/message identifiers or content. One lazy, memory-only official React Native SDK adapter transports the approved events through the existing first-party proxy, with automatic collection and remote configuration disabled. No new Host API, persistence domain or RPC interceptor is introduced. A 30-second connection observation deadline never terminates Host reconnection. See [ADR-0214](../adr/0214-mobile-statistics-use-explicit-consent-and-an-independent-install-id.md) and [privacy/qualification runbook](../mobile-statistics.md). + +手机统计新安装默认开启,保留已有关闭选择,开关独立于 Host 并位于设置最底部,说明默认折叠。首次说明沿用 Web 客户端:可查看隐私、源码和设置,点击知道了后记住已读状态;这是信息告知,不是采集门禁或条款同意。生产资格仍需独立验收。已有配对、Host client、Conversation 和 PushSession 保留产品权威。统计只接收粗粒度结果;关闭后清除本地标识与待发队列,不能撤回服务端已收到的事件。生产配置需真机、PostHog 收数、公开隐私说明和 Human QA 分别验收,不能以自动化测试或 CI 替代。 + +### DSH 原生会话列表的 Bot 文件夹(#1997) + +首个 tracer 增加统一的 DeepSeekBot 产品分组,下面按 Bot/角色建立子组,以显式 PersonaBot ownership 收纳 Orchestrator 与 Assignment 根 Session,与 cwd 无关。两级标题复用 Channel sidebar 的 PanelHeader,并各自保存 Client 本地折叠偏好。现有 botSessionIds 查询在旧 ID 列表之外返回分组元数据;标题、执行状态和导航继续使用原生 Session 事实及 UiWorkspace。分组不修改 Workspace 成员、Grant 或 Session header。RC2 没有附加分组 Slot,因此有界的应用适配层在原生树中增加自有 portal 挂载点,并仅在挂载期间遮蔽重复行;找不到结构时保留原生列表。原生搜索仍独立工作;新增分组行中的原生菜单留待首个 tracer 的 Human 反馈后扩展。 + +### Searchable Bot Settings + +The application-defined Bot Settings modal keeps a single active settings Slot mounted. Its localized search index resolves only currently registered pages, then selects the page and focuses the matching settings row or section; searching never mounts hidden providers or starts their requests. Appearance contains a nested layout Slot so the live workspace retains ownership of gap/reset state. DeepSeekBot appearance is an application-defined Client-local authority: independent light/dark/system selection, separate per-mode palettes and Primary overrides persist per browser/site or Desktop application, without a Host key. Its disposable palette scope covers only owned roots and modal/menu surfaces. Per-mode global Header/Body material overrides resolve against one of four presets, painting Dock header/content surfaces with background alpha and backdrop blur while keeping message/control surfaces independent. Stable functional Panel overrides are stored per mode and edited only from Header context menus, with per-property global inheritance and target-scoped reset. An authenticated, ephemeral Host delivery lease lets the CLI address one online Client and use the same local owner; it stores no preference mirror. Language remains a DSH-native preference through `locale.setLocale`. See [ADR-0236](../adr/0236-deepseekbot-appearance-is-client-local-and-scoped.md). Notifications and Keyboard shortcuts are first-level pages. The shortcuts page reuses existing Bot bindings and the DSH-native Panel command catalog/edit protocol, including revision checks, conflicts and native recording interception. The General tutorial describes the current Panel workspace and retains the narrow-layout fallback. + +### Channel workspace split topology (#2076) + +The Client owns a per-Channel binary split tree inside Messages. A leaf is message content or one registered Channel subpanel; a branch stores its axis and first-child fraction. Hidden/unavailable leaves collapse only in the rendered projection, leaving the saved tree intact. Bot-only availability and shortcut dispatch still use the existing ChannelDock owner. No Host capability, data authority, or execution permission changes. + +Version 7 retains the old per-panel open/hidden-control records and migrates legacy side/fraction geometry lazily into an equivalent tree. It backs up the prior raw record before upgrade. Explicit dragging splits the destination leaf; resizing only updates the selected branch. Stable keyed portals render into leaf-owned DOM containers, which move between layout slots without remounting content. Width changes never rewrite topology. See ADR-0222. + +## DSH compatibility admission + +For the 1.4.0 candidate after the 0.2.1 API migration, application-defined product packaging declares `>=0.2.0-rc.2 <0.3.0-0` in first-party DSH engines. Host-provided peers follow that policy and additionally name the qualified 0.2.1 prerelease tuple for npm default SemVer matching. The DSH-native Bundle/Profile preflight owns admission and includes prereleases; no application exemption bypass is introduced. Exact development and native-patch pins remain reproducible, while `productDshVersions` enumerates only real versions selected for artifact qualification. The application-defined compatibility adapter chooses native legacy or activation-based Subagent APIs before execution, keeps native delivery/cancellation ownership, and validates Owner identity using that Host’s trusted invocation. Native shell Plugins come from the running Host; the bundled scoped file Plugin retains its read_image fix and adapts the older Session cwd locally when the workingDirectory service is absent. The preset uses the common native baseline and the native ComputerUseRegistry child Plugin owned by the product Computer Plugin; its pinned implementation is identical across both qualified releases, without a second incompatible native-package installation. Forward admission does not establish future runtime compatibility. See [ADR-0087's current correction](../adr/0087-dsh-compatibility-is-a-semver-range-with-a-verified-floor.md) and the [1.4.0 qualification plan](../agents/dsh-1.4-release-qualification.md). + +### Mobile cloud service sessions (T1 UAT) + +The phone can enter **Cloud services** without a paired Host. Its application-defined `CloudClient` owns trusted public configuration, the live PKCE transaction and separately stored cloud credentials; `HostSession` continues to own local Host pairing. Strictly validated public JSON from a QR or explicit paste is reviewed by the Human, compared with credential-free discovery, then pinned. System-browser WorkOS login and the service's verified `/api/me` establish instance admission. SecureStore rotation precedes readback; local-only logout is visibly distinct from remote-confirmed revocation. Cold OAuth callbacks cannot resume a transaction. See [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md) and [native UAT gates](../agents/tob-mobile-uat.md). Cloud workspaces use the same client owner: create sends a stable request ID saved before the network call, selection rotates the independent WorkOS session for the target organization, and the service verifies current membership. The phone displays owner/member from the verified response; only owner can submit versioned description updates. Workspace settings live in TOB Org DO storage, not the local Host. With TOB authenticated-user policy enabled, the client accepts explicit admin or user roles and retries server-owned default-workspace onboarding without granting instance administration. An ordinary user can create and select additional workspaces; instance admission and current organization membership remain separate checks. UAT Simulator ordinary-user creation, CLI agreement, restart/refresh and logout isolation are recorded in the multi-user evidence; the next HTTP channel slice adds owner creation, organization-wide channel discovery and durable text through the same CloudClient. Its pending operation is bound to identity, organization and channel before I/O, then retried with the same key after uncertain failure. Channel SQLite remains TOB-owned; The next invitation slice uses this same owner: owner send/list/revoke and verified-recipient inbox/accept, with a user/org/email-bound pending operation persisted before issuance. Accepted members explicitly select the workspace through the existing token refresh; invitation IDs grant no authority. WorkOS owns membership activation and TOB Org DO journals issuance and first active observation for new-member history. Direct channel guests use a separate inbox/channel entry in the same CloudClient; acceptance does not select or join the host organization. Channel DO owns the user-bound grant and exact message-sequence floor. Guest sends persist a distinct channel-bound retry scope. The initial path targets existing verified users without email-delivery claims. Its native/UAT qualification, Agents, WebSocket and physical-device qualification remain separate gates. + +Mobile cloud Agent management uses the TOB channel authority independently of Host pairing. The existing SecureStore vault retains one explicit credential handoff; public snapshots omit its secret. Copy revalidates owner rights, and revoke removes remote access. This does not grant local Bot work authority; see ADR-0229. Members can request a fixed health check through the same CloudClient. SecureStore retains the exact target, deadline and operation before transmission; restart retry cannot extend authority. TOB Channel DO stores the canonical request/result, while the Agent operator separately grants local work. Local retry deletion does not cancel an accepted request. The current phone path reloads history explicitly; Host forwarding and WebSocket remain separate increments. + +### Cloud Agent Host connections (#2155) + +A channel-scoped TOB Agent can be explicitly imported into a Host and bound through the existing Messaging account/Bot surface. `CloudConnections` keeps public identity, policy and cursor in the Operational Database; the DSH native Credentials Provider alone stores the secret. Discovery precedes bearer transmission, and public identity qualification precedes activation. `botharness/tob` consumes bounded HTTP history into the existing Source Event/Admission/Inbox; it preserves remote Agent authorship and original-Human work provenance. Current cloud work policy is rechecked at Messaging admission and source consumption, above ordinary-message and bridge wake settings; context sharing does not transfer execution authority. Canonical commit precedes the transport checkpoint, so interrupted reads replay rather than skip. Existing Outbox operation IDs supply cloud send idempotency. This application-defined connector is separate from the protected local steward Tob. The application-defined `CloudWork` owner and generation-101 intent/result ledger expose the same bounded health operation through an Agent-scoped native Tool and authenticated direct CLI. Canonical source/current Human policy supply authority; durable execution claims prevent silent reruns and a fixed one-hop forwarding intent prevents execute fallback after uncertain delegation. Transport, checkpoint replay and fixed model-mediated A-to-B health work are qualified on two UAT Hosts, including both Host restarts with no repeat execution. A fresh mobile-to-two-Host run and WebSocket remain separate gates; see [ADR-0230](../adr/0230-cloud-agent-connections-use-native-credentials-and-messaging.md). + +### Mobile workspace directory (#2161) + +The root shell renders the existing drawer once across computer and cloud screens. HostSession retains paired-computer authority; CloudClient retains cloud configuration and sessions. An in-memory, paginated directory combines verified organization memberships with organization identity obtained from authorized channel-guest listings. Membership wins per instance/organization. The directory is presentation only: guest selection calls filtered guest routes and cannot acquire organization membership or sibling channels. Opening refreshes current grants; closing invalidates old responses. See the unified-navigation extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). Source tests do not replace native switch/revoke/large-text acceptance. + +### Mobile mixed conversation entry (#2179) + +The application-defined mobile conversation directory combines the selected Host roster and the authenticated cloud account's paginated organization/guest Channel listings. It is a client projection: Computer messages remain Host-owned; cloud messages remain TOB-owned. Cloud directory reads borrow and restore organization token scope under the existing CloudClient lock without changing the selected workspace. Account/config changes discard late directory results. Host routes bind a Host ID and cloud routes bind an account/config scope, refusing an old destination after a source change. Host caches and unsent text are source-namespaced; offline Computer sends are disabled and text drafts have no automatic retry transport. This first slice retains one active Host; parallel Hosts and Web/Desktop follow under #2180. Cloud server read/pin/recent-message summaries follow under #2181, and ordinary cloud wake policy under #2182. Source checks are not native/UAT qualification. + +### Mobile Chats environment paging (#2194) + +Chats shares its persisted device-local conversation filter with Activity. The native source pager derives tabs from verified Organization listings, including empty memberships and authorized guest labels, in enrolled-source order. Source/account-qualified keys preserve identity while swiping; tab selection does not switch Host management authority. Unsupported Cloud Activity has an explicit notice and no borrowed Computer badge; All discloses partial Computer-only coverage. This first slice retains one active Computer. See the Chats environment extension in [ADR-0229](../adr/0229-mobile-cloud-sessions-are-independent-of-host-pairing.md). + +### Browser Remote Hosts and Host Feed (#2500) + +Remote Bot Browser and Computer viewers reuse `apps/mobile/src/host/browser-viewer.ts` and `computer-viewer.ts`. The Web adapter owns one mounted viewer against the selected Host client, projects its state through `useSyncExternalStore`, and uses the existing viewer title bar and design-system controls. Frame polling and input use device-authenticated HTTP; the Host Feed mux retains the one-time stream-ticket handshake. Closing or disconnecting aborts polls and input, waits for pending takeover requests, then releases control on the original Host. Replacement viewers wait for that cleanup. Inputs belong to one explicit control generation: resuming the Bot, losing control or losing frames invalidates that generation, even while the Host Feed remains connected. Recovered frames remain read-only until another explicit takeover. Shared Browser/Computer frame loops discard the cursor after a failed frame request, or a 204 received while not live, and fetch the current frame through the existing route; unchanged screens can therefore recover without a new sequence. The Web panel localizes recovery guidance while retaining the raw notice in the viewer snapshot for diagnostics. Removing a Host first closes its viewers and forgets the local entry, then waits up to five seconds for pending takeover/release cleanup before revoking the pairing; an unresponsive Host does not block removal indefinitely, but cleanup after that deadline may require the Host operator to resume Browser. The Computer remains the profile's shared desktop, while Browser scope includes the Bot slug. No Remote Host full-client fallback or new Host route is introduced. + +The application-defined browser `RemoteHosts` owner keeps its pairings and selected Host tab in device-local storage. Each Remote Host reuses the phone's Host client and conversation module through the device gateway; This Host never relays the Remote Host's credential. The shared `createInbox(client, { activity: true })` supplies each Remote Host's Human Inbox and unread snapshot. The first browser subscriber starts the client and Inbox, and the last stops them while retaining the last Inbox snapshot. Authenticated avatar fetches publish an immutable URL-to-object-URL map; removal, roster replacement and final unsubscribe release obsolete objects and discard late fetch results. The storage-key rename imports existing browser pairings and the selected tab. See [ADR-0242](../adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md) and [ADR-0243](../adr/0243-every-client-reads-every-host-through-one-host-feed.md). + +Remote conversation presentation retains the source Channel Journal entry through the shared conversation and message-display modules, then uses the existing Web message body and request cards. A conversation-scoped adapter supplies the source Host client and validates the active Host/Channel before decisions. Attachment reads resolve through that client's `resource(path)` and device credential, with no local-store or local-URL fallback; changing conversations aborts reads and releases object URLs. Activity and conversation cards share `createHostRequestActions`, with request references from the Inbox and Journal respectively. Only a Host-accepted decision completes a submission; stale decisions trigger the existing cards' status reload. Decisions reuse the shared attention actions and remain subject to the source Host's ownership and permission checks. + +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. 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. + +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. + +### Version-bound Memory image previews (#2214, first Web slice) + +The application-defined Memory Service exposes bounded PNG representations alongside its existing current-file and Git-diff queries. Metadata identifies worktree/index/exact reachable commit plus a SHA-256 byte revision; authenticated image transfer rechecks that owner, repository selector, path and revision before returning `image/png` with no-store semantics. It does not accept arbitrary blob IDs, revision expressions or Host paths. Current reads retain path containment, control-directory exclusion and safe-open checks; Git reads accept regular-file blobs only. A changed mutable source returns an explicit stale refusal rather than current bytes under an old version label. + +The Web reader and shared diff renderer display PNGs, with before/after panels that stack on narrow screens and reuse the existing image gallery for enlargement. Staged compares HEAD/index, unstaged index/worktree, current HEAD/worktree, and historical commits their first parent. Image bytes stay outside text/JSON diffs and are requested lazily by the browser. `memory-image` exports the same representation through the authenticated Host to a new file, with a JSON receipt. The preview budget is 25 MiB, 16,384 pixels per axis and 40 million pixels; existing text and download budgets remain separate. The second Web slice below adds Markdown rendering; other local image formats, editor/file-card previews and mobile remain in #2214. + +### Web Memory Markdown previews (#2214, second tracer) + +The application-defined Memory Service returns bounded full Markdown documents alongside existing current-file and working/commit diff results. Git remains the durable authority. A document descriptor carries its selected `source`, SHA-256 `revision`, status and original UTF-8 body. The shared safe-open / regular-Git-blob reader preserves containment, reachable-commit, mutable-read and byte limits. Each document is bounded at 4 MiB; commit queries share a 4 MiB budget across document bodies. Existing line-diff and image limits remain independent. + +Web readers render `.md` and `.markdown` by default with a source toggle. Working and historical diffs default to lines and offer labeled rendered before/after documents, stacking on narrow Web viewports. The document renderer uses bundled `react-markdown` / `remark-gfm` because the pinned native Markdown component cannot override HTTP(S) image loading before its request; it keeps DSH tokens and the existing image gallery. Raw HTML remains literal text. Local file links stay inert; ordinary external HTTP(S) links use browser navigation. + +Visible inline local PNGs use the existing authenticated Connection Fetch image route with the document path/source/revision and authored `reference`. The owning Memory query resolves a directory-relative or repository-root path, validates the document revision before and after image reading, and applies the same bounded PNG checks as standalone previews. The Client receives a temporary Blob URL and aborts/releases it on selection changes or source mode. Index and commit references read their actual Git blobs; missing historical images never substitute current files. Mutable images are observations of the selected source at load time; refresh reloads their dependencies. + +External HTTPS images require a per-image click and load directly in the browser with anonymous cross-origin access and no referrer; CORS/network restrictions may refuse them. These are live external references, including in historical documents. No Host proxy, new durable store, SessionEvent or preview-triggered Bot wake is introduced. The existing `memory-image` CLI offers the identical local-image query through `--image-reference`. Other local formats, editor and message-file previews, and native mobile remain later slices. + +## 原生 Panel Plugin 开发目标(ADR-0234) + +首片实现入口见 [Panel Plugin 开发指南](../panel-plugin.md):独立 VRM Bundle 消费 UI 提供的公开 `dockPanels` / `botPresentation` Service;Activity 仍由现有 ClientStore 投影维护,已提交 DM 输出由按 Bot 共享的文字时钟呈现,固定实例元数据扩展现有布局文档。 + +[ADR-0234](../adr/0234-panels-are-native-plugins-with-independent-binding.md) 记录已确认目标;以下为整体目标,已实现的 T1 范围以首片指南与验收记录为准。第一方与独立作者的面板复用原生 Plugin/Fiber 和 Bundle/Profile 组合。领域 owner 的 Host Service 与具名实时 Cordis Event 独立于面板显隐;浏览器 Consumer 沿用认证后的 Typert/API Gateway 与支持的 stream。状态型展示需恢复当前状态,首版不建设通用离线事件补发。后续 Memory 观测要覆盖无对话时的外部工作树变化,保留既有 Observation/Admission owner,并与 Git commit 区分。 + +实现、实例、绑定与工作区位置分开。普通实现允许多个实例,重复安装不重复创建。消息面板内的 Bot-bound Panel 跟随 PersonaBot 私聊;群聊不显示内容和打开入口,也不增加成员选择器。显式绑定的全局工作区实例保留并清楚标明所选 Bot/Channel;Group-channel-bound 复用 Channel 绑定并限制为群聊。现有频道子面板不因此获得跨工作区拖放,绑定不授予数据访问或代码隔离。 + +必需系统角色保留其唯一性、已注册的第一方兜底和独立恢复入口。替换优先复用原生 Slots 的优先级与有界渲染错误退让;这不等于 HMR 回滚,也不是现有应用 Registry 的重复 id 拒绝已经具备的能力。 + +首个纵向切片 [T1 #2222](https://github.com/BotHarness/DeepSeekBot/issues/2222) 为使用 Three.js/three-vrm 的独立 **VRM+Activity+说话表现面板**。动作消费已有 Activity 事实;说话表现独立消费真实、已提交的 Bot 输出,允许边工作边说话。首版使用文字节奏驱动表情、嘴型与轻手势,不要求音素级口型或新增 TTS。验收原生安装、两个 Bot 绑定、群聊无入口、当前状态与实时更新、重连不重播旧话或重复发声、减少动态效果、资产/渲染失败降级及 GPU 资源释放。此方向替代仅复用旧角色 renderer 的提议,不要求整体迁移浮窗交互或构建生活模拟世界。 + +[T2 #2223](https://github.com/BotHarness/DeepSeekBot/issues/2223) 将办公室切片改为无 Bot/Channel 绑定的 **全局 Task Kanban**,管理当前 Profile 内共享的 Task。Human 卡片操作与 Bot 查询/创建/更新工具使用同一授权 Service、持久记录和提交后通知;关闭面板不影响工具工作。演进 #1792 已有 to-do owner 并兼容旧记录,不增加第二份存储。Task 状态与进度独立于 Session 内计划和 Assignment 执行,创建、移动、完成或重开卡片不自动启动执行。验收重启/重开一致性、显式并发冲突及直接 CLI/工具 parity。 + +[Spec #2219](https://github.com/BotHarness/DeepSeekBot/issues/2219)、T1–T7、[T8–T15 迁移父工单 #2234](https://github.com/BotHarness/DeepSeekBot/issues/2234) 及其子工单统一规划在 [Panel Plugins Project #21](https://github.com/orgs/BotHarness/projects/21) 的 v2.0。T1 已进入首片实现与隔离 Host 验证;Human 验收尚待反馈,仍是扩展门槛。交付状态与依赖以工单为准:T2–T6 依赖 T1,T7 依赖 T6,迁移父票与 T8–T15 各自依赖 T1。迁移与 T2–T7 同属 T1 后的扩展分支,子票不等待协调父票完成,也不隐式等待 T7;并与 #1791/#1792 作者能力、#2096 布局 CLI 协调。 + +T4 保留 ADR-0220 的 Sessions 范围:DM 中为 Bot 所有的 Sessions,Group 默认按原始 Source Event 查看 Assignments,并保留已有显式成员 Bot 筛选。群聊不显示 Bot-bound 面板不等于移除该筛选,也不改变 Session ownership。T5 的 Members 在 DM 消息内隐藏,但全局固定 Group 实例不受当前聊天切换影响。Panel Consumer 释放与卸载领域 Provider/整个 Bundle 分开;后者遵循原生生命周期,能力缺失须明确显示不可用。 + +Nested appearance surfaces use the same Client-local owner and functional `dock:` identities as top-level panels. Memory detail views share one identity, while file lists and evolution remain separate. Direct surface paint preserves global inheritance independently of parent customization; real registered Plugin contributions receive the generic wrapper without new domain or layout authority (ADR-0236, #2261). + +DeepSeekBot's image wallpaper has a separate Client-local media owner and one atomic IndexedDB Blob/crop record. One disposable layer spans the enabled workspace behind the sidebar and panels; original bytes stay on the current browser/site or Desktop application. The CLI transfers bounded chunks to one explicitly selected online Client and commits through that same owner, while the Host remains a transient transport (ADR-0236, #2262). + +Video wallpaper extends the same local media record with an original video and generated poster. A disposable Client playback controller observes focus/visibility, reports actual playing/paused/poster/inactive state to the same owner, and unloads sources on exit or replacement. Failure displays the persisted poster without changing media authority or DSH preferences (ADR-0236, #2263). + +Appearance recovery composes the existing local preference and media owners without a third store. Current-mode clear affects only its functional Panel map. Explicit full reset preflights both revisions, saves defaults, then deletes the atomic media record; partial media failure is reported and retryable rather than presented as a cross-store transaction. A transient resource generation invalidates previews on accepted media changes, including removal from an already-empty store (ADR-0236, #2264). + +### Desktop whole-window zoom + +[ADR-0237](../adr/0237-desktop-window-zoom-is-client-owned.md) defines an application-owned Client +zoom Service on the stable desktop origin. Settings, native Shortcut Registry commands and an +explicitly addressed CLI share its versioned local default/current-primary-window document; +the authenticated Host route is an ephemeral delivery relay with no preference copy. A pinned, +MIT-preserving downstream theme composition leases only the original font-shortcut Fiber, +then returns it on disposal. The product build carries that licensed theme and a portable ESM +schema module. Bundle patches retain the original `ui-theme` row and canonical Settings +namespace. An explicit `deepseekbot-zoom-setup enable` command installs one Profile-local +package link through the selected Desktop's bundled package manager and requires a restart. +The macOS path and Windows candidate require Desktop 0.2.1-rc.2, refusing other overrides. +Windows reads executable FileVersion, checks live processes with CIM, and validates owned +junctions under the same Profile writer lock. The Client checks a matching platform lease +before registering the same native Shortcut commands; Ctrl+0 restores the saved default. +`disable` removes only this dependency and its exact owned link, preserving font preferences; +run it before product removal. Installing the Bundle alone does not activate compatibility. +Windows native qualification remains in #2270; Linux remains disabled. +It does not edit the installed desktop shell. Ordinary Web zoom is unchanged. Native-input, +packaged-install and Human QA evidence remain separate from source coverage; numberpad, +broader reliability and other desktop platforms are tracked in #2269/#2270. + +Bot-mode Dockview content overlays and splitter movement convert viewport coordinates to local CSS units through the repository-pinned Dockview compatibility patch. The patch is bundled in the Client; it changes no DSH executable and preserves persistent panel instances and the existing layout store. The macOS workspace corner separately retains the native 88×40 viewport-pixel traffic-light footprint and converts measured group intersections into local header padding; zoom, layout and fullscreen updates do not change that physical footprint. + +Bot-owned choice controls use the shared `ds/Select` → `PointerMenu` geometry owner under +window zoom. It keeps the native Menu renderer and keyboard owner inside the owning Modal, +converts viewport anchor coordinates through effective CSS zoom, clamps to the safe viewport +and returns focus after dismissal. No second shortcut router or Panel state is introduced. diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts index 210a6a2894..5cea7b9d2d 100644 --- a/packages/client/src/client/remote-notifications.ts +++ b/packages/client/src/client/remote-notifications.ts @@ -79,7 +79,6 @@ export function createRemoteNotifications(options: { credential: string; inbox: RemoteHost['inbox']; baseline: boolean; - high: string; seen: Set; } >(); @@ -246,7 +245,6 @@ export function createRemoteNotifications(options: { credential: remote.host.credential, inbox: { status: 'idle' }, baseline: false, - high: '', seen: new Set(), }; state.set(id, entry); @@ -264,11 +262,6 @@ export function createRemoteNotifications(options: { } if (remote.inbox.notificationItems === undefined) continue; if (entry.inbox === remote.inbox) continue; - const previousIds = new Set( - entry.inbox.status === 'ready' - ? entry.inbox.notificationItems?.map((item) => item.id) - : [], - ); entry.inbox = remote.inbox; const items = remote.inbox.notificationItems; const active = new Set( @@ -283,13 +276,12 @@ export function createRemoteNotifications(options: { const pref = choice(id); for (const item of items) { const key = `${id}:${item.id}`; - const known = entry.seen.has(item.id) || previousIds.has(item.id); + const known = entry.seen.has(item.id); entry.seen.add(item.id); const kind = kinds[item.kind]; if ( !entry.baseline || known || - item.createdAt < entry.high || kind === undefined || item.isUnread === false || !pref.enabled || @@ -324,12 +316,7 @@ export function createRemoteNotifications(options: { } } } - entry.high = items.reduce( - (high, item) => (item.createdAt > high ? item.createdAt : high), - entry.high, - ); entry.baseline = true; - while (entry.seen.size > 2000) entry.seen.delete(entry.seen.values().next().value!); } }, stop() { diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index 9537a4cf60..84bd696e4c 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -1,6 +1,7 @@ import { afterEach, assert, describe, expect, it, vi } from 'vitest'; import type { HostCarrier } from '../../../apps/mobile/src/host/carrier.js'; +import type { HumanAttentionItem } from '../../../apps/mobile/src/host/wire.js'; import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; import { createHostActivityActions } from '../src/client/host-activity-actions.js'; import type { HumanAttentionItem } from '../src/client/store.js'; @@ -205,6 +206,85 @@ function liveHost() { } describe('Host Feed lifecycle', () => { + it('keeps notification identities across reconnect and active resubscription, but releases them on final disposal', async () => { + const show = vi.fn(); + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + onclick = null; + close() {} + constructor(title: string, options: unknown) { + show(title, options); + } + }, + }); + const { host, storage } = liveHost(); + let items: HumanAttentionItem[] = []; + host.methods.set('humanAttention', (args) => + args.notifications ? { notificationVersion: 1, items } : { queryVersion: 1, items: [] }, + ); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + const primary = sources.subscribe(() => {}); + releases.push(primary); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { enabled: true }); + const received: HumanAttentionItem = { + id: 'received', + kind: 'user-question', + category: 'action', + botSlug: 'ada', + createdAt: '2026-01-01', + summary: 'Question', + }; + const refresh = async (next: typeof items) => { + const before = sources.getSnapshot().hosts[0]?.inbox; + items = next; + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + await vi.waitFor(() => { + const after = sources.getSnapshot().hosts[0]?.inbox; + expect(after).not.toBe(before); + expect(after).toMatchObject({ status: 'ready', notificationItems: next }); + }); + }; + await refresh([received]); + expect(show).toHaveBeenCalledTimes(1); + await refresh([]); + const secondary = sources.subscribe(() => {}); + primary(); + const resubscribed = sources.subscribe(() => {}); + releases.push(resubscribed); + secondary(); + await refresh([]); + await refresh([received]); + expect(show).toHaveBeenCalledTimes(1); + await refresh([]); + host.kill(); + host.restart(); + await vi.waitFor( + () => { + expect(sources.getSnapshot().hosts[0]?.snapshot.connection.status).toBe('connected'); + expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ + status: 'ready', + notificationItems: [], + }); + }, + { timeout: 3000 }, + ); + await refresh([received]); + expect(show).toHaveBeenCalledTimes(1); + await refresh([]); + resubscribed(); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => { + expect(sources.getSnapshot().hosts[0]?.snapshot.connection.status).toBe('connected'); + expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ + status: 'ready', + notificationItems: [], + }); + }); + await refresh([received]); + expect(show).toHaveBeenCalledTimes(2); + }); it('marks the remote DM channel read through its own credential and refreshes its Inbox', async () => { const { host, storage } = liveHost(); host.methods.set('channels', () => ({ @@ -310,7 +390,7 @@ describe('Host Feed lifecycle', () => { kind: 'channel-mention', category: 'replies', botSlug: 'ada', - createdAt: 'same-time', + createdAt: '2026-10-22T12:00:00.000Z', summary: `old-${index}`, })); let finish!: () => void; @@ -346,7 +426,7 @@ describe('Host Feed lifecycle', () => { kind: 'channel-reply', category: 'replies', botSlug: 'ada', - createdAt: 'same-time', + createdAt: '2026-10-22T12:00:00.000Z', summary: id, })), ); @@ -366,11 +446,40 @@ describe('Host Feed lifecycle', () => { kind: 'channel-reply', category: 'replies', botSlug: 'ada', - createdAt: 'same-time', + createdAt: '2026-10-22T12:00:00.000Z', summary: 'later', }); live(); await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(3)); + items.push( + ...[ + 'tool-approval', + 'user-question', + 'private-input', + 'channel-mention', + 'channel-reply', + 'channel-reply', + ].map((kind, index) => ({ + id: `late-attention-${index}`, + kind, + category: index < 3 ? 'action' : 'replies', + botSlug: 'ada', + createdAt: `2026-01-0${6 - index}T00:00:00.000Z`, + summary: `late-attention-${index}`, + })), + ); + live(); + await vi.waitFor(() => expect(show).toHaveBeenCalledTimes(9)); + for (let index = 0; index < 6; index++) + expect( + show.mock.calls.filter( + ([, options]) => + options.tag === `botharness-remote:${host.hostId}:late-attention-${index}`, + ), + ).toHaveLength(1); + await sources.notifications.set(host.hostId, { enabled: false }); + await sources.notifications.set(host.hostId, { enabled: true }); + expect(show).toHaveBeenCalledTimes(9); }); it.each(['switch', 'remove', 'dispose'] as const)( diff --git a/packages/client/test/remote-notifications.test.ts b/packages/client/test/remote-notifications.test.ts index 14f4218d5b..4a4df3448e 100644 --- a/packages/client/test/remote-notifications.test.ts +++ b/packages/client/test/remote-notifications.test.ts @@ -282,9 +282,32 @@ describe('Remote Host browser alerts', () => { f.notices.update([remote('a', [item('1'), item('2', 'private-input')])]); expect(f.show).toHaveBeenCalledTimes(1); }); - it('suppresses reconnect backlogs and old pagination, closes on refusal and disposal', async () => { + it('retains identities beyond 2000 after they leave attention, without suppressing new older IDs', async () => { const f = fixture(); await f.notices.set('a', { enabled: true }); + const old = Array.from({ length: 2001 }, (_, index) => + item(`old-${index}`, 'channel-mention', '2026-10-22'), + ); + f.notices.update([remote('a', old)]); + f.notices.update([remote('a', [...old])]); + expect(f.show).not.toHaveBeenCalled(); + f.notices.update([remote('a', [])]); + f.notices.update([remote('a', old)]); + expect(f.show).not.toHaveBeenCalled(); + const fresh = item('new-earlier', 'user-question', '2026-01-01'); + f.notices.update([remote('a', [...old, fresh])]); + expect(f.show).toHaveBeenCalledExactlyOnceWith('a · ada', { + body: 'Request preview', + tag: 'botharness-remote:a:new-earlier', + }); + f.notices.update([remote('a', [])]); + f.notices.update([remote('a', [...old, fresh])]); + expect(f.show).toHaveBeenCalledTimes(1); + }); + it('suppresses reconnect backlogs and seen identities that reenter, closes on disposal', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + f.notices.update([remote('a', [item('1'), item('2')])]); const first = remote('a', [item('2')]); f.notices.update([first]); f.notices.update([{ ...first, snapshot: { connection: { status: 'stopped', route: '' } } }]); @@ -297,6 +320,29 @@ describe('Remote Host browser alerts', () => { expect(f.handles[0]!.close).toHaveBeenCalled(); expect(f.handles[0]!.onclick).toBeNull(); }); + it.each(['remove', 'credential', 'stop'] as const)( + 'releases old identities on %s and establishes a silent complete baseline for the new owner', + async (operation) => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + f.notices.update([remote('a', [])]); + f.notices.update([remote('a', [item('previous')])]); + expect(f.show).toHaveBeenCalledTimes(1); + if (operation === 'remove') f.notices.update([]); + if (operation === 'stop') f.notices.stop(); + const next = (items: HumanAttentionItem[]) => { + const host = remote('a', items); + if (operation === 'credential') host.host.credential = 'replacement'; + return host; + }; + f.notices.update([next([item('baseline')])]); + expect(f.show).toHaveBeenCalledTimes(1); + expect(f.handles[0]!.close).toHaveBeenCalled(); + f.notices.update([next([item('baseline'), item('previous')])]); + f.notices.update([next([item('baseline'), item('previous')])]); + expect(f.show).toHaveBeenCalledTimes(2); + }, + ); it('keeps denied permission off and handles a late permission result after disabling', async () => { const f = fixture(); f.permission('denied'); From 54bd755409df4594bfd44cb10b4d65e14b713317 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 03:35:23 +0900 Subject: [PATCH 10/18] fix: preserve notification baseline across connected read failures Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../features/remote-hosts.md | 2 + changes/2500-device-notifications.md | 4 +- docs/architecture/botharness-architecture.md | 2 +- .../client/src/client/remote-notifications.ts | 12 +-- packages/client/test/remote-hosts.test.ts | 92 +++++++++++++++++++ .../client/test/remote-notifications.test.ts | 51 ++++++++++ 6 files changed, 154 insertions(+), 9 deletions(-) diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index 034236a62f..634f0268f9 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -60,3 +60,5 @@ Activation race regression: while replies are disabled, write 35 old mentions an 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. diff --git a/changes/2500-device-notifications.md b/changes/2500-device-notifications.md index a8ee53f7b3..27fd6ce589 100644 --- a/changes/2500-device-notifications.md +++ b/changes/2500-device-notifications.md @@ -5,9 +5,9 @@ section: Changed ## en - Notification kinds now stay on the phone and sync to every paired Host, including after pairing again; each Remote Host in the browser has its own notification choices for alerts while the page is open ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). -- Browser discovery includes ordinary DM replies and replies beyond the Activity page; enabling waits for a complete baseline so pending old items stay silent. Newly admitted attention is deduplicated by identity even when its Source Event is older; previously seen items do not replay after leaving and reentering attention. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Browser discovery includes ordinary DM replies and replies beyond the Activity page; enabling waits for a complete baseline so pending old items stay silent. Newly admitted attention is deduplicated by identity even when its Source Event is older; previously seen items do not replay after leaving and reentering attention. Temporary Activity read failures preserve the notification baseline so new items still alert after recovery. Offline phone copies retry while foreground; development-client AX can operate the phone's own choice, and `inbox --notifications` exposes the same Host discovery query ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). ## zh - 通知类型现在保存在手机,并同步到所有已配对 Host,重新配对后也会恢复;浏览器可为每个远程单独选择通知,仅在页面打开时提醒([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 -- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复,启用前等待完整基线,避免补发扫描中的旧条目。新进入 attention 的条目按身份去重,不因来源事件较早而漏报;已见条目离开后再进入也不重播。手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 浏览器通知发现覆盖普通 DM 回复和 Activity 当前页之外的回复,启用前等待完整基线,避免补发扫描中的旧条目。新进入 attention 的条目按身份去重,不因来源事件较早而漏报;已见条目离开后再进入也不重播。Activity 读取短暂失败时保留通知基线,恢复后仍提醒新条目。手机前台自动补同步离线副本。development-client AX 可操作手机自己的选择,`inbox --notifications` 提供同一 Host 通知查询([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 18e0222e6c..e231ed7989 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -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. 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. 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. diff --git a/packages/client/src/client/remote-notifications.ts b/packages/client/src/client/remote-notifications.ts index 5cea7b9d2d..6ccb7b7b7c 100644 --- a/packages/client/src/client/remote-notifications.ts +++ b/packages/client/src/client/remote-notifications.ts @@ -249,17 +249,17 @@ export function createRemoteNotifications(options: { }; state.set(id, entry); } - if ( - remote.snapshot.connection.status !== 'connected' || - remote.inbox.status !== 'ready' || - remote.inbox.stale - ) { - if (remote.snapshot.connection.status !== 'connected') cancel(id); + if (remote.snapshot.connection.status !== 'connected') { + cancel(id); entry.inbox = remote.inbox; entry.baseline = false; close(id); continue; } + if (remote.inbox.status !== 'ready' || remote.inbox.stale) { + close(id); + continue; + } if (remote.inbox.notificationItems === undefined) continue; if (entry.inbox === remote.inbox) continue; entry.inbox = remote.inbox; diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index 84bd696e4c..e4d590b6d3 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -206,6 +206,98 @@ function liveHost() { } describe('Host Feed lifecycle', () => { + it.each(['Activity', 'notification page'] as const)( + 'preserves a completed notification baseline when %s alone fails on a connected Host', + async (failure) => { + const show = vi.fn(); + vi.stubGlobal('window', { + Notification: class { + static permission = 'granted'; + onclick = null; + close() {} + constructor(title: string, options: unknown) { + show(title, options); + } + }, + }); + const { host, storage } = liveHost(); + const old: HumanAttentionItem = { + id: 'old', + kind: 'user-question', + category: 'action', + botSlug: 'ada', + createdAt: '2026-10-22T12:00:00.000Z', + summary: 'Old question', + }; + const items = [old]; + let fail = false; + host.methods.set('humanAttention', (args) => { + if (args.notifications) { + if (args.cursor) { + if (fail && failure === 'notification page') + throw new Error('notification-page-unavailable'); + return { notificationVersion: 1, items: items.slice(1) }; + } + return { notificationVersion: 1, items: items.slice(0, 1), nextCursor: 'tail' }; + } + if (fail && failure === 'Activity' && args.category === 'action') + throw new Error('activity-unavailable'); + return { queryVersion: 1, items: [] }; + }); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await sources.notifications.set(host.hostId, { enabled: true }); + const refresh = async () => { + const before = sources.getSnapshot().hosts[0]?.inbox; + host.push('botharness/followActivity', { type: 'changed', snapshot: { bots: [] } }); + await vi.waitFor(() => { + const remote = sources.getSnapshot().hosts[0]; + expect(remote?.inbox).not.toBe(before); + expect(remote?.snapshot.connection.status).toBe('connected'); + expect(remote?.host.credential).toBe(host.credential); + }); + }; + fail = true; + await refresh(); + expect(sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ + status: 'ready', + ...(failure === 'Activity' + ? { stale: expect.any(String) } + : { notificationItems: undefined }), + }); + items.push({ ...old, id: 'new', createdAt: '2026-01-01T00:00:00.000Z' }); + await refresh(); + expect(show).not.toHaveBeenCalled(); + fail = false; + await refresh(); + expect(show).toHaveBeenCalledExactlyOnceWith('Studio · Ada', { + body: 'Old question', + tag: `botharness-remote:${host.hostId}:new`, + }); + await refresh(); + expect(show).toHaveBeenCalledTimes(1); + await sources.notifications.set(host.hostId, { enabled: false }); + const saved = storage.getItem('botharness.remoteNotifications.v1'); + fail = true; + await expect(sources.notifications.set(host.hostId, { enabled: true })).rejects.toThrow( + 'notification-baseline-unavailable', + ); + expect(sources.notifications.choice(host.hostId).enabled).toBe(false); + expect(storage.getItem('botharness.remoteNotifications.v1')).toBe(saved); + items.push({ ...old, id: 'while-disabled' }); + fail = false; + await sources.notifications.set(host.hostId, { enabled: true }); + await refresh(); + expect(show).toHaveBeenCalledTimes(1); + items.push({ ...old, id: 'after-enable', createdAt: '2025-01-01T00:00:00.000Z' }); + await refresh(); + expect(show).toHaveBeenCalledTimes(2); + expect(show.mock.calls[1]?.[1]).toMatchObject({ + tag: `botharness-remote:${host.hostId}:after-enable`, + }); + }, + ); it('keeps notification identities across reconnect and active resubscription, but releases them on final disposal', async () => { const show = vi.fn(); vi.stubGlobal('window', { diff --git a/packages/client/test/remote-notifications.test.ts b/packages/client/test/remote-notifications.test.ts index 4a4df3448e..37c769bbb4 100644 --- a/packages/client/test/remote-notifications.test.ts +++ b/packages/client/test/remote-notifications.test.ts @@ -63,6 +63,35 @@ function fixture() { } describe('Remote Host browser alerts', () => { + it.each(['idle', 'loading', 'error', 'stale', 'partial'] as const)( + 'does not establish or erase a notification baseline from a connected %s read', + async (status) => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + const old = item('old', 'channel-mention', '2026-10-22'); + const fresh = item('new', 'user-question', '2026-01-01'); + const failed = () => { + const host = remote('a', [fresh]); + if (status === 'idle' || status === 'loading') host.inbox = { status }; + else if (status === 'error') host.inbox = { status, error: 'read-unavailable' }; + else if (host.inbox.status === 'ready') { + if (status === 'stale') host.inbox.stale = 'read-unavailable'; + else host.inbox.notificationItems = undefined; + } + return host; + }; + f.notices.update([failed()]); + f.notices.update([remote('a', [old])]); + expect(f.show).not.toHaveBeenCalled(); + f.notices.update([failed()]); + f.notices.update([remote('a', [old, fresh])]); + f.notices.update([remote('a', [old, fresh])]); + expect(f.show).toHaveBeenCalledExactlyOnceWith('a · ada', { + body: 'Request preview', + tag: 'botharness-remote:a:new', + }); + }, + ); it('rechecks the relay deadline at commit even when its timer has not run', async () => { vi.useFakeTimers(); const f = fixture(); @@ -93,6 +122,28 @@ describe('Remote Host browser alerts', () => { vi.useRealTimers(); } }); + it('waits for a complete reconnect baseline across read failures without losing seen identities', async () => { + const f = fixture(); + await f.notices.set('a', { enabled: true }); + const old = item('old', 'user-question', '2026-10-22'); + f.notices.update([remote('a', [old])]); + const disconnected = remote('a', [old]); + disconnected.snapshot = { connection: { status: 'stopped', route: '' } }; + f.notices.update([disconnected]); + const failed = remote('a', [item('offline')]); + if (failed.inbox.status === 'ready') failed.inbox.stale = 'read-unavailable'; + f.notices.update([failed]); + const partial = remote('a', []); + if (partial.inbox.status === 'ready') partial.inbox.notificationItems = undefined; + f.notices.update([partial]); + f.notices.update([remote('a', [item('offline')])]); + expect(f.show).not.toHaveBeenCalled(); + f.notices.update([remote('a', [old, item('offline'), item('new', 'user-question', '2025')])]); + expect(f.show).toHaveBeenCalledExactlyOnceWith('a · ada', { + body: 'Request preview', + tag: 'botharness-remote:a:new', + }); + }); it('refuses a timed-out enable without changing the persisted choice and allows a fresh retry', async () => { vi.useFakeTimers(); From 449fd4532ba59e9d11bedacb3ed139ed38353515 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 05:16:18 +0900 Subject: [PATCH 11/18] fix: narrow remote notification translation contract Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- packages/client/src/client/remote-hosts.ts | 11 ++++++----- packages/client/test/remote-hosts.test.ts | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index 6d0db9936e..d371109999 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -29,7 +29,7 @@ import { createRemoteActivityProjection, type RemoteBotActivity } from './remote import { releaseRemoteHostViewers } from './remote-host-viewer.js'; import { browserNotificationApi } from './desktop-notifications.js'; import { createRemoteNotifications, type RemoteNotifications } from './remote-notifications.js'; -import { en, type BotHarnessTranslate } from './locale.js'; +import { en } from './locale.js'; export const REMOTE_HOSTS_STORAGE_KEY = 'botharness.remoteHosts.v1'; export const HOST_TAB_STORAGE_KEY = 'botharness.hostTab.v1'; @@ -131,13 +131,14 @@ interface HostEntry { } const idleInbox: InboxSnapshot = { status: 'idle' }; +type NotificationTranslate = (key: 'remoteHosts.notifications.privateBody') => string; export function createRemoteHosts(options: { storage?: HostStorage | undefined; carrier: HostCarrier; loadImage?: (url: string, headers: Record) => Promise; releaseImage?: (image: string) => void; - translate?: BotHarnessTranslate; + translate?: NotificationTranslate; }): RemoteHosts { const { storage, carrier, loadImage, releaseImage } = options; const listeners = new Set<() => void>(); @@ -474,9 +475,9 @@ async function browserImage( } let shared: RemoteHosts | undefined; -let translate: BotHarnessTranslate = (key) => en[key]; +let translate: NotificationTranslate = (key) => en[key]; -export function remoteHosts(translator?: BotHarnessTranslate): RemoteHosts { +export function remoteHosts(translator?: NotificationTranslate): RemoteHosts { if (translator !== undefined) translate = translator; if (shared !== undefined) return shared; shared = createRemoteHosts({ @@ -490,7 +491,7 @@ export function remoteHosts(translator?: BotHarnessTranslate): RemoteHosts { carrier: browserCarrier(), loadImage: browserImage, releaseImage: (image) => URL.revokeObjectURL(image), - translate: (key, params) => translate(key, params), + translate: (key) => translate(key), }); const sources = shared; let selection = store.getSnapshot().selection; diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index e4d590b6d3..9898ef355a 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -6,7 +6,7 @@ import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; import { createHostActivityActions } from '../src/client/host-activity-actions.js'; import type { HumanAttentionItem } from '../src/client/store.js'; import { pairingLink } from '../../core/src/devices/http.js'; -import { en, zh, type BotHarnessTranslate } from '../src/client/locale.js'; +import { en, zh } from '../src/client/locale.js'; import { ALL_HOSTS, HOST_TAB_STORAGE_KEY, @@ -431,7 +431,7 @@ describe('Host Feed lifecycle', () => { }); vi.stubGlobal('document', { documentElement: { lang: 'en' } }); let dictionary = en; - const translate: BotHarnessTranslate = (key) => dictionary[key]; + const translate = (key: 'remoteHosts.notifications.privateBody') => dictionary[key]; const { host, storage } = liveHost(); const sources = createRemoteHosts({ carrier: host.carrier, storage, translate }); releases.push(sources.subscribe(() => {})); From 4d48e255cbc42305ceafea642e983e108f13de1f Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 05:32:21 +0900 Subject: [PATCH 12/18] docs: refresh remote notification runtime screenshots Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .github/pr-screenshots/n4/a-d.png | Bin 76105 -> 69184 bytes .github/pr-screenshots/n4/a-l.png | Bin 80014 -> 72412 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/pr-screenshots/n4/a-d.png b/.github/pr-screenshots/n4/a-d.png index a0bc9619b24fdc8428891f1a38687935adecdd18..bba0d444ee6343f6a77090899157b3bea84e3977 100644 GIT binary patch literal 69184 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 literal 80014 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࿌OJE&r>V3qpPIE@S-J&+es&$m4jS!m%jSJwmW%x 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 Nff{I5Jb3*2{{ZuMX6*m~ From c9c9ad76606c89b2d29012b2bfc09a0dc0dea1f9 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 05:41:45 +0900 Subject: [PATCH 13/18] test: isolate notification relay recovery and delayed delivery Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../client/test/notification-control.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/client/test/notification-control.test.ts b/packages/client/test/notification-control.test.ts index 00542c21a6..1eb996abfa 100644 --- a/packages/client/test/notification-control.test.ts +++ b/packages/client/test/notification-control.test.ts @@ -4,6 +4,150 @@ import { Appearance } from '../src/client/appearance.js'; import { mountAppearanceControl } from '../src/client/appearance-control.js'; import { createRemoteNotifications } from '../src/client/remote-notifications.js'; +it.each(['normal', 'delayed-delivery'] as const)( + 'recovers the same relay after cancellation and expiry with %s transport', + async (transport) => { + vi.useFakeTimers(); + const host = createAppearanceControl(); + const data = new Map(); + const writes = vi.fn((key: string, value: string) => data.set(key, value)); + const finishes: (() => void)[] = []; + const prepareEnable = vi.fn(() => new Promise((resolve) => finishes.push(resolve))); + const notices = createRemoteNotifications({ + storage: { getItem: (key) => data.get(key) ?? null, setItem: writes }, + api: { permission: () => 'granted', requestPermission: async () => 'granted', show: vi.fn() }, + open() {}, + privateBody: () => 'private', + prepareEnable, + }); + shared.set('sources', { + notifications: notices, + getSnapshot: () => ({ hosts: [{ host: { hostId: 'remote' } }] }), + }); + let holdDelivery = false; + let deliver: (() => void) | undefined; + let polls = 0; + let registrations = 0; + vi.stubGlobal('location', { protocol: 'http:' }); + vi.stubGlobal('fetch', async (_url: string, init?: RequestInit) => { + const { action } = JSON.parse(String(init?.body)); + if (action === 'register') registrations++; + const response = host.fetch( + new Request('http://localhost/api/botharness/appearance-control', init), + ); + if (action === 'poll') polls++; + const result = await response; + if (action === 'poll' && holdDelivery) { + holdDelivery = false; + await new Promise((resolve) => { + deliver = resolve; + }); + } + return result; + }); + let release = mountAppearanceControl(new Appearance(undefined, () => 'light')); + const targets = async () => + (await (await host.fetch(new Request('http://localhost'))).json()).clients; + const flush = () => vi.advanceTimersByTimeAsync(0); + const request = (clientId: string, enable = false) => + host.fetch( + new Request('http://localhost/api/botharness/appearance-control', { + method: 'POST', + body: JSON.stringify({ + action: 'request', + clientId, + notifications: { + hostId: 'remote', + ...(enable + ? { patch: { enabled: true, preferences: notices.choice('remote').preferences } } + : {}), + }, + ...(enable ? { expectedRevision: JSON.stringify(notices.choice('remote')) } : {}), + }), + }), + ); + try { + await flush(); + const [{ id }] = await targets(); + for (const [index, reason] of ['cancel', 'deadline', 'cancel', 'deadline'].entries()) { + const before = polls; + const response = request(id, true); + await flush(); + expect(prepareEnable).toHaveBeenCalledTimes(index + 1); + if (reason === 'cancel') notices.cancel('remote'); + else await vi.advanceTimersByTimeAsync(8000); + await flush(); + expect(await (await response).json()).toMatchObject({ + error: { + code: + reason === 'cancel' + ? 'notification-activation-cancelled' + : 'appearance-client-timeout', + }, + }); + expect(writes).not.toHaveBeenCalled(); + expect(polls).toBe(before + 1); + const read = request(id); + await flush(); + expect(await (await read).json()).toMatchObject({ enabled: false }); + finishes.at(-1)!(); + await flush(); + expect(writes).not.toHaveBeenCalled(); + } + if (transport === 'delayed-delivery') { + holdDelivery = true; + const calls = prepareEnable.mock.calls.length; + const response = request(id, true); + await flush(); + expect(deliver).toBeTypeOf('function'); + await vi.advanceTimersByTimeAsync(8000); + expect(await (await response).json()).toMatchObject({ + error: { code: 'appearance-client-timeout' }, + }); + expect(await (await request(id)).json()).toMatchObject({ + error: { code: 'appearance-client-unavailable' }, + }); + expect(prepareEnable).toHaveBeenCalledTimes(calls); + await vi.advanceTimersByTimeAsync(1000); + deliver!(); + await flush(); + const read = request(id); + await flush(); + expect(await (await read).json()).toMatchObject({ enabled: false }); + } + expect(registrations).toBe(1); + expect(await targets()).toEqual([{ id, platform: 'web' }]); + expect(writes).not.toHaveBeenCalled(); + const pending = request(id, true); + await flush(); + release(); + await flush(); + expect(await (await pending).json()).toMatchObject({ + error: { code: 'appearance-client-stale' }, + }); + expect(await targets()).toEqual([]); + finishes.at(-1)!(); + await flush(); + expect(writes).not.toHaveBeenCalled(); + release = mountAppearanceControl(new Appearance(undefined, () => 'light')); + await flush(); + const [{ id: next }] = await targets(); + expect(next).not.toBe(id); + const accepted = request(next, true); + await flush(); + finishes.at(-1)!(); + await flush(); + expect(await (await accepted).json()).toMatchObject({ enabled: true }); + expect(writes).toHaveBeenCalledTimes(1); + } finally { + deliver?.(); + release(); + notices.stop(); + host.dispose(); + } + }, +); + const shared = vi.hoisted(() => new Map()); vi.mock('../src/client/remote-hosts.js', () => ({ remoteHosts: () => shared.get('sources') })); From 5ee65090156b65ace2175549be8ceb3c3e2467ee Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 07:16:55 +0900 Subject: [PATCH 14/18] test: distinguish Host notification and Activity attention fixtures Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- packages/client/test/remote-hosts.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index 9898ef355a..e90034eec1 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -1,7 +1,7 @@ import { afterEach, assert, describe, expect, it, vi } from 'vitest'; import type { HostCarrier } from '../../../apps/mobile/src/host/carrier.js'; -import type { HumanAttentionItem } from '../../../apps/mobile/src/host/wire.js'; +import type { HumanAttentionItem as HostAttentionItem } from '../../../apps/mobile/src/host/wire.js'; import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; import { createHostActivityActions } from '../src/client/host-activity-actions.js'; import type { HumanAttentionItem } from '../src/client/store.js'; @@ -221,7 +221,7 @@ describe('Host Feed lifecycle', () => { }, }); const { host, storage } = liveHost(); - const old: HumanAttentionItem = { + const old: HostAttentionItem = { id: 'old', kind: 'user-question', category: 'action', @@ -311,7 +311,7 @@ describe('Host Feed lifecycle', () => { }, }); const { host, storage } = liveHost(); - let items: HumanAttentionItem[] = []; + let items: HostAttentionItem[] = []; host.methods.set('humanAttention', (args) => args.notifications ? { notificationVersion: 1, items } : { queryVersion: 1, items: [] }, ); @@ -320,7 +320,7 @@ describe('Host Feed lifecycle', () => { releases.push(primary); await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); await sources.notifications.set(host.hostId, { enabled: true }); - const received: HumanAttentionItem = { + const received: HostAttentionItem = { id: 'received', kind: 'user-question', category: 'action', From c6a2b5b6a9425dc9832f1ccf306fdf5a96f98ef9 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 07:17:32 +0900 Subject: [PATCH 15/18] test: provide the complete relay storage fixture contract Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- packages/client/test/notification-control.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/client/test/notification-control.test.ts b/packages/client/test/notification-control.test.ts index 1eb996abfa..f27dd153a2 100644 --- a/packages/client/test/notification-control.test.ts +++ b/packages/client/test/notification-control.test.ts @@ -14,7 +14,13 @@ it.each(['normal', 'delayed-delivery'] as const)( const finishes: (() => void)[] = []; const prepareEnable = vi.fn(() => new Promise((resolve) => finishes.push(resolve))); const notices = createRemoteNotifications({ - storage: { getItem: (key) => data.get(key) ?? null, setItem: writes }, + storage: { + getItem: (key) => data.get(key) ?? null, + setItem: writes, + removeItem(key) { + data.delete(key); + }, + }, api: { permission: () => 'granted', requestPermission: async () => 'granted', show: vi.fn() }, open() {}, privateBody: () => 'private', From 40eff7bbcf03fced60791f64852cdf7c2e8143bd Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 07:38:47 +0900 Subject: [PATCH 16/18] test: synchronize notification CLI deadlines with delivered requests Include notifications in the gateway parameter contract and assert forwarding while preserving unavailable-owner refusals. Split inspector refusal scenarios, wait for this evaluation rather than a global socket count, assert the exact 20-second deadline, and await bounded fixture cleanup. The controlled idle-socket/delayed-discovery probe exposes a premature clock advance. The original CI timeout has no phase trace, so its exact phase is not claimed as proven. No production code or timeout configuration changes. Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- packages/core/test/bridge-rpc.test.ts | 38 +++- .../core/test/cli-phone-notifications.test.ts | 194 ++++++++++++++---- 2 files changed, 187 insertions(+), 45 deletions(-) diff --git a/packages/core/test/bridge-rpc.test.ts b/packages/core/test/bridge-rpc.test.ts index e7069fd810..f8b5303ba0 100644 --- a/packages/core/test/bridge-rpc.test.ts +++ b/packages/core/test/bridge-rpc.test.ts @@ -400,8 +400,9 @@ describe('bridge typert service', () => { assignmentModels, }); }); - it('exposes Activity query arguments through the gateway service and refuses an unavailable owner', () => { - const { service } = setup(); + it('exposes Activity and notification query arguments and refuses an unavailable owner', () => { + const { service, methods } = setup(); + const query = vi.spyOn(methods, 'humanAttention'); expect(parameterNames(service.humanAttention)).toEqual([ 'category', 'botSlug', @@ -415,6 +416,7 @@ describe('bridge typert service', () => { 'unreadOnly', 'queryVersion', 'dismissed', + 'notifications', ]); expect(parameterNames(service.humanAttentionRestore)).toEqual(['itemId', 'sourceKey']); expect(() => @@ -432,6 +434,38 @@ describe('bridge typert service', () => { 1, ), ).toThrow('Activity queries are unavailable'); + expect(() => + service.humanAttention( + undefined, + undefined, + undefined, + 100, + 'notification-cursor', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + ), + ).toThrow('notification queries are unavailable'); + expect(query).toHaveBeenLastCalledWith({ + category: undefined, + botSlug: undefined, + channelId: undefined, + limit: 100, + cursor: 'notification-cursor', + sort: undefined, + snoozed: undefined, + kind: undefined, + search: undefined, + unreadOnly: undefined, + queryVersion: undefined, + dismissed: undefined, + notifications: true, + }); }); it('exposes and forwards model revision and advanced preset arguments through the gateway façade', async () => { diff --git a/packages/core/test/cli-phone-notifications.test.ts b/packages/core/test/cli-phone-notifications.test.ts index f482bcd467..3ec8f3dce4 100644 --- a/packages/core/test/cli-phone-notifications.test.ts +++ b/packages/core/test/cli-phone-notifications.test.ts @@ -10,7 +10,31 @@ import { } from '../../../apps/mobile/src/host/device-notification-preferences.js'; import type { PushPreferences } from '../../../apps/mobile/src/host/push.js'; -const { WebSocketServer } = createRequire(import.meta.url)('ws'); +const { WebSocketServer, WebSocket } = createRequire(import.meta.url)('ws'); +const schedule = globalThis.setTimeout; +const cancel = globalThis.clearTimeout; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((ready) => { + resolve = ready; + }); + return { promise, resolve }; +} + +async function bounded(pending: Promise, phase: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + pending, + new Promise((_, reject) => { + timer = schedule(() => reject(Error(`inspector fixture stalled: ${phase}`)), 2000); + }), + ]); + } finally { + cancel(timer); + } +} function context(owner: unknown, dev = true, copies = 1) { return { @@ -91,31 +115,42 @@ it('the development CLI and UI use one persistent owner and distinguish pending ).rejects.toThrow('NOTIFICATION_OWNER_NOT_READY'); }); -it('requires an exact unique development target and reports release, disconnect and timeout refusals', async () => { - let duplicates = false; - let deviceName = 'Assigned phone'; - let release = false; - let disconnect = false; - let stall = false; +async function inspectorFixture( + options: { + duplicates?: boolean; + deviceName?: string; + release?: boolean; + disconnect?: boolean; + stall?: boolean; + discovery?: Promise; + } = {}, +) { + const received = deferred(); + const discovered = deferred(); const server = createServer((_request, response) => { const target = { id: 'target', - deviceName, - appId: release ? 'ai.botharness.deepseekbot' : 'ai.botharness.deepseekbot.dev', + deviceName: options.deviceName ?? 'Assigned phone', + appId: options.release ? 'ai.botharness.deepseekbot' : 'ai.botharness.deepseekbot.dev', webSocketDebuggerUrl: `ws://127.0.0.1:${port}/inspector`, }; + discovered.resolve(); response.setHeader('content-type', 'application/json'); - response.end(JSON.stringify(duplicates ? [target, target] : [target])); + const reply = () => + response.end(JSON.stringify(options.duplicates ? [target, target] : [target])); + if (options.discovery) void options.discovery.then(reply); + else reply(); }); const sockets = new WebSocketServer({ server }); sockets.on('connection', (socket: any) => { socket.on('message', (raw: Buffer) => { - if (disconnect) { + const request = JSON.parse(String(raw)); + received.resolve(request); + if (options.disconnect) { socket.close(); return; } - if (stall) return; - const request = JSON.parse(String(raw)); + if (options.stall) return; socket.send( JSON.stringify({ id: request.id, @@ -124,7 +159,7 @@ it('requires an exact unique development target and reports release, disconnect ); }); }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + await bounded(new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)), 'listen'); const address = server.address(); if (!address || typeof address === 'string') throw Error('address required'); const port = address.port; @@ -144,38 +179,111 @@ it('requires an exact unique development target and reports release, disconnect ); return JSON.parse(output[0]!); }; + return { + run, + received: received.promise, + discovered: discovered.promise, + async idleConnection() { + const socket = new WebSocket(`ws://127.0.0.1:${port}/inspector`); + await bounded( + new Promise((resolve) => socket.once('open', resolve)), + 'idle connection', + ); + return socket; + }, + async close() { + for (const socket of sockets.clients) socket.terminate(); + const closed = new Promise((resolve) => sockets.close(() => resolve())); + server.closeAllConnections(); + await bounded( + Promise.all([closed, new Promise((resolve) => server.close(() => resolve()))]), + 'server cleanup', + ); + expect(sockets.clients.size).toBe(0); + expect(server.listening).toBe(false); + }, + }; +} + +it.each([ + { + name: 'assigned development target', + options: {}, + target: 'target', + expected: { owner: 'phone' }, + }, + { + name: 'missing target', + options: {}, + target: 'missing', + expected: { error: { code: 'explicit-unique-target-required' } }, + }, + { + name: 'duplicate targets', + options: { duplicates: true }, + target: 'target', + expected: { error: { code: 'explicit-unique-target-required' } }, + }, + { + name: 'wrong device', + options: { deviceName: 'Wrong phone' }, + target: 'target', + expected: { error: { code: 'explicit-simulator-app-target-required' } }, + }, + { + name: 'release app', + options: { release: true }, + target: 'target', + expected: { error: { code: 'explicit-simulator-app-target-required' } }, + }, + { + name: 'disconnected inspector', + options: { disconnect: true }, + target: 'target', + expected: { error: { code: 'inspector-closed' } }, + }, +])('$name returns its exact CLI result', async ({ name, options, target, expected }) => { + const fixture = await inspectorFixture(options); try { - expect(await run()).toMatchObject({ owner: 'phone' }); - expect(await run('missing')).toMatchObject({ - error: { code: 'explicit-unique-target-required' }, - }); - duplicates = true; - expect(await run()).toMatchObject({ error: { code: 'explicit-unique-target-required' } }); - duplicates = false; - deviceName = 'Wrong phone'; - expect(await run()).toMatchObject({ - error: { code: 'explicit-simulator-app-target-required' }, - }); - deviceName = 'Assigned phone'; - release = true; - expect(await run()).toMatchObject({ - error: { code: 'explicit-simulator-app-target-required' }, - }); - release = false; - disconnect = true; - expect(await run()).toMatchObject({ error: { code: 'inspector-closed' } }); - disconnect = false; - stall = true; + expect(await bounded(fixture.run(target), name)).toMatchObject(expected); + } finally { + await fixture.close(); + } +}); + +it('times out the received evaluation at 20 seconds despite an idle socket and delayed discovery', async () => { + const discovery = deferred(); + const fixture = await inspectorFixture({ stall: true, discovery: discovery.promise }); + let pending: Promise | undefined; + try { + await fixture.idleConnection(); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const pending = run(); - await vi.waitUntil(() => sockets.clients.size === 1); + pending = fixture.run(); + const settled = vi.fn(); + void pending.then(settled); + await bounded(fixture.discovered, 'target discovery'); await vi.advanceTimersByTimeAsync(20_100); - expect(await pending).toMatchObject({ error: { code: 'inspector-timeout' } }); + expect(settled).not.toHaveBeenCalled(); + discovery.resolve(); + expect(await bounded(fixture.received, 'Runtime.evaluate delivery')).toMatchObject({ + id: 1, + method: 'Runtime.evaluate', + params: { awaitPromise: true, returnByValue: true }, + }); + await vi.advanceTimersByTimeAsync(19_999); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(await bounded(pending, 'evaluation deadline result')).toMatchObject({ + error: { code: 'inspector-timeout' }, + }); } finally { - vi.useRealTimers(); - for (const socket of sockets.clients) socket.terminate(); - sockets.close(); - server.closeAllConnections(); - await new Promise((resolve) => server.close(() => resolve())); + discovery.resolve(); + try { + await fixture.close(); + if (pending) await bounded(pending, 'pending CLI cleanup'); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } } }); From d47d76f07f0d37bd63dfb356ec23ecad80b74503 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 08:00:32 +0900 Subject: [PATCH 17/18] test: await inspector WebSocket peer closure before timer assertions The server close callback can finish before the client socket close event. The client ws close handshake then still owns its 30-second timer even though the 20-second request timeout and CLI result have completed. Observe real peer close events, wait for them during fixture teardown, and restore the observer. Keep the zero-timer assertion and the original business deadline unchanged. Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- .../core/test/cli-phone-notifications.test.ts | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/core/test/cli-phone-notifications.test.ts b/packages/core/test/cli-phone-notifications.test.ts index 3ec8f3dce4..ee7dea8024 100644 --- a/packages/core/test/cli-phone-notifications.test.ts +++ b/packages/core/test/cli-phone-notifications.test.ts @@ -163,6 +163,16 @@ async function inspectorFixture( const address = server.address(); if (!address || typeof address === 'string') throw Error('address required'); const port = address.port; + const peersClosed: Promise[] = []; + const originalClose = WebSocket.prototype.close; + const closeSpy = vi.spyOn(WebSocket.prototype, 'close').mockImplementation(function ( + this: any, + ...args: any[] + ) { + if (this.readyState !== WebSocket.CLOSED) + peersClosed.push(new Promise((resolve) => this.once('close', resolve))); + return originalClose.apply(this, args); + }); const run = async (target = 'target') => { const output: string[] = []; await runBotCreateCli( @@ -185,6 +195,7 @@ async function inspectorFixture( discovered: discovered.promise, async idleConnection() { const socket = new WebSocket(`ws://127.0.0.1:${port}/inspector`); + peersClosed.push(new Promise((resolve) => socket.once('close', resolve))); await bounded( new Promise((resolve) => socket.once('open', resolve)), 'idle connection', @@ -192,15 +203,20 @@ async function inspectorFixture( return socket; }, async close() { - for (const socket of sockets.clients) socket.terminate(); - const closed = new Promise((resolve) => sockets.close(() => resolve())); - server.closeAllConnections(); - await bounded( - Promise.all([closed, new Promise((resolve) => server.close(() => resolve()))]), - 'server cleanup', - ); - expect(sockets.clients.size).toBe(0); - expect(server.listening).toBe(false); + try { + for (const socket of sockets.clients) socket.terminate(); + const closed = new Promise((resolve) => sockets.close(() => resolve())); + server.closeAllConnections(); + await bounded( + Promise.all([closed, new Promise((resolve) => server.close(() => resolve()))]), + 'server cleanup', + ); + await bounded(Promise.all(peersClosed), 'inspector peers close'); + expect(sockets.clients.size).toBe(0); + expect(server.listening).toBe(false); + } finally { + closeSpy.mockRestore(); + } }, }; } From 39780fbd2b2c625af87e7185daad67281487bc2b Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Thu, 22 Oct 2026 08:25:30 +0900 Subject: [PATCH 18/18] test: control stalled Git source deadline independently of fallback I/O Advance only the 49+1ms source timeout, then await real fallback installation under a separate real-time bound. Assert source order, abort scope and timer cleanup. Controlled delayed filesystem callbacks reproduce the old timing-sensitive failure; the original CI phase is not observed. Issue: #2500 Agent-Task: codex/local/01a15ee9-f10e-7e21-90ba-727ffccb2183 --- packages/core/test/managed-git.test.ts | 50 ++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/core/test/managed-git.test.ts b/packages/core/test/managed-git.test.ts index 6716e89f18..0e58e1f9c9 100644 --- a/packages/core/test/managed-git.test.ts +++ b/packages/core/test/managed-git.test.ts @@ -11,7 +11,7 @@ import { } from 'node:fs'; import { delimiter, join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createBridgeMethods } from '../src/bridge/methods.js'; import { probeGit, type GitAvailability } from '../src/memory/git-probe.js'; @@ -184,9 +184,13 @@ describe.skipIf(process.platform === 'win32')('Managed Git install', () => { }); it('gives up on a source that stops sending data and tries the next one', async () => { + const requested: string[] = []; + const aborted: string[] = []; const { dist, logs, options } = setup({ stallTimeoutMs: 50, fetchImpl: (async (url: string, init?: RequestInit) => { + requested.push(url); + init?.signal?.addEventListener('abort', () => aborted.push(url)); if (url.startsWith('https://mirror.test/')) { return new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(init.signal?.reason)), @@ -196,12 +200,44 @@ describe.skipIf(process.platform === 'win32')('Managed Git install', () => { }) as typeof fetch, }); const git = createGitService(options); - git.install(); - await git.settled(); - expect(git.status()).toMatchObject({ available: true, source: 'managed' }); - expect(logs).toContainEqual( - expect.stringMatching(/source=mirror\.test error=no data for 0\.05s/u), - ); + const scheduleDeadline = setTimeout; + const cancelDeadline = clearTimeout; + let deadline: ReturnType | undefined; + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + git.install(); + await vi.advanceTimersByTimeAsync(49); + const beforeDeadline = { requested: [...requested], aborted: [...aborted] }; + await vi.advanceTimersByTimeAsync(1); + const atDeadline = { requested: [...requested], aborted: [...aborted] }; + await Promise.race([ + git.settled(), + new Promise((_resolve, reject) => { + deadline = scheduleDeadline( + () => reject(new Error('Managed Git fallback did not settle')), + 2_000, + ); + }), + ]); + expect(beforeDeadline).toEqual({ requested: [`https://mirror.test/${ASSET}`], aborted: [] }); + expect(atDeadline).toEqual({ + requested: [`https://mirror.test/${ASSET}`, `https://github.test/${ASSET}`], + aborted: [`https://mirror.test/${ASSET}`], + }); + expect(requested).toEqual([`https://mirror.test/${ASSET}`, `https://github.test/${ASSET}`]); + expect(aborted).toEqual([`https://mirror.test/${ASSET}`]); + expect(git.status()).toMatchObject({ available: true, source: 'managed' }); + expect(logs).toContainEqual( + expect.stringMatching(/source=mirror\.test error=no data for 0\.05s/u), + ); + expect(logs).toContainEqual( + expect.stringMatching(/^git phase=downloaded source=github\.test /u), + ); + expect(vi.getTimerCount()).toBe(0); + } finally { + cancelDeadline(deadline); + vi.useRealTimers(); + } }); it('reports a network failure naming every source when all downloads fail', async () => {