diff --git a/bun.lock b/bun.lock index 4723445ea..cf96a6c5d 100644 --- a/bun.lock +++ b/bun.lock @@ -882,6 +882,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.16.3", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-ZQmI1T62R+1+VL4bG/0x/uC8Y2pffgFj8kwl0tpBOUcjWWi8VKOIvjc7kcwtTdBplQ6vm8GAcyDb4kWJCP1ZQA=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.16.3", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-JUz67NYpF46liBeiRGKteIwDposDDXrlYQMPu7NTW5ItUvFL3v0A5LtAdmUoBODnXEmJIz5PiYyCcCaas9wbBQ=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index aa797e960..73872fe44 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -171,6 +171,9 @@ export class AcpSdkBackend implements AgentBackend { stopReason = isObject(response) ? asString(response.stopReason) : null; } finally { + // Start the trailing-update quiet window from prompt response completion, + // not from the last pre-response update (which could be much earlier). + this.lastSessionUpdateAt = Date.now(); await this.waitForSessionUpdateQuiet( AcpSdkBackend.UPDATE_QUIET_PERIOD_MS, AcpSdkBackend.UPDATE_DRAIN_TIMEOUT_MS diff --git a/hub/README.md b/hub/README.md index 24119c09f..5792aed4b 100644 --- a/hub/README.md +++ b/hub/README.md @@ -41,6 +41,8 @@ See `src/configuration.ts` for all options. - `HAPI_RELAY_AUTH` - Relay auth key (default: hapi). - `HAPI_RELAY_FORCE_TCP` - Force TCP relay mode (true/1). - `VAPID_SUBJECT` - Contact email/URL for Web Push. +- `BARK_DEVICE_KEY` - Bark device key. When set, Bark notifications are enabled. +- `BARK_SERVER_URL` - Bark server base URL (default: `https://api.day.app`). ## Running diff --git a/hub/src/config/serverSettings.ts b/hub/src/config/serverSettings.ts index 095fc2dbc..1408d3eb4 100644 --- a/hub/src/config/serverSettings.ts +++ b/hub/src/config/serverSettings.ts @@ -17,6 +17,8 @@ export interface ServerSettings { listenPort: number publicUrl: string corsOrigins: string[] + barkDeviceKey: string | null + barkServerUrl: string } export interface ServerSettingsResult { @@ -28,6 +30,8 @@ export interface ServerSettingsResult { listenPort: 'env' | 'file' | 'default' publicUrl: 'env' | 'file' | 'default' corsOrigins: 'env' | 'file' | 'default' + barkDeviceKey: 'env' | 'file' | 'default' + barkServerUrl: 'env' | 'file' | 'default' } savedToFile: boolean } @@ -91,6 +95,8 @@ export async function loadServerSettings(dataDir: string): Promise file > null let telegramBotToken: string | null = null @@ -203,6 +209,34 @@ export async function loadServerSettings(dataDir: string): Promise file > null + let barkDeviceKey: string | null = null + if (process.env.BARK_DEVICE_KEY) { + barkDeviceKey = process.env.BARK_DEVICE_KEY + sources.barkDeviceKey = 'env' + if (settings.barkDeviceKey === undefined) { + settings.barkDeviceKey = barkDeviceKey + needsSave = true + } + } else if (settings.barkDeviceKey !== undefined) { + barkDeviceKey = settings.barkDeviceKey ?? null + sources.barkDeviceKey = 'file' + } + + // barkServerUrl: env > file > default + let barkServerUrl = 'https://api.day.app' + if (process.env.BARK_SERVER_URL) { + barkServerUrl = process.env.BARK_SERVER_URL + sources.barkServerUrl = 'env' + if (settings.barkServerUrl === undefined) { + settings.barkServerUrl = barkServerUrl + needsSave = true + } + } else if (settings.barkServerUrl !== undefined) { + barkServerUrl = settings.barkServerUrl + sources.barkServerUrl = 'file' + } + // Save settings if any new values were added if (needsSave) { await writeSettings(settingsFile, settings) @@ -216,6 +250,8 @@ export async function loadServerSettings(dataDir: string): Promise { + it('removes trailing slashes', () => { + expect(normalizeBarkServerUrl('https://api.day.app///')).toBe('https://api.day.app') + }) +}) + +describe('BarkDelivery', () => { + it('posts payload to {base}/push with injected device key', async () => { + const calls: Array<{ url: string; body: string }> = [] + const fetchImpl: BarkFetch = async (input, init) => { + calls.push({ + url: String(input), + body: String(init?.body ?? '') + }) + return new Response('', { status: 200 }) + } + + const delivery = new BarkDelivery({ + baseUrl: 'https://api.day.app/', + deviceKey: 'device-key', + fetchImpl + }) + + await delivery.send({ + title: 'Ready for input', + body: 'Codex is waiting in demo', + group: 'ready-session-1', + url: 'https://example.com/sessions/session-1' + }) + + expect(calls).toHaveLength(1) + expect(calls[0]?.url).toBe('https://api.day.app/push') + expect(JSON.parse(calls[0]?.body ?? '')).toEqual({ + title: 'Ready for input', + body: 'Codex is waiting in demo', + device_key: 'device-key', + group: 'ready-session-1', + url: 'https://example.com/sessions/session-1' + }) + }) + + it('retries once on transient 5xx failure', async () => { + let calls = 0 + const fetchImpl: BarkFetch = async () => { + calls += 1 + if (calls === 1) { + return new Response('', { status: 500 }) + } + return new Response('', { status: 200 }) + } + + const delivery = new BarkDelivery({ + baseUrl: 'https://api.day.app', + deviceKey: 'device-key', + fetchImpl + }) + + await delivery.send({ + title: 'Permission Request', + body: 'demo (Edit)', + group: 'permission-session-1', + url: 'https://example.com/sessions/session-1' + }) + + expect(calls).toBe(2) + }) + + it('does not retry on 4xx failure', async () => { + let calls = 0 + const fetchImpl: BarkFetch = async () => { + calls += 1 + return new Response('', { status: 400 }) + } + + const delivery = new BarkDelivery({ + baseUrl: 'https://api.day.app', + deviceKey: 'device-key', + fetchImpl + }) + + await expect( + delivery.send({ + title: 'Permission Request', + body: 'demo', + group: 'permission-session-1', + url: 'https://example.com/sessions/session-1' + }) + ).rejects.toThrow() + + expect(calls).toBe(1) + }) + + it('retries once on network error', async () => { + let calls = 0 + const fetchImpl: BarkFetch = async () => { + calls += 1 + if (calls === 1) { + throw new TypeError('network failed') + } + return new Response('', { status: 200 }) + } + + const delivery = new BarkDelivery({ + baseUrl: 'https://api.day.app', + deviceKey: 'device-key', + fetchImpl + }) + + await delivery.send({ + title: 'Ready for input', + body: 'Agent is waiting in demo', + group: 'ready-session-1', + url: 'https://example.com/sessions/session-1' + }) + + expect(calls).toBe(2) + }) +}) diff --git a/hub/src/notifications/barkDelivery.ts b/hub/src/notifications/barkDelivery.ts new file mode 100644 index 000000000..f8e9f36ae --- /dev/null +++ b/hub/src/notifications/barkDelivery.ts @@ -0,0 +1,124 @@ +export type BarkAttentionPayload = { + title: string + body: string + group: string + url: string +} + +type BarkPushPayload = BarkAttentionPayload & { + device_key: string +} + +export type BarkFetch = ( + input: string | URL | Request, + init?: RequestInit +) => Promise + +export type BarkDeliveryOptions = { + baseUrl: string + deviceKey: string + fetchImpl?: BarkFetch + timeoutMs?: number +} + +const DEFAULT_TIMEOUT_MS = 5_000 + +class BarkHttpError extends Error { + constructor( + readonly status: number, + readonly transient: boolean + ) { + super(`Bark request failed with status ${status}`) + } +} + +class BarkTransientError extends Error {} + +export function normalizeBarkServerUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, '') +} + +export class BarkDelivery { + private readonly baseUrl: string + private readonly deviceKey: string + private readonly fetchImpl: BarkFetch + private readonly timeoutMs: number + + constructor(options: BarkDeliveryOptions) { + this.baseUrl = normalizeBarkServerUrl(options.baseUrl) + this.deviceKey = options.deviceKey + this.fetchImpl = options.fetchImpl ?? fetch + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + } + + async send(payload: BarkAttentionPayload): Promise { + const barkPayload: BarkPushPayload = { + ...payload, + device_key: this.deviceKey + } + + try { + await this.sendOnce(barkPayload) + return + } catch (error) { + if (!this.shouldRetry(error)) { + throw error + } + } + + await this.sendOnce(barkPayload) + } + + private async sendOnce(payload: BarkPushPayload): Promise { + const abortController = new AbortController() + const timeoutHandle = setTimeout(() => { + abortController.abort() + }, this.timeoutMs) + + try { + const response = await this.fetchImpl(this.getPushUrl(), { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify(payload), + signal: abortController.signal + }) + + if (response.ok) { + return + } + + const transient = response.status >= 500 + throw new BarkHttpError(response.status, transient) + } catch (error) { + if (error instanceof BarkHttpError) { + throw error + } + if (this.isAbortError(error)) { + throw new BarkTransientError('Bark request timed out') + } + throw new BarkTransientError('Bark request failed') + } finally { + clearTimeout(timeoutHandle) + } + } + + private shouldRetry(error: unknown): boolean { + if (error instanceof BarkHttpError) { + return error.transient + } + return error instanceof BarkTransientError + } + + private isAbortError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + return error.name === 'AbortError' + } + + private getPushUrl(): string { + return `${this.baseUrl}/push` + } +} diff --git a/hub/src/notifications/barkNotificationChannel.test.ts b/hub/src/notifications/barkNotificationChannel.test.ts new file mode 100644 index 000000000..23a641e93 --- /dev/null +++ b/hub/src/notifications/barkNotificationChannel.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'bun:test' +import type { Session } from '../sync/syncEngine' +import { BarkNotificationChannel, createBarkNotificationChannel, type BarkNotificationSender } from './barkNotificationChannel' +import type { BarkAttentionPayload } from './barkDelivery' + +class RecordingSender implements BarkNotificationSender { + readonly payloads: BarkAttentionPayload[] = [] + + async send(payload: BarkAttentionPayload): Promise { + this.payloads.push(payload) + } +} + +function createSession(overrides: Partial = {}): Session { + return { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: 0, + updatedAt: 0, + active: true, + activeAt: 0, + metadata: null, + metadataVersion: 0, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + ...overrides + } +} + +describe('createBarkNotificationChannel', () => { + it('returns null when device key is missing', () => { + const channel = createBarkNotificationChannel({ + deviceKey: null, + serverUrl: 'https://api.day.app', + publicUrl: 'https://app.example.com' + }) + expect(channel).toBeNull() + }) + + it('returns channel when device key exists', () => { + const channel = createBarkNotificationChannel({ + deviceKey: 'abc', + serverUrl: 'https://api.day.app', + publicUrl: 'https://app.example.com' + }) + expect(channel).not.toBeNull() + }) +}) + +describe('BarkNotificationChannel', () => { + it('maps permission payload fields with tool hint', async () => { + const sender = new RecordingSender() + const channel = new BarkNotificationChannel(sender, 'https://app.example.com') + const session = createSession({ + id: 'sid-1', + metadata: { + path: '/tmp/demo-session', + host: 'localhost', + name: 'demo-session' + }, + agentState: { + requests: { + r1: { + tool: 'Edit', + arguments: {}, + createdAt: 1 + } + } + } + }) + + await channel.sendPermissionRequest(session) + + expect(sender.payloads).toHaveLength(1) + expect(sender.payloads[0]).toEqual({ + title: 'Permission Request', + body: 'demo-session (Edit)', + group: 'permission-sid-1', + url: 'https://app.example.com/sessions/sid-1' + }) + }) + + it('maps ready payload fields using session + agent naming helpers', async () => { + const sender = new RecordingSender() + const channel = new BarkNotificationChannel(sender, 'https://app.example.com') + const session = createSession({ + id: 'sid-2', + metadata: { + host: 'localhost', + path: '/tmp/project-a', + flavor: 'codex' + } + }) + + await channel.sendReady(session) + + expect(sender.payloads).toHaveLength(1) + expect(sender.payloads[0]).toEqual({ + title: 'Ready for input', + body: 'Codex is waiting in project-a', + group: 'ready-sid-2', + url: 'https://app.example.com/sessions/sid-2' + }) + }) + + it('preserves public URL path prefix in session links', async () => { + const sender = new RecordingSender() + const channel = new BarkNotificationChannel(sender, 'https://app.example.com/hapi') + const session = createSession({ id: 'sid-3' }) + + await channel.sendReady(session) + + expect(sender.payloads).toHaveLength(1) + expect(sender.payloads[0]?.url).toBe('https://app.example.com/hapi/sessions/sid-3') + }) +}) diff --git a/hub/src/notifications/barkNotificationChannel.ts b/hub/src/notifications/barkNotificationChannel.ts new file mode 100644 index 000000000..fb7d35375 --- /dev/null +++ b/hub/src/notifications/barkNotificationChannel.ts @@ -0,0 +1,86 @@ +import type { Session } from '../sync/syncEngine' +import type { NotificationChannel } from './notificationTypes' +import { BarkDelivery } from './barkDelivery' +import type { BarkAttentionPayload, BarkFetch } from './barkDelivery' +import { getAgentName, getSessionName } from './sessionInfo' + +export type BarkNotificationSender = { + send: (payload: BarkAttentionPayload) => Promise +} + +export class BarkNotificationChannel implements NotificationChannel { + constructor( + private readonly sender: BarkNotificationSender, + private readonly publicUrl: string + ) {} + + async sendPermissionRequest(session: Session): Promise { + if (!session.active) { + return + } + + const name = getSessionName(session) + const request = session.agentState?.requests + ? Object.values(session.agentState.requests)[0] + : null + const toolName = request?.tool ? ` (${request.tool})` : '' + + await this.sender.send({ + title: 'Permission Request', + body: `${name}${toolName}`, + group: `permission-${session.id}`, + url: this.buildSessionUrl(session.id) + }) + } + + async sendReady(session: Session): Promise { + if (!session.active) { + return + } + + const agentName = getAgentName(session) + const name = getSessionName(session) + + await this.sender.send({ + title: 'Ready for input', + body: `${agentName} is waiting in ${name}`, + group: `ready-${session.id}`, + url: this.buildSessionUrl(session.id) + }) + } + + private buildSessionUrl(sessionId: string): string { + try { + const normalizedBase = `${this.publicUrl.replace(/\/+$/, '')}/` + return new URL(`sessions/${sessionId}`, normalizedBase).toString() + } catch { + return `${this.publicUrl.replace(/\/+$/, '')}/sessions/${sessionId}` + } + } +} + +type CreateBarkChannelOptions = { + deviceKey: string | null + serverUrl: string + publicUrl: string + fetchImpl?: BarkFetch + timeoutMs?: number +} + +export function createBarkNotificationChannel( + options: CreateBarkChannelOptions +): BarkNotificationChannel | null { + const deviceKey = options.deviceKey?.trim() ?? '' + if (!deviceKey) { + return null + } + + const delivery = new BarkDelivery({ + baseUrl: options.serverUrl, + deviceKey, + fetchImpl: options.fetchImpl, + timeoutMs: options.timeoutMs + }) + + return new BarkNotificationChannel(delivery, options.publicUrl) +} diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index 8aeef30ff..5cc8075f6 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -42,6 +42,14 @@ class StubChannel implements NotificationChannel { } } +class FailingReadyChannel implements NotificationChannel { + async sendReady(): Promise { + throw new Error('bark failed') + } + + async sendPermissionRequest(): Promise {} +} + function createSession(overrides: Partial = {}): Session { return { id: 'session-1', @@ -154,4 +162,42 @@ describe('NotificationHub', () => { hub.stop() }) + + it('keeps notifying other channels when one channel fails', async () => { + const engine = new FakeSyncEngine() + const failing = new FailingReadyChannel() + const healthy = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [failing, healthy], { + permissionDebounceMs: 1, + readyCooldownMs: 1 + }) + + const session = createSession() + engine.setSession(session) + + const readyEvent: SyncEvent = { + type: 'message-received', + sessionId: session.id, + message: { + id: 'message-1', + seq: 1, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + id: 'event-1', + type: 'event', + data: { type: 'ready' } + } + } + } + } + + engine.emit(readyEvent) + await sleep(10) + + expect(healthy.readySessions).toHaveLength(1) + hub.stop() + }) })