fix(opencode): delay transient APN permission notifications

This commit is contained in:
Ryan Vogel
2026-04-08 18:23:25 +00:00
parent f29cb81c7d
commit 13f89d5aa5
2 changed files with 121 additions and 5 deletions
@@ -20,6 +20,7 @@ type Input = {
hostname: string
port: number
advertiseHosts?: string[]
permissionDelayMs?: number
}
type State = {
@@ -30,6 +31,8 @@ type State = {
seen: Map<string, number>
parent: Map<string, string | undefined>
gc: number
permissionTimers: Map<string, ReturnType<typeof setTimeout>>
permissionDelayMs: number
}
type Event = {
@@ -407,6 +410,53 @@ function dedupe(input: { type: Type; sessionID: string }) {
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
@@ -494,8 +544,15 @@ export namespace PushRelay {
}
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)
@@ -511,6 +568,8 @@ export namespace PushRelay {
seen: new Map(),
parent: new Map(),
gc: 0,
permissionTimers: new Map(),
permissionDelayMs: input.permissionDelayMs ?? PERMISSION_DELAY_MS,
}
log.info("enabled", {
@@ -527,6 +586,10 @@ export namespace PushRelay {
log.info("stopping push relay")
state = undefined
next.stop()
for (const timer of next.permissionTimers.values()) {
clearTimeout(timer)
}
next.permissionTimers.clear()
}
export function status() {
@@ -24,8 +24,9 @@ function created(sessionID: string, parentID?: string) {
})
}
async function waitForCalls(count: number) {
for (let i = 0; i < 50; i++) {
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))
}
@@ -51,6 +52,7 @@ beforeEach(() => {
relaySecret: "test-secret",
hostname: "127.0.0.1",
port: 4096,
permissionDelayMs: 200,
})
})
@@ -103,15 +105,65 @@ describe("push relay event mapping", () => {
expect(callBody()?.eventType).toBe("error")
})
test("relays permission prompts", async () => {
test("relays permission prompts after delay when not replied", async () => {
emit("permission.asked", {
id: "per_unreplied",
sessionID: "ses_permission",
})
await waitForCalls(1)
// 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")
@@ -143,10 +195,11 @@ describe("push relay event mapping", () => {
created("ses_subagent", "ses_root")
emit("permission.asked", {
id: "per_subagent_perm",
sessionID: "ses_subagent",
})
await waitForCalls(1)
await waitForCalls(1, 500)
expect(callBody()?.eventType).toBe("permission")
expect(callBody()?.sessionID).toBe("ses_root")
})