diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts index e33683a58f..3503e35513 100644 --- a/packages/opencode/src/server/push-relay.ts +++ b/packages/opencode/src/server/push-relay.ts @@ -20,6 +20,7 @@ type Input = { hostname: string port: number advertiseHosts?: string[] + permissionDelayMs?: number } type State = { @@ -30,6 +31,8 @@ type State = { seen: Map parent: Map gc: number + permissionTimers: Map> + 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() { diff --git a/packages/opencode/test/server/push-relay.test.ts b/packages/opencode/test/server/push-relay.test.ts index 5e5799eb80..53571b1a96 100644 --- a/packages/opencode/test/server/push-relay.test.ts +++ b/packages/opencode/test/server/push-relay.test.ts @@ -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") })