Przeglądaj źródła

feat: add APN relay MVP and experimental push bridge

Ryan Vogel 4 miesięcy temu
rodzic
commit
f276a8db42

+ 11 - 0
packages/apn-relay/.env.example

@@ -0,0 +1,11 @@
+PORT=8787
+
+DATABASE_HOST=
+DATABASE_USERNAME=
+DATABASE_PASSWORD=
+DATABASE_NAME=main
+
+APNS_TEAM_ID=
+APNS_KEY_ID=
+APNS_PRIVATE_KEY=
+APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice

+ 14 - 0
packages/apn-relay/Dockerfile

@@ -0,0 +1,14 @@
+FROM oven/bun:1.3.11-alpine
+
+WORKDIR /app
+
+COPY package.json ./
+COPY tsconfig.json ./
+COPY drizzle.config.ts ./
+RUN bun install --production
+
+COPY src ./src
+
+EXPOSE 8787
+
+CMD ["bun", "run", "src/index.ts"]

+ 46 - 0
packages/apn-relay/README.md

@@ -0,0 +1,46 @@
+# APN Relay
+
+Minimal APNs relay for OpenCode mobile background notifications.
+
+## What it does
+
+- Registers iOS device tokens for a shared secret.
+- Receives OpenCode event posts (`complete`, `permission`, `error`).
+- Sends APNs notifications to mapped devices.
+- Stores delivery rows in PlanetScale.
+
+## Routes
+
+- `GET /health`
+- `GET /` (simple dashboard)
+- `POST /v1/device/register`
+- `POST /v1/device/unregister`
+- `POST /v1/event`
+
+## Environment
+
+Use `.env.example` as a starting point.
+
+- `DATABASE_HOST`
+- `DATABASE_USERNAME`
+- `DATABASE_PASSWORD`
+- `APNS_TEAM_ID`
+- `APNS_KEY_ID`
+- `APNS_PRIVATE_KEY`
+- `APNS_DEFAULT_BUNDLE_ID`
+
+## Run locally
+
+```bash
+bun install
+bun run src/index.ts
+```
+
+## Docker
+
+Build from this directory:
+
+```bash
+docker build -t apn-relay .
+docker run --rm -p 8787:8787 --env-file .env apn-relay
+```

+ 17 - 0
packages/apn-relay/drizzle.config.ts

@@ -0,0 +1,17 @@
+import { defineConfig } from "drizzle-kit"
+
+export default defineConfig({
+  out: "./migration",
+  strict: true,
+  schema: ["./src/**/*.sql.ts"],
+  dialect: "mysql",
+  dbCredentials: {
+    host: process.env.DATABASE_HOST ?? "",
+    user: process.env.DATABASE_USERNAME ?? "",
+    password: process.env.DATABASE_PASSWORD ?? "",
+    database: process.env.DATABASE_NAME ?? "main",
+    ssl: {
+      rejectUnauthorized: false,
+    },
+  },
+})

+ 26 - 0
packages/apn-relay/package.json

@@ -0,0 +1,26 @@
+{
+  "$schema": "https://json.schemastore.org/package.json",
+  "name": "@opencode-ai/apn-relay",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "license": "MIT",
+  "scripts": {
+    "dev": "bun run src/index.ts",
+    "typecheck": "tsgo --noEmit"
+  },
+  "dependencies": {
+    "@planetscale/database": "1.19.0",
+    "drizzle-orm": "catalog:",
+    "hono": "catalog:",
+    "jose": "6.0.11",
+    "zod": "catalog:"
+  },
+  "devDependencies": {
+    "@tsconfig/bun": "catalog:",
+    "@types/bun": "catalog:",
+    "@typescript/native-preview": "catalog:",
+    "drizzle-kit": "catalog:",
+    "typescript": "catalog:"
+  }
+}

+ 148 - 0
packages/apn-relay/src/apns.ts

@@ -0,0 +1,148 @@
+import { connect } from "node:http2"
+import { SignJWT, importPKCS8 } from "jose"
+import { env } from "./env"
+
+export type PushEnv = "sandbox" | "production"
+
+type PushInput = {
+  token: string
+  bundle: string
+  env: PushEnv
+  title: string
+  body: string
+  data: Record<string, unknown>
+}
+
+type PushResult = {
+  ok: boolean
+  code: number
+  error?: string
+}
+
+let jwt = ""
+let exp = 0
+let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
+
+function host(input: PushEnv) {
+  if (input === "sandbox") return "api.sandbox.push.apple.com"
+  return "api.push.apple.com"
+}
+
+function key() {
+  if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
+  return env.APNS_PRIVATE_KEY
+}
+
+async function sign() {
+  if (!pk) pk = await importPKCS8(key(), "ES256")
+  const now = Math.floor(Date.now() / 1000)
+  if (jwt && now < exp) return jwt
+  jwt = await new SignJWT({})
+    .setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
+    .setIssuer(env.APNS_TEAM_ID)
+    .setIssuedAt(now)
+    .sign(pk)
+  exp = now + 50 * 60
+  return jwt
+}
+
+function post(input: {
+  host: string
+  token: string
+  auth: string
+  bundle: string
+  payload: string
+}): Promise<{ code: number; body: string }> {
+  return new Promise((resolve, reject) => {
+    const cli = connect(`https://${input.host}`)
+    let done = false
+    let code = 0
+    let body = ""
+
+    const stop = (fn: () => void) => {
+      if (done) return
+      done = true
+      fn()
+    }
+
+    cli.on("error", (err) => {
+      stop(() => reject(err))
+      cli.close()
+    })
+
+    const req = cli.request({
+      ":method": "POST",
+      ":path": `/3/device/${input.token}`,
+      authorization: `bearer ${input.auth}`,
+      "apns-topic": input.bundle,
+      "apns-push-type": "alert",
+      "apns-priority": "10",
+      "content-type": "application/json",
+    })
+
+    req.setEncoding("utf8")
+    req.on("response", (headers) => {
+      code = Number(headers[":status"] ?? 0)
+    })
+    req.on("data", (chunk) => {
+      body += chunk
+    })
+    req.on("end", () => {
+      stop(() => resolve({ code, body }))
+      cli.close()
+    })
+    req.on("error", (err) => {
+      stop(() => reject(err))
+      cli.close()
+    })
+    req.end(input.payload)
+  })
+}
+
+export async function send(input: PushInput): Promise<PushResult> {
+  const auth = await sign().catch((err) => {
+    return `error:${String(err)}`
+  })
+  if (auth.startsWith("error:")) {
+    return {
+      ok: false,
+      code: 0,
+      error: auth,
+    }
+  }
+
+  const payload = JSON.stringify({
+    aps: {
+      alert: {
+        title: input.title,
+        body: input.body,
+      },
+      sound: "default",
+    },
+    ...input.data,
+  })
+
+  const out = await post({
+    host: host(input.env),
+    token: input.token,
+    auth,
+    bundle: input.bundle,
+    payload,
+  }).catch((err) => ({
+    code: 0,
+    body: String(err),
+  }))
+
+  if (out.code === 200) {
+    return {
+      ok: true,
+      code: 200,
+    }
+  }
+
+  return {
+    ok: false,
+    code: out.code,
+    error: out.body,
+  }
+}

+ 11 - 0
packages/apn-relay/src/db.ts

@@ -0,0 +1,11 @@
+import { Client } from "@planetscale/database"
+import { drizzle } from "drizzle-orm/planetscale-serverless"
+import { env } from "./env"
+
+const client = new Client({
+  host: env.DATABASE_HOST,
+  username: env.DATABASE_USERNAME,
+  password: env.DATABASE_PASSWORD,
+})
+
+export const db = drizzle(client)

+ 14 - 0
packages/apn-relay/src/env.ts

@@ -0,0 +1,14 @@
+import { z } from "zod"
+
+const schema = z.object({
+  PORT: z.coerce.number().int().positive().default(8787),
+  DATABASE_HOST: z.string().min(1),
+  DATABASE_USERNAME: z.string().min(1),
+  DATABASE_PASSWORD: z.string().min(1),
+  APNS_TEAM_ID: z.string().min(1),
+  APNS_KEY_ID: z.string().min(1),
+  APNS_PRIVATE_KEY: z.string().min(1),
+  APNS_DEFAULT_BUNDLE_ID: z.string().min(1),
+})
+
+export const env = schema.parse(process.env)

+ 5 - 0
packages/apn-relay/src/hash.ts

@@ -0,0 +1,5 @@
+import { createHash } from "node:crypto"
+
+export function hash(input: string) {
+  return createHash("sha256").update(input).digest("hex")
+}

+ 275 - 0
packages/apn-relay/src/index.ts

@@ -0,0 +1,275 @@
+import { randomUUID } from "node:crypto"
+import { and, desc, eq, sql } from "drizzle-orm"
+import { Hono } from "hono"
+import { z } from "zod"
+import { send } from "./apns"
+import { db } from "./db"
+import { env } from "./env"
+import { hash } from "./hash"
+import { delivery_log, device_registration } from "./schema.sql"
+import { setup } from "./setup"
+
+const reg = z.object({
+  secret: z.string().min(1),
+  deviceToken: z.string().min(1),
+  bundleId: z.string().min(1).optional(),
+  apnsEnv: z.enum(["sandbox", "production"]).default("production"),
+})
+
+const unreg = z.object({
+  secret: z.string().min(1),
+  deviceToken: z.string().min(1),
+})
+
+const evt = z.object({
+  secret: z.string().min(1),
+  eventType: z.enum(["complete", "permission", "error"]),
+  sessionID: z.string().min(1),
+  title: z.string().min(1).optional(),
+  body: z.string().min(1).optional(),
+})
+
+function title(input: z.infer<typeof evt>["eventType"]) {
+  if (input === "complete") return "Session complete"
+  if (input === "permission") return "Action needed"
+  return "Session error"
+}
+
+function body(input: z.infer<typeof evt>["eventType"]) {
+  if (input === "complete") return "OpenCode finished your session."
+  if (input === "permission") return "OpenCode needs your permission decision."
+  return "OpenCode reported an error for your session."
+}
+
+const app = new Hono()
+
+app.onError((err, c) => {
+  return c.json(
+    {
+      ok: false,
+      error: err.message,
+    },
+    500,
+  )
+})
+
+app.notFound((c) => {
+  return c.json(
+    {
+      ok: false,
+      error: "Not found",
+    },
+    404,
+  )
+})
+
+app.get("/health", async (c) => {
+  const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
+  const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
+  return c.json({
+    ok: true,
+    devices: Number(a?.value ?? 0),
+    deliveries: Number(b?.value ?? 0),
+  })
+})
+
+app.get("/", async (c) => {
+  const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
+  const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
+  const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
+
+  const html = `<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>APN Relay</title>
+    <style>
+      body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
+      h1 { margin: 0 0 12px 0; }
+      .stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
+      .card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
+      .muted { color: #6b7280; font-size: 12px; }
+      table { border-collapse: collapse; width: 100%; }
+      th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
+      th { background: #f9fafb; }
+    </style>
+  </head>
+  <body>
+    <h1>APN Relay</h1>
+    <p class="muted">MVP dashboard</p>
+    <div class="stats">
+      <div class="card">
+        <div class="muted">Registered devices</div>
+        <div>${Number(a?.value ?? 0)}</div>
+      </div>
+      <div class="card">
+        <div class="muted">Delivery log rows</div>
+        <div>${Number(b?.value ?? 0)}</div>
+      </div>
+    </div>
+    <table>
+      <thead>
+        <tr>
+          <th>time</th>
+          <th>event</th>
+          <th>session</th>
+          <th>status</th>
+          <th>error</th>
+        </tr>
+      </thead>
+      <tbody>
+        ${rows
+          .map(
+            (row) => `<tr>
+          <td>${new Date(row.created_at).toISOString()}</td>
+          <td>${row.event_type}</td>
+          <td>${row.session_id}</td>
+          <td>${row.status}</td>
+          <td>${row.error ?? ""}</td>
+        </tr>`,
+          )
+          .join("")}
+      </tbody>
+    </table>
+  </body>
+</html>`
+
+  return c.html(html)
+})
+
+app.post("/v1/device/register", async (c) => {
+  const raw = await c.req.json().catch(() => undefined)
+  const check = reg.safeParse(raw)
+  if (!check.success) {
+    return c.json(
+      {
+        ok: false,
+        error: "Invalid request body",
+      },
+      400,
+    )
+  }
+
+  const now = Date.now()
+  const key = hash(check.data.secret)
+  const row = {
+    id: randomUUID(),
+    secret_hash: key,
+    device_token: check.data.deviceToken,
+    bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
+    apns_env: check.data.apnsEnv,
+    created_at: now,
+    updated_at: now,
+  }
+
+  await db
+    .insert(device_registration)
+    .values(row)
+    .onDuplicateKeyUpdate({
+      set: {
+        bundle_id: row.bundle_id,
+        apns_env: row.apns_env,
+        updated_at: now,
+      },
+    })
+
+  return c.json({ ok: true })
+})
+
+app.post("/v1/device/unregister", async (c) => {
+  const raw = await c.req.json().catch(() => undefined)
+  const check = unreg.safeParse(raw)
+  if (!check.success) {
+    return c.json(
+      {
+        ok: false,
+        error: "Invalid request body",
+      },
+      400,
+    )
+  }
+
+  await db
+    .delete(device_registration)
+    .where(
+      and(
+        eq(device_registration.secret_hash, hash(check.data.secret)),
+        eq(device_registration.device_token, check.data.deviceToken),
+      ),
+    )
+
+  return c.json({ ok: true })
+})
+
+app.post("/v1/event", async (c) => {
+  const raw = await c.req.json().catch(() => undefined)
+  const check = evt.safeParse(raw)
+  if (!check.success) {
+    return c.json(
+      {
+        ok: false,
+        error: "Invalid request body",
+      },
+      400,
+    )
+  }
+
+  const key = hash(check.data.secret)
+  const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
+  if (!list.length) {
+    return c.json({
+      ok: true,
+      sent: 0,
+      failed: 0,
+    })
+  }
+
+  const out = await Promise.all(
+    list.map((row) =>
+      send({
+        token: row.device_token,
+        bundle: row.bundle_id,
+        env: row.apns_env === "sandbox" ? "sandbox" : "production",
+        title: check.data.title ?? title(check.data.eventType),
+        body: check.data.body ?? body(check.data.eventType),
+        data: {
+          eventType: check.data.eventType,
+          sessionID: check.data.sessionID,
+        },
+      }),
+    ),
+  )
+
+  const now = Date.now()
+  await db.insert(delivery_log).values(
+    out.map((item) => ({
+      id: randomUUID(),
+      secret_hash: key,
+      event_type: check.data.eventType,
+      session_id: check.data.sessionID,
+      status: item.ok ? "sent" : "failed",
+      error: item.error,
+      created_at: now,
+    })),
+  )
+
+  const sent = out.filter((item) => item.ok).length
+  return c.json({
+    ok: true,
+    sent,
+    failed: out.length - sent,
+  })
+})
+
+await setup()
+
+if (import.meta.main) {
+  Bun.serve({
+    port: env.PORT,
+    fetch: app.fetch,
+  })
+  console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
+}
+
+export default app

+ 35 - 0
packages/apn-relay/src/schema.sql.ts

@@ -0,0 +1,35 @@
+import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
+
+export const device_registration = mysqlTable(
+  "device_registration",
+  {
+    id: varchar("id", { length: 36 }).primaryKey(),
+    secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
+    device_token: varchar("device_token", { length: 255 }).notNull(),
+    bundle_id: varchar("bundle_id", { length: 255 }).notNull(),
+    apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"),
+    created_at: bigint("created_at", { mode: "number" }).notNull(),
+    updated_at: bigint("updated_at", { mode: "number" }).notNull(),
+  },
+  (table) => [
+    uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token),
+    index("device_registration_secret_hash_idx").on(table.secret_hash),
+  ],
+)
+
+export const delivery_log = mysqlTable(
+  "delivery_log",
+  {
+    id: varchar("id", { length: 36 }).primaryKey(),
+    secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
+    event_type: varchar("event_type", { length: 32 }).notNull(),
+    session_id: varchar("session_id", { length: 255 }).notNull(),
+    status: varchar("status", { length: 16 }).notNull(),
+    error: varchar("error", { length: 1024 }),
+    created_at: bigint("created_at", { mode: "number" }).notNull(),
+  },
+  (table) => [
+    index("delivery_log_secret_hash_idx").on(table.secret_hash),
+    index("delivery_log_created_at_idx").on(table.created_at),
+  ],
+)

+ 34 - 0
packages/apn-relay/src/setup.ts

@@ -0,0 +1,34 @@
+import { sql } from "drizzle-orm"
+import { db } from "./db"
+
+export async function setup() {
+  await db.execute(sql`
+    CREATE TABLE IF NOT EXISTS device_registration (
+      id varchar(36) NOT NULL,
+      secret_hash varchar(64) NOT NULL,
+      device_token varchar(255) NOT NULL,
+      bundle_id varchar(255) NOT NULL,
+      apns_env varchar(16) NOT NULL DEFAULT 'production',
+      created_at bigint NOT NULL,
+      updated_at bigint NOT NULL,
+      PRIMARY KEY (id),
+      UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token),
+      KEY device_registration_secret_hash_idx (secret_hash)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+  `)
+
+  await db.execute(sql`
+    CREATE TABLE IF NOT EXISTS delivery_log (
+      id varchar(36) NOT NULL,
+      secret_hash varchar(64) NOT NULL,
+      event_type varchar(32) NOT NULL,
+      session_id varchar(255) NOT NULL,
+      status varchar(16) NOT NULL,
+      error varchar(1024) NULL,
+      created_at bigint NOT NULL,
+      PRIMARY KEY (id),
+      KEY delivery_log_secret_hash_idx (secret_hash),
+      KEY delivery_log_created_at_idx (created_at)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+  `)
+}

+ 8 - 0
packages/apn-relay/tsconfig.json

@@ -0,0 +1,8 @@
+{
+  "$schema": "https://json.schemastore.org/tsconfig",
+  "extends": "@tsconfig/bun/tsconfig.json",
+  "compilerOptions": {
+    "lib": ["ESNext", "DOM", "DOM.Iterable"],
+    "noUncheckedIndexedAccess": false
+  }
+}

+ 33 - 1
packages/opencode/src/cli/cmd/serve.ts

@@ -5,10 +5,20 @@ import { Flag } from "../../flag/flag"
 import { Workspace } from "../../control-plane/workspace"
 import { Project } from "../../project/project"
 import { Installation } from "../../installation"
+import { PushRelay } from "../../server/push-relay"
 
 export const ServeCommand = cmd({
   command: "serve",
-  builder: (yargs) => withNetworkOptions(yargs),
+  builder: (yargs) =>
+    withNetworkOptions(yargs)
+      .option("relay-url", {
+        type: "string",
+        describe: "experimental APN relay URL",
+      })
+      .option("relay-secret", {
+        type: "string",
+        describe: "experimental APN relay secret",
+      }),
   describe: "starts a headless opencode server",
   handler: async (args) => {
     if (!Flag.OPENCODE_SERVER_PASSWORD) {
@@ -18,6 +28,28 @@ export const ServeCommand = cmd({
     const server = Server.listen(opts)
     console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
 
+    const relayURL = (
+      args["relay-url"] ??
+      process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
+      "https://relay.opencode.ai"
+    ).trim()
+    const relaySecret = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
+    if (relayURL && relaySecret) {
+      const host = server.hostname ?? opts.hostname
+      const port = server.port || opts.port || 4096
+      const pair = PushRelay.start({
+        relayURL,
+        relaySecret,
+        hostname: host,
+        port,
+      })
+      if (pair) {
+        console.log("experimental push relay enabled")
+        console.log("qr payload")
+        console.log(JSON.stringify(pair, null, 2))
+      }
+    }
+
     await new Promise(() => {})
     await server.stop()
   },

+ 252 - 0
packages/opencode/src/server/push-relay.ts

@@ -0,0 +1,252 @@
+import os from "node:os"
+import { Bus } from "@/bus"
+import { Log } from "@/util/log"
+
+type Type = "complete" | "permission" | "error"
+
+type Pair = {
+  v: 1
+  relayURL: string
+  relaySecret: string
+  hosts: string[]
+}
+
+type Input = {
+  relayURL: string
+  relaySecret: string
+  hostname: string
+  port: number
+}
+
+type State = {
+  relayURL: string
+  relaySecret: string
+  pair: Pair
+  stop: () => void
+  seen: Map<string, number>
+  gc: number
+}
+
+type Event = {
+  type: string
+  properties: unknown
+}
+
+const log = Log.create({ service: "push-relay" })
+
+let state: State | undefined
+
+function obj(input: unknown): input is Record<string, unknown> {
+  return typeof input === "object" && input !== null
+}
+
+function str(input: unknown) {
+  return typeof input === "string" && input.length > 0 ? input : undefined
+}
+
+function norm(input: string) {
+  return input.replace(/\/+$/, "")
+}
+
+function list(hostname: string, port: number) {
+  const urls = new Set<string>()
+  const add = (host: string) => {
+    if (!host) return
+    if (host === "0.0.0.0") return
+    if (host === "::") return
+    urls.add(`http://${host}:${port}`)
+  }
+
+  add(hostname)
+  add("127.0.0.1")
+
+  const nets = Object.values(os.networkInterfaces())
+    .flatMap((item) => item ?? [])
+    .filter((item) => item.family === "IPv4" && !item.internal)
+    .map((item) => item.address)
+
+  nets.forEach(add)
+
+  return [...urls]
+}
+
+function map(event: Event): { type: Type; sessionID: string } | undefined {
+  if (!obj(event.properties)) return
+
+  if (event.type === "permission.asked") {
+    const sessionID = str(event.properties.sessionID)
+    if (!sessionID) return
+    return { type: "permission", sessionID }
+  }
+
+  if (event.type === "session.error") {
+    const sessionID = str(event.properties.sessionID)
+    if (!sessionID) return
+    return { type: "error", sessionID }
+  }
+
+  if (event.type === "session.idle") {
+    const sessionID = str(event.properties.sessionID)
+    if (!sessionID) return
+    return { type: "complete", sessionID }
+  }
+
+  if (event.type !== "session.status") return
+  const sessionID = str(event.properties.sessionID)
+  if (!sessionID) return
+  if (!obj(event.properties.status)) return
+  if (event.properties.status.type !== "idle") return
+  return { type: "complete", sessionID }
+}
+
+function dedupe(input: { type: Type; sessionID: string }) {
+  if (input.type !== "complete") return false
+  const next = state
+  if (!next) return false
+  const now = Date.now()
+
+  if (next.seen.size > 2048 || now - next.gc > 60_000) {
+    next.gc = now
+    for (const [key, time] of next.seen) {
+      if (now - time > 60_000) {
+        next.seen.delete(key)
+      }
+    }
+    const drop = next.seen.size - 2048
+    if (drop > 0) {
+      let i = 0
+      for (const key of next.seen.keys()) {
+        next.seen.delete(key)
+        i += 1
+        if (i >= drop) break
+      }
+    }
+  }
+
+  const key = `${input.type}:${input.sessionID}`
+  const prev = next.seen.get(key)
+  next.seen.set(key, now)
+  if (!prev) return false
+  return now - prev < 5_000
+}
+
+function post(input: { type: Type; sessionID: string }) {
+  const next = state
+  if (!next) return false
+  if (dedupe(input)) return true
+
+  void fetch(`${next.relayURL}/v1/event`, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+    },
+    body: JSON.stringify({
+      secret: next.relaySecret,
+      eventType: input.type,
+      sessionID: input.sessionID,
+    }),
+  })
+    .then(async (res) => {
+      if (res.ok) return
+      const error = await res.text().catch(() => "")
+      log.warn("relay post failed", {
+        status: res.status,
+        type: input.type,
+        sessionID: input.sessionID,
+        error,
+      })
+    })
+    .catch((error) => {
+      log.warn("relay post failed", {
+        type: input.type,
+        sessionID: input.sessionID,
+        error: String(error),
+      })
+    })
+
+  return true
+}
+
+export namespace PushRelay {
+  export function start(input: Input) {
+    const relayURL = norm(input.relayURL.trim())
+    const relaySecret = input.relaySecret.trim()
+    if (!relayURL) return
+    if (!relaySecret) return
+
+    stop()
+
+    const pair: Pair = {
+      v: 1,
+      relayURL,
+      relaySecret,
+      hosts: list(input.hostname, input.port),
+    }
+
+    let unsub: (() => void) | undefined
+    try {
+      unsub = Bus.subscribeAll((event) => {
+        const next = map(event)
+        if (!next) return
+        post(next)
+      })
+    } catch (error) {
+      log.warn("failed to subscribe", {
+        error: String(error),
+      })
+      return
+    }
+    if (!unsub) return
+
+    state = {
+      relayURL,
+      relaySecret,
+      pair,
+      stop: unsub,
+      seen: new Map(),
+      gc: 0,
+    }
+
+    log.info("enabled", {
+      relayURL,
+      hosts: pair.hosts,
+    })
+
+    return pair
+  }
+
+  export function stop() {
+    const next = state
+    if (!next) return
+    state = undefined
+    next.stop()
+  }
+
+  export function status() {
+    const next = state
+    if (!next) {
+      return {
+        enabled: false,
+        relaySecretSet: false,
+      } as const
+    }
+    return {
+      enabled: true,
+      relaySecretSet: next.relaySecret.length > 0,
+    } as const
+  }
+
+  export function pair() {
+    return state?.pair
+  }
+
+  export function test(input: { type: Type; sessionID: string }) {
+    return post(input)
+  }
+
+  export function auth(input: string) {
+    const next = state
+    if (!next) return false
+    return next.relaySecret === input
+  }
+}

+ 94 - 0
packages/opencode/src/server/routes/experimental.ts

@@ -12,6 +12,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
 import { errors } from "../error"
 import { lazy } from "../../util/lazy"
 import { WorkspaceRoutes } from "./workspace"
+import { PushRelay } from "../push-relay"
 
 export const ExperimentalRoutes = lazy(() =>
   new Hono()
@@ -267,5 +268,98 @@ export const ExperimentalRoutes = lazy(() =>
       async (c) => {
         return c.json(await MCP.resources())
       },
+    )
+    .get(
+      "/push",
+      describeRoute({
+        summary: "Get push relay status",
+        description: "Get experimental push relay runtime status for this server.",
+        operationId: "experimental.push.status",
+        responses: {
+          200: {
+            description: "Push relay status",
+            content: {
+              "application/json": {
+                schema: resolver(
+                  z.object({
+                    enabled: z.boolean(),
+                    relaySecretSet: z.boolean(),
+                  }),
+                ),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        return c.json(PushRelay.status())
+      },
+    )
+    .post(
+      "/push/test",
+      describeRoute({
+        summary: "Send test push event",
+        description: "Send a test push event through the experimental APN relay integration.",
+        operationId: "experimental.push.test",
+        responses: {
+          200: {
+            description: "Test event accepted",
+            content: {
+              "application/json": {
+                schema: resolver(
+                  z.object({
+                    ok: z.boolean(),
+                    enabled: z.boolean(),
+                  }),
+                ),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator(
+        "json",
+        z.object({
+          secret: z.string(),
+          sessionID: z.string().optional(),
+          eventType: z.enum(["complete", "permission", "error"]).optional(),
+        }),
+      ),
+      async (c) => {
+        const body = c.req.valid("json")
+        const status = PushRelay.status()
+        if (!status.enabled) {
+          return c.json(
+            {
+              data: { enabled: false },
+              errors: [{ message: "Push relay is not enabled" }],
+              success: false,
+            },
+            400,
+          )
+        }
+
+        if (!PushRelay.auth(body.secret)) {
+          return c.json(
+            {
+              data: { enabled: true },
+              errors: [{ message: "Invalid push relay secret" }],
+              success: false,
+            },
+            400,
+          )
+        }
+
+        const ok = PushRelay.test({
+          type: body.eventType ?? "permission",
+          sessionID: body.sessionID ?? `test-${Date.now()}`,
+        })
+
+        return c.json({
+          ok,
+          enabled: true,
+        })
+      },
     ),
 )

+ 173 - 0
specs/apn-relay-mvp-layout.md

@@ -0,0 +1,173 @@
+# APN Relay MVP Layout
+
+This is the minimum setup to get reliable mobile background notifications working with OpenCode.
+
+## Part 1: APN Relay spec and routes
+
+### Goal
+
+- Receive event posts from OpenCode.
+- Look up device tokens by shared secret.
+- Send APNs notifications to iOS devices.
+- Keep the service small and easy to run in Docker.
+
+### Stack
+
+- Runtime: Bun
+- Web framework: Hono
+- Database: PlanetScale MySQL (via Drizzle ORM)
+- Deployment artifact: Docker image (`packages/apn-relay`)
+
+### Minimal data model
+
+- `device_registration`
+  - `id`
+  - `secret_hash` (hash of shared secret)
+  - `device_token` (APNs token)
+  - `bundle_id`
+  - `apns_env` (`sandbox` or `production`)
+  - `created_at`
+  - `updated_at`
+- `delivery_log` (optional but recommended)
+  - `id`
+  - `secret_hash`
+  - `event_type`
+  - `session_id`
+  - `status` (`sent` or `failed`)
+  - `error`
+  - `created_at`
+
+### API routes
+
+#### `GET /health`
+
+- Response: `{ ok: true }`
+
+#### `POST /v1/device/register`
+
+- Purpose: upsert device token for a shared secret.
+- Body:
+  - `secret` (string)
+  - `deviceToken` (string)
+  - `bundleId` (string)
+  - `apnsEnv` (`sandbox` or `production`)
+- Response: `{ ok: true }`
+
+#### `POST /v1/device/unregister`
+
+- Purpose: remove token mapping for a shared secret.
+- Body:
+  - `secret` (string)
+  - `deviceToken` (string)
+- Response: `{ ok: true }`
+
+#### `POST /v1/event`
+
+- Purpose: receive event from OpenCode and push to all devices for that secret.
+- Body:
+  - `secret` (string)
+  - `eventType` (`complete` or `permission` or `error`)
+  - `sessionID` (string)
+  - `title` (optional string)
+  - `body` (optional string)
+- Response:
+  - `{ ok: true, sent: number, failed: number }`
+
+### APNs behavior for MVP
+
+- Use APNs auth key (`.p8`) with JWT auth.
+- Default to user-visible alert pushes for reliability.
+  - `apns-push-type: alert`
+  - `apns-priority: 10`
+- Payload includes `eventType` and `sessionID` in `data`.
+- Keep advanced silent/background tuning out of scope for MVP.
+
+### Env vars
+
+- `PORT`
+- `DATABASE_URL`
+- `APNS_TEAM_ID`
+- `APNS_KEY_ID`
+- `APNS_PRIVATE_KEY`
+- `APNS_DEFAULT_BUNDLE_ID` (fallback)
+
+## Part 2: Mobile app setup (`packages/mobile-voice`)
+
+### Goal
+
+- Pair app with OpenCode server using QR data.
+- Register APNs token in relay using shared secret.
+- Keep existing foreground SSE behavior.
+- Receive APNs when app is backgrounded or terminated.
+
+### Pairing flow (simple)
+
+1. User runs OpenCode serve with relay enabled.
+2. OpenCode prints a QR code that includes:
+   - `hosts` (array of server URLs)
+   - `relayURL`
+   - `relaySecret`
+3. User scans QR in mobile app.
+4. App saves `relaySecret` in secure storage and server profile metadata.
+
+### Token registration flow
+
+1. App gets APNs token (`Notifications.getDevicePushTokenAsync()`).
+2. App calls `POST {relayURL}/v1/device/register` with secret and token.
+3. App re-registers on token change and on app startup.
+
+### Prompt and monitoring flow
+
+1. App sends prompt to OpenCode (`POST /session/:id/prompt_async`).
+2. If app stays foregrounded, existing SSE monitor still updates UI quickly.
+3. If app goes backgrounded, APNs notification from relay carries state updates.
+
+### Mobile changes
+
+- Replace Expo push relay integration with APNs relay integration.
+- Keep local notification behavior for handling incoming payload data.
+- Store `relaySecret` with secure storage, not plain AsyncStorage.
+- Remove session-specific monitor start/stop calls for MVP.
+
+## Part 3: OpenCode serve setup and modifications (`packages/opencode`)
+
+### Goal
+
+- Watch all sessions for the current OpenCode server.
+- Detect target events in OpenCode server.
+- Forward those events to APN relay using shared secret.
+
+### Serve config and terminal UX
+
+- Add serve options:
+  - `--relay-url`
+  - `--relay-secret` (optional; generate random if missing)
+- Default relay URL: `https://relay.opencode.ai`
+- If relay is configured, print QR payload in terminal:
+  - `hosts` (local LAN and configured host, including Tailscale IP when present)
+  - `relayURL`
+  - `relaySecret`
+
+### New experimental routes
+
+- No required monitor routes for MVP.
+- Optional debug route:
+  - `POST /experimental/push/test`
+  - Purpose: force-send a test event to relay to validate config.
+
+### Event forwarding behavior
+
+- Subscribe to existing OpenCode events.
+- For all sessions under the running OpenCode server:
+  - On `permission.asked` -> send `eventType=permission`
+  - On `session.error` -> send `eventType=error`
+  - On `session.status` idle (or `session.idle`) -> send `eventType=complete`
+- Include `sessionID` in every relay request so the mobile app can label the event.
+- Best effort posting only for MVP (log failures, no complex retry queue yet).
+
+### Out of scope for this MVP
+
+- Certificate-based trust between OpenCode and relay.
+- Complex key rotation UX.
+- Multi-tenant dashboard auth model.
+- Guaranteed delivery semantics.