From 750e8f9f4dfd16baddbcae73b7dccfa4358b7fc3 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:27 +0800 Subject: [PATCH 01/15] host: carry a final dispatch failure onto the hold as retry: false (#954) --- core/src/host/hooks.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 18220fb03..99936760c 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -465,6 +465,9 @@ export function install(rt) { } catch (error) { approval.status = 'failed' approval.error = error?.message ?? String(error) + // A sender that knows its failure can never succeed says so (#954): the card then offers no Retry, which + // would only repeat the same refusal. + if (error?.noRetry === true) approval.noRetry = true } } @@ -503,7 +506,7 @@ export function install(rt) { turn: a.request.meta.replyIn.turn, to: toRemote(a.request.to)?.handle ?? null, ...(a.recipientName ? { toOwner: a.recipientName } : {}), - ...(a.status === 'failed' && a.error ? { error: String(a.error).slice(0, 200) } : {}), + ...(a.status === 'failed' && a.error ? { error: String(a.error).slice(0, 200), ...(a.noRetry === true ? { retry: false } : {}) } : {}), })) rt.heldRepliesOf = (botId) => [...autoSentOf(botId), ...heldOf(botId)] From 9d9a5c59cd6704325f306f61c1fce78ff7b2d79f Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:29 +0800 Subject: [PATCH 02/15] host: reuse the gone-Bot sentence and stop doubling "Try again." (#954) --- core/src/host/remote-chat.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 2c17f5488..bc3c4e215 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -36,6 +36,13 @@ export function notSentLine(error) { if (error?.resend === true) return 'Not sent: Bot Social paused that chat. Send your message again: it starts a new chat.' return 'Not sent: this chat is paused. Approve any message waiting here, or remove and re-add this friend to start a new chat.' } + // #954: the friend's Bot itself is gone (Bot Social 404 bot_not_found). One sentence that names the Bot and the + // next step; the social plugin already writes that sentence, so it is reused whole instead of translated again. + if (/bot_not_found|no such remote bot/i.test(blob)) { + if (/no longer shared with you/i.test(raw)) return `Not sent: ${body.replace(/^Not sent:\s*/i, "")}` + const who = typeof error?.handle === "string" && error.handle ? error.handle : "that Bot" + return `Not sent: ${who} is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.` + } if (/has no grant|has not been granted|cannot message friends|cannot message their Bots/i.test(blob)) { return 'Not sent: none of your Bots is invited to this friend. Invite them from Bot settings.' } @@ -57,7 +64,8 @@ export function notSentLine(error) { if (/timeout|no answer/i.test(blob)) { return 'Not sent: Bot Social did not answer. Try again.' } - const short = (body || raw).slice(0, 80).replace(/\.$/, '') + // One "Try again." only: an earlier layer may already have said it (#954), and two reads as a stutter. + const short = (body || raw).slice(0, 80).replace(/\.$/, '').replace(/\.?\s*Try again\.?$/i, '').replace(/\.$/, '') if (!short || /^[a-z0-9_]+$/i.test(short)) { return 'Not sent: the message could not leave. Try again.' } From d09ac48d6de3af96e8325c2223d056532c00fc90 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:33 +0800 Subject: [PATCH 03/15] social: turn bot_not_found into the one sentence and mark it noRetry (#954) --- plugins/social/src/social.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 940bac88d..be8d6a0ae 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1499,7 +1499,18 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const who = friendBotTitle(handle) return Object.assign(new Error(`${lead}${who} is not shared with your Bots yet, so the message was not delivered. Bot Social told its owner, who can share it with you. Sending again will not help until then.`), { code: 'bot_not_shared' }) } + // The friend's Bot itself stopped existing (its owner removed it): Bot Social answers 404 bot_not_found, + // "no such remote bot". Nothing can be delivered, and sending again cannot help until that owner shares a Bot + // with us again, so the line names the Bot, says what to do, and is marked not retryable (#954). + const isBotGone = error => error?.code === "bot_not_found" || /bot_not_found|no such remote bot/i.test(String(error?.message ?? error)) + const botGoneError = (handle) => { + const key = norm(handle) + const who = friendBotTitle(key) + const named = who === key ? who : `${who} (${key})` + return Object.assign(new Error(`${named} is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.`), { code: "bot_not_found", handle: key, noRetry: true }) + } const mapSendError = (error, handle) => { + if (isBotGone(error)) return botGoneError(handle) if (isNotShared(error)) return notSharedError(handle, { lead: 'Not delivered: ' }) if (!isPaused(error)) return error // Only steps a FlyBot owner can take (#404), never the Bot Social website. The pause belongs to that @@ -1510,6 +1521,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // remote-chat host prefixes "Not sent:". Keep the Bot Social tool name out of the UI. const userFacingSendError = (error, handle) => { if (isPaused(error)) return mapSendError(error) + if (isBotGone(error)) return botGoneError(handle) // The host prefixes "Not sent:"; the sentence keeps its own capital (a Bot's name). if (isNotShared(error)) return notSharedError(handle) const raw = String(error?.message ?? error ?? '').replace(/\s+/g, ' ').trim() @@ -1544,7 +1556,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (/timeout|no answer/i.test(blob)) { return new Error('Bot Social did not answer. Try again.') } - const short = (body || raw).slice(0, 100).replace(/\.$/, '') + // One "Try again." only: an earlier layer may already have said it (#954), and two reads as a stutter. + const short = (body || raw).slice(0, 100).replace(/\.$/, '').replace(/\.?\s*Try again\.?$/i, '').replace(/\.$/, '') if (!short || /^[a-z0-9_]+$/i.test(short)) { return new Error('the message could not leave. Try again.') } From 4098c69a438074194edb7d4d4cd5897035c34a5e Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:35 +0800 Subject: [PATCH 04/15] social: draw no Retry on a failed hold that can never succeed (#954) --- plugins/social/client.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/social/client.js b/plugins/social/client.js index 76b31ac5b..8114bf881 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1934,7 +1934,9 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-label' }, 'Not sent'), item.from || item.to || item.toName ? h('div', { className: 'fs-sub' }, `${parties.from} → ${parties.to}`) : null, item.error ? h('div', { className: 'fs-sub', 'data-flybot': 'hold-error' }, item.error) : null, - h('div', { className: 'fs-actions fs-chat-actions' }, + // A failure the sender marked as never retryable (#954) offers no Retry: the Bot is gone, so the button + // could only repeat the same card. + item.retry === false ? null : h('div', { className: 'fs-actions fs-chat-actions' }, h('button', { type: 'button', className: 'fs-btn fs-btn-sm fs-btn-primary', 'data-flybot': 'hold-retry', disabled: !!busy, onClick: () => onDecide?.(item, 'retry') }, 'Retry'))) } const { from, to } = holdParties(item) From 5419436add29c3c34819fb84265f3851973ff5e7 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:38 +0800 Subject: [PATCH 05/15] test: the gone-Bot not-sent line (#954) --- core/test/remote-chat.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index a00b799f2..e95a8a240 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -154,6 +154,15 @@ test('a failed 1:1 send is one short English line with a next step', () => { // The plugin's pause says to send again: the next send opens a fresh chat; nothing names Extend or turns. assert.equal(notSentLine(Object.assign(new Error('Bot Social paused that chat (loop detected). Send your message again: it starts a new chat.'), { code: 'conversation_paused', resend: true })), 'Not sent: Bot Social paused that chat. Send your message again: it starts a new chat.') assert.doesNotMatch(notSentLine(Object.assign(new Error('paused'), { resend: true })), /Extend|turns/) + // #954: a Bot that no longer exists says one sentence with the next step, names the Bot when the sender + // knows which one it is, and never repeats "Try again." (the plugin's sentence already ended the line). + const gone = notSentLine(Object.assign(new Error('Bot Social send_as_member: no such remote bot'), { code: 'bot_not_found', handle: '@coder.bob.home' })) + assert.equal(gone, 'Not sent: @coder.bob.home is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.') + assert.doesNotMatch(gone, /no such remote bot|Try again/) + const relayed = notSentLine(Object.assign(new Error('Coder (Bob) (@coder.bob.home) is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.'), { code: 'bot_not_found', handle: '@coder.bob.home' })) + assert.equal(relayed, 'Not sent: Coder (Bob) (@coder.bob.home) is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.') + // One "Try again." only, whatever the layer below already wrote (#954). + assert.equal(notSentLine(new Error('Bot Social send_as_member: gateway hiccup. Try again.')), 'Not sent: gateway hiccup. Try again.') assert.equal(notSentLine(new Error('Bot Social send_as_member: weird_code')), 'Not sent: the message could not leave. Try again.') assert.doesNotMatch(notSentLine(new Error('something odd happened')), /console/) }); From ec79d654c85ca641446a2f3ab311c421e4e94369 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:40 +0800 Subject: [PATCH 06/15] test: retry: false reaches the chat, an ordinary failure keeps Retry (#954) --- core/test/host-approval-status.test.js | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/core/test/host-approval-status.test.js b/core/test/host-approval-status.test.js index f1d7f8b4d..6e7e1d63c 100644 --- a/core/test/host-approval-status.test.js +++ b/core/test/host-approval-status.test.js @@ -240,3 +240,38 @@ test('#754: host and client import the same judgment, and neither keeps a litera assert.match(files['channel.js'], /isApprovalWaiting\(reply\.status\)/, 'the wait badge asks the rule') assert.match(files['cells.js'], /isApprovalWaiting\(held\.status\)/, 'the chat bubble asks the rule') }) + +// #954: a sender that knows its refusal can never succeed says so, and the card it failed on must not +// offer Retry — the button would only repeat the same refusal. A failure that may pass later keeps it. +test('#954: a final failure reaches the chat as retry: false, an ordinary one keeps Retry', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + holdFlagged(fb) + // The team is read before a grant can be written, and a remote send needs one. + await call('state', {}) + await fb.grants.set('a', [{ grantee: '@bob.bot' }]) + let error + fb.transports.add('bot-social', { send: async () => { throw error } }) + const holdToBob = () => heldId(fb, { + from: 'a', to: { kind: 'remote', handle: '@bob.bot' }, text: 'are you still there?', + via: 'auto-reply', meta: { hold: true, replyIn: { sessionId: 's1', turn: 4 } }, + }) + const view = async id => (await call('state', {})).value.bots.find(bot => bot.id === 'a').heldReplies.find(entry => entry.id === id) + + error = Object.assign(new Error('@bob.bot is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.'), { code: 'bot_not_found', handle: '@bob.bot', noRetry: true }) + const id = await holdToBob() + const approved = await call('flybot/decide', { id, decision: 'approve' }) + assert.equal(approved.ok, true, JSON.stringify(approved)) + assert.equal(approved.value.status, 'failed', JSON.stringify(approved)) + const failed = await view(id) + assert.equal(failed.retry, false, 'the sender said this can never succeed, so no Retry is offered') + assert.match(failed.error, /no longer shared with you/) + + // The negative half: a failure that may pass later keeps the Retry it always had. + error = new Error('Bot Social did not answer') + const retryId = await holdToBob() + assert.equal((await call('flybot/decide', { id: retryId, decision: 'approve' })).value.status, 'failed') + const retryable = await view(retryId) + assert.equal(retryable.error, 'Bot Social did not answer') + assert.notEqual(retryable.retry, false, 'a retryable failure still offers Retry') +}) From f86ecb77bbb2e06c3638e243f547d9a6dc8514d3 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:46 +0800 Subject: [PATCH 07/15] test: one sentence, once, with no Retry (#954) --- plugins/social/test/social.test.js | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 7db8cfe3b..9a5f0cce0 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -2180,6 +2180,33 @@ test('a paused conversation explains the pause with steps a FlyBot owner can tak await assert.rejects(ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'hello from chief' }), /paused that chat .*Send your message again/) }) +test('#954: a line to a Bot that no longer exists says one sentence, once, and is not retryable', async (t) => { + const { ctx, stop } = await team() + t.after(stop) + const server = mockServer() + const base = server.fetch + server.fetch = async (url, init) => { + const body = JSON.parse(init.body) + if (body.params?.name === 'send_as_member') { + server.calls.push({ url, name: body.params.name, args: body.params.arguments }) + return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { isError: true, content: [{ type: 'text', text: JSON.stringify({ code: 'bot_not_found', message: 'no such remote bot' }) }] } }), { status: 200 }) + } + return base(url, init) + } + const social = createSocial(ctx.flybot, { inboxPollMs: 0, autoAccept: true }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + const error = await social.sendChat({ handle: '@coder.bob.home', text: 'one more line' }).then(() => null, e => e) + assert.equal(error?.message, 'Coder (Bob) (@coder.bob.home) is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.') + assert.equal(error?.noRetry, true, 'the host draws no Retry for it') + assert.doesNotMatch(error.message, /no such remote bot|Try again/) + // The transport a Bot's reply uses says the same thing, not the server's wording. + const viaBot = await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'reply' }).then(() => null, e => e) + assert.match(viaBot?.message ?? '', /^Coder \(Bob\) \(@coder\.bob\.home\) is no longer shared with you, so messages to it cannot be delivered\. Ask its owner for a new invite\.$/) + assert.equal(viaBot?.noRetry, true) +}) + test('sendChat failures are short English with a next step', async (t) => { const { ctx, stop } = await team() t.after(stop) @@ -3705,6 +3732,18 @@ test('#52: a failed local hold reads Not sent with its error and offers Retry', assert.deepEqual(calls.filter(([endpoint]) => endpoint === 'decide').map(([, payload]) => [payload.id, payload.decision]), [['f1', 'retry']]) }) +test('#954: a failed hold that can never succeed offers no Retry', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const friend = { id: 'r1', name: 'Coder', remote: { handle: '@coder.bob.home' } } + const roster = { bots: [friend] } + const hold = { id: 'f2', kind: 'outbound_message', source: 'local', status: 'failed', retry: false, error: 'Coder (Bob) (@coder.bob.home) is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.', from: 'Chief', to: '@coder.bob.home', toName: 'Bob', text: 'x' } + const tree = walk(pluginOf([[], [hold], '']).ChatApprovalDock({ bot: friend, roster })) + const card = tree.find(node => node.props['data-flybot'] === 'chat-approval') + assert.equal(card?.props['data-state'], 'failed') + assert.ok(tree.some(node => node.props['data-flybot'] === 'hold-error' && node.children.includes(hold.error))) + assert.equal(tree.some(node => node.props['data-flybot'] === 'hold-retry'), false, 'no Retry on a failure that cannot succeed') +}) + test('#126: a face-room hold shows in that chat with a readable name', () => { const { walk, pluginOf } = loadSocialClient(async () => ({})) const plugin = pluginOf([ From d1944a28eb29b2b51658662f243c7900406f73c3 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:48 +0800 Subject: [PATCH 08/15] patches: mirror core/src/host/hooks.js (#954) --- patches/ds-bot/files/src/host/hooks.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 18220fb03..99936760c 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -465,6 +465,9 @@ export function install(rt) { } catch (error) { approval.status = 'failed' approval.error = error?.message ?? String(error) + // A sender that knows its failure can never succeed says so (#954): the card then offers no Retry, which + // would only repeat the same refusal. + if (error?.noRetry === true) approval.noRetry = true } } @@ -503,7 +506,7 @@ export function install(rt) { turn: a.request.meta.replyIn.turn, to: toRemote(a.request.to)?.handle ?? null, ...(a.recipientName ? { toOwner: a.recipientName } : {}), - ...(a.status === 'failed' && a.error ? { error: String(a.error).slice(0, 200) } : {}), + ...(a.status === 'failed' && a.error ? { error: String(a.error).slice(0, 200), ...(a.noRetry === true ? { retry: false } : {}) } : {}), })) rt.heldRepliesOf = (botId) => [...autoSentOf(botId), ...heldOf(botId)] From 469fefd67566a8233d3c101f3e17894a858a6224 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:50 +0800 Subject: [PATCH 09/15] patches: mirror core/src/host/remote-chat.js (#954) --- patches/ds-bot/files/src/host/remote-chat.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index 2c17f5488..bc3c4e215 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -36,6 +36,13 @@ export function notSentLine(error) { if (error?.resend === true) return 'Not sent: Bot Social paused that chat. Send your message again: it starts a new chat.' return 'Not sent: this chat is paused. Approve any message waiting here, or remove and re-add this friend to start a new chat.' } + // #954: the friend's Bot itself is gone (Bot Social 404 bot_not_found). One sentence that names the Bot and the + // next step; the social plugin already writes that sentence, so it is reused whole instead of translated again. + if (/bot_not_found|no such remote bot/i.test(blob)) { + if (/no longer shared with you/i.test(raw)) return `Not sent: ${body.replace(/^Not sent:\s*/i, "")}` + const who = typeof error?.handle === "string" && error.handle ? error.handle : "that Bot" + return `Not sent: ${who} is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.` + } if (/has no grant|has not been granted|cannot message friends|cannot message their Bots/i.test(blob)) { return 'Not sent: none of your Bots is invited to this friend. Invite them from Bot settings.' } @@ -57,7 +64,8 @@ export function notSentLine(error) { if (/timeout|no answer/i.test(blob)) { return 'Not sent: Bot Social did not answer. Try again.' } - const short = (body || raw).slice(0, 80).replace(/\.$/, '') + // One "Try again." only: an earlier layer may already have said it (#954), and two reads as a stutter. + const short = (body || raw).slice(0, 80).replace(/\.$/, '').replace(/\.?\s*Try again\.?$/i, '').replace(/\.$/, '') if (!short || /^[a-z0-9_]+$/i.test(short)) { return 'Not sent: the message could not leave. Try again.' } From b94cb3fc1d134ec3f786dfddc2ca7b0af30212ca Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:53 +0800 Subject: [PATCH 10/15] docs: the final-failure hold and the gone-Bot sentence (#954) --- docs/subsystems/core-host.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index d519598dc..9d0452c4d 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -264,13 +264,13 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Remote chats and mentions -- Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). Still not team mode. +- Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. A failed dispatch keeps its error, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend's Bot no longer shared — is passed on as `retry: false` so the chat draws no Retry (#954); every other failure keeps the Retry it always had. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). Still not team mode. - Channel `live()` also sends `pluginTools`: the names of plugin tools a per-Bot mask may take away (`rt.pluginToolNames`, from `hidePluginTools`' gated set), so the client hides their `Tool added` / `Tool removed` rows (#419). A plugin reload changes them without a new revision, so they ride every `state` answer, an `unchanged` one too. - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does not count; an older client that sends nothing counts as on screen. `rt.watched()` is true for 8 s after such a read and reaches plugins as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/test/flybot.test.js` #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). - `remoteChats.sync(plugin, list)` (`src/host/remote-chat.js`) mirrors a chat whose Bot left the published list (`gone`) into `bot.hidden`, so it leaves the sidebar without deleting its history, and drops that hide when the Bot is published again. It remembers which chats were `gone` before `open()` clears the marker, and only a real change of `gone` touches `hidden`: a chat the user hid himself (`roster.setFlags`, "Hide from sidebar") while its Bot is still published stays hidden instead of returning on the next publish. The marker `bot.remote.hiddenByGone` records a hide the sync itself imposed (and only when the chat was not already hidden), so returning drops that hide alone and a hide the user set survives the Bot leaving the list and coming back (#631). -- `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says `resend`, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. +- `notSentLine` (`src/host/remote-chat.js`) is the one short line for a failed send to a friend's Bot. It names only steps a FlyBot owner can take (#404): a paused chat says to approve a waiting message or remove and re-add the friend (or, when the plugin's error says `resend`, to send the message again, which starts a new chat; there is no Extend and no turn limit), and the fallbacks end in *Try again.*; it never points to the Bot Social console, which a FlyBot owner cannot sign in to. A friend's Bot Bot Social no longer knows (`bot_not_found` / "no such remote bot") gets its own sentence — *the Bot is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.* — which the social plugin also writes and the host reuses whole instead of translating again, and no line carries *Try again.* twice (#954). - A friend chat waits on its reply (#443): a successful `forward` sets `state.remoteAwaiting[chatId]` (saved before the send turn ends), the friend's reply clears it as it arrives (`deliver`, before the reply is written into the chat) and again in the echo turn. A failed send never sets it. `channel.js` exposes it as `roster.remoteAwaiting`, and `authz.js` narrows it per owner like `remoteTypedTurns`. - Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). - `0002-flybot-api-host` `cleanRemote` sanitizes those peer-controlled `name` / `homeName` / `owner` labels (strip `@` and controls/newlines, 60-char cap) before they are stored. Details in [plugin-runtime](plugin-runtime.md) (#75). From 743637dde3b6170364bb284da3bf70a66f98b575 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:56 +0800 Subject: [PATCH 11/15] docs: Invariant 8 draws Retry only while it could work (#954) --- docs/subsystems/social.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index b14b07464..d65687482 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -76,7 +76,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Inline approvals and compact Friends' Bots (#114, #115) - A held send is never without a place (#371): the approval carries the Bot Social identity that wrote it (`approvalView` → `botId`; `pendingApprovals` maps it to the local Bot as `fromId`) and Bot Social's DLP categories (`holdReasons`: `commitment`, `pii.phone`, `pii.email`, `money`, `owner.sensitive_term`, `calendar.details`; the plain "all outbound needs approval" reason is not one). When the friend's chat is hidden, gone, home-level or not in the roster (`listedFriendChat`), the sender Bot's own chat draws the card; a listed friend chat alone shows it (#278). The card's second line reads `Held for your approval: contains commitment language` (host `t`, so zh in a zh UI) and the held text stays on the card, the bubble says only that it waits. The sender Bot's row also takes the wait dot (the `chatApprovals` provider answers for local Bots by `fromId`). No message is posted into a Bot's chat for this: it would reach the Bot's model. -- Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as a compact in-chat card (`flybot.session.dock`, max-width ~460px, right-aligned like a bubble — not full chat width; a Bot's held reply to a friend has its card on that reply's bubble instead, invariant 8): **Waiting for approval**, one `Name → Name` line (real names, never raw `@handle` / `bot_01…`), a 3-line clamped preview with **Show more**, and **Approve** / **Reject** buttons plus a quiet **Always approve** link on one row for the owner (#260). **Always approve** is owner-only: it approves this card and turns on the existing Auto-send for that pair (per-friend `social/auto-accept`, or in a face room the writing Bot's own `social/face-auto` switch, #457) so later messages on that pair are not held; it does not decide a different sender/recipient hold and is off by default. A toast says **Auto-send on for X → Y. Change it anytime in settings.** **Approve all** sits above the cards when several are waiting; anyone else sees **Waiting for the owner's approval** with no buttons. The owner does not get a waiting bubble in their 1:1; composer holds are labelled **You**. New friends start with **Auto-send** off. The owner's click is consent (`confirm_code` in the same `decide_approval` / `set_auto_approve` call). After a decision the card reads Approved or Rejected. The social strip "N to approve" block is a one-line jump list (chat name + truncated text, no explainer, no wrapping one word per line). Auto-send of face-room posts is one compact checkbox per Bot of mine on the open room's overview (#457) — not on each pending row or room card, and not duplicated on a Face-to-face rooms header. A local hold that failed (`status: 'failed'`) draws as **Not sent** with its error and a **Retry** (the core `decide` `retry`), never Approve / Reject; **Approve all** covers Bot Social holds (`status: 'pending'`) as well as local ones and leaves a failed hold to Retry, and a failed decision says why instead of throwing silently. The "Waiting for the owner's approval" ledger (`settings.approvalNotices`) keeps one entry per still-pending hold and drops a hold's entry as soon as it leaves the pending queue, so the persisted settings file does not grow with every hold the install ever had. +- Held Bot Social drafts (`social/status`, `social/decide-approval`) and matching local `flybot/approvals` holds show as a compact in-chat card (`flybot.session.dock`, max-width ~460px, right-aligned like a bubble — not full chat width; a Bot's held reply to a friend has its card on that reply's bubble instead, invariant 8): **Waiting for approval**, one `Name → Name` line (real names, never raw `@handle` / `bot_01…`), a 3-line clamped preview with **Show more**, and **Approve** / **Reject** buttons plus a quiet **Always approve** link on one row for the owner (#260). **Always approve** is owner-only: it approves this card and turns on the existing Auto-send for that pair (per-friend `social/auto-accept`, or in a face room the writing Bot's own `social/face-auto` switch, #457) so later messages on that pair are not held; it does not decide a different sender/recipient hold and is off by default. A toast says **Auto-send on for X → Y. Change it anytime in settings.** **Approve all** sits above the cards when several are waiting; anyone else sees **Waiting for the owner's approval** with no buttons. The owner does not get a waiting bubble in their 1:1; composer holds are labelled **You**. New friends start with **Auto-send** off. The owner's click is consent (`confirm_code` in the same `decide_approval` / `set_auto_approve` call). After a decision the card reads Approved or Rejected. The social strip "N to approve" block is a one-line jump list (chat name + truncated text, no explainer, no wrapping one word per line). Auto-send of face-room posts is one compact checkbox per Bot of mine on the open room's overview (#457) — not on each pending row or room card, and not duplicated on a Face-to-face rooms header. A local hold that failed (`status: 'failed'`) draws as **Not sent** with its error, never Approve / Reject, and offers **Retry** (the core `decide` `retry`) only while trying again could work: the error a sender marks with `noRetry` — a friend's Bot no longer shared with this home, where Bot Social answers `bot_not_found` (#954) — arrives as `retry: false` and the card draws no button, so no click repeats a refusal that cannot change; **Approve all** covers Bot Social holds (`status: 'pending'`) as well as local ones and leaves a failed hold to Retry, and a failed decision says why instead of throwing silently. The "Waiting for the owner's approval" ledger (`settings.approvalNotices`) keeps one entry per still-pending hold and drops a hold's entry as soon as it leaves the pending queue, so the persisted settings file does not grow with every hold the install ever had. - Live face-to-face rooms and friends' Bots sit in the core `TeamSidebar` list (globe / people mark), not a second **Friends' Bots** section. Closed, left, and ended matching rooms sit under **Past rooms**; the whole row opens the room (#304). The list does not say "The number is no longer valid." 1:1 friend chats get the same wait badge as rooms (`flybot.chatApprovals.setProvider` → `bot.pendingCount`). The social strip is **N to approve**, the + panels, and Past rooms — no **+ Add**, no per-friend groups, no Copy-handle row. **Add friend** and **Face-to-face room** are `flybot.new-chat.action` rows on the sidebar **+**. Add friend opens the join panel (handle, invite link, or code). Per-friend **Auto-send** and **Remove this Bot** live on that friend's Bot settings (`flybot.bot.details`). ### Face-to-face waiting grid, leave, and social + (#124, #125, #126) From 860c4dcc6ca4d187b1ccbceadcdafe85be92e7e5 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:16:58 +0800 Subject: [PATCH 12/15] changelog: a gone friend Bot reads one sentence, no Retry (#954) --- changelog.d/954-bot-gone-no-retry.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/954-bot-gone-no-retry.fixed.md diff --git a/changelog.d/954-bot-gone-no-retry.fixed.md b/changelog.d/954-bot-gone-no-retry.fixed.md new file mode 100644 index 000000000..d7e494262 --- /dev/null +++ b/changelog.d/954-bot-gone-no-retry.fixed.md @@ -0,0 +1 @@ +A message to a friend's Bot that no longer exists now reads one sentence — the Bot is no longer shared with you, so ask its owner for a new invite — instead of repeating "no such remote bot. Try again. Try again.", and the failed hold it leaves offers no **Retry**, because sending again cannot succeed ([#954](https://github.com/ThinkFlowLab/FlyBot/issues/954)). From a09d5fbf55b5f01743644774aa35e6b7913d0d76 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:18:06 +0800 Subject: [PATCH 13/15] patches: mirror core/test/remote-chat.test.js (#954) --- patches/ds-bot/files/test/remote-chat.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index a00b799f2..e95a8a240 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -154,6 +154,15 @@ test('a failed 1:1 send is one short English line with a next step', () => { // The plugin's pause says to send again: the next send opens a fresh chat; nothing names Extend or turns. assert.equal(notSentLine(Object.assign(new Error('Bot Social paused that chat (loop detected). Send your message again: it starts a new chat.'), { code: 'conversation_paused', resend: true })), 'Not sent: Bot Social paused that chat. Send your message again: it starts a new chat.') assert.doesNotMatch(notSentLine(Object.assign(new Error('paused'), { resend: true })), /Extend|turns/) + // #954: a Bot that no longer exists says one sentence with the next step, names the Bot when the sender + // knows which one it is, and never repeats "Try again." (the plugin's sentence already ended the line). + const gone = notSentLine(Object.assign(new Error('Bot Social send_as_member: no such remote bot'), { code: 'bot_not_found', handle: '@coder.bob.home' })) + assert.equal(gone, 'Not sent: @coder.bob.home is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.') + assert.doesNotMatch(gone, /no such remote bot|Try again/) + const relayed = notSentLine(Object.assign(new Error('Coder (Bob) (@coder.bob.home) is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.'), { code: 'bot_not_found', handle: '@coder.bob.home' })) + assert.equal(relayed, 'Not sent: Coder (Bob) (@coder.bob.home) is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.') + // One "Try again." only, whatever the layer below already wrote (#954). + assert.equal(notSentLine(new Error('Bot Social send_as_member: gateway hiccup. Try again.')), 'Not sent: gateway hiccup. Try again.') assert.equal(notSentLine(new Error('Bot Social send_as_member: weird_code')), 'Not sent: the message could not leave. Try again.') assert.doesNotMatch(notSentLine(new Error('something odd happened')), /console/) }); From 1628291590a3090d6391276ba0df00780f142063 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:18:08 +0800 Subject: [PATCH 14/15] patches: mirror core/test/host-approval-status.test.js (#954) --- .../files/test/host-approval-status.test.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/patches/ds-bot/files/test/host-approval-status.test.js b/patches/ds-bot/files/test/host-approval-status.test.js index f1d7f8b4d..6e7e1d63c 100644 --- a/patches/ds-bot/files/test/host-approval-status.test.js +++ b/patches/ds-bot/files/test/host-approval-status.test.js @@ -240,3 +240,38 @@ test('#754: host and client import the same judgment, and neither keeps a litera assert.match(files['channel.js'], /isApprovalWaiting\(reply\.status\)/, 'the wait badge asks the rule') assert.match(files['cells.js'], /isApprovalWaiting\(held\.status\)/, 'the chat bubble asks the rule') }) + +// #954: a sender that knows its refusal can never succeed says so, and the card it failed on must not +// offer Retry — the button would only repeat the same refusal. A failure that may pass later keeps it. +test('#954: a final failure reaches the chat as retry: false, an ordinary one keeps Retry', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + holdFlagged(fb) + // The team is read before a grant can be written, and a remote send needs one. + await call('state', {}) + await fb.grants.set('a', [{ grantee: '@bob.bot' }]) + let error + fb.transports.add('bot-social', { send: async () => { throw error } }) + const holdToBob = () => heldId(fb, { + from: 'a', to: { kind: 'remote', handle: '@bob.bot' }, text: 'are you still there?', + via: 'auto-reply', meta: { hold: true, replyIn: { sessionId: 's1', turn: 4 } }, + }) + const view = async id => (await call('state', {})).value.bots.find(bot => bot.id === 'a').heldReplies.find(entry => entry.id === id) + + error = Object.assign(new Error('@bob.bot is no longer shared with you, so messages to it cannot be delivered. Ask its owner for a new invite.'), { code: 'bot_not_found', handle: '@bob.bot', noRetry: true }) + const id = await holdToBob() + const approved = await call('flybot/decide', { id, decision: 'approve' }) + assert.equal(approved.ok, true, JSON.stringify(approved)) + assert.equal(approved.value.status, 'failed', JSON.stringify(approved)) + const failed = await view(id) + assert.equal(failed.retry, false, 'the sender said this can never succeed, so no Retry is offered') + assert.match(failed.error, /no longer shared with you/) + + // The negative half: a failure that may pass later keeps the Retry it always had. + error = new Error('Bot Social did not answer') + const retryId = await holdToBob() + assert.equal((await call('flybot/decide', { id: retryId, decision: 'approve' })).value.status, 'failed') + const retryable = await view(retryId) + assert.equal(retryable.error, 'Bot Social did not answer') + assert.notEqual(retryable.retry, false, 'a retryable failure still offers Retry') +}) From c6c5add74b745b2fa182502f520fefbfc4a563d8 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 19:24:08 +0800 Subject: [PATCH 15/15] docs: a final dispatch failure reaches the card as retry: false (#954) --- docs/subsystems/plugin-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 9fb4ab9e0..3bd6e08d5 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -34,7 +34,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots | File | Role | | --- | --- | -| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). | +| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. | | `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered' \| 'decision')` (#241). `turn.end` also carries the turn's token counts (summed from its `assistant/message` usage, floored and capped, counts only) under `tokens` (#357). `decision` is fired by `decisions.js` (#396), not folded from a Session event. | | `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems`, `nodeActions`). Every `flybot.sidebar.section` entry is wrapped in `div.bt-flybot-section`, and a wrapper a section leaves empty (its component renders null) draws no separator: the `:empty` rule zeroes that margin, border and padding, keyed on emptiness rather than on a section id, so the next null-rendering section is covered too (#818; `core/test/flybot-section-empty.test.js` renders the host entry and pins both rules). Its Approve / Reject and the held-reply card both ask `approval-waiting.js` `isApprovalWaiting()` (`pending` and `pending_approval` wait), so a hold carrying the spelling Bot Social's own outbound hold uses stays answerable (#734). | | `core/src/client/flybot-registry.js` | Registry. |