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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions cli/src/agent/backends/acp/AcpSdkBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions hub/src/config/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface ServerSettings {
listenPort: number
publicUrl: string
corsOrigins: string[]
barkDeviceKey: string | null
barkServerUrl: string
}

export interface ServerSettingsResult {
Expand All @@ -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
}
Expand Down Expand Up @@ -91,6 +95,8 @@ export async function loadServerSettings(dataDir: string): Promise<ServerSetting
listenPort: 'default',
publicUrl: 'default',
corsOrigins: 'default',
barkDeviceKey: 'default',
barkServerUrl: 'default',
}
// telegramBotToken: env > file > null
let telegramBotToken: string | null = null
Expand Down Expand Up @@ -203,6 +209,34 @@ export async function loadServerSettings(dataDir: string): Promise<ServerSetting
corsOrigins = deriveCorsOrigins(publicUrl)
}

// barkDeviceKey: env > 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)
Expand All @@ -216,6 +250,8 @@ export async function loadServerSettings(dataDir: string): Promise<ServerSetting
listenPort,
publicUrl,
corsOrigins,
barkDeviceKey,
barkServerUrl,
},
sources,
savedToFile: needsSave,
Expand Down
2 changes: 2 additions & 0 deletions hub/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface Settings {
listenPort?: number
publicUrl?: string
corsOrigins?: string[]
barkDeviceKey?: string
barkServerUrl?: string
// Legacy field names (for migration, read-only)
webappHost?: string
webappPort?: number
Expand Down
12 changes: 12 additions & 0 deletions hub/src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* - HAPI_LISTEN_PORT: Port for HTTP service (default: 3006)
* - HAPI_PUBLIC_URL: Public URL for external access (e.g., Telegram Mini App)
* - CORS_ORIGINS: Comma-separated CORS origins
* - BARK_DEVICE_KEY: Bark target device key (enables Bark notifications)
* - BARK_SERVER_URL: Bark server base URL (default: https://api.day.app)
* - HAPI_RELAY_API: Relay API domain for tunwg (default: relay.hapi.run)
* - HAPI_RELAY_AUTH: Relay auth key for tunwg (default: hapi)
* - HAPI_RELAY_FORCE_TCP: Force TCP relay mode when UDP is unavailable (true/1)
Expand All @@ -37,6 +39,8 @@ export interface ConfigSources {
listenPort: ConfigSource
publicUrl: ConfigSource
corsOrigins: ConfigSource
barkDeviceKey: ConfigSource
barkServerUrl: ConfigSource
cliApiToken: 'env' | 'file' | 'generated'
}

Expand Down Expand Up @@ -80,6 +84,12 @@ class Configuration {
/** Allowed CORS origins for Mini App + Socket.IO (comma-separated env override) */
public readonly corsOrigins: string[]

/** Bark device key; Bark notifications enabled when set */
public readonly barkDeviceKey: string | null

/** Bark server base URL */
public readonly barkServerUrl: string

/** Sources of each configuration value */
public readonly sources: ConfigSources

Expand All @@ -102,6 +112,8 @@ class Configuration {
this.listenPort = serverSettings.listenPort
this.publicUrl = serverSettings.publicUrl
this.corsOrigins = serverSettings.corsOrigins
this.barkDeviceKey = serverSettings.barkDeviceKey
this.barkServerUrl = serverSettings.barkServerUrl

// CLI API token - will be set by _setCliApiToken() before create() returns
this.cliApiToken = ''
Expand Down
16 changes: 16 additions & 0 deletions hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Store } from './store'
import { SyncEngine, type SyncEvent } from './sync/syncEngine'
import { NotificationHub } from './notifications/notificationHub'
import type { NotificationChannel } from './notifications/notificationTypes'
import { createBarkNotificationChannel } from './notifications/barkNotificationChannel'
import { HappyBot } from './telegram/bot'
import { startWebServer } from './web/server'
import { getOrCreateJwtSecret } from './config/jwtSecret'
Expand Down Expand Up @@ -150,6 +151,12 @@ async function main() {
console.log(`[Hub] Telegram notifications: ${config.telegramNotification ? 'enabled' : 'disabled'} (${notificationSource})`)
}

const barkEnabled = Boolean(config.barkDeviceKey?.trim())
const barkDeviceSource = formatSource(config.sources.barkDeviceKey)
const barkServerSource = formatSource(config.sources.barkServerUrl)
console.log(`[Hub] Bark notifications: ${barkEnabled ? 'enabled' : 'disabled'} (${barkDeviceSource})`)
console.log(`[Hub] BARK_SERVER_URL: ${config.barkServerUrl} (${barkServerSource})`)

// Display tunnel status
if (relayFlag.enabled) {
console.log(`[Hub] Tunnel: enabled (${relayFlag.source}), API: ${relayApiDomain}`)
Expand Down Expand Up @@ -188,6 +195,15 @@ async function main() {
new PushNotificationChannel(pushService, sseManager, visibilityTracker, config.publicUrl)
]

const barkChannel = createBarkNotificationChannel({
deviceKey: config.barkDeviceKey,
serverUrl: config.barkServerUrl,
publicUrl: config.publicUrl
})
if (barkChannel) {
notificationChannels.push(barkChannel)
}

// Initialize Telegram bot (optional)
if (config.telegramEnabled && config.telegramBotToken) {
happyBot = new HappyBot({
Expand Down
122 changes: 122 additions & 0 deletions hub/src/notifications/barkDelivery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'bun:test'
import { BarkDelivery, normalizeBarkServerUrl } from './barkDelivery'
import type { BarkFetch } from './barkDelivery'

describe('normalizeBarkServerUrl', () => {
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)
})
})
Loading
Loading