Skip to content

Commit 13f89d5

Browse files
author
Ryan Vogel
committed
fix(opencode): delay transient APN permission notifications
1 parent f29cb81 commit 13f89d5

2 files changed

Lines changed: 121 additions & 5 deletions

File tree

packages/opencode/src/server/push-relay.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type Input = {
2020
hostname: string
2121
port: number
2222
advertiseHosts?: string[]
23+
permissionDelayMs?: number
2324
}
2425

2526
type State = {
@@ -30,6 +31,8 @@ type State = {
3031
seen: Map<string, number>
3132
parent: Map<string, string | undefined>
3233
gc: number
34+
permissionTimers: Map<string, ReturnType<typeof setTimeout>>
35+
permissionDelayMs: number
3336
}
3437

3538
type Event = {
@@ -407,6 +410,53 @@ function dedupe(input: { type: Type; sessionID: string }) {
407410
return isDupe
408411
}
409412

413+
/**
414+
* Delay before sending a permission APN notification.
415+
* If the permission is replied to within this window (e.g. auto-approved
416+
* by the web UI, or the user is actively watching and approves manually),
417+
* the notification is cancelled — avoiding phone spam for every file edit
418+
* during a generation.
419+
*
420+
* 15 seconds gives enough time for both auto-approvals (~5ms) and a user
421+
* who is actively watching the machine to act before a push fires.
422+
*/
423+
const PERMISSION_DELAY_MS = 15_000
424+
425+
function cancelPendingPermission(event: Event) {
426+
const next = state
427+
if (!next) return
428+
if (event.type !== "permission.replied") return
429+
if (!obj(event.properties)) return
430+
const requestID = str(event.properties.requestID)
431+
if (!requestID) return
432+
const timer = next.permissionTimers.get(requestID)
433+
if (!timer) return
434+
clearTimeout(timer)
435+
next.permissionTimers.delete(requestID)
436+
log.info("permission notification cancelled (replied before delay)", { requestID })
437+
}
438+
439+
function schedulePermission(permissionID: string | undefined, input: { type: Type; sessionID: string }) {
440+
const next = state
441+
if (!next) return
442+
const key = permissionID ?? `anon:${input.sessionID}:${Date.now()}`
443+
const delayMs = next.permissionDelayMs
444+
const existing = next.permissionTimers.get(key)
445+
if (existing) {
446+
clearTimeout(existing)
447+
}
448+
const timer = setTimeout(() => {
449+
next.permissionTimers.delete(key)
450+
void post(input)
451+
}, delayMs)
452+
next.permissionTimers.set(key, timer)
453+
log.info("permission notification scheduled", {
454+
permissionID: key,
455+
sessionID: input.sessionID,
456+
delayMs,
457+
})
458+
}
459+
410460
async function post(input: { type: Type; sessionID: string }) {
411461
const next = state
412462
if (!next) return false
@@ -494,8 +544,15 @@ export namespace PushRelay {
494544
}
495545

496546
const callback = (event: { payload: Event }) => {
547+
cancelPendingPermission(event.payload)
497548
const next = map(event.payload)
498549
if (!next) return
550+
if (next.type === "permission") {
551+
const props = event.payload.properties
552+
const permissionID = obj(props) ? str(props.id) : undefined
553+
schedulePermission(permissionID, next)
554+
return
555+
}
499556
void post(next)
500557
}
501558
GlobalBus.on("event", callback)
@@ -511,6 +568,8 @@ export namespace PushRelay {
511568
seen: new Map(),
512569
parent: new Map(),
513570
gc: 0,
571+
permissionTimers: new Map(),
572+
permissionDelayMs: input.permissionDelayMs ?? PERMISSION_DELAY_MS,
514573
}
515574

516575
log.info("enabled", {
@@ -527,6 +586,10 @@ export namespace PushRelay {
527586
log.info("stopping push relay")
528587
state = undefined
529588
next.stop()
589+
for (const timer of next.permissionTimers.values()) {
590+
clearTimeout(timer)
591+
}
592+
next.permissionTimers.clear()
530593
}
531594

532595
export function status() {

packages/opencode/test/server/push-relay.test.ts

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ function created(sessionID: string, parentID?: string) {
2424
})
2525
}
2626

27-
async function waitForCalls(count: number) {
28-
for (let i = 0; i < 50; i++) {
27+
async function waitForCalls(count: number, timeoutMs = 500) {
28+
const iterations = Math.ceil(timeoutMs / 10)
29+
for (let i = 0; i < iterations; i++) {
2930
if (fetchMock.mock.calls.length >= count) return
3031
await new Promise((resolve) => setTimeout(resolve, 10))
3132
}
@@ -51,6 +52,7 @@ beforeEach(() => {
5152
relaySecret: "test-secret",
5253
hostname: "127.0.0.1",
5354
port: 4096,
55+
permissionDelayMs: 200,
5456
})
5557
})
5658

@@ -103,15 +105,65 @@ describe("push relay event mapping", () => {
103105
expect(callBody()?.eventType).toBe("error")
104106
})
105107

106-
test("relays permission prompts", async () => {
108+
test("relays permission prompts after delay when not replied", async () => {
107109
emit("permission.asked", {
110+
id: "per_unreplied",
108111
sessionID: "ses_permission",
109112
})
110113

111-
await waitForCalls(1)
114+
// should NOT fire immediately
115+
await new Promise((resolve) => setTimeout(resolve, 40))
116+
expect(fetchMock.mock.calls.length).toBe(0)
117+
118+
// should fire after the permission delay (200ms in tests)
119+
await waitForCalls(1, 500)
112120
expect(callBody()?.eventType).toBe("permission")
113121
})
114122

123+
test("cancels permission notification when replied before delay", async () => {
124+
emit("permission.asked", {
125+
id: "per_auto_approved",
126+
sessionID: "ses_auto",
127+
})
128+
129+
// reply arrives quickly (simulating web UI auto-approve)
130+
await new Promise((resolve) => setTimeout(resolve, 5))
131+
emit("permission.replied", {
132+
sessionID: "ses_auto",
133+
requestID: "per_auto_approved",
134+
reply: "once",
135+
})
136+
137+
// wait past the delay window — notification should never fire
138+
await new Promise((resolve) => setTimeout(resolve, 500))
139+
expect(fetchMock.mock.calls.length).toBe(0)
140+
})
141+
142+
test("cancels repeated permission updates when replied", async () => {
143+
emit("permission.asked", {
144+
id: "per_updated",
145+
sessionID: "ses_updated",
146+
})
147+
148+
await new Promise((resolve) => setTimeout(resolve, 100))
149+
150+
emit("permission.asked", {
151+
id: "per_updated",
152+
sessionID: "ses_updated",
153+
permission: "updated",
154+
})
155+
156+
await new Promise((resolve) => setTimeout(resolve, 5))
157+
emit("permission.replied", {
158+
sessionID: "ses_updated",
159+
requestID: "per_updated",
160+
reply: "once",
161+
})
162+
163+
await new Promise((resolve) => setTimeout(resolve, 500))
164+
expect(fetchMock.mock.calls.length).toBe(0)
165+
})
166+
115167
test("does not relay subagent completion events", async () => {
116168
created("ses_root")
117169
created("ses_subagent", "ses_root")
@@ -143,10 +195,11 @@ describe("push relay event mapping", () => {
143195
created("ses_subagent", "ses_root")
144196

145197
emit("permission.asked", {
198+
id: "per_subagent_perm",
146199
sessionID: "ses_subagent",
147200
})
148201

149-
await waitForCalls(1)
202+
await waitForCalls(1, 500)
150203
expect(callBody()?.eventType).toBe("permission")
151204
expect(callBody()?.sessionID).toBe("ses_root")
152205
})

0 commit comments

Comments
 (0)