From 47745ad5360c58610087eee31192888982b9b12d Mon Sep 17 00:00:00 2001 From: hsliu Date: Sun, 18 Oct 2026 00:17:37 +0800 Subject: [PATCH 1/2] Fail the build on a bare catch, and say why each one is empty A catch clause whose block holds neither a statement nor a comment swallows the error with nothing on record saying why that is safe. check-empty-catch in .github/scripts/ flags them (statement form only; the void p.catch(() => {}) idiom and test-helper retries stay out of scope); its test runs in the package job of tests-ok and in npm run verify. The sixteen bare catches in core and plugins/social now name their reason, and the rule is recorded in docs/code-standards.md. Fixes #205 Docs-not-needed: comment-only edits to owned sources; the rule and its gate live in docs/code-standards.md and .github/scripts/ --- .github/scripts/check-empty-catch.mjs | 81 +++++++++++++++++++ .github/scripts/check-empty-catch.test.mjs | 25 ++++++ core/index.js | 2 +- core/src/client/flybot-registry.js | 2 +- core/src/client/index.js | 2 +- core/src/host/diagnostics.js | 4 +- core/src/host/home-lock.js | 6 +- core/src/host/hooks.js | 2 +- docs/code-standards.md | 5 ++ patches/ds-bot/0001-core-fixes.patch | 12 +++ patches/ds-bot/0002-flybot-api-host.patch | 32 +++++++- patches/ds-bot/0003-flybot-api-client.patch | 2 +- .../files/src/client/flybot-registry.js | 2 +- patches/ds-bot/files/src/host/hooks.js | 2 +- patches/ds-bot/topics.json | 1 + plugins/social/client.js | 2 +- plugins/social/index.js | 4 +- plugins/social/src/mcp-client.js | 2 +- plugins/social/src/social.js | 6 +- 19 files changed, 174 insertions(+), 20 deletions(-) create mode 100644 .github/scripts/check-empty-catch.mjs create mode 100644 .github/scripts/check-empty-catch.test.mjs diff --git a/.github/scripts/check-empty-catch.mjs b/.github/scripts/check-empty-catch.mjs new file mode 100644 index 00000000..f051de29 --- /dev/null +++ b/.github/scripts/check-empty-catch.mjs @@ -0,0 +1,81 @@ +// A catch clause whose block holds neither a statement nor a comment swallows the +// error with nothing on record saying why that is safe (#205). The comment is the +// reason; a block with statements handles the error and needs none. Only the +// statement form is checked: the `void p.catch(() => {})` promise form is a separate +// fire-and-forget idiom, and test helpers may retry through a bare catch. +// Usage: node .github/scripts/check-empty-catch.mjs (exit 1 lists every violation) +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +const CATCH = /(? { + let depth = 0 + for (let at = open; at < source.length; at += 1) { + if (source[at] === '{') depth += 1 + if (source[at] === '}') { + depth -= 1 + if (depth === 0) return source.slice(open + 1, at) + } + } + return source.slice(open + 1) +} + +// What remains of the block once comments are gone; `comment` says whether any was. +const stripComments = (block) => { + let comment = false + const code = block.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (found) => { + comment = true + return '' + }) + return { code, comment } +} + +export function scanSource(name, source) { + const found = [] + for (const match of source.matchAll(CATCH)) { + const open = match.index + match[0].length - 1 + const { code, comment } = stripComments(blockOf(source, open)) + if (code.trim() === '' && !comment) { + const line = source.slice(0, match.index).split('\n').length + found.push(`${name}:${line}`) + } + } + return found +} + +const under = (dir, names) => { + const out = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'dev' || entry.name === 'test') continue + const path = join(dir, entry.name) + if (entry.isDirectory()) out.push(...under(path, names)) + else if (entry.isFile() && entry.name.endsWith('.js')) out.push(path) + } + return out +} + +export function collectTargets(root) { + const files = [join(root, 'core/index.js'), join(root, 'core/web-search.js'), ...under(join(root, 'core/src'))] + for (const plugin of readdirSync(join(root, 'plugins'), { withFileTypes: true })) { + if (!plugin.isDirectory()) continue + const dir = join(root, 'plugins', plugin.name) + for (const name of ['index.js', 'client.js']) files.push(join(dir, name)) + files.push(...under(join(dir, 'src'))) + } + return files +} + +export function scanTree(root) { + return collectTargets(root).flatMap((path) => scanSource(path, readFileSync(path, 'utf8'))) +} + +if (process.argv[1] === import.meta.filename) { + const root = join(import.meta.dirname, '..', '..') + const violations = scanTree(root) + for (const where of violations) console.log(`${where}: empty catch: the error is swallowed with no reason on record`) + if (violations.length > 0) process.exit(1) +} diff --git a/.github/scripts/check-empty-catch.test.mjs b/.github/scripts/check-empty-catch.test.mjs new file mode 100644 index 00000000..b657997f --- /dev/null +++ b/.github/scripts/check-empty-catch.test.mjs @@ -0,0 +1,25 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { scanSource, scanTree } from './check-empty-catch.mjs' + +test('scanSource: a catch block with no statement and no comment is a violation', () => { + assert.deepEqual(scanSource('a.js', 'try { x() } catch {}\n'), ['a.js:1']) + assert.deepEqual(scanSource('a.js', 'try { x() } catch (e) {}\n'), ['a.js:1']) + assert.deepEqual(scanSource('a.js', 'try {\n x()\n} catch {\n\n}\n'), ['a.js:3']) +}) + +test('scanSource: a comment in the block is the reason; statements handle the error', () => { + assert.deepEqual(scanSource('a.js', 'try { x() } catch { /* racing unlink */ }\n'), []) + assert.deepEqual(scanSource('a.js', 'try { x() } catch (e) { return }\n'), []) + assert.deepEqual(scanSource('a.js', 'try { x() } catch {\n // retries on the next poll\n}\n'), []) +}) + +test('scanSource: the promise form, plain blocks and other identifiers are out of scope', () => { + assert.deepEqual(scanSource('a.js', 'void save().catch(() => {})\n'), []) + assert.deepEqual(scanSource('a.js', 'for (;;) {}\nconst o = { catch: 1 }\n'), []) +}) + +test('the shipped sources carry no bare catch', () => { + assert.deepEqual(scanTree(join(import.meta.dirname, '..', '..')), []) +}) diff --git a/core/index.js b/core/index.js index 35e18d2d..92991c36 100644 --- a/core/index.js +++ b/core/index.js @@ -68,7 +68,7 @@ export function provideFlyBot(ctx, flybot) { try { ctx.set('flybot', flybot) return - } catch {} + } catch { /* the key is refused: fall through to the plain property */ } } ctx.flybot = flybot } diff --git a/core/src/client/flybot-registry.js b/core/src/client/flybot-registry.js index 3de27a11..3e5174a3 100644 --- a/core/src/client/flybot-registry.js +++ b/core/src/client/flybot-registry.js @@ -96,7 +96,7 @@ export function createClientApi({ call: callFn, registry: slots = registry, ever const { seq: _seq, ...event } = change for (const fn of [...listeners]) fn(event) } - } catch {} + } catch { /* a failed poll leaves the fields as they are; the next one catches up */ } } return { bots: () => callFn('bots'), diff --git a/core/src/client/index.js b/core/src/client/index.js index a53be966..50761bb0 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -47,7 +47,7 @@ export function apply(ctx) { if (typeof ctx.provide === 'function') ctx.provide('flybot', flybot) else if (typeof ctx.reflect?.provide === 'function') ctx.reflect.provide('flybot', flybot) else if (typeof ctx.set === 'function') ctx.set('flybot', flybot) - } catch {} + } catch { /* every provide form refused: __flybot__.api above still serves plugins */ } ctx.effect(() => installLocale(ctx.get('locale')), 'ds-bot: interface copy') ctx.effect(() => installBrandChrome(), 'ds-bot: brand chrome') const roster = createSource(EMPTY_ROSTER) diff --git a/core/src/host/diagnostics.js b/core/src/host/diagnostics.js index 9d82f769..433e7213 100644 --- a/core/src/host/diagnostics.js +++ b/core/src/host/diagnostics.js @@ -105,9 +105,9 @@ const dshVersion = () => { try { const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) if (manifest.name === '@deepseek-ai/dsh-llm') return manifest.version - } catch {} + } catch { /* no readable manifest at this depth: keep walking up */ } } - } catch {} + } catch { /* the package does not resolve here: no version to report */ } return undefined } diff --git a/core/src/host/home-lock.js b/core/src/host/home-lock.js index 90ddd598..34e67f7f 100644 --- a/core/src/host/home-lock.js +++ b/core/src/host/home-lock.js @@ -50,7 +50,7 @@ export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostn return } const time = new Date(now()) - try { utimesSync(path, time, time) } catch {} + try { utimesSync(path, time, time) } catch { /* best effort: a stale mtime only lets a live check retake it */ } } const stop = () => { if (timer !== undefined) clearInterval(timer) @@ -80,7 +80,7 @@ export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostn ? current?.token === holder.token : JSON.stringify(current) === JSON.stringify(holder) if (unchanged) { - try { unlinkSync(path) } catch {} + try { unlinkSync(path) } catch { /* another claimer removed it first */ } } } return read() ?? {} @@ -89,7 +89,7 @@ export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostn function release() { stop() if (owns()) { - try { unlinkSync(path) } catch {} + try { unlinkSync(path) } catch { /* already gone, or taken over */ } } } diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index ea31ce74..e7a9ef8e 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -816,7 +816,7 @@ export function install(rt) { // A message the user addressed to a friend's Bot is that Bot's to answer; its // reply comes back into this chat. The local Bot does not answer it itself. if (!results.some(result => result.action !== 'deny') || rt.roomOf(id)) return - try { (await rt.resolveAgent(id)).cancel({ kind: 'user' }) } catch {} + try { (await rt.resolveAgent(id)).cancel({ kind: 'user' }) } catch { /* already ended: nothing to cancel */ } }) .catch(error => warn('flybot: user mention failed: %s', error)) }) diff --git a/docs/code-standards.md b/docs/code-standards.md index ff395349..cb1a4327 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -24,6 +24,11 @@ When you report status on a change: - If review or a test finds one escape path, fix the shared helper and cover sibling markers (`untrusted-message`, `untrusted-file`, trusted headers). - Same-class reds tighten LESSONS / this file; do not paper over with a special case. +## Silent catches + +- A `catch` that swallows the error says why in a comment inside the block (or handles it); `check-empty-catch` in `.github/scripts/` fails the build on a bare one (#205). +- The `void p.catch(() => {})` promise form is a separate fire-and-forget idiom and test-helper retries stay out of the gate's scope. + ## Security: fences and remote strings - Untrusted bodies stay inside fences; closing markers must not reconstitute after a single strip. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 0564ac33..aed83489 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -380,6 +380,18 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js row('DSH', facts.dshVersion), row('Surface', client.surface), row('OS', facts.os), +@@ -105,9 +105,9 @@ const dshVersion = () => { + try { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + if (manifest.name === '@deepseek-ai/dsh-llm') return manifest.version +- } catch {} ++ } catch { /* no readable manifest at this depth: keep walking up */ } + } +- } catch {} ++ } catch { /* the package does not resolve here: no version to report */ } + return undefined + } + @@ -167,7 +167,9 @@ export function install(rt) { const error = args.find(arg => arg instanceof Error) const line = clean(format(template, ...args.map(arg => (arg instanceof Error ? errorText(arg) : arg)))) diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 319bc3e2..07f331db 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -51,12 +51,42 @@ diff --git a/index.js b/index.js + try { + ctx.set('flybot', flybot) + return -+ } catch {} ++ } catch { /* the key is refused: fall through to the plain property */ } + } + ctx.flybot = flybot } export function createRuntime(ctx, config = {}) { +diff --git a/src/host/home-lock.js b/src/host/home-lock.js +--- a/src/host/home-lock.js ++++ b/src/host/home-lock.js +@@ -50,7 +50,7 @@ export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostn + return + } + const time = new Date(now()) +- try { utimesSync(path, time, time) } catch {} ++ try { utimesSync(path, time, time) } catch { /* best effort: a stale mtime only lets a live check retake it */ } + } + const stop = () => { + if (timer !== undefined) clearInterval(timer) +@@ -80,7 +80,7 @@ export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostn + ? current?.token === holder.token + : JSON.stringify(current) === JSON.stringify(holder) + if (unchanged) { +- try { unlinkSync(path) } catch {} ++ try { unlinkSync(path) } catch { /* another claimer removed it first */ } + } + } + return read() ?? {} +@@ -89,7 +89,7 @@ export function homeLock(path, { staleMs = 60_000, beatMs = 15_000, host = hostn + function release() { + stop() + if (owns()) { +- try { unlinkSync(path) } catch {} ++ try { unlinkSync(path) } catch { /* already gone, or taken over */ } + } + } + diff --git a/src/host/roster.js b/src/host/roster.js --- a/src/host/roster.js +++ b/src/host/roster.js diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index acd2cdc6..09862262 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -490,7 +490,7 @@ diff --git a/src/client/index.js b/src/client/index.js + if (typeof ctx.provide === 'function') ctx.provide('flybot', flybot) + else if (typeof ctx.reflect?.provide === 'function') ctx.reflect.provide('flybot', flybot) + else if (typeof ctx.set === 'function') ctx.set('flybot', flybot) -+ } catch {} ++ } catch { /* every provide form refused: __flybot__.api above still serves plugins */ } ctx.effect(() => installLocale(ctx.get('locale')), 'ds-bot: interface copy') + ctx.effect(() => installBrandChrome(), 'ds-bot: brand chrome') const roster = createSource(EMPTY_ROSTER) diff --git a/patches/ds-bot/files/src/client/flybot-registry.js b/patches/ds-bot/files/src/client/flybot-registry.js index 3de27a11..3e5174a3 100644 --- a/patches/ds-bot/files/src/client/flybot-registry.js +++ b/patches/ds-bot/files/src/client/flybot-registry.js @@ -96,7 +96,7 @@ export function createClientApi({ call: callFn, registry: slots = registry, ever const { seq: _seq, ...event } = change for (const fn of [...listeners]) fn(event) } - } catch {} + } catch { /* a failed poll leaves the fields as they are; the next one catches up */ } } return { bots: () => callFn('bots'), diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index ea31ce74..e7a9ef8e 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -816,7 +816,7 @@ export function install(rt) { // A message the user addressed to a friend's Bot is that Bot's to answer; its // reply comes back into this chat. The local Bot does not answer it itself. if (!results.some(result => result.action !== 'deny') || rt.roomOf(id)) return - try { (await rt.resolveAgent(id)).cancel({ kind: 'user' }) } catch {} + try { (await rt.resolveAgent(id)).cancel({ kind: 'user' }) } catch { /* already ended: nothing to cancel */ } }) .catch(error => warn('flybot: user mention failed: %s', error)) }) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 77204c12..a6251b5d 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -21,6 +21,7 @@ "src/host/store.js", "src/host/roster.js", "src/host/tools.js", + "src/host/home-lock.js", "test/fake-ctx.js" ], "0003-flybot-api-client": [ diff --git a/plugins/social/client.js b/plugins/social/client.js index cba950f5..60cd9f33 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -897,7 +897,7 @@ window.__ModuleLoader__.load({ await clip?.writeText?.(handle) setCopied(true) setTimeout(() => setCopied(false), 1500) - } catch {} + } catch { /* clipboard denied: skip the Copied feedback */ } } return h('div', { className: 'fs-me', 'data-flybot': 'my-handle-menu' }, h('span', { className: 'fs-me-handle', title: 'Your Bot Social handle' }, handle), diff --git a/plugins/social/index.js b/plugins/social/index.js index fd2402cf..0b5ed455 100644 --- a/plugins/social/index.js +++ b/plugins/social/index.js @@ -32,9 +32,9 @@ export function apply(ctx, config = {}) { .catch((error) => { const reason = startFailureReason(error) console.warn('flybot-social: start failed:', reason) - try { ctx.logger?.warn?.('flybot-social: start failed: %s', reason) } catch {} + try { ctx.logger?.warn?.('flybot-social: start failed: %s', reason) } catch { /* the logger failed while reporting the failure */ } // Host diagnostics (Settings → Feedback). Optional on older cores without flybot.warn. - try { ctx.flybot?.warn?.('flybot-social: start failed: %s', error) } catch {} + try { ctx.flybot?.warn?.('flybot-social: start failed: %s', error) } catch { /* the optional channel itself failed */ } if (!stopped) unavailableOff = registerUnavailable(ctx.flybot, reason) }) ctx.effect(() => () => { diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 90f4a19e..04b35f43 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -145,7 +145,7 @@ function parseBody(text, type) { if (/text\/event-stream/i.test(type) || /^(event|data|id):/m.test(text.trimStart().slice(0, 6))) { const data = text.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).filter(Boolean) for (const line of data.reverse()) { - try { const value = JSON.parse(line); if (value && (value.result !== undefined || value.error !== undefined)) return value } catch {} + try { const value = JSON.parse(line); if (value && (value.result !== undefined || value.error !== undefined)) return value } catch { /* not the JSON-RPC answer: try the next data line */ } } throw new BotSocialError('Bot Social: the event stream had no answer', 'sse') } diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index f1473d17..81aa43fd 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -294,7 +294,7 @@ export const readCredentials = (path) => { export const writeCredentials = (path, value) => { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, JSON.stringify(value, null, 2), { mode: 0o600 }) - try { chmodSync(path, 0o600) } catch {} + try { chmodSync(path, 0o600) } catch { /* file systems that ignore the write mode */ } } export function createSocial(service, config = {}, { client: given, api: givenApi, home: givenHome, now = Date.now, setInterval: every = globalThis.setInterval, clearInterval: clear = globalThis.clearInterval, faceApi: givenFace } = {}) { @@ -389,7 +389,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (CREDENTIAL_NAME.test(basename(file))) return 'it looks like a credential' for (const dir of privateDirs()) { let real = dir - try { real = realpathSync(dir) } catch {} + try { real = realpathSync(dir) } catch { /* not a symlink or gone: compare the path as it is */ } if (file === real || file.startsWith(real + sep)) return 'it is a private FlyBot file' } if (!(extname(file).toLowerCase() in MIME)) return `only ${Object.keys(MIME).join(' ')} files` @@ -1097,7 +1097,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp try { const message = await client.callTool('send_message', { conversation_id: bot.conversationId, text: msg.text }) return { exchangeId: message?.message_id ?? msg.exchangeId } - } catch {} + } catch { /* the old conversation is gone: start a fresh one below */ } } const started = await client.callTool('start_conversation', { friend: bot.handle, text: msg.text, ...(config.botId ? { bot_id: config.botId } : {}) }) // Kept, so a file sent right after goes into this conversation. From 59e0bc98c3656de113e756d554ce9b774a738f7a Mon Sep 17 00:00:00 2001 From: hsliu Date: Sun, 18 Oct 2026 00:28:16 +0800 Subject: [PATCH 2/2] Flag destructuring catch bindings in the empty-catch gate The binding pattern now matches any catch(...) argument, so catch ({ message }) {} and catch ({}) {} cannot evade the gate. The hooks.js comment also covers a resolveAgent failure, not only an ended session. Docs-not-needed: comment-only edit to an owned source; no behavior change --- .github/scripts/check-empty-catch.mjs | 2 +- .github/scripts/check-empty-catch.test.mjs | 2 ++ core/src/host/hooks.js | 2 +- patches/ds-bot/files/src/host/hooks.js | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/check-empty-catch.mjs b/.github/scripts/check-empty-catch.mjs index f051de29..88f3deb5 100644 --- a/.github/scripts/check-empty-catch.mjs +++ b/.github/scripts/check-empty-catch.mjs @@ -7,7 +7,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' -const CATCH = /(? { assert.deepEqual(scanSource('a.js', 'try { x() } catch {}\n'), ['a.js:1']) assert.deepEqual(scanSource('a.js', 'try { x() } catch (e) {}\n'), ['a.js:1']) + assert.deepEqual(scanSource('a.js', 'try { x() } catch ({ message }) {}\n'), ['a.js:1']) + assert.deepEqual(scanSource('a.js', 'try { x() } catch ({}) {}\n'), ['a.js:1']) assert.deepEqual(scanSource('a.js', 'try {\n x()\n} catch {\n\n}\n'), ['a.js:3']) }) diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index e7a9ef8e..5a172337 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -816,7 +816,7 @@ export function install(rt) { // A message the user addressed to a friend's Bot is that Bot's to answer; its // reply comes back into this chat. The local Bot does not answer it itself. if (!results.some(result => result.action !== 'deny') || rt.roomOf(id)) return - try { (await rt.resolveAgent(id)).cancel({ kind: 'user' }) } catch { /* already ended: nothing to cancel */ } + try { (await rt.resolveAgent(id)).cancel({ kind: 'user' }) } catch { /* ended or already gone: nothing to cancel */ } }) .catch(error => warn('flybot: user mention failed: %s', error)) }) diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index e7a9ef8e..5a172337 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -816,7 +816,7 @@ export function install(rt) { // A message the user addressed to a friend's Bot is that Bot's to answer; its // reply comes back into this chat. The local Bot does not answer it itself. if (!results.some(result => result.action !== 'deny') || rt.roomOf(id)) return - try { (await rt.resolveAgent(id)).cancel({ kind: 'user' }) } catch { /* already ended: nothing to cancel */ } + try { (await rt.resolveAgent(id)).cancel({ kind: 'user' }) } catch { /* ended or already gone: nothing to cancel */ } }) .catch(error => warn('flybot: user mention failed: %s', error)) })