diff --git a/.github/scripts/check-empty-catch.mjs b/.github/scripts/check-empty-catch.mjs index 238286f85..cc31b892c 100644 --- a/.github/scripts/check-empty-catch.mjs +++ b/.github/scripts/check-empty-catch.mjs @@ -1,8 +1,12 @@ // 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. +// statement form of `catch` is checked there; the promise form is the next rule. +// Promise form (#213): a `.catch` whose handler does nothing (`() => {}`, `() => undefined`, +// `() => null`) and whose result is dropped (a statement or an `await` statement) needs a +// `void` prefix or a one-line reason comment (in the handler, after the call on the same +// line, or on the line above the statement). A handler that returns a fallback VALUE that is +// used (`const x = await p.catch(() => undefined)`, an argument, a `return`) stays allowed. // The text `catch {}` inside a comment, a string, a template or a regex literal is // not code, so it is never reported (#213). // Usage: node .github/scripts/check-empty-catch.mjs (exit 1 lists every violation) @@ -145,6 +149,74 @@ export function scanSource(name, source) { return found } +// A `.catch` handler that does nothing: an arrow whose body is empty, or just undefined / null. +const IDLE_CATCH = /\.catch\(\s*(?:\([^()]*\)|[\w$]+)\s*=>\s*(\{\s*\}|undefined\b|null\b|void\s+0\b)\s*\)/g + +// Where the statement holding the call at `at` begins, walking back over balanced brackets. +// Returns { start, argument }: `argument` when an unmatched `(` / `[` / `,` shows the call sits +// inside another expression (an argument, an element), so its result is used. +function statementStart(code, at) { + let depth = 0 + for (let i = at - 1; i >= 0; i -= 1) { + const char = code[i] + if (char === ')' || char === ']') depth += 1 + else if (char === '(' || char === '[') { + if (depth === 0) return { start: i + 1, argument: true } + depth -= 1 + } else if (depth === 0) { + if (char === ';' || char === '{' || char === '}') return { start: i + 1, argument: false } + if (char === ',') return { start: i + 1, argument: true } + if (char === '\n') { + // A new statement begins here unless this line, or the one before it, continues an expression. + const lineStart = i + 1 + const lineEnd = code.indexOf('\n', lineStart) + const line = code.slice(lineStart, lineEnd === -1 ? code.length : lineEnd).trim() + const before = code.slice(0, i).trimEnd().slice(-2) + const continues = /^[.?:+\-*/%&|=<>,]/.test(line) && !/^(?:\+\+|--)/.test(line) + const open = /[.?:+\-*/%&|=<>,(\[{]$/.test(before) && !/(?:\+\+|--|\)|\})$/.test(before) + if (!continues && !open && line !== '') return { start: lineStart, argument: false } + } + } + } + return { start: 0, argument: false } +} + +// `text` with the inside of every (...), [...] and {...} removed. +function outside(text) { + let depth = 0 + let out = '' + for (const char of text) { + if ('([{'.includes(char)) { depth += 1; out += depth === 1 ? char : ''; continue } + if (')]}'.includes(char)) { depth -= 1; out += depth === 0 ? char : ''; continue } + if (depth === 0) out += char + } + return out +} + +export function scanPromiseSource(name, source) { + const found = [] + const { bare, commented } = maskSource(source) + for (const match of bare.matchAll(IDLE_CATCH)) { + const { start, argument } = statementStart(bare, match.index) + if (argument) continue + const head = bare.slice(start, match.index).trim() + // The statement's own text, without what sits inside brackets (callbacks, arguments, objects). + const top = outside(head) + // A used value: returned, thrown, declared, assigned, an arrow's expression body, a ternary, negated. + if (/^(?:return|throw|yield|const|let|var|export)\b|=>|(?])=(?![=>])|\?(?!\.)\s|^!/.test(top)) continue + if (/\bvoid\b/.test(top)) continue + // A reason: a comment in the handler, after the call on its line, or on the line above the statement. + const handler = commented.slice(match.index, match.index + match[0].length) + const eol = commented.indexOf('\n', match.index + match[0].length) + const rest = commented.slice(match.index + match[0].length, eol === -1 ? commented.length : eol) + const startLine = commented.lastIndexOf('\n', commented.indexOf(bare.slice(start).trimStart().slice(0, 1) || ' ', start)) + const above = commented.slice(commented.lastIndexOf('\n', Math.max(0, startLine - 1)) + 1, startLine === -1 ? 0 : startLine) + if (/\/\*|\/\//.test(handler) || /\/\*|\/\//.test(rest) || /^\s*(?:\/\/|\/\*|\*)/.test(above)) continue + found.push(`${name}:${source.slice(0, match.index).split('\n').length}`) + } + return found +} + const under = (dir, names) => { const out = [] for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -171,9 +243,15 @@ export function scanTree(root) { return collectTargets(root).flatMap((path) => scanSource(path, readFileSync(path, 'utf8'))) } +export function scanPromiseTree(root) { + return collectTargets(root).flatMap((path) => scanPromiseSource(path, readFileSync(path, 'utf8'))) +} + if (process.argv[1] === import.meta.filename) { const root = join(import.meta.dirname, '..', '..') const violations = scanTree(root) + const dropped = scanPromiseTree(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) + for (const where of dropped) console.log(`${where}: .catch handler does nothing and its result is dropped: add \`void\` or a one-line reason comment`) + if (violations.length + dropped.length > 0) process.exit(1) } diff --git a/.github/scripts/check-empty-catch.test.mjs b/.github/scripts/check-empty-catch.test.mjs index 55ef94eaf..51050b79c 100644 --- a/.github/scripts/check-empty-catch.test.mjs +++ b/.github/scripts/check-empty-catch.test.mjs @@ -1,7 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { join } from 'node:path' -import { maskSource, scanSource, scanTree } from './check-empty-catch.mjs' +import { maskSource, scanPromiseSource, scanPromiseTree, 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']) @@ -17,7 +17,8 @@ test('scanSource: a comment in the block is the reason; statements handle the er 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', () => { +test('scanSource: the promise form, plain blocks and other identifiers are out of scope here', () => { + assert.deepEqual(scanSource('a.js', 'save().catch(() => {})\n'), []) assert.deepEqual(scanSource('a.js', 'void save().catch(() => {})\n'), []) assert.deepEqual(scanSource('a.js', 'for (;;) {}\nconst o = { catch: 1 }\n'), []) }) @@ -65,6 +66,57 @@ test('maskSource keeps length and line breaks, blanks literals, and keeps commen assert.equal(maskSource("const e = '😀'\n").bare.length, "const e = '😀'\n".length) }) +const promise = source => scanPromiseSource('a.js', source) + +test('promise form: a discarded `.catch(() => {})` needs `void` or a one-line reason (#213)', () => { + assert.deepEqual(promise('save().catch(() => {})\n'), ['a.js:1']) + assert.deepEqual(promise('await save().catch(() => {})\n'), ['a.js:1']) + assert.deepEqual(promise('await save().catch((e) => {})\n'), ['a.js:1']) + assert.deepEqual(promise('await save().catch(() => undefined)\n'), ['a.js:1']) + assert.deepEqual(promise('await save().catch(() => null)\n'), ['a.js:1']) + assert.deepEqual(promise('if (fresh) await unlink(path).catch(() => {})\n'), ['a.js:1']) + assert.deepEqual(promise('for (const id of ids) await drop(id).catch(() => {})\n'), ['a.js:1']) + assert.deepEqual(promise('x.then((v) => { use(v) }).catch(() => {})\n'), ['a.js:1'], 'a callback in the chain is not a used value') + assert.deepEqual(promise('async function f() {\n await a()\n await b()\n .catch(() => {})\n}\n'), ['a.js:4'], 'a multi-line chain is one statement') + assert.deepEqual(promise('run(() => { save().catch(() => {}) })\n'), ['a.js:1'], 'a statement in a callback body is dropped too') +}) + +test('promise form: `void` or a reason comment is the answer', () => { + assert.deepEqual(promise('void save().catch(() => {})\n'), []) + assert.deepEqual(promise('if (fresh) void save().catch(() => {})\n'), []) + assert.deepEqual(promise('void save().then(() => b()).catch(() => {})\n'), []) + assert.deepEqual(promise('await unlink(path).catch(() => { /* may not exist */ })\n'), []) + assert.deepEqual(promise('await unlink(path).catch(() => {}) // may not exist\n'), []) + assert.deepEqual(promise('// may not exist\nawait unlink(path).catch(() => {})\n'), []) + assert.deepEqual(promise('/* may not exist */\nawait unlink(path).catch(() => {})\n'), []) + assert.deepEqual(promise('await unlink(path).catch(() => {})\n\n// a later comment is not its reason\n'), ['a.js:1']) +}) + +test('promise form: a fallback value that is used stays allowed', () => { + assert.deepEqual(promise('const info = await load().catch(() => undefined)\n'), []) + assert.deepEqual(promise('const info = await load().catch(() => null)\n'), []) + assert.deepEqual(promise('info = await load().catch(() => undefined)\n'), []) + assert.deepEqual(promise('return load().catch(() => undefined)\n'), []) + assert.deepEqual(promise('if ((await load().catch(() => undefined))?.ok) go()\n'), []) + assert.deepEqual(promise('use(await load().catch(() => undefined))\n'), []) + assert.deepEqual(promise('const member = known ?? await register(bot).catch(() => undefined)\n'), []) + assert.deepEqual(promise('const refresh = () => call(x).catch(() => {})\n'), []) + assert.deepEqual(promise('writing = run.catch(() => {})\n'), []) + assert.deepEqual(promise('list.map(item => item.done.catch(() => {}))\n'), []) + assert.deepEqual(promise('const v = ok ? await a().catch(() => undefined) : 1\n'), []) +}) + +test('promise form: handlers that do something, and literals, are not reported', () => { + assert.deepEqual(promise('await save().catch(() => log())\n'), []) + assert.deepEqual(promise('await save().catch((e) => { warn(e) })\n'), []) + assert.deepEqual(promise('// await save().catch(() => {})\n'), []) + assert.deepEqual(promise("const hint = 'save().catch(() => {})'\n"), []) +}) + +test('the shipped sources carry no bare discarded `.catch` (#213)', () => { + assert.deepEqual(scanPromiseTree(join(import.meta.dirname, '..', '..')), []) +}) + test('the shipped sources carry no bare catch', () => { assert.deepEqual(scanTree(join(import.meta.dirname, '..', '..')), []) }) diff --git a/core/client.js b/core/client.js index 49415432a..c1117d198 100644 --- a/core/client.js +++ b/core/client.js @@ -8595,7 +8595,7 @@ function useMemoryCount(botId, actions) { countListeners.add(listener); listener(); let stop = false; - actions.memoryView?.(botId).then((view) => { + void actions.memoryView?.(botId).then((view) => { if (!stop) noteCount(botId, view.entries.length); }).catch(() => { }); @@ -9927,7 +9927,7 @@ function useMicLevel(enabled) { let audio = null; let frame = 0; let stopped = false; - navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => { + void navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => { if (stopped) { media.getTracks().forEach((track) => track.stop()); return; diff --git a/core/src/client/bot-browsers.js b/core/src/client/bot-browsers.js index e23b86481..0cd0e18bb 100644 --- a/core/src/client/bot-browsers.js +++ b/core/src/client/bot-browsers.js @@ -171,7 +171,7 @@ export function createBotBrowsers({ bridge, createSource, storage = globalThis.l async function create(tabId) { const botId = owner.get(tabId) const reservation = await bridge.acquire(`bot:${botId}`) - if (disposed) { await bridge.release(reservation.lease).catch(() => {}); return undefined } + if (disposed) { await bridge.release(reservation.lease).catch(() => { /* best effort: the lease may already be gone */ }); return undefined } const element = doc.createElement('webview') // The shell forwards app shortcuts from a focused webview that carries these // attributes, as it does for its own Sidebar browser. @@ -293,7 +293,7 @@ export function createBotBrowsers({ bridge, createSource, storage = globalThis.l async function closeTab(botId, tabId) { const state = tabs.getSnapshot()[botId] if (state === undefined || !state.ids.includes(tabId)) return - await opening.get(tabId)?.catch(() => {}) + await opening.get(tabId)?.catch(() => { /* its opener reports a failure; this only waits for it to settle */ }) if (shown === tabId) await show(null) await release(tabId) // The waits above can overlap a new tab or an active-tab switch; drop the @@ -375,7 +375,7 @@ export function createBotBrowsers({ bridge, createSource, storage = globalThis.l async function forget(botId) { ensureTabs(botId) for (const tabId of tabs.getSnapshot()[botId]?.ids ?? []) { - await opening.get(tabId)?.catch(() => {}) + await opening.get(tabId)?.catch(() => { /* its opener reports a failure; this only waits for it to settle */ }) if (shown === tabId) await show(null) await release(tabId) owner.delete(tabId) diff --git a/core/src/client/index.js b/core/src/client/index.js index fdc13421c..8c614412c 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -338,7 +338,7 @@ export function apply(ctx) { ctx.inject(['shortcuts'], scope => { scope.effect(() => holdShellShortcuts(scope.shortcuts), 'ds-bot: settings shortcut') }) - const after = promise => promise.then(async (value) => { await pull().catch(() => {}); return value }) + const after = promise => promise.then(async (value) => { await pull().catch(() => { /* a stale team list is fixed by the next pull; the call itself succeeded */ }); return value }) // A Bot id, or any part of its chat, opens the Bot's current part. const partFor = (id) => { const bot = roster.getSnapshot().byId[id] @@ -423,7 +423,7 @@ export function apply(ctx) { // `last` is the Bot conversation the view held before it cleared: when that part // was archived for a fresh one, the view goes on in the fresh one. landOn: async (id, last) => { - if (last) await pull().catch(() => {}) + if (last) await pull().catch(() => { /* a stale team list is fixed by the next pull */ }) const bot = last ? roster.getSnapshot().byId[last.botId] : undefined setPlain(false) const target = bot && currentPart(bot) !== last.sessionId ? currentPart(bot) : partFor(id) diff --git a/core/src/client/memory-panel.js b/core/src/client/memory-panel.js index f038abb9a..932f74f7c 100644 --- a/core/src/client/memory-panel.js +++ b/core/src/client/memory-panel.js @@ -27,7 +27,7 @@ export function useMemoryCount(botId, actions) { countListeners.add(listener) listener() let stop = false - actions.memoryView?.(botId).then((view) => { if (!stop) noteCount(botId, view.entries.length) }).catch(() => {}) + void actions.memoryView?.(botId).then((view) => { if (!stop) noteCount(botId, view.entries.length) }).catch(() => {}) return () => { stop = true; countListeners.delete(listener) } }, [botId]) return count diff --git a/core/src/client/voice.js b/core/src/client/voice.js index d68e6b6ff..feb1ad0ff 100644 --- a/core/src/client/voice.js +++ b/core/src/client/voice.js @@ -27,7 +27,7 @@ function useMicLevel(enabled) { let audio = null let frame = 0 let stopped = false - navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => { + void navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => { if (stopped) { media.getTracks().forEach(track => track.stop()); return } stream = media audio = new AudioContext() diff --git a/core/src/host/agents.js b/core/src/host/agents.js index 2ca06f289..cd6b1fe72 100644 --- a/core/src/host/agents.js +++ b/core/src/host/agents.js @@ -129,8 +129,8 @@ export function install(rt) { // tools never denied. const createdSessions = [...agent.sessions] delete state.agents[agent.id] - for (const sessionId of createdSessions) await ctx.workspaceRegistry.archiveSession(sessionId).catch(() => {}) - await rt.save().catch(() => {}) + for (const sessionId of createdSessions) await ctx.workspaceRegistry.archiveSession(sessionId).catch(() => { /* rollback is best effort; the original error is what the caller sees */ }) + await rt.save().catch(() => { /* rollback is best effort; the original error is what the caller sees */ }) throw error } return agent diff --git a/core/src/host/capabilities.js b/core/src/host/capabilities.js index de5f272e0..5809757f6 100644 --- a/core/src/host/capabilities.js +++ b/core/src/host/capabilities.js @@ -264,7 +264,7 @@ export function install(rt) { return { transport: 'streamable-http', serverName: item.name, url: item.url, headers: resolveValues(item.headers, botId), failOnStartupError: true } } const disposeMount = async mount => { - if (mount?.fiber) await Promise.resolve().then(() => mount.fiber.dispose()).catch(() => {}) + if (mount?.fiber) await Promise.resolve().then(() => mount.fiber.dispose()).catch(() => { /* a mount that fails to dispose must not block unloading */ }) } const mountOne = async (agent, bot, agentMounts, name) => { const sessionId = agent.session.id diff --git a/core/src/host/chat-index.js b/core/src/host/chat-index.js index e7c3067b5..c605abc5b 100644 --- a/core/src/host/chat-index.js +++ b/core/src/host/chat-index.js @@ -82,7 +82,7 @@ export function install(rt) { // A damaged file is rebuilt from the logs once. for (const fresh of [false, true]) { try { - if (fresh) await unlink(indexPath).catch(() => {}) + if (fresh) await unlink(indexPath).catch(() => { /* the index file may not exist yet */ }) return createIndex(new DatabaseSync(indexPath)) } catch (error) { if (fresh) rt.warn('ds-bot: no chat index, read_own_chat scans the logs: %s', error) diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index 65c431bd0..0b2d1c1e9 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -51,7 +51,7 @@ export function install(rt) { const unmount = async () => { const fiber = mount?.fiber mount = null - if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => {}) + if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => { /* a fiber that fails to dispose must not block unloading */ }) } // The MCP client wraps the server's answer (a 401, say) as the cause. diff --git a/core/src/host/diagnostics.js b/core/src/host/diagnostics.js index 433e72136..1ca25f6f5 100644 --- a/core/src/host/diagnostics.js +++ b/core/src/host/diagnostics.js @@ -219,7 +219,7 @@ export function install(rt) { }) async function report(body = {}) { - await rt.loadSecrets?.().catch(() => {}) + await rt.loadSecrets?.().catch(() => { /* without stored secrets the report is still built, redacting what it can */ }) const list = await entries() const client = sanitizeClient(body.client) const clientEntries = sanitizeClientEntries(body.clientErrors) @@ -234,7 +234,7 @@ export function install(rt) { } async function logText() { - await rt.loadSecrets?.().catch(() => {}) + await rt.loadSecrets?.().catch(() => { /* without stored secrets the log is still built, redacting what it can */ }) const list = await entries() return list.map(entry => (entry.stack ? `${entryLine(entry)}\n${entry.stack.replace(/^/gm, ' ')}` : entryLine(entry))).join('\n') } diff --git a/core/src/host/memory.js b/core/src/host/memory.js index 0d564eeb5..d484e0573 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -649,7 +649,7 @@ export function install(rt) { news.delete(botId) return queued(async () => { if (rt.readOnly() !== undefined) return - await unlink(join(root, 'summaries', `${safeId(botId)}.json`)).catch(() => {}) + await unlink(join(root, 'summaries', `${safeId(botId)}.json`)).catch(() => { /* the summary file may not exist */ }) const dir = dirOf(ownScope(botId)) if (!existsSync(dir)) return const name = `${safeId(botId)}-${new Date().toISOString().replace(/[:.]/g, '-')}` diff --git a/core/src/host/secrets.js b/core/src/host/secrets.js index b035baf5a..fe8cd7ef6 100644 --- a/core/src/host/secrets.js +++ b/core/src/host/secrets.js @@ -123,7 +123,7 @@ export function install(rt) { ...(Number.isFinite(entry.lastUsedAt) ? { lastUsedAt: entry.lastUsedAt } : {}), }) } - if (!loadedReadOnly) await chmod(path, 0o600).catch(() => {}) + if (!loadedReadOnly) await chmod(path, 0o600).catch(() => { /* best effort: some file systems have no modes */ }) } catch (error) { if (error?.code === 'ENOENT') secrets.clear() else rt.warn('ds-bot: unreadable secrets file %s (%s)', path, reason(error)) @@ -144,7 +144,7 @@ export function install(rt) { await chmod(temp, 0o600) await rename(temp, path) } catch (error) { - await unlink(temp).catch(() => {}) + await unlink(temp).catch(() => { /* the temp file may already be gone */ }) throw error } }) @@ -357,7 +357,7 @@ export function install(rt) { } if (touched) { changed() - await persist().catch(() => {}) + await persist().catch(() => { /* the change stays in memory and the next save writes it */ }) } } diff --git a/core/src/host/store.js b/core/src/host/store.js index be5c11e32..0ce342708 100644 --- a/core/src/host/store.js +++ b/core/src/host/store.js @@ -451,7 +451,7 @@ export function install(rt) { seen = stamp() rt.capabilitiesStateSaved?.() } catch (error) { - await unlink(temp).catch(() => {}) + await unlink(temp).catch(() => { /* the temp file may already be gone */ }) throw error } }) diff --git a/core/src/host/usage.js b/core/src/host/usage.js index 1c021e3d6..137668cc0 100644 --- a/core/src/host/usage.js +++ b/core/src/host/usage.js @@ -250,7 +250,7 @@ export function install(rt) { const damaged = error => /malformed|not a database|corrupt/i.test(String(error?.message ?? error)) for (const fresh of [false, true]) { try { - if (fresh) await unlink(path).catch(() => {}) + if (fresh) await unlink(path).catch(() => { /* the ledger file may not exist */ }) return sqliteLedger(DatabaseSync, path) } catch (error) { if (fresh || !damaged(error)) { diff --git a/docs/code-standards.md b/docs/code-standards.md index 6a8139331..32ddb12fd 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -27,7 +27,8 @@ When you report status on a change: ## 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). Text inside comments, strings, templates and regex literals is not code, so a `catch {}` written there is never reported (#213). -- The `void p.catch(() => {})` promise form is a separate fire-and-forget idiom and test-helper retries stay out of the gate's scope. +- The promise form follows the same rule (#213): a `.catch` whose handler does nothing (`() => {}`, `() => undefined`, `() => null`) and whose result is dropped (a statement, or an `await` statement) needs a `void` prefix or a one-line reason comment (in the handler, after the call, or on the line above). A handler that returns a fallback value that is *used* (`const x = await p.catch(() => undefined)`, an argument, a `return`, an assignment) stays allowed. `void` suits fire-and-forget; an `await` that waits for the work to settle takes the reason comment. +- Test helpers (`test/`, `dev/`) stay out of the gate's scope. ## Security: fences and remote strings diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index d21bfcc27..4075bdcf2 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -105,6 +105,10 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - The held-send overlay (`FlyBotApprovals`, `flybot-slots.js`) runs every hook before its `if (pending.length === 0) return null`: a hook after that return made React throw on the first held message and no card ever drew (#254). `test/overlay-approvals.test.js` renders it with a stand-in that enforces React's hook-count rule. +### Discarded promise `.catch` (#213) + +A discarded `.catch(() => {})` in the client sources either starts with `void` (fire and forget) or carries a one-line reason comment (an awaited wait); a `.catch(() => undefined)` whose value is used stays. `check-empty-catch` enforces it (see `docs/code-standards.md`). + ## Tests `npm test` in `core/` (it also fails when `client.js` is stale). diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 1955f4358..3a7f38356 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -140,6 +140,10 @@ credentials and team mode remain separate work under #55/#198. - A friend's Bot @-mentioned in a group never has its reply delivered back into that group (it lands in the friend's Bot's own chat), so the host posts one receipt line into the room per mention (`src/host/hooks.js` `mentionReceipt`, source role `mention`) and the client renders it as a muted event line (`src/client/cells.js`) (#80, #81). The role stays outside the room's round trigger (`groups.js` `opens`) and outside `RELAY_ROLES`, so the receipt opens no round and folds no turns. When that friend's Bot is already a current participant of the room, `sendUserMentions` skips the 1:1 `bot.send` entirely (#143): the human line in the room is the delivery, and Chief's grant is not consulted. A friend's line Bot Social attests they typed themselves (#227) arrives as `remoteChats.deliver({ human: true, ownerName })`: the message source carries `typedBy`, `turns.js` `claimTrigger` records the turn in `state.remoteTypedTurns[sessionId][turn]` (capped at 400 per Session, dropped with the part), `channel.js` publishes it as `roster.remoteTypedTurns` (narrowed per owner in `authz.js`), and the client draws that turn's reply as the person (`typedBy` in `src/client/text.js`, `PersonMessage` in `cells.js`). The flag is never derived from text. A failed 1:1 remote-chat send (`remote-chat.js` `forward` / `notSentLine`) is one short English line with a next step, never a raw `Bot Social send_as_member: …` preview. - `deliver()` (`src/host/delivery.js`) copies a cleaned, plugin-attested `remote.typedBy` onto the message `source.remote` for a friend's line to a local Bot; the client uses it to draw the person (#233 follow-up). +### Discarded promise `.catch` (#213) + +A discarded `.catch(() => {})` in the host sources either starts with `void` (fire and forget) or carries a one-line reason comment (an awaited wait); a `.catch(() => undefined)` whose value is used stays. `check-empty-catch` enforces it (see `docs/code-standards.md`). + ## Tests `npm test` in `core/`; smoke test for `index.js` / `src/host/` changes. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 003b45c3d..5732d654d 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -133,6 +133,10 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Bot Social has no FlyBot-group record (`list_conversations` is 1:1; inbox is `message.received` after a send). The plugin therefore carries a `FLYBOT-ROOM-SHARE` envelope on `send_as_member` text. `pollOnce` calls `flybot.rooms.applyInbound` before 1:1 chat delivery, so the Bot's owner sees the group as soon as they are added. `via: 'room'` / `via: 'room-share'` skip the social outbound hold (the add-to-group click is consent); the invited owner's Bot replies still go through `pending_approval`. A roster fetch that no longer lists a remote, or `social/revoke-grant`, leaves those rooms / hides inbound projections. +### Discarded promise `.catch` (#213) + +The plugin's discarded `.catch(() => {})` calls carry a one-line reason comment or `void`; `check-empty-catch` enforces it (see `docs/code-standards.md`). + ## Tests Covered by the plugin's own tests under `plugins/social/` / core test suite as wired. Packaged-install import (#153) is `scripts/lint-plugins.mjs` plus `.github/scripts/plugin-imports.test.mjs`. diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 16cc78966..c652e0447 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -45,6 +45,10 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). - The Bot list itself uses the core `.bt-tree-toggle` (see [core-client](core-client.md)). When `sidebarSection: true` brings this plugin's separate Tree section back, `.flybot-tree-toggle` matches that control: 28×28 hit target, 16px inner `.flybot-tree-caret`, hover, focus ring, and rotation of the glyph only. Leaf spacers (`.flybot-tree-dot`) keep the same 28px width. `aria-expanded` and Expand/Collapse `{name}` labels stay on the button. - The right-click menu entry from `menuItems({ bot })` is a single **Copy invite link** (bare URL, shown when `social.canInvite(bot.id)`). The invite with instructions is offered only in the Bot's Sharing section, so the menu does not list the same invite twice (#173). +### Discarded promise `.catch` (#213) + +The plugin's discarded `.catch(() => {})` calls carry a one-line reason comment or `void`; `check-empty-catch` enforces it (see `docs/code-standards.md`). + ## Tests Covered by the plugin's own tests under `plugins/tree/` / core test suite as wired. `#16 Option B` in `test/tree.test.js` (and the matching host test) asserts a non-Main tree Bot can create a child under itself, that `maxChildren`/depth still refuse, and that the child is not auto-granted. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 91da4c99e..917d1a9d6 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -12,6 +12,36 @@ diff --git a/README.md b/README.md - Give every model a `contextWindow`. DS Bot takes its lines from it: the usable window is the window minus the reply reserve (`maxTokens`), the hard line is 95% of that, and the check line is `checkRatio` of it. A model without `contextWindow` gets the adapter's default (262,144 tokens for `llm-pi-ai`), which may be wrong for that model; a model that reports no window at all counts as 500,000. - To send images, the provider needs `defaultInput: [ text, image ]`. +diff --git a/src/client/bot-browsers.js b/src/client/bot-browsers.js +--- a/src/client/bot-browsers.js ++++ b/src/client/bot-browsers.js +@@ -171,7 +171,7 @@ export function createBotBrowsers({ bridge, createSource, storage = globalThis.l + async function create(tabId) { + const botId = owner.get(tabId) + const reservation = await bridge.acquire(`bot:${botId}`) +- if (disposed) { await bridge.release(reservation.lease).catch(() => {}); return undefined } ++ if (disposed) { await bridge.release(reservation.lease).catch(() => { /* best effort: the lease may already be gone */ }); return undefined } + const element = doc.createElement('webview') + // The shell forwards app shortcuts from a focused webview that carries these + // attributes, as it does for its own Sidebar browser. +@@ -293,7 +293,7 @@ export function createBotBrowsers({ bridge, createSource, storage = globalThis.l + async function closeTab(botId, tabId) { + const state = tabs.getSnapshot()[botId] + if (state === undefined || !state.ids.includes(tabId)) return +- await opening.get(tabId)?.catch(() => {}) ++ await opening.get(tabId)?.catch(() => { /* its opener reports a failure; this only waits for it to settle */ }) + if (shown === tabId) await show(null) + await release(tabId) + // The waits above can overlap a new tab or an active-tab switch; drop the +@@ -375,7 +375,7 @@ export function createBotBrowsers({ bridge, createSource, storage = globalThis.l + async function forget(botId) { + ensureTabs(botId) + for (const tabId of tabs.getSnapshot()[botId]?.ids ?? []) { +- await opening.get(tabId)?.catch(() => {}) ++ await opening.get(tabId)?.catch(() => { /* its opener reports a failure; this only waits for it to settle */ }) + if (shown === tabId) await show(null) + await release(tabId) + owner.delete(tabId) diff --git a/src/client/feedback.js b/src/client/feedback.js --- a/src/client/feedback.js +++ b/src/client/feedback.js @@ -303,6 +333,18 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js } Object.assign(ZH, HOST_ZH) +diff --git a/src/client/memory-panel.js b/src/client/memory-panel.js +--- a/src/client/memory-panel.js ++++ b/src/client/memory-panel.js +@@ -27,7 +27,7 @@ export function useMemoryCount(botId, actions) { + countListeners.add(listener) + listener() + let stop = false +- actions.memoryView?.(botId).then((view) => { if (!stop) noteCount(botId, view.entries.length) }).catch(() => {}) ++ void actions.memoryView?.(botId).then((view) => { if (!stop) noteCount(botId, view.entries.length) }).catch(() => {}) + return () => { stop = true; countListeners.delete(listener) } + }, [botId]) + return count diff --git a/src/client/model-menu.js b/src/client/model-menu.js --- a/src/client/model-menu.js +++ b/src/client/model-menu.js @@ -411,6 +453,68 @@ diff --git a/src/client/text.js b/src/client/text.js } return '' } +diff --git a/src/client/voice.js b/src/client/voice.js +--- a/src/client/voice.js ++++ b/src/client/voice.js +@@ -27,7 +27,7 @@ function useMicLevel(enabled) { + let audio = null + let frame = 0 + let stopped = false +- navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => { ++ void navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => { + if (stopped) { media.getTracks().forEach(track => track.stop()); return } + stream = media + audio = new AudioContext() +diff --git a/src/host/agents.js b/src/host/agents.js +--- a/src/host/agents.js ++++ b/src/host/agents.js +@@ -129,8 +129,8 @@ export function install(rt) { + // tools never denied. + const createdSessions = [...agent.sessions] + delete state.agents[agent.id] +- for (const sessionId of createdSessions) await ctx.workspaceRegistry.archiveSession(sessionId).catch(() => {}) +- await rt.save().catch(() => {}) ++ for (const sessionId of createdSessions) await ctx.workspaceRegistry.archiveSession(sessionId).catch(() => { /* rollback is best effort; the original error is what the caller sees */ }) ++ await rt.save().catch(() => { /* rollback is best effort; the original error is what the caller sees */ }) + throw error + } + return agent +diff --git a/src/host/capabilities.js b/src/host/capabilities.js +--- a/src/host/capabilities.js ++++ b/src/host/capabilities.js +@@ -264,7 +264,7 @@ export function install(rt) { + return { transport: 'streamable-http', serverName: item.name, url: item.url, headers: resolveValues(item.headers, botId), failOnStartupError: true } + } + const disposeMount = async mount => { +- if (mount?.fiber) await Promise.resolve().then(() => mount.fiber.dispose()).catch(() => {}) ++ if (mount?.fiber) await Promise.resolve().then(() => mount.fiber.dispose()).catch(() => { /* a mount that fails to dispose must not block unloading */ }) + } + const mountOne = async (agent, bot, agentMounts, name) => { + const sessionId = agent.session.id +diff --git a/src/host/chat-index.js b/src/host/chat-index.js +--- a/src/host/chat-index.js ++++ b/src/host/chat-index.js +@@ -82,7 +82,7 @@ export function install(rt) { + // A damaged file is rebuilt from the logs once. + for (const fresh of [false, true]) { + try { +- if (fresh) await unlink(indexPath).catch(() => {}) ++ if (fresh) await unlink(indexPath).catch(() => { /* the index file may not exist yet */ }) + return createIndex(new DatabaseSync(indexPath)) + } catch (error) { + if (fresh) rt.warn('ds-bot: no chat index, read_own_chat scans the logs: %s', error) +diff --git a/src/host/connectors.js b/src/host/connectors.js +--- a/src/host/connectors.js ++++ b/src/host/connectors.js +@@ -51,7 +51,7 @@ export function install(rt) { + const unmount = async () => { + const fiber = mount?.fiber + mount = null +- if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => {}) ++ if (fiber !== undefined) await Promise.resolve().then(() => fiber.dispose()).catch(() => { /* a fiber that fails to dispose must not block unloading */ }) + } + + // The MCP client wraps the server's answer (a 401, say) as the cause. diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js --- a/src/host/diagnostics.js +++ b/src/host/diagnostics.js @@ -446,6 +550,24 @@ diff --git a/src/host/diagnostics.js b/src/host/diagnostics.js console.error(`[ds-bot] ${line}${frames.length > 0 ? `\n${clean(frames.join('\n'))}` : ''}`) note({ level: 'warn', +@@ -217,7 +217,7 @@ export function install(rt) { + }) + + async function report(body = {}) { +- await rt.loadSecrets?.().catch(() => {}) ++ await rt.loadSecrets?.().catch(() => { /* without stored secrets the report is still built, redacting what it can */ }) + const list = await entries() + const client = sanitizeClient(body.client) + const clientEntries = sanitizeClientEntries(body.clientErrors) +@@ -232,7 +232,7 @@ export function install(rt) { + } + + async function logText() { +- await rt.loadSecrets?.().catch(() => {}) ++ await rt.loadSecrets?.().catch(() => { /* without stored secrets the log is still built, redacting what it can */ }) + const list = await entries() + return list.map(entry => (entry.stack ? `${entryLine(entry)}\n${entry.stack.replace(/^/gm, ' ')}` : entryLine(entry))).join('\n') + } diff --git a/src/host/memory.js b/src/host/memory.js --- a/src/host/memory.js +++ b/src/host/memory.js @@ -507,6 +629,15 @@ diff --git a/src/host/memory.js b/src/host/memory.js // `raw` names a scope: "own", "team", or a Bot. Undefined for the default. function namedScope(raw, botId) { const value = unquote(raw) +@@ -632,7 +632,7 @@ export function install(rt) { + news.delete(botId) + return queued(async () => { + if (rt.readOnly() !== undefined) return +- await unlink(join(root, 'summaries', `${safeId(botId)}.json`)).catch(() => {}) ++ await unlink(join(root, 'summaries', `${safeId(botId)}.json`)).catch(() => { /* the summary file may not exist */ }) + const dir = dirOf(ownScope(botId)) + if (!existsSync(dir)) return + const name = `${safeId(botId)}-${new Date().toISOString().replace(/[:.]/g, '-')}` @@ -941,6 +941,8 @@ export function install(rt) { // desiredModel only falls back for models that are not served; so a failed call // is tried once more on the profile default. @@ -703,6 +834,18 @@ diff --git a/src/host/turns.js b/src/host/turns.js if (!expectsReply(source) || answersUser(source)) return const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot' // A sender that wrote from a group gets the reply in its Session there, while +diff --git a/src/host/usage.js b/src/host/usage.js +--- a/src/host/usage.js ++++ b/src/host/usage.js +@@ -250,7 +250,7 @@ export function install(rt) { + const damaged = error => /malformed|not a database|corrupt/i.test(String(error?.message ?? error)) + for (const fresh of [false, true]) { + try { +- if (fresh) await unlink(path).catch(() => {}) ++ if (fresh) await unlink(path).catch(() => { /* the ledger file may not exist */ }) + return sqliteLedger(DatabaseSync, path) + } catch (error) { + if (fresh || !damaged(error)) { diff --git a/test/client-bundle.test.js b/test/client-bundle.test.js --- a/test/client-bundle.test.js +++ b/test/client-bundle.test.js diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 91869004f..5ffee68eb 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -612,6 +612,15 @@ diff --git a/src/host/store.js b/src/host/store.js const load = () => loaded ??= (async () => { claimLock() if (holder !== undefined) warn('ds-bot: %s', readOnly()) +@@ -262,7 +262,7 @@ export function install(rt) { + seen = stamp() + rt.capabilitiesStateSaved?.() + } catch (error) { +- await unlink(temp).catch(() => {}) ++ await unlink(temp).catch(() => { /* the temp file may already be gone */ }) + throw error + } + }) @@ -335,9 +335,10 @@ export function install(rt) { } const findBot = (ref) => { diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 209991291..045741ddc 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -611,6 +611,24 @@ diff --git a/src/client/index.js b/src/client/index.js // Bot mode paints the whole team surface; Agent mode withdraws every slot and // style so the shell's own interface returns. `plain` stays false until a Bot-mode // view hosts a plain session, which drops only the conversation replacements. +@@ -325,7 +325,7 @@ export function apply(ctx) { + + ctx.inject(['shortcuts'], scope => { scope.effect(() => holdShellShortcuts(scope.shortcuts), 'ds-bot: settings shortcut') }) + +- const after = promise => promise.then(async (value) => { await pull().catch(() => {}); return value }) ++ const after = promise => promise.then(async (value) => { await pull().catch(() => { /* a stale team list is fixed by the next pull; the call itself succeeded */ }); return value }) + // A Bot id, or any part of its chat, opens the Bot's current part. + const partFor = (id) => { + const bot = roster.getSnapshot().byId[id] +@@ -410,7 +410,7 @@ export function apply(ctx) { + // `last` is the Bot conversation the view held before it cleared: when that part + // was archived for a fresh one, the view goes on in the fresh one. + landOn: async (id, last) => { +- if (last) await pull().catch(() => {}) ++ if (last) await pull().catch(() => { /* a stale team list is fixed by the next pull */ }) + const bot = last ? roster.getSnapshot().byId[last.botId] : undefined + setPlain(false) + const target = bot && currentPart(bot) !== last.sessionId ? currentPart(bot) : partFor(id) @@ -444,8 +444,15 @@ export function apply(ctx) { // Opening the conversation would move focus to its composer and blur the name field. renameBot: (id) => { ui.set(state => ({ ...state, details: id, view: 'details', renaming: true, tab: null })) }, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 4fd20469d..efee3fa18 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5758,7 +5758,7 @@ diff --git a/client.js b/client.js var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); -@@ -7384,8 +7384,8 @@ function noteCount(botId, count) { +@@ -7384,13 +7384,13 @@ function noteCount(botId, count) { for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { @@ -5769,6 +5769,12 @@ diff --git a/client.js b/client.js const listener = () => setCount(counts.get(botId) ?? null); countListeners.add(listener); listener(); + let stop = false; +- actions.memoryView?.(botId).then((view) => { ++ void actions.memoryView?.(botId).then((view) => { + if (!stop) noteCount(botId, view.entries.length); + }).catch(() => { + }); @@ -7404,16 +7404,16 @@ function useMemoryCount(botId, actions) { function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); @@ -7062,7 +7068,7 @@ diff --git a/client.js b/client.js var plainSpeech = (text) => stripHeader(text).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); var VOICE_LABELS = { listening: "Listening", -@@ -8665,8 +8665,8 @@ var VOICE_LABELS = { +@@ -8665,14 +8665,14 @@ var VOICE_LABELS = { }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { @@ -7073,6 +7079,13 @@ diff --git a/client.js b/client.js if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0; let stream = null; let audio = null; + let frame = 0; + let stopped = false; +- navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => { ++ void navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => { + if (stopped) { + media.getTracks().forEach((track) => track.stop()); + return; @@ -8716,18 +8716,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 214aa0406..2a4974152 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -220,6 +220,33 @@ diff --git a/src/host/parts.js b/src/host/parts.js diff --git a/src/host/secrets.js b/src/host/secrets.js --- a/src/host/secrets.js +++ b/src/host/secrets.js +@@ -123,7 +123,7 @@ export function install(rt) { + ...(Number.isFinite(entry.lastUsedAt) ? { lastUsedAt: entry.lastUsedAt } : {}), + }) + } +- if (!loadedReadOnly) await chmod(path, 0o600).catch(() => {}) ++ if (!loadedReadOnly) await chmod(path, 0o600).catch(() => { /* best effort: some file systems have no modes */ }) + } catch (error) { + if (error?.code === 'ENOENT') secrets.clear() + else rt.warn('ds-bot: unreadable secrets file %s (%s)', path, reason(error)) +@@ -144,7 +144,7 @@ export function install(rt) { + await chmod(temp, 0o600) + await rename(temp, path) + } catch (error) { +- await unlink(temp).catch(() => {}) ++ await unlink(temp).catch(() => { /* the temp file may already be gone */ }) + throw error + } + }) +@@ -357,7 +357,7 @@ export function install(rt) { + } + if (touched) { + changed() +- await persist().catch(() => {}) ++ await persist().catch(() => { /* the change stays in memory and the next save writes it */ }) + } + } + @@ -499,7 +499,7 @@ export function install(rt) { try { unregister = sub.shellEnv.register({ diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index 050902288..29143e632 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -15,6 +15,14 @@ "test/home-lock.test.js", "test/client-bundle.test.js", "test/install.test.js", + "src/client/bot-browsers.js", + "src/client/memory-panel.js", + "src/client/voice.js", + "src/host/agents.js", + "src/host/capabilities.js", + "src/host/chat-index.js", + "src/host/connectors.js", + "src/host/usage.js", "README.md" ], "0002-flybot-api-host": [ diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 7f4898be9..59576e528 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1516,7 +1516,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const next = handles.length ? revealHiddenIfPresent(settings, handles) : settings if (next !== settings) { settings = next; saveSettings() } fetchedAt = 0 - await remoteBots({ fresh: true }).catch(() => {}) + await remoteBots({ fresh: true }).catch(() => { /* the roster refreshes on the next poll */ }) } catch (revealError) { warn('could not unhide after relationship_exists', revealError) } @@ -1547,7 +1547,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp settings = again saveSettings() fetchedAt = 0 - await remoteBots({ fresh: true }).catch(() => {}) + await remoteBots({ fresh: true }).catch(() => { /* the roster refreshes on the next poll */ }) } if (offered?.handle) rememberInbound(offered.handle) const inviterHome = joined.inviter?.handle ? homeOfPeer(joined.inviter.handle) : added[0]?.home @@ -1609,7 +1609,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp disposers.push(flybot.api.handle('social/access', async ({ botId } = {}) => { const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : undefined if (!bot) throw new Error('Choose one of your Bots') - await syncOutgoingGrants({ only: bot.id }).catch(() => {}) + await syncOutgoingGrants({ only: bot.id }).catch(() => { /* shows the grants as last synced when Bot Social is unreachable */ }) const grants = (flybot.grants?.of?.(bot.id) ?? []).filter(grant => !grant.roomId) const rooms = roomsUsing(bot.id) return { @@ -2454,7 +2454,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp disposers.push(flybot.api.handle('social/face-join', async ({ code, botId } = {}) => joinFaceRoom({ code, botId }))) disposers.push(flybot.api.handle('social/face-open', async (payload) => openFaceRoom(payload), { read: true })) disposers.push(flybot.api.handle('social/face-rooms', async (payload) => { - if (payload?.poll !== false) await pollFaceRooms().catch(() => {}) + if (payload?.poll !== false) await pollFaceRooms().catch(() => { /* a failed poll shows the rooms as last polled */ }) const rooms = Object.entries(settings.faceRooms).map(([faceRoomId, meta]) => { const room = flybot.rooms.ofFace?.(faceRoomId) return viewHostedFace(room, { faceRoomId, localRoomId: meta.localRoomId, cursor: meta.cursor, joinCode: meta.joinCode }) || { faceRoomId, roomId: meta.localRoomId, status: 'candidate', joinCode: meta.joinCode } diff --git a/plugins/tree/client.js b/plugins/tree/client.js index 614277a96..75fa4f650 100644 --- a/plugins/tree/client.js +++ b/plugins/tree/client.js @@ -190,7 +190,7 @@ window.__ModuleLoader__.load({ if (typeof value !== 'string' || !value.trim()) throw new Error(kind === 'text' ? 'The invite came back without its message' : 'The invite came back without its link') return value })() - message.catch(() => {}) + message.catch(() => { /* the awaiting caller below reports it; this only marks it handled */ }) try { const clip = globalThis.navigator?.clipboard const Item = globalThis.ClipboardItem