Apply PR #19545: feat: opencode remote control + opencode serve dependencies

This commit is contained in:
opencode-agent[bot] 2026-04-23 01:51:18 +00:00
commit ada2e5f549
119 changed files with 15098 additions and 447 deletions

View file

@ -66,6 +66,7 @@
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@types/qrcode": "1.5.5",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
@ -161,6 +162,7 @@
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"qrcode": "1.5.4",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",

View file

@ -1,20 +1,306 @@
import { spawnSync } from "node:child_process"
import { createHash, randomBytes } from "node:crypto"
import { writeFileSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "../../flag/flag"
import { Workspace } from "../../control-plane/workspace"
import { Project } from "../../project"
import { Installation } from "../../installation"
import { PushRelay } from "../../server/push-relay"
import { Log } from "../../util"
import { Global } from "../../global"
// dynamic import: static `import * as` of CJS package triggers Bun bundler splitting bug
import type * as QRCodeType from "qrcode"
const log = Log.create({ service: "serve" })
async function getOrCreatePersistedRelaySecret(): Promise<string> {
const filePath = path.join(Global.Path.state, "relay-secret")
try {
const existing = (await Bun.file(filePath).text()).trim()
if (existing.length > 0) return existing
} catch {
// file doesn't exist yet
}
const secret = randomBytes(18).toString("base64url")
writeFileSync(filePath, secret, { mode: 0o600 })
return secret
}
type PairPayload = {
serverID?: string
relayURL: string
relaySecret: string
hosts: string[]
}
type PairQRCodePayload = {
relaySecret: string
hosts: string[]
}
type TailscaleStatus = {
Self?: {
DNSName?: unknown
TailscaleIPs?: unknown
}
}
function ipTier(address: string): number {
const parts = address.split(".")
if (parts.length !== 4) return 4
const a = Number(parts[0])
const b = Number(parts[1])
if (a === 127) return 4
if (a === 169 && b === 254) return 3
if (a === 10) return 2
if (a === 172 && b >= 16 && b <= 31) return 2
if (a === 192 && b === 168) return 2
if (a === 100 && b >= 64 && b <= 127) return 1
return 0
}
function norm(input: string) {
return input.replace(/\/+$/, "")
}
function advertiseURL(input: string, port: number): string | undefined {
const raw = input.trim()
if (!raw) return
try {
const hasScheme = raw.includes("://")
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
if (!parsed.hostname) return
if (!parsed.port && !hasScheme) {
parsed.port = String(port)
}
return norm(`${parsed.protocol}//${parsed.host}`)
} catch {
return
}
}
function hosts(hostname: string, port: number, advertised: string[] = [], includeLocal = true) {
const seen = new Set<string>()
const preferred: string[] = []
const entries: Array<{ url: string; tier: number }> = []
const addPreferred = (value: string) => {
const url = advertiseURL(value, port)
if (!url) return
if (seen.has(url)) return
seen.add(url)
preferred.push(url)
}
const add = (item: string) => {
if (!item) return
if (item === "0.0.0.0") return
if (item === "::") return
const url = `http://${item}:${port}`
if (seen.has(url)) return
seen.add(url)
entries.push({ url, tier: ipTier(item) })
}
advertised.forEach(addPreferred)
if (includeLocal) {
add(hostname)
Object.values(os.networkInterfaces())
.flatMap((item) => item ?? [])
.filter((item) => item.family === "IPv4" && !item.internal)
.map((item) => item.address)
.forEach(add)
}
entries.sort((a, b) => a.tier - b.tier)
return [...preferred, ...entries.map((item) => item.url)]
}
function pairLink(pair: PairQRCodePayload) {
const payload: PairQRCodePayload = {
relaySecret: pair.relaySecret,
hosts: pair.hosts,
}
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(payload))}`
}
function secretHash(input: string) {
if (!input) return "none"
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
}
export function autoTailscaleAdvertiseHost(hostname: string, status: unknown): string | undefined {
const self = (status as TailscaleStatus | undefined)?.Self
if (!self) return
const dnsName = typeof self.DNSName === "string" ? self.DNSName.replace(/\.+$/, "") : ""
if (!dnsName || !dnsName.toLowerCase().endsWith(".ts.net")) return
if (hostname === "0.0.0.0" || hostname === "::" || hostname === dnsName) {
return dnsName
}
const tailscaleIPs = Array.isArray(self.TailscaleIPs)
? self.TailscaleIPs.filter((item): item is string => typeof item === "string" && item.length > 0)
: []
if (tailscaleIPs.includes(hostname)) {
return dnsName
}
}
function readTailscaleAdvertiseHost(hostname: string) {
try {
const result = spawnSync("tailscale", ["status", "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
})
if (result.status !== 0 || result.error || !result.stdout.trim()) return
return autoTailscaleAdvertiseHost(hostname, JSON.parse(result.stdout))
} catch {
return
}
}
async function printPairQR(pair: PairPayload) {
const link = pairLink(pair)
const qrConfig = {
type: "terminal" as const,
small: true,
errorCorrectionLevel: "M" as const,
}
log.info("pair qr", {
relayURL: pair.relayURL,
relaySecretHash: secretHash(pair.relaySecret),
serverID: pair.serverID,
hosts: pair.hosts,
hostCount: pair.hosts.length,
hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")),
linkLength: link.length,
qr: qrConfig,
})
const QRCode: typeof QRCodeType = await import("qrcode")
const code = await QRCode.toString(link, {
...qrConfig,
})
console.log("scan qr code in mobile app or phone camera (latest 1.0.2.1)")
console.log(code)
}
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",
})
.option("advertise-host", {
type: "string",
array: true,
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
})
.option("connect-qr", {
type: "boolean",
default: false,
describe: "print mobile connect QR and exit without starting the server",
}),
describe: "starts a headless opencode server",
handler: async (args) => {
const opts = await resolveNetworkOptions(args)
const relayURL = (
args["relay-url"] ??
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
"https://apn.dev.opencode.ai"
).trim()
const advertiseHostArg = args["advertise-host"]
const advertiseHostsFromArg = Array.isArray(advertiseHostArg)
? advertiseHostArg
: typeof advertiseHostArg === "string"
? [advertiseHostArg]
: []
const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
const tailscaleAdvertiseHost = readTailscaleAdvertiseHost(opts.hostname)
const advertiseHosts = [
...new Set([
...advertiseHostsFromArg,
...advertiseHostsFromEnv,
...(tailscaleAdvertiseHost ? [tailscaleAdvertiseHost] : []),
]),
]
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
const relaySecret = input || (await getOrCreatePersistedRelaySecret())
const connectQR = Boolean(args["connect-qr"])
if (connectQR) {
const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false)
if (!pairHosts.length) {
console.log("connect qr mode requires at least one valid advertised host")
return
}
if (!input) {
log.info("using persisted relay secret", { hash: secretHash(relaySecret) })
}
console.log("printing connect qr without starting the server")
await printPairQR({
relayURL,
relaySecret,
hosts: pairHosts,
})
return
}
if (!Flag.OPENCODE_SERVER_PASSWORD) {
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
}
const opts = await resolveNetworkOptions(args)
const server = await Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
if (!input) {
log.info("using persisted relay secret", { hash: secretHash(relaySecret) })
}
if (relayURL && relaySecret) {
const host = server.hostname ?? opts.hostname
const port = server.port || opts.port || 4096
const started = PushRelay.start({
relayURL,
relaySecret,
hostname: host,
port,
advertiseHosts,
})
const pair = started ??
PushRelay.pair() ?? {
relayURL,
relaySecret,
hosts: hosts(host, port, advertiseHosts),
}
if (!started) {
console.log("experimental push relay failed to initialize; showing setup qr anyway")
}
if (pair) {
console.log("experimental push relay enabled")
await printPairQR(pair)
}
}
await new Promise(() => {})
await server.stop()
},

View file

@ -34,6 +34,7 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogPair } from "@tui/component/dialog-pair"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
@ -624,6 +625,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
category: "System",
},
{
title: "Pair mobile device",
value: "pair.show",
slash: {
name: "pair",
},
onSelect: () => {
dialog.replace(() => <DialogPair />)
},
category: "System",
},
{
title: "Help",
value: "help.show",

View file

@ -0,0 +1,126 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { createResource, onMount, Show } from "solid-js"
import * as QRCode from "qrcode"
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
const BLOCK = {
WW: " ",
WB: "▄",
BB: "█",
BW: "▀",
}
function renderQR(link: string): string {
const qr = QRCode.create(link, { errorCorrectionLevel: "L" })
const size = qr.modules.size
const data = qr.modules.data
const margin = 2
const get = (r: number, c: number) => {
if (r < 0 || r >= size || c < 0 || c >= size) return false
return Boolean(data[r * size + c])
}
const totalW = size + margin * 2
const blank = BLOCK.WW.repeat(totalW)
const lines: string[] = []
// top margin
for (let i = 0; i < margin / 2; i++) lines.push(blank)
// QR rows, 2 at a time using half-block chars
for (let r = -margin; r < size + margin; r += 2) {
let row = ""
for (let c = -margin; c < size + margin; c++) {
const top = get(r, c)
const bottom = get(r + 1, c)
if (top && bottom) row += BLOCK.BB
else if (top) row += BLOCK.BW
else if (bottom) row += BLOCK.WB
else row += BLOCK.WW
}
lines.push(row)
}
return lines.join("\n")
}
export function DialogPair() {
const dialog = useDialog()
const { theme } = useTheme()
const sdk = useSDK()
onMount(() => {
dialog.setSize("large")
})
const [data] = createResource(async () => {
const res = await sdk.fetch(`${sdk.url}/experimental/push/pair`)
if (!res.ok) return { enabled: false as const }
const json = (await res.json()) as PairResult
if (!json.enabled) return json
const qrText = renderQR(json.link)
return { ...json, qrText }
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Pair Mobile Device
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={data.loading}>
<text fg={theme.textMuted}>Loading pairing info...</text>
</Show>
<Show when={data.error}>
<box gap={1}>
<text fg={theme.error}>Could not load pairing info.</text>
<text fg={theme.textMuted} wrapMode="word">
Check that the server is reachable and try again.
</text>
</box>
</Show>
<Show when={!data.loading && !data.error && data()}>
{(result) => (
<Show
when={result().enabled && result()}
fallback={
<box gap={1}>
<text fg={theme.warning}>Push relay is not enabled.</text>
<text fg={theme.textMuted} wrapMode="word">
Start the server with push relay options to enable mobile pairing:
</text>
<text fg={theme.text} wrapMode="word">
opencode serve --relay-url &lt;url&gt; --relay-secret &lt;secret&gt;
</text>
</box>
}
>
{(pair) => (
<box gap={1} alignItems="center">
<text fg={theme.text}>{(pair() as any).qrText}</text>
<box gap={0} alignItems="center">
<text fg={theme.textMuted} wrapMode="word">
Scan with the OpenCode Control app
</text>
<text fg={theme.textMuted} wrapMode="word">
to pair your device for push notifications.
</text>
</box>
</box>
)}
</Show>
)}
</Show>
</box>
)
}

View file

@ -39,9 +39,9 @@ export function Dialog(
width={dimensions().width}
height={dimensions().height}
alignItems="center"
justifyContent="center"
position="absolute"
zIndex={3000}
paddingTop={dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}

View file

@ -1,5 +1,6 @@
export { Config } from "./config"
export { Server } from "./server/server"
export { PushRelay } from "./server/push-relay"
export { bootstrap } from "./cli/bootstrap"
export { Log } from "./util"
export { Database } from "./storage"

View file

@ -0,0 +1,629 @@
import os from "node:os"
import { createHash } from "node:crypto"
import { SessionID } from "@/session/schema"
import { GlobalBus } from "@/bus/global"
import { Log } from "@/util"
type Type = "complete" | "permission" | "error"
type Pair = {
v: 1
serverID?: string
relayURL: string
relaySecret: string
hosts: string[]
}
type Input = {
relayURL: string
relaySecret: string
hostname: string
port: number
advertiseHosts?: string[]
permissionDelayMs?: number
}
type State = {
relayURL: string
relaySecret: string
pair: Pair
stop: () => void
seen: Map<string, number>
parent: Map<string, string | undefined>
gc: number
permissionTimers: Map<string, ReturnType<typeof setTimeout>>
permissionDelayMs: number
}
type Event = {
type: string
properties: unknown
}
type Notify = {
type: Type
sessionID: string
title?: string
body?: string
}
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 shouldNotifyError(input: unknown) {
if (!obj(input)) return true
const name = str(input.name)
if (!name) return true
if (name === "ContextOverflowError") return false
if (name === "MessageAbortedError") return false
return true
}
function norm(input: string) {
return input.replace(/\/+$/, "")
}
function secretHash(input: string) {
if (!input) return "none"
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
}
function serverID(input: { relayURL: string; relaySecret: string }) {
return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16)
}
function recordSession(event: Event) {
if (!obj(event.properties)) return
const next = state
if (!next) return
if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") {
return
}
const info = obj(event.properties.info) ? event.properties.info : undefined
const id = str(info?.id)
if (!id) return
if (event.type === "session.deleted") {
next.parent.delete(id)
return
}
next.parent.set(id, str(info?.parentID))
}
function routeSession(sessionID: string) {
const next = state
if (!next) {
return {
sessionID,
subagent: false,
}
}
const visited = new Set<string>()
let current = sessionID
let target = sessionID
let subagent = false
while (true) {
if (visited.has(current)) break
visited.add(current)
if (!next.parent.has(current)) break
const parentID = next.parent.get(current)
if (!parentID) break
subagent = true
target = parentID
current = parentID
}
return {
sessionID: target,
subagent,
}
}
/**
* Classify an IPv4 address into a reachability tier.
* Lower number = more likely reachable from an external/overlay network device.
*
* 0 public / routable
* 1 CGNAT / shared (100.64.0.0/10) used by Tailscale, Cloudflare WARP, carrier NAT, etc.
* 2 private LAN (10.0.0.0/8, 172.16-31.x, 192.168.x)
* 3 link-local (169.254.x)
* 4 loopback (127.x)
*/
function ipTier(address: string): number {
const parts = address.split(".")
if (parts.length !== 4) return 4
const a = Number(parts[0])
const b = Number(parts[1])
// loopback 127.0.0.0/8
if (a === 127) return 4
// link-local 169.254.0.0/16
if (a === 169 && b === 254) return 3
// private 10.0.0.0/8
if (a === 10) return 2
// private 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return 2
// private 192.168.0.0/16
if (a === 192 && b === 168) return 2
// CGNAT / shared address space 100.64.0.0/10 (100.64.x 100.127.x)
if (a === 100 && b >= 64 && b <= 127) return 1
// everything else is routable
return 0
}
function advertiseURL(input: string, port: number): string | undefined {
const raw = input.trim()
if (!raw) return
try {
const hasScheme = raw.includes("://")
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
if (!parsed.hostname) return
if (!parsed.port && !hasScheme) {
parsed.port = String(port)
}
return norm(`${parsed.protocol}//${parsed.host}`)
} catch {
return
}
}
function list(hostname: string, port: number, advertised: string[] = []) {
const seen = new Set<string>()
const preferred: string[] = []
const hosts: Array<{ url: string; tier: number }> = []
const addPreferred = (input: string) => {
const url = advertiseURL(input, port)
if (!url) return
if (seen.has(url)) return
seen.add(url)
preferred.push(url)
}
const add = (host: string) => {
if (!host) return
if (host === "0.0.0.0") return
if (host === "::") return
const url = `http://${host}:${port}`
if (seen.has(url)) return
seen.add(url)
hosts.push({ url, tier: ipTier(host) })
}
advertised.forEach(addPreferred)
add(hostname)
const nets = Object.values(os.networkInterfaces())
.flatMap((item) => item ?? [])
.filter((item) => item.family === "IPv4" && !item.internal)
.map((item) => item.address)
nets.forEach(add)
// sort: most externally reachable first, loopback last
hosts.sort((a, b) => a.tier - b.tier)
return [...preferred, ...hosts.map((item) => item.url)]
}
function map(event: Event): { type: Type; sessionID: string } | undefined {
recordSession(event)
if (!obj(event.properties)) return
if (event.type === "permission.asked") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
const route = routeSession(sessionID)
log.info("map: matched permission.asked", {
eventType: event.type,
sessionID: route.sessionID,
originalSessionID: sessionID,
subagent: route.subagent,
})
return { type: "permission", sessionID: route.sessionID }
}
if (event.type === "session.error") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
const route = routeSession(sessionID)
if (route.subagent) {
log.info("map: skipped session.error (subagent)", { sessionID })
return
}
if (!shouldNotifyError(event.properties.error)) {
log.info("map: skipped session.error (suppressed error type)", {
sessionID,
errorName: obj(event.properties.error) ? str(event.properties.error.name) : undefined,
})
return
}
log.info("map: matched session.error", { sessionID })
return { type: "error", sessionID }
}
if (event.type === "session.status") {
const sessionID = str(event.properties.sessionID)
if (!sessionID) return
if (!obj(event.properties.status)) return
const statusType = str(event.properties.status.type)
if (statusType !== "idle") {
log.info("map: skipped session.status (non-idle)", { sessionID, statusType })
return
}
const route = routeSession(sessionID)
if (route.subagent) {
log.info("map: skipped session.status idle (subagent)", { sessionID })
return
}
log.info("map: matched session.status idle", { sessionID })
return { type: "complete", sessionID }
}
// not a push-eligible event type
return
}
function text(input: string) {
return input.replace(/\s+/g, " ").trim()
}
function words(input: string, max = 18, chars = 140) {
const clean = text(input)
if (!clean) return ""
const split = clean.split(" ")
const cut = split.slice(0, max).join(" ")
if (cut.length <= chars && split.length <= max) return cut
const short = cut.slice(0, chars).trim()
return short.endsWith("…") ? short : `${short}`
}
function fallback(input: Type) {
if (input === "complete") return "Session complete."
if (input === "permission") return "OpenCode needs your permission decision."
return "OpenCode reported an error for your session."
}
function titlePrefix(input: Type) {
if (input === "permission") return "Action Needed"
if (input === "error") return "Error"
return
}
function titleForType(input: Type, title: string) {
const next = text(title)
if (!next) return next
const prefix = titlePrefix(input)
if (!prefix) return next
const tagged = `${prefix}:`
if (next.toLowerCase().startsWith(tagged.toLowerCase())) return next
return `${tagged} ${next}`
}
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
const out: Notify = {
type: input.type,
sessionID: input.sessionID,
}
try {
const [{ Session }, { MessageV2 }, { SessionTable }, { use, eq }] = await Promise.all([
import("@/session"),
import("@/session/message-v2"),
import("@/session/session.sql"),
import("@/storage/db"),
])
const sessionID = SessionID.make(input.sessionID)
const row = use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())
const session = row ? Session.fromRow(row) : undefined
out.title = session?.title
let latestUser: string | undefined
for await (const msg of MessageV2.stream(sessionID)) {
const body = msg.parts
.map((part) => {
if (part.type !== "text") return ""
if (part.ignored) return ""
return part.text
})
.filter(Boolean)
.join(" ")
const next = words(body)
if (!next) continue
if (msg.info.role === "assistant") {
out.body = next
break
}
if (!latestUser && msg.info.role === "user") {
latestUser = next
}
}
if (!out.body) {
out.body = latestUser
}
} catch (error) {
log.info("notification metadata unavailable", {
type: input.type,
sessionID: input.sessionID,
error: String(error),
})
}
if (!out.title) out.title = `Session ${input.type}`
out.title = titleForType(input.type, out.title)
if (!out.body) out.body = fallback(input.type)
return out
}
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
const isDupe = now - prev < 5_000
if (isDupe) {
log.info("dedupe: suppressed duplicate", {
type: input.type,
sessionID: input.sessionID,
elapsedMs: now - prev,
})
}
return isDupe
}
/**
* Delay before sending a permission APN notification.
* If the permission is replied to within this window (e.g. auto-approved
* by the web UI, or the user is actively watching and approves manually),
* the notification is cancelled avoiding phone spam for every file edit
* during a generation.
*
* 15 seconds gives enough time for both auto-approvals (~5ms) and a user
* who is actively watching the machine to act before a push fires.
*/
const PERMISSION_DELAY_MS = 15_000
function cancelPendingPermission(event: Event) {
const next = state
if (!next) return
if (event.type !== "permission.replied") return
if (!obj(event.properties)) return
const requestID = str(event.properties.requestID)
if (!requestID) return
const timer = next.permissionTimers.get(requestID)
if (!timer) return
clearTimeout(timer)
next.permissionTimers.delete(requestID)
log.info("permission notification cancelled (replied before delay)", { requestID })
}
function schedulePermission(permissionID: string | undefined, input: { type: Type; sessionID: string }) {
const next = state
if (!next) return
const key = permissionID ?? `anon:${input.sessionID}:${Date.now()}`
const delayMs = next.permissionDelayMs
const existing = next.permissionTimers.get(key)
if (existing) {
clearTimeout(existing)
}
const timer = setTimeout(() => {
next.permissionTimers.delete(key)
void post(input)
}, delayMs)
next.permissionTimers.set(key, timer)
log.info("permission notification scheduled", {
permissionID: key,
sessionID: input.sessionID,
delayMs,
})
}
async function post(input: { type: Type; sessionID: string }) {
const next = state
if (!next) return false
if (dedupe(input)) return true
const content = await notify(input)
log.info("[ APN RELAY ] posting event", {
serverID: next.pair.serverID,
relayURL: next.relayURL,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
void fetch(`${next.relayURL}/v1/event`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: next.relaySecret,
serverID: next.pair.serverID,
eventType: input.type,
sessionID: input.sessionID,
title: content.title,
body: content.body,
}),
})
.then(async (res) => {
if (res.ok) {
log.info("[ APN RELAY ] relay accepted event", {
status: res.status,
serverID: next.pair.serverID,
secretHash: secretHash(next.relaySecret),
type: input.type,
sessionID: input.sessionID,
title: content.title,
})
return
}
const error = await res.text().catch(() => "")
log.warn("relay post failed", {
status: res.status,
type: input.type,
sessionID: input.sessionID,
title: content.title,
error,
})
})
.catch((error) => {
log.warn("relay post failed", {
type: input.type,
sessionID: input.sessionID,
title: content.title,
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) {
log.warn("start: relay URL is empty, push relay disabled")
return
}
if (!relaySecret) {
log.warn("start: relay secret is empty, push relay disabled")
return
}
stop()
const pair: Pair = {
v: 1,
serverID: serverID({ relayURL, relaySecret }),
relayURL,
relaySecret,
hosts: list(input.hostname, input.port, input.advertiseHosts ?? []),
}
const callback = (event: { payload: Event }) => {
cancelPendingPermission(event.payload)
const next = map(event.payload)
if (!next) return
if (next.type === "permission") {
const props = event.payload.properties
const permissionID = obj(props) ? str(props.id) : undefined
schedulePermission(permissionID, next)
return
}
void post(next)
}
GlobalBus.on("event", callback)
const unsub = () => {
GlobalBus.off("event", callback)
}
state = {
relayURL,
relaySecret,
pair,
stop: unsub,
seen: new Map(),
parent: new Map(),
gc: 0,
permissionTimers: new Map(),
permissionDelayMs: input.permissionDelayMs ?? PERMISSION_DELAY_MS,
}
log.info("enabled", {
relayURL,
hosts: pair.hosts,
})
return pair
}
export function stop() {
const next = state
if (!next) return
log.info("stopping push relay")
state = undefined
next.stop()
for (const timer of next.permissionTimers.values()) {
clearTimeout(timer)
}
next.permissionTimers.clear()
}
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 }) {
void post(input)
return true
}
export function auth(input: string) {
const next = state
if (!next) return false
return next.relaySecret === input
}
}

View file

@ -1,3 +1,4 @@
import { createHash } from "node:crypto"
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
@ -15,9 +16,55 @@ import { AccountID, OrgID } from "@/account/schema"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Effect, Option } from "effect"
import { PushRelay } from "../../push-relay"
// dynamic import: static `import * as` of CJS package triggers Bun bundler splitting bug
import type * as QRCodeType from "qrcode"
import { Agent } from "@/agent/agent"
import { jsonRequest, runRequest } from "./trace"
const PushPairPayload = z
.object({
relaySecret: z.string(),
hosts: z.array(z.string()),
})
.meta({ ref: "PushPairPayload" })
const PushPairResult = z
.discriminatedUnion("enabled", [
z.object({
enabled: z.literal(false),
}),
z.object({
enabled: z.literal(true),
hosts: z.array(z.string()),
relayURL: z.string(),
serverID: z.string().optional(),
relaySecretHash: z.string(),
link: z.string(),
qr: z.string(),
}),
])
.meta({ ref: "PushPairResult" })
const pushPairQROptions = {
errorCorrectionLevel: "M" as const,
margin: 1,
width: 256,
}
function pushPairLink(input: { relaySecret: string; hosts: string[] }) {
const payload: z.infer<typeof PushPairPayload> = {
relaySecret: input.relaySecret,
hosts: input.hosts,
}
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(payload))}`
}
async function pushPairQRCode(input: { relaySecret: string; hosts: string[] }) {
const QRCode: typeof QRCodeType = await import("qrcode")
return QRCode.toDataURL(pushPairLink(input), pushPairQROptions)
}
const ConsoleOrgOption = z.object({
accountID: z.string(),
accountEmail: z.string(),
@ -404,5 +451,140 @@ export const ExperimentalRoutes = lazy(() =>
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
)
.get(
"/push/pair",
describeRoute({
summary: "Get push relay pairing QR",
description: "Get the active push relay pairing payload and QR code for mobile setup.",
operationId: "experimental.push.pair",
responses: {
200: {
description: "Push relay pairing info",
content: {
"application/json": {
schema: resolver(PushPairResult),
},
},
},
},
}),
async (c) => {
const pair = PushRelay.pair()
if (!pair) {
return c.json({
enabled: false,
})
}
const link = pushPairLink(pair)
const qr = await pushPairQRCode(pair)
const relaySecretHash = pair.relaySecret
? `${createHash("sha256").update(pair.relaySecret).digest("hex").slice(0, 12)}...`
: "none"
return c.json({
enabled: true,
hosts: pair.hosts,
relayURL: pair.relayURL,
serverID: pair.serverID,
relaySecretHash,
link,
qr,
})
},
)
.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,
})
},
),
)

View file

@ -77,7 +77,10 @@ export const layer = Layer.effect(
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing || !existing.busy) {
yield* status.set(sessionID, { type: "idle" })
const current = yield* status.get(sessionID)
if (current.type !== "idle") {
yield* status.set(sessionID, { type: "idle" })
}
return
}
yield* existing.cancel

View file

@ -246,7 +246,7 @@ export const Event = {
"session.error",
z.object({
sessionID: SessionID.zod.optional(),
// z.lazy defers access to break circular dep: session → message-v2 → provider → plugin → session
// z.lazy defers access to break circular dep: session -> message-v2 -> provider -> plugin -> session
error: z.lazy(() => (MessageV2.Assistant.zod as unknown as z.ZodObject<any>).shape.error),
}),
),

View file

@ -1,10 +1,13 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { Log } from "@/util"
import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect"
import z from "zod"
const log = Log.create({ service: "session-status" })
export const Info = z
.union([
z.object({
@ -70,6 +73,12 @@ export const layer = Layer.effect(
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
const data = yield* InstanceState.get(state)
const prev = data.get(sessionID)
log.info("session status change", {
sessionID,
from: prev?.type ?? "idle",
to: status.type,
})
yield* bus.publish(Event.Status, { sessionID, status })
if (status.type === "idle") {
yield* bus.publish(Event.Idle, { sessionID })

View file

@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { autoTailscaleAdvertiseHost } from "../../src/cli/cmd/serve"
describe("autoTailscaleAdvertiseHost", () => {
const status = {
Self: {
DNSName: "exos.husky-tilapia.ts.net.",
TailscaleIPs: ["100.76.251.88", "fd7a:115c:a1e0::435:fb58"],
},
}
test("advertises the MagicDNS hostname for all-interface listeners", () => {
expect(autoTailscaleAdvertiseHost("0.0.0.0", status)).toBe("exos.husky-tilapia.ts.net")
})
test("advertises the MagicDNS hostname for Tailscale-bound listeners", () => {
expect(autoTailscaleAdvertiseHost("100.76.251.88", status)).toBe("exos.husky-tilapia.ts.net")
})
test("skips the MagicDNS hostname for unrelated listeners", () => {
expect(autoTailscaleAdvertiseHost("192.168.1.20", status)).toBeUndefined()
})
})

View file

@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { GlobalBus } from "../../src/bus/global"
import { PushRelay } from "../../src/server/push-relay"
let originalFetch: typeof fetch
let fetchMock: ReturnType<typeof mock>
function emit(type: string, properties: unknown) {
GlobalBus.emit("event", {
payload: {
type,
properties,
},
})
}
function created(sessionID: string, parentID?: string) {
emit("session.created", {
sessionID,
info: {
id: sessionID,
parentID,
},
})
}
async function waitForCalls(count: number, timeoutMs = 500) {
const iterations = Math.ceil(timeoutMs / 10)
for (let i = 0; i < iterations; i++) {
if (fetchMock.mock.calls.length >= count) return
await new Promise((resolve) => setTimeout(resolve, 10))
}
expect(fetchMock.mock.calls.length).toBe(count)
}
function callBody(index = 0) {
const init = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined
if (!init?.body) return
return JSON.parse(String(init.body)) as {
eventType: "complete" | "permission" | "error"
sessionID: string
}
}
beforeEach(() => {
originalFetch = globalThis.fetch
fetchMock = mock(() => Promise.resolve(new Response("ok", { status: 200 })))
globalThis.fetch = fetchMock as unknown as typeof fetch
PushRelay.start({
relayURL: "https://relay.example.com",
relaySecret: "test-secret",
hostname: "127.0.0.1",
port: 4096,
permissionDelayMs: 200,
})
})
afterEach(() => {
PushRelay.stop()
globalThis.fetch = originalFetch
})
describe("push relay event mapping", () => {
test("relays completion from session.status idle", async () => {
emit("session.status", {
sessionID: "ses_status_idle",
status: { type: "idle" },
})
await waitForCalls(1)
expect(callBody()?.eventType).toBe("complete")
})
test("ignores deprecated session.idle events", async () => {
emit("session.idle", {
sessionID: "ses_deprecated_idle",
})
await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchMock.mock.calls.length).toBe(0)
})
test("ignores non-actionable session errors", async () => {
emit("session.error", {
sessionID: "ses_aborted",
error: { name: "MessageAbortedError", data: { message: "Aborted" } },
})
emit("session.error", {
sessionID: "ses_overflow",
error: { name: "ContextOverflowError", data: { message: "Too long" } },
})
await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchMock.mock.calls.length).toBe(0)
})
test("relays actionable session errors", async () => {
emit("session.error", {
sessionID: "ses_unknown_error",
error: { name: "UnknownError", data: { message: "boom" } },
})
await waitForCalls(1)
expect(callBody()?.eventType).toBe("error")
})
test("relays permission prompts after delay when not replied", async () => {
emit("permission.asked", {
id: "per_unreplied",
sessionID: "ses_permission",
})
// should NOT fire immediately
await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchMock.mock.calls.length).toBe(0)
// should fire after the permission delay (200ms in tests)
await waitForCalls(1, 500)
expect(callBody()?.eventType).toBe("permission")
})
test("cancels permission notification when replied before delay", async () => {
emit("permission.asked", {
id: "per_auto_approved",
sessionID: "ses_auto",
})
// reply arrives quickly (simulating web UI auto-approve)
await new Promise((resolve) => setTimeout(resolve, 5))
emit("permission.replied", {
sessionID: "ses_auto",
requestID: "per_auto_approved",
reply: "once",
})
// wait past the delay window — notification should never fire
await new Promise((resolve) => setTimeout(resolve, 500))
expect(fetchMock.mock.calls.length).toBe(0)
})
test("cancels repeated permission updates when replied", async () => {
emit("permission.asked", {
id: "per_updated",
sessionID: "ses_updated",
})
await new Promise((resolve) => setTimeout(resolve, 100))
emit("permission.asked", {
id: "per_updated",
sessionID: "ses_updated",
permission: "updated",
})
await new Promise((resolve) => setTimeout(resolve, 5))
emit("permission.replied", {
sessionID: "ses_updated",
requestID: "per_updated",
reply: "once",
})
await new Promise((resolve) => setTimeout(resolve, 500))
expect(fetchMock.mock.calls.length).toBe(0)
})
test("does not relay subagent completion events", async () => {
created("ses_root")
created("ses_subagent", "ses_root")
emit("session.status", {
sessionID: "ses_subagent",
status: { type: "idle" },
})
await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchMock.mock.calls.length).toBe(0)
})
test("does not relay subagent errors", async () => {
created("ses_root")
created("ses_subagent", "ses_root")
emit("session.error", {
sessionID: "ses_subagent",
error: { name: "UnknownError", data: { message: "boom" } },
})
await new Promise((resolve) => setTimeout(resolve, 40))
expect(fetchMock.mock.calls.length).toBe(0)
})
test("relays subagent permission prompts to parent session", async () => {
created("ses_root")
created("ses_subagent", "ses_root")
emit("permission.asked", {
id: "per_subagent_perm",
sessionID: "ses_subagent",
})
await waitForCalls(1, 500)
expect(callBody()?.eventType).toBe("permission")
expect(callBody()?.sessionID).toBe("ses_root")
})
})