diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 1d4aa129..6aca8964 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -213,8 +213,9 @@ how long a desktop notification stays up before Codeman closes it (**Browser notification display time**, default 8; both per device, and your OS may close a desktop notification sooner), the idle threshold that decides when a quiet session counts as needing you, and the server-wide -webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode). See -[Notifications And Approvals](Notifications-And-Approvals). +webhook (ntfy, Slack, Discord or generic JSON; admins only in multi-user mode), and the +Discord group for the two-way bot (token, IDs, thread and ping options; see +[Discord Bridge](Discord-Bridge)). See [Notifications And Approvals](Notifications-And-Approvals). ### Voice diff --git a/src/discord-bridge.ts b/src/discord-bridge.ts index e43d0525..2ec15282 100644 --- a/src/discord-bridge.ts +++ b/src/discord-bridge.ts @@ -535,6 +535,8 @@ export class DiscordGateway { } private handle(sock: GatewaySocket, raw: string): void { + // `ws` keeps emitting while a socket closes: a frame after stop() or from a replaced socket must start nothing. + if (this.stopped || sock !== this.sock) return; let p: { op: number; d?: unknown; s?: number | null; t?: string | null }; try { p = JSON.parse(raw); @@ -947,7 +949,10 @@ export class DiscordBridge { * Claude gets the same text as a launch flag instead, and a shell must never receive it. */ async sessionIdle(sessionId: string, hintable: boolean): Promise { - if (!this.fresh.delete(sessionId)) return; + if (!this.fresh.has(sessionId)) return; + // A dialog is up (codex's trust prompt reads as idle): Enter would pick its highlighted option, so wait for the next idle. + if (this.deps.awaitingChoice?.(sessionId)) return; + this.fresh.delete(sessionId); if (!hintable || !this.cfg.enabled || !this.cfg.startupHint || !this.cfg.typedHint) return; const text = sanitiseReply(`${DISCORD_STARTUP_HINT} Reply with just: ok`); // fromUser false: this is Codeman talking, so it must not auto-name the tab or count as the person's input. diff --git a/test/discord-bridge.test.ts b/test/discord-bridge.test.ts index 9f69fa70..be45ea27 100644 --- a/test/discord-bridge.test.ts +++ b/test/discord-bridge.test.ts @@ -247,6 +247,15 @@ describe('DiscordGateway', () => { gw.stop(); }); + it('ignores a frame that arrives after stop(): no heartbeat timer, nothing sent', () => { + const { gw, sockets } = setup(); + gw.start(); + gw.stop(); + sockets[0].recv({ op: 10, d: { heartbeat_interval: 1000 } }); + expect(vi.getTimerCount()).toBe(0); + expect(sockets[0].sent).toHaveLength(0); + }); + it('stops for good on a close code that retrying cannot fix', () => { const { gw, sockets } = setup(); gw.start(); @@ -920,6 +929,20 @@ describe('DiscordBridge files and startup hint', () => { b.stop(); }); + it('holds the startup hint while a choice is pending and sends it at the next idle', async () => { + let choice = true; + const b = await make({}, undefined, () => choice); + await b.sessionCreated('s6', 'w6', t - 1000); + await b.sessionIdle('s6', true); + expect(sent).toHaveLength(0); + choice = false; + await b.sessionIdle('s6', true); + expect(sent).toHaveLength(1); + await b.sessionIdle('s6', true); + expect(sent).toHaveLength(1); + b.stop(); + }); + it('says the agent has exited and types nothing when the tab reports it', async () => { const b = await make({}, undefined, undefined, () => true); await b.onGatewayMessage(reply({ content: 'hello', attachments: [] }));