Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1141.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A group @ inserts the seated Bot's name, and Bots that share a live group can message each other without a separate friendship. A deleted friend home is dropped from the room and its chat is hidden ([#1141](https://github.com/ThinkFlowLab/FlyBot/issues/1141)).
21 changes: 19 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1576,6 +1576,21 @@ function remoteBotTitle(record) {
const name = remoteBotName(record, isChiefRecord(record) ? "Chief" : cleanPeerName(record?.handle, "Bot"));
return `${name} (${owner})`;
}
function remoteCallName(record) {
const handle = String(record?.handle ?? "").replace(/^@+/, "").trim();
const bare2 = remoteBotName(record, "").replace(/^@+/, "").trim();
const unusable = (value) => {
const text = String(value ?? "").replace(/^@+/, "").trim();
if (!text || /^friend$/i.test(text)) return true;
if (text.includes("@") || /\.home\b/i.test(text)) return true;
if (handle && text.toLowerCase() === handle.toLowerCase()) return true;
return false;
};
if (!unusable(bare2)) return bare2;
const slug = handle.split(".")[0];
if (slug && !unusable(slug)) return slug;
return "Bot";
}
function remoteOwnerBadge(record) {
return ownerNameOf(record) || cleanPeerName(record?.handle, "Friend");
}
Expand Down Expand Up @@ -9759,11 +9774,13 @@ function mentionRows(roster, sessionId, query = "") {
// A remote seat already listed under its handle (a duplicate participant record) is offered once.
...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({
id: p.handle || p.id,
name: remoteBotTitle(p),
// A face room inserts the disambiguated title. An ordinary group inserts the name the
// room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)".
name: face ? remoteBotTitle(p) : remoteCallName(p),
kind: "bot",
remote: p,
handle: p.handle,
...face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) } : {}
label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p)
}))
] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id);
const needle = query.trim().toLowerCase();
Expand Down
10 changes: 7 additions & 3 deletions core/src/client/at-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// rows stay beside them, because a source cannot be hidden for one Session (a face
// room filters the session rows out of view: face-at-filter.js).
import { mentionCandidates, remoteMentionRows } from './flybot-registry.js'
import { cleanPeerName, friendBotTitle, ownerNameOf, remoteBotTitle, listedChatBots, seatOwnerName } from '../shared/remote-label.js'
import { cleanPeerName, friendBotTitle, ownerNameOf, remoteBotTitle, remoteCallName, listedChatBots, seatOwnerName } from '../shared/remote-label.js'
import { t } from './i18n.js'
import { currentPart, relativeShort } from './text.js'

Expand Down Expand Up @@ -39,8 +39,12 @@ export function mentionRows(roster, sessionId, query = '') {
// A remote seat already listed under its handle (a duplicate participant record) is offered once.
...uniqueBy(live.filter(p => p.remote), p => p.handle || p.id)
.map(p => ({
id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle,
...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot')) } : {}),
id: p.handle || p.id,
// A face room inserts the disambiguated title. An ordinary group inserts the name the
// room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)".
name: face ? remoteBotTitle(p) : remoteCallName(p),
kind: 'bot', remote: p, handle: p.handle,
label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot')) : remoteBotTitle(p),
})),
]
: listedChatBots(roster).filter(bot => bot.id !== here.self.id)
Expand Down
29 changes: 21 additions & 8 deletions core/src/host/reach.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,21 @@ export function sharedRoom(state, botId, peer) {
return room === undefined ? undefined : String(room.id)
}

// #1126: a mention of a Bot that already sits in a live local or inbound group with the sender.
// The seat is the consent, so the mention does not also need a Sharing grant. A seat a group
// invite admitted (`admittedBy: 'ticket'`) counts while it is still in the room: that is how a
// local-group link seats the other home. Face-to-face stays on sharedRoom (it names a server
// room). A plain line and message_bot do not come here — the caller passes `via`.
// A live seat this sender still shares an ordinary group with (#1126, #1141). The exact seated
// Bot matches, and so does a send addressed to that seat's home (`@flybot….home`) — not some other
// Bot of the same home. The seat is the consent for every send, not only a user mention.
function seatReaches(seat, peer) {
const key = handleKey(peer)
if (!key) return false
if (handleKey(seat.handle || seat.id) === key) return true
const home = handleKey(seat.ownerHome || seat.home || homeOfHandle(seat.handle || seat.id))
return home !== '' && home === key && homeOfHandle(key) === key
}

// #1126, #1141: a Bot that already sits in a live local or inbound group with the sender.
// The seat is the consent, so the send does not also need a Sharing grant. A seat a group
// invite admitted (`admittedBy: 'ticket'`) counts while it is still in the room. Face-to-face
// stays on sharedRoom (it names a server room).
export function mentionRoom(state, botId, peer) {
if (typeof botId !== 'string' || state.bots?.[botId] === undefined) return undefined
const key = handleKey(peer)
Expand All @@ -238,7 +248,7 @@ export function mentionRoom(state, botId, peer) {
&& candidate.origin !== 'face-to-face'
&& !candidate.hidden
&& (candidate.members ?? []).includes(botId)
&& remotesInRoom(candidate).some(seat => handleKey(seat.handle || seat.id) === key))
&& remotesInRoom(candidate).some(seat => seatReaches(seat, peer)))
return room === undefined ? undefined : String(room.id)
}

Expand Down Expand Up @@ -365,12 +375,15 @@ export function install(rt) {
const named = msg.meta?.roomId ?? msg.roomId ?? msg.fromRoomId ?? msg.deliverArgs?.roomId ?? msg.deliverArgs?.fromRoomId
// #1126: a user mention of a live local-group seat is judged under that room even when the send
// names none. Any other via keeps asking for a grant or an explicit roomId.
const mentioned = named === undefined && msg.via === 'user-mention' && isRemoteRef(to) && !isRemoteRef(from)
// #1126, #1141: a live ordinary-group seat is the consent for this send even when the caller
// names no room. message_bot stays a 1:1: it still needs a grant (#1126). A face-to-face
// room stays on sharedRoom.
const coRoom = named === undefined && msg.via !== 'message_bot' && isRemoteRef(to) && !isRemoteRef(from)
? mentionRoom(state, from, to)
: undefined
// #948: a local Bot's line to a friend's Bot is judged under the room the two of them are still in,
// even when the send names no room: that room is the consent, and the transport names it outbound.
const roomId = named ?? mentioned ?? (isRemoteRef(to) && !isRemoteRef(from) ? sharedRoom(state, from, to) : undefined)
const roomId = named ?? coRoom ?? (isRemoteRef(to) && !isRemoteRef(from) ? sharedRoom(state, from, to) : undefined)
return reach(state, { from, to, roomId, scope: msg.meta?.scope })
}
rt.ticketPeers = () => ticketPeers(state)
Expand Down
3 changes: 2 additions & 1 deletion core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
// of a friend's room that invited their granted Bot.

import { randomBytes } from 'node:crypto'
import { cleanPeerName, disambiguatedRemoteTitle, faceAliases, isChiefRecord, remoteBotName, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
import { cleanPeerName, disambiguatedRemoteTitle, faceAliases, isChiefRecord, remoteBotName, remoteBotTitle, remoteCallName, sharedRoomTitle } from '../shared/remote-label.js'
import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js'
import { HISTORY_MAX, HISTORY_TEXT, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js'
Expand Down Expand Up @@ -473,6 +473,7 @@ export function install(rt) {
const aliases = [
...faceAliases(peer, bare || rosterName),
remoteBotTitle(peer),
remoteCallName(peer),
rosterName,
peer.label,
social.replace(/^@+/, ''),
Expand Down
20 changes: 20 additions & 0 deletions core/src/shared/remote-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,26 @@ export function remoteBotTitle(record) {
return `${name} (${owner})`
}

// What a group @ inserts and what the room routes on (#1141). The menu may still show
// remoteBotTitle ("Architect (flybot682b8a43)"); the token is the Bot's own name. A handle,
// a ".home" address, or the placeholder owner "Friend" is not a call token — a handle falls
// back to its first label (architect.flybot….home → architect).
export function remoteCallName(record) {
const handle = String(record?.handle ?? '').replace(/^@+/, '').trim()
const bare = remoteBotName(record, '').replace(/^@+/, '').trim()
const unusable = (value) => {
const text = String(value ?? '').replace(/^@+/, '').trim()
if (!text || /^friend$/i.test(text)) return true
if (text.includes('@') || /\.home\b/i.test(text)) return true
if (handle && text.toLowerCase() === handle.toLowerCase()) return true
return false
}
if (!unusable(bare)) return bare
const slug = handle.split('.')[0]
if (slug && !unusable(slug)) return slug
return 'Bot'
}

// The owner part of that name, for a badge next to the Bot's own name.
export function remoteOwnerBadge(record) {
return ownerNameOf(record) || cleanPeerName(record?.handle, 'Friend')
Expand Down
32 changes: 31 additions & 1 deletion core/test/at-menu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ test('#305: a face room offers each of its Bots once, named Bot (Person), and no
// A friend's Bot already seated in the group is offered once, by its seat (#384).
assert.deepEqual(mentionRows(group, 'face').filter(row => row.remote).map(row => row.remote.handle), ['@helper.other.home'])
// That seat is still found by its handle, the second line of its row (#384).
assert.deepEqual(mentionRows(group, 'face', 'chief.selfa').map(row => row.text), ['@Chief (SelfA) '])
assert.deepEqual(mentionRows(group, 'face', 'chief.selfa').map(row => row.text), ['@Chief '])
})

test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, by its handle, under its one name (owner)', async () => {
Expand Down Expand Up @@ -209,3 +209,33 @@ test('#305: "Everyone" in a group counts the Bots it reaches, seated friends\' B
assert.equal(await everyone(room([{ kind: 'human', id: 'ann', displayName: 'Ann' }])), '1 member')
assert.equal(await everyone(room([{ kind: 'bot', remote: true, id: '@coder.bob.home', handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob' }])), '2 members')
})

test('#1141: a group @ inserts the seat name, never a .home handle or (Friend)', async () => {
const group = indexRoster({
bots: [{ id: 'chief', name: 'Chief', sessionId: 'room' }],
rooms: [{
id: 'room', name: '研发组', members: ['chief'],
participants: [
{ kind: 'bot', id: 'chief', displayName: 'Chief' },
{ kind: 'bot', remote: true, id: '@architect.flybot682b8a43.home', handle: '@architect.flybot682b8a43.home', displayName: '@architect.flybot682b8a43.home', ownerName: 'flybot682b8a43' },
{ kind: 'bot', remote: true, id: 'backend-seat', handle: ' ', displayName: 'Backend' },
],
}],
})
const rows = mentionRows(group, 'room')
assert.deepEqual(rows.map(row => row.text), ['@Chief ', '@architect ', '@Backend ', '@Everyone '])
assert.ok(rows.every(row => !row.text.includes('@@') && !/\.home/.test(row.text) && !/\(Friend\)/.test(row.text)))
const [bots] = atSources({
roster: { getSnapshot: () => group },
sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } },
launched: () => false,
iconFor: () => 'bot',
})
const listed = await bots.candidates({ sessionId: 'room' }, { query: '' })
const architect = listed.find(row => row.description === '@architect.flybot682b8a43.home')
assert.equal(architect.value, '@architect ')
assert.match(architect.name, /architect/i)
const backend = listed.find(row => row.value === '@Backend ')
assert.ok(backend)
assert.equal(backend.name, 'Backend (Friend)')
})
24 changes: 17 additions & 7 deletions core/test/reach-policy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,22 +267,32 @@ test('#948: a room-only Bot may line the seat it shares a room with, and the roo
assert.equal(left.action, 'deny', 'the seat it shares is the consent, and it is gone with the seat')
assert.equal(left.reason, refused)
assert.deepEqual(out, [])
// #948's scope is the face-to-face room, whose seats are admitted by a number or a link: a room of
// this home is opened and closed by its members, and that path keeps asking for a grant or an
// explicit roomId. Same pair, same handles, only the room's kind differs.
// #948's face-to-face room is the consent for a room-only identity. An ordinary group is too
// (#1141): the live seat is enough, and another Bot of that home who is not seated is not.
live.rooms.g = {
id: 'g', members: ['a2'], participants: [
{ kind: 'human', id: 'alice', role: 'admin', joinedSeq: 0 },
{ kind: 'bot', id: 'a2', role: 'member', joinedSeq: 0 },
{ kind: 'bot', remote: true, handle: '@coder.bob.home', joinedSeq: 0 },
{ kind: 'bot', remote: true, handle: '@coder.bob.home', ownerHome: '@bob.home', joinedSeq: 0 },
],
}
const group = await fb.bot.send({ from: 'a2', to: seat, text: 'a room of this home' })
assert.equal(group.action, 'deny', 'only a face-to-face room is read here')
assert.equal(group.reason, refused)
assert.deepEqual(out, [])
assert.equal(group.action, 'allow', 'a live ordinary group is enough, with no Sharing grant')
assert.deepEqual(out, ['@coder.bob.home'])
out.length = 0
const sameHome = await fb.bot.send({ from: 'a2', to: { kind: 'remote', handle: '@writer.bob.home' }, text: 'same home, not seated' })
assert.equal(sameHome.action, 'deny', 'another Bot of that home is not the seat')
assert.equal(sameHome.reason, refused)
const homeLine = await fb.bot.send({ from: 'a2', to: { kind: 'remote', handle: '@bob.home' }, text: 'the friend home' })
assert.equal(homeLine.action, 'allow', 'a send to the seated home is that seat')
assert.deepEqual(out, ['@bob.home'])
assert.equal(reach(live, { from: 'a2', to: seat, roomId: 'g' }).action, 'allow',
'an explicit roomId still names its room, face-to-face or not')
live.rooms.g.participants[2].leftSeq = 4
out.length = 0
const leftGroup = await fb.bot.send({ from: 'a2', to: seat, text: 'after the group seat left' })
assert.equal(leftGroup.action, 'deny', 'leaving the group ends the consent')
assert.equal(leftGroup.reason, refused)
})

test('a held message is judged again when it is approved: revoke while it waited means refused', async (t) => {
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second
- **Group and face-room Bot labels (#526, owner decision 2026-10-20, option a).** The same person-first format: `Reviewer (flybot682b8a43)`, never Bot Social's member label plus the handle (`Reviewer · FlyBot 682b8a43 (flybot682b8a43)`). `remoteBotName` drops a trailing ` · <owner>` from a Bot's name when it is that Bot's owner (the server's `label` reaching a name slot, e.g. a bubble header with no seat yet); `ownerNameOf` passes over an owner name made up from an install id (`isAutoOwnerName`: `FlyBot` + hex id) to the owner's username from the home handle, and keeps it only when no handle is known; `seatOwnerName` reads my own seat's owner the same way (`face-seats.js`, `at-menu.js`). The Bot names in `FacePersonRow` and the remote rows of the group Members lists (`group-settings.js`, `details-drawer.js`) carry the handle as a tooltip, as bubble headers already do. Covered by `core/test/group-bot-label.test.js`.
- A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306). Any other group's subtitle, command-palette row and Groups-list tag use `seatedCount`: humans, this home's Bots, and remote Bots still in the room — the same people the Members card lists — so a shared room does not read as only the Bots this home brought. A remote that left is not counted. The "Add a Bot first" note uses that same total.
- The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`Chief (SelfA)`, `Chief (SelfB)`), plus **Everyone** with the room's Bot count (`2 Bots`, #457). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305).
- An ordinary group's `@` still shows the seat as `remoteBotTitle` (`Backend (Friend)`, handle on the second line) but inserts `remoteCallName` (#1141): the Bot's own name (`@Backend`, `@architect`), which `callsIn` also aliases. It does not insert a second `@`, a `.home` handle, or the placeholder `(Friend)`.
- A face room's `@` menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in `reference` source cannot be dropped for one Session, so `installHeaderEffects` (`header-effects.js`) starts `actions.filterFaceAtMenu` for a face room, which watches that Session's trigger controller (`inputTriggers.sessionOf`, the same one the composer uses) with `watchFaceMenu` (`face-at-filter.js`): a `<style data-bt-face-at>` hides the `reference` rows whose icon is `session` (Sessions and Subagents) and their section title by MenuView's option ids, `controller.hover` moves the highlight past them in the direction of the key (so arrow keys skip them and Enter never picks one), and a menu with nothing else left is `dismiss()`ed, so Enter sends. File rows stay. Elsewhere, **Everyone**'s count is the Bots it reaches (local members and seated friends' Bots), `1 member` / `2 members`.

### Silent creation (#645)
Expand Down
2 changes: 2 additions & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,5 @@ A discarded `.catch(() => {})` in the host sources either starts with `void` (fi
- That placeholder names the file when the Bot's own file tools can open it: `attachmentFilePath` takes the attachment store's `imageHostPath` through the file sandbox's `processPathFromHostPath` (the same mapping `dsh-spill-policy` applies to spooled tool output) and falls back to the local object layout `<home>/attachments/v1/objects/<first two>/<sha256>`. Either candidate must `stat` as a regular file, so a Bot is never sent after a file that is not there (a host path the sandbox cannot open is worse than no path), and only a `sha256:<64 hex>` id has an object path at all.
- Nothing is copied into a Bot's working folder: a copy would need a lifecycle and a cleanup policy of its own, and the readable path already lets a Bot move or submit the bytes itself.
- Covered by `core/test/images.test.js` and, through the Session hook and the group relay, `core/test/session-identity.test.js`.

- A live ordinary group is consent for a send to a seated remote Bot, and for a send addressed to that seat's home, even when the caller names no room and the line is not a user mention (#1141, `mentionRoom`). Another Bot of the same home who is not seated stays denied. Face-to-face room-only lines stay on `sharedRoom` (#948). `callsIn` also matches `remoteCallName`, the token a group `@` inserts.
Loading
Loading